From cfb43e5c6707fd60fa5b6b9df3014b3320480297 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 9 Oct 2026 03:42:11 +1100 Subject: [PATCH 1/7] fix: mobile nav --- src/app/globals.css | 4 ++- src/components/app-shell.tsx | 30 ++++++++++++++++++++++ src/components/friends/direct-messages.tsx | 8 ++++++ src/components/groups/group-chat.tsx | 8 ++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/app/globals.css b/src/app/globals.css index b3b1020..5674d57 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -240,7 +240,9 @@ body { .mac-mobile-nav { position: fixed; inset-inline: 0; - bottom: 0; + /* Anchored from the top: in iOS home-screen apps a fixed `bottom: 0` lands + above the real screen edge. */ + top: calc(var(--app-viewport-height) - var(--mobile-nav-height)); z-index: 40; width: 100%; height: var(--mobile-nav-height); diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 0c7ccb1..88190f0 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -162,6 +162,36 @@ export function AppShell({ }; }, []); + // iOS home-screen apps report 100dvh (and innerHeight) short by the status + // bar inset, which leaves a dead band under the nav. The screen has no + // browser chrome there, so size the app from the screen itself. + useEffect(() => { + const isIosStandalone = + (navigator as Navigator & { standalone?: boolean }).standalone === true; + if (!isIosStandalone) return; + + const root = document.documentElement; + + function syncViewportHeight() { + const landscape = window.innerWidth > window.innerHeight; + const screenHeight = landscape + ? Math.min(window.screen.width, window.screen.height) + : Math.max(window.screen.width, window.screen.height); + const height = Math.max(screenHeight, window.innerHeight); + root.style.setProperty("--app-viewport-height", `${height}px`); + } + + syncViewportHeight(); + window.addEventListener("resize", syncViewportHeight); + window.addEventListener("orientationchange", syncViewportHeight); + + return () => { + window.removeEventListener("resize", syncViewportHeight); + window.removeEventListener("orientationchange", syncViewportHeight); + root.style.removeProperty("--app-viewport-height"); + }; + }, []); + useEffect(() => { function prefetchAll() { navItems.forEach((item) => { diff --git a/src/components/friends/direct-messages.tsx b/src/components/friends/direct-messages.tsx index 61b0b9f..638e3cc 100644 --- a/src/components/friends/direct-messages.tsx +++ b/src/components/friends/direct-messages.tsx @@ -227,7 +227,15 @@ export function DirectMessages({ frame = window.requestAnimationFrame(() => { if (!chat) return; + // Only follow the visual viewport while the keyboard is up; otherwise + // the CSS height (full screen, see AppShell) applies. iOS home-screen + // apps report a visual viewport short by the status bar inset. + const keyboardOpen = + Boolean(visualViewport) && + visualViewport!.height < window.innerHeight - 120; + if ( + !keyboardOpen || !isWorkspaceVisible() || !window.matchMedia("(max-width: 1023px)").matches ) { diff --git a/src/components/groups/group-chat.tsx b/src/components/groups/group-chat.tsx index 5f67038..20e7f14 100644 --- a/src/components/groups/group-chat.tsx +++ b/src/components/groups/group-chat.tsx @@ -431,7 +431,15 @@ export function GroupChat({ frame = window.requestAnimationFrame(() => { if (!chat) return; + // Only follow the visual viewport while the keyboard is up; otherwise + // the CSS height (full screen, see AppShell) applies. iOS home-screen + // apps report a visual viewport short by the status bar inset. + const keyboardOpen = + Boolean(visualViewport) && + visualViewport!.height < window.innerHeight - 120; + if ( + !keyboardOpen || !isWorkspaceVisible() || !window.matchMedia("(max-width: 1023px)").matches ) { From f5fe454c4ded25dbcd4b2ddaf778c87d5722f182 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 9 Oct 2026 05:35:44 +1100 Subject: [PATCH 2/7] Revert "fix: mobile nav" This reverts commit cfb43e5c6707fd60fa5b6b9df3014b3320480297. --- src/app/globals.css | 4 +-- src/components/app-shell.tsx | 30 ---------------------- src/components/friends/direct-messages.tsx | 8 ------ src/components/groups/group-chat.tsx | 8 ------ 4 files changed, 1 insertion(+), 49 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index 5674d57..b3b1020 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -240,9 +240,7 @@ body { .mac-mobile-nav { position: fixed; inset-inline: 0; - /* Anchored from the top: in iOS home-screen apps a fixed `bottom: 0` lands - above the real screen edge. */ - top: calc(var(--app-viewport-height) - var(--mobile-nav-height)); + bottom: 0; z-index: 40; width: 100%; height: var(--mobile-nav-height); diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 88190f0..0c7ccb1 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -162,36 +162,6 @@ export function AppShell({ }; }, []); - // iOS home-screen apps report 100dvh (and innerHeight) short by the status - // bar inset, which leaves a dead band under the nav. The screen has no - // browser chrome there, so size the app from the screen itself. - useEffect(() => { - const isIosStandalone = - (navigator as Navigator & { standalone?: boolean }).standalone === true; - if (!isIosStandalone) return; - - const root = document.documentElement; - - function syncViewportHeight() { - const landscape = window.innerWidth > window.innerHeight; - const screenHeight = landscape - ? Math.min(window.screen.width, window.screen.height) - : Math.max(window.screen.width, window.screen.height); - const height = Math.max(screenHeight, window.innerHeight); - root.style.setProperty("--app-viewport-height", `${height}px`); - } - - syncViewportHeight(); - window.addEventListener("resize", syncViewportHeight); - window.addEventListener("orientationchange", syncViewportHeight); - - return () => { - window.removeEventListener("resize", syncViewportHeight); - window.removeEventListener("orientationchange", syncViewportHeight); - root.style.removeProperty("--app-viewport-height"); - }; - }, []); - useEffect(() => { function prefetchAll() { navItems.forEach((item) => { diff --git a/src/components/friends/direct-messages.tsx b/src/components/friends/direct-messages.tsx index 638e3cc..61b0b9f 100644 --- a/src/components/friends/direct-messages.tsx +++ b/src/components/friends/direct-messages.tsx @@ -227,15 +227,7 @@ export function DirectMessages({ frame = window.requestAnimationFrame(() => { if (!chat) return; - // Only follow the visual viewport while the keyboard is up; otherwise - // the CSS height (full screen, see AppShell) applies. iOS home-screen - // apps report a visual viewport short by the status bar inset. - const keyboardOpen = - Boolean(visualViewport) && - visualViewport!.height < window.innerHeight - 120; - if ( - !keyboardOpen || !isWorkspaceVisible() || !window.matchMedia("(max-width: 1023px)").matches ) { diff --git a/src/components/groups/group-chat.tsx b/src/components/groups/group-chat.tsx index 20e7f14..5f67038 100644 --- a/src/components/groups/group-chat.tsx +++ b/src/components/groups/group-chat.tsx @@ -431,15 +431,7 @@ export function GroupChat({ frame = window.requestAnimationFrame(() => { if (!chat) return; - // Only follow the visual viewport while the keyboard is up; otherwise - // the CSS height (full screen, see AppShell) applies. iOS home-screen - // apps report a visual viewport short by the status bar inset. - const keyboardOpen = - Boolean(visualViewport) && - visualViewport!.height < window.innerHeight - 120; - if ( - !keyboardOpen || !isWorkspaceVisible() || !window.matchMedia("(max-width: 1023px)").matches ) { From e54c53b5a9a478f7e0499262d95cbab98d6ad781 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 9 Oct 2026 05:42:16 +1100 Subject: [PATCH 3/7] fix: mobile layout change --- src/app/layout.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8deb6bd..b1c8e40 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,7 +14,7 @@ export const metadata: Metadata = { manifest: "/manifest.webmanifest", appleWebApp: { capable: true, - statusBarStyle: "black-translucent", + statusBarStyle: "black", title: "MAC Study", }, icons: { From afeaf058fb8ea32c234f8a988b8f434a3976d5b4 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 9 Oct 2026 06:26:11 +1100 Subject: [PATCH 4/7] docs: for mobile nav --- docs/ios-pwa-mobile-nav.md | 99 ++++++++++++++++++++++++ src/app/layout.tsx | 3 + src/components/timer/timer-dashboard.tsx | 2 +- 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 docs/ios-pwa-mobile-nav.md diff --git a/docs/ios-pwa-mobile-nav.md b/docs/ios-pwa-mobile-nav.md new file mode 100644 index 0000000..b9ae417 --- /dev/null +++ b/docs/ios-pwa-mobile-nav.md @@ -0,0 +1,99 @@ +# iOS home-screen app: mobile nav and the bottom gap + +How the mobile nav fits the screen on an installed iPhone app, and why the +status bar style is `black` rather than `black-translucent`. + +## Symptom + +On an iPhone running the app from the home screen (standalone PWA), a dark +band about 59pt tall sat under the bottom nav. The nav and the page content +both stopped short of the bottom edge. It showed on a cold launch and had been +there since the PWA was first set up. Safari in a normal tab was not affected. + +## Cause + +This is an iOS 26 WebKit regression. It hits standalone web apps that use: + +- `apple-mobile-web-app-status-bar-style: black-translucent`, and +- `viewport-fit=cover`. + +With that combination, iOS makes the page's layout viewport shorter than the +screen by the top safe-area inset, which is the status bar height (about 59pt +on Dynamic Island iPhones, 47pt on notched ones). Every height the page can +read reports the short value: `100dvh`, `100vh`, `window.innerHeight` and +`visualViewport.height`. The page is also clipped at that line, so +`position: fixed; bottom: 0` elements end up above the real bottom edge. Only +the page background colour shows in the strip below. + +Measured from a screenshot on our device, the scroll area and the nav both +ended about 59pt above the bottom, matching the top inset exactly. + +## What did not work (commit `cfb43e5`, reverted in `f5fe454`) + +We read the real screen height (`screen.height`) on iOS standalone, wrote it to +`--app-viewport-height`, and anchored the nav from the top instead of +`bottom: 0`. The nav moved down but was **cut off** at the same line where the +gap used to begin. That is how we confirmed the strip is outside the area the +page is allowed to draw in, so no CSS or JS resizing can reach it. Other +developers hitting the same bug report the same result. + +## Fix (commit `e54c53b`) + +`src/app/layout.tsx`: + +```ts +appleWebApp: { + capable: true, + statusBarStyle: "black", + title: "MAC Study", +}, +``` + +With `black`, iOS places the web view **below** the status bar instead of +under it. The top safe-area inset becomes 0, the layout viewport is no longer +short, and the nav reaches the bottom of the screen. + +### Trade-off + +The status bar strip is pure black (`#000`) instead of the app background +(`#171717`), so there is a faint colour step at the very top. Content no longer +draws behind the status bar. + +### Things that still apply + +- `--safe-area-top` resolves to `0` in the installed app now, so headers that + pad by it (for example the chat headers) simply lose that padding. No change + needed. +- `--safe-area-bottom` is still the home indicator inset (about 34pt). The + `@media (display-mode: standalone)` block in `src/app/globals.css` uses it to + lift the nav icons just clear of the indicator. Adjust the `- 1.25rem` in + `--mobile-nav-control-bottom` to change that spacing. + +## Testing a change here + +iOS reads the status bar style **when the app is added to the home screen**. +After changing it: + +1. Deploy. +2. Delete the app from the home screen and add it again. Relaunching alone keeps + the old setting. +3. Cold-launch the app and check: + - The nav sits on the bottom edge, with no band below and nothing cut off. + - Headers aren't hidden under the status bar. + - In a chat, the composer sits at the bottom, rides above the keyboard, and + drops back when the keyboard closes. + +## Before switching back to `black-translucent` + +Only switch back once Apple fixes the regression, and test the result on a real +iOS 26+ device from the home screen. The simulator and Safari tabs do not show +the bug. + +## References + +- busssss PR #23, which traces the 47px bottom gap to an iOS 26 WebKit + regression: https://github.com/tpdbf5509/busssss/pull/23 +- arc PR #8, which fixes the 59pt black strip by using `black` instead of + `black-translucent`: https://github.com/joejohnston72-dev/arc/pull/8 +- 0xchat PR #135, the same gap, tried with `100lvh`: + https://github.com/endziu/0xchat/pull/135 diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b1c8e40..7d1f1bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,6 +14,9 @@ export const metadata: Metadata = { manifest: "/manifest.webmanifest", appleWebApp: { capable: true, + // Not "black-translucent": on iOS 26 that shrinks the installed app's + // viewport and leaves a gap under the mobile nav. + // See docs/ios-pwa-mobile-nav.md. statusBarStyle: "black", title: "MAC Study", }, diff --git a/src/components/timer/timer-dashboard.tsx b/src/components/timer/timer-dashboard.tsx index 07b7b39..ff8bb2f 100644 --- a/src/components/timer/timer-dashboard.tsx +++ b/src/components/timer/timer-dashboard.tsx @@ -932,7 +932,7 @@ export function TimerDashboard({ /> ) : ( Date: Fri, 9 Oct 2026 06:38:21 +1100 Subject: [PATCH 5/7] fix: add Safari menu step to iOS install guide Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + src/components/pwa/install-onboarding.tsx | 2 ++ 2 files changed, 3 insertions(+) diff --git a/README.md b/README.md index 8f0f615..b9216de 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,7 @@ phone. iPhone: - Open the URL in Safari. +- Tap the ... menu. - Tap Share. - Tap Add to Home Screen. - Open MAC Study from the new home-screen icon. diff --git a/src/components/pwa/install-onboarding.tsx b/src/components/pwa/install-onboarding.tsx index b5d63ba..663a108 100644 --- a/src/components/pwa/install-onboarding.tsx +++ b/src/components/pwa/install-onboarding.tsx @@ -12,6 +12,7 @@ import { import { Download, MonitorDown, + MoreHorizontal, MoreVertical, Pin, Share, @@ -53,6 +54,7 @@ type LauncherKind = "pc" | "phone"; const phoneGuides: Record = { ios: [ { title: "Open study.monashcoding.com in Safari" }, + { glyph: MoreHorizontal, title: "Tap the menu" }, { glyph: Share, title: "Tap Share" }, { glyph: SquarePlus, title: "Add to Home Screen" }, ], From 7cfb4744795fa1f5d42a9165a3575e7bd37a5076 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 9 Oct 2026 06:47:26 +1100 Subject: [PATCH 6/7] update: mobile installation instructions --- src/components/app-shell.tsx | 33 +++++++++++++++++++++--- src/components/timer/timer-dashboard.tsx | 5 +++- src/lib/client-cache.test.ts | 31 +++++++++++++++++++++- src/lib/client-cache.ts | 24 ++++++++++++++--- 4 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/components/app-shell.tsx b/src/components/app-shell.tsx index 0c7ccb1..6028902 100644 --- a/src/components/app-shell.tsx +++ b/src/components/app-shell.tsx @@ -16,16 +16,20 @@ import { } from "lucide-react"; import type { AppAuthState } from "@/lib/auth/app-auth"; import { + cacheRemoteFriendsSnapshot, cacheRemoteGroupsSnapshot, cacheRemoteTimerState, cacheRemoteUnitState, dedupeRemoteRequest, + getCachedRemoteFriendsSnapshot, getCachedRemoteGroupsSnapshot, getCachedRemoteTimerState, + getStaleRemoteTimerState, } from "@/lib/client-cache"; import { getMascotSrc, MASCOT_KEYS } from "@/lib/mascots"; import { fetchRemoteDirectMessageUnreadCount, + fetchRemoteFriendsSnapshot, fetchRemoteGroupsSnapshot, fetchRemoteTimerState, fetchRemoteUnitState, @@ -203,6 +207,23 @@ export function AppShell({ } })(); + if (!getCachedRemoteFriendsSnapshot(cacheUserId)) { + void (async () => { + try { + const supabase = createSupabaseBrowserClient(); + const snapshot = await dedupeRemoteRequest({ + key: "friends", + load: () => fetchRemoteFriendsSnapshot(supabase), + userId: cacheUserId, + }); + + if (!cancelled && snapshot) cacheRemoteFriendsSnapshot(snapshot); + } catch { + // Preloading is opportunistic; the Friends view can retry on demand. + } + })(); + } + if (getCachedRemoteGroupsSnapshot(cacheUserId)) return; void (async () => { @@ -219,7 +240,9 @@ export function AppShell({ // Preloading is opportunistic; the Groups view can retry on demand. } })(); - }, 350); + // Queued behind the timer request so Home still goes first, but early + // enough that the other tabs are warm by the time the splash leaves. + }, 0); return () => { cancelled = true; @@ -255,8 +278,12 @@ export function AppShell({ const cacheUserId = currentUserId; let cancelled = false; - // Fresh cached timer data is enough; don't refetch on every navigation. - if (getCachedRemoteTimerState(cacheUserId)) { + // Any recent timer snapshot is enough to paint Home: the dashboard shows + // it straight away and refetches behind it, so don't hold the splash. + if ( + getCachedRemoteTimerState(cacheUserId) || + getStaleRemoteTimerState(cacheUserId) + ) { revealApp(); return () => window.cancelAnimationFrame(readyFrame); } diff --git a/src/components/timer/timer-dashboard.tsx b/src/components/timer/timer-dashboard.tsx index ff8bb2f..ff59ebc 100644 --- a/src/components/timer/timer-dashboard.tsx +++ b/src/components/timer/timer-dashboard.tsx @@ -29,6 +29,7 @@ import { cacheRemoteTimerState, dedupeRemoteRequest, getCachedRemoteTimerState, + getStaleRemoteTimerState, subscribeToRemoteTableChanges, } from "@/lib/client-cache"; import { @@ -229,7 +230,9 @@ export function TimerDashboard({ let cancelled = false; async function loadInitialState() { - const cachedRemoteState = getCachedRemoteTimerState(userId); + // A stale snapshot paints immediately; the fetch below replaces it. + const cachedRemoteState = + getCachedRemoteTimerState(userId) ?? getStaleRemoteTimerState(userId); if (cachedRemoteState) { applyRemoteTimerState(cachedRemoteState); diff --git a/src/lib/client-cache.test.ts b/src/lib/client-cache.test.ts index 32299ab..f5ac144 100644 --- a/src/lib/client-cache.test.ts +++ b/src/lib/client-cache.test.ts @@ -7,6 +7,7 @@ import type { } from "./supabase/app-data/types"; import { REMOTE_CACHE_MAX_AGE_MS, + REMOTE_STALE_CACHE_MAX_AGE_MS, cacheRemoteFriendsSnapshot, cacheRemoteGroupsSnapshot, cacheRemoteTimerState, @@ -17,6 +18,7 @@ import { getCachedRemoteGroupsSnapshot, getCachedRemoteTimerState, getCachedRemoteUnitState, + getStaleRemoteTimerState, invalidateRemoteCachesForTable, subscribeToRemoteTableChanges, } from "./client-cache"; @@ -72,17 +74,44 @@ afterEach(() => { }); describe("remote client cache freshness", () => { - it("expires cached data and removes it from persistent storage", () => { + it("stops serving expired data but keeps it stored for the stale window", () => { cacheRemoteFriendsSnapshot(friendsSnapshot); expect(getCachedRemoteFriendsSnapshot("viewer")).toEqual(friendsSnapshot); expect(storage.size).toBe(1); vi.advanceTimersByTime(REMOTE_CACHE_MAX_AGE_MS + 1); + expect(getCachedRemoteFriendsSnapshot("viewer")).toBeNull(); + expect(storage.size).toBe(1); + + vi.advanceTimersByTime(REMOTE_STALE_CACHE_MAX_AGE_MS); + expect(getCachedRemoteFriendsSnapshot("viewer")).toBeNull(); expect(storage.size).toBe(0); }); + it("serves stale timer state for painting until the stale window ends", () => { + cacheRemoteTimerState(timerState); + vi.advanceTimersByTime(REMOTE_CACHE_MAX_AGE_MS + 1); + + expect(getCachedRemoteTimerState("viewer")).toBeNull(); + expect(getStaleRemoteTimerState("viewer")).toEqual(timerState); + expect(getStaleRemoteTimerState("someone-else")).toBeNull(); + + vi.advanceTimersByTime(REMOTE_STALE_CACHE_MAX_AGE_MS); + + expect(getStaleRemoteTimerState("viewer")).toBeNull(); + }); + + it("drops stale timer state once a table change invalidates it", () => { + cacheRemoteTimerState(timerState); + vi.advanceTimersByTime(REMOTE_CACHE_MAX_AGE_MS + 1); + + invalidateRemoteCachesForTable("study_sessions"); + + expect(getStaleRemoteTimerState("viewer")).toBeNull(); + }); + it("invalidates only the caches affected by a changed table", () => { cacheRemoteTimerState(timerState); cacheRemoteFriendsSnapshot(friendsSnapshot); diff --git a/src/lib/client-cache.ts b/src/lib/client-cache.ts index 386ffdf..b29d4cd 100644 --- a/src/lib/client-cache.ts +++ b/src/lib/client-cache.ts @@ -8,6 +8,9 @@ import type { } from "@/lib/supabase/app-data/types"; export const REMOTE_CACHE_MAX_AGE_MS = 2 * 60 * 1000; +// Past the fresh window, data is still good enough to paint on launch while +// a refetch runs, so the app opens straight onto the last-known screen. +export const REMOTE_STALE_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000; const REMOTE_TIMER_CACHE_KEY = "mac-study-remote-timer-cache-v3"; const REMOTE_FRIENDS_CACHE_KEY = "mac-study-remote-friends-cache-v3"; @@ -46,6 +49,17 @@ export function getCachedRemoteTimerState(userId: string | null) { return value; } +/** Last-known timer state up to a day old, for painting before a refetch. */ +export function getStaleRemoteTimerState(userId: string | null) { + if (!userId) return null; + timerCache ??= readCache(REMOTE_TIMER_CACHE_KEY); + if (!timerCache || timerCache.userId !== userId) return null; + if (Date.now() - timerCache.cachedAt > REMOTE_STALE_CACHE_MAX_AGE_MS) { + return null; + } + return timerCache.value; +} + export function cacheRemoteTimerState(state: RemoteTimerState) { timerCache = createEnvelope(state, state.currentUserId); writeCache(REMOTE_TIMER_CACHE_KEY, timerCache); @@ -215,14 +229,16 @@ function getFreshValue( ): T | null { if (!envelope) return null; - if ( - envelope.userId !== userId || - Date.now() - envelope.cachedAt > REMOTE_CACHE_MAX_AGE_MS - ) { + const age = Date.now() - envelope.cachedAt; + + if (envelope.userId !== userId || age > REMOTE_STALE_CACHE_MAX_AGE_MS) { removeCache(key); return null; } + // Expired but within the stale window: keep it stored for stale readers. + if (age > REMOTE_CACHE_MAX_AGE_MS) return null; + return envelope.value; } From 8ee24f99d71c024273844acfe98a01dc19dca807 Mon Sep 17 00:00:00 2001 From: Steven Date: Fri, 9 Oct 2026 06:51:35 +1100 Subject: [PATCH 7/7] remove scroll bar mobile --- src/app/globals.css | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/app/globals.css b/src/app/globals.css index b3b1020..802e068 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -151,6 +151,21 @@ body { } } +/* Hide scroll indicators on mobile; scrolling itself still works. */ +@media (max-width: 63.999rem) { + html, + body, + * { + scrollbar-width: none; + } + + ::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } +} + @media (min-width: 64rem) { .mac-desktop-shell { background: var(--color-background);