diff --git a/frontend/src/components/ui/bar-tint-reset.ts b/frontend/src/components/ui/bar-tint-reset.ts new file mode 100644 index 00000000..dc25462e --- /dev/null +++ b/frontend/src/components/ui/bar-tint-reset.ts @@ -0,0 +1,66 @@ +import * as React from 'react'; + +// Tested on iPhone (Safari 26, Low Power Mode too). Bottom edge: in the app +// 6px works and 4px doesn't (4px was enough on a bare test page, 2px never). +// Top edge: 16px works, 6px doesn't. One painted frame is enough; a 16ms +// timer isn't, because it can fire before a paint (Low Power Mode drops the +// refresh rate), so each strip lives for one frame. + +/** + * Shows a page-coloured strip on one edge for a single painted frame. At most + * one strip per edge at a time: every useDarkTheme consumer applies a theme + * change, and one strip is enough. + */ +function flashStrip(edge: 'top' | 'bottom', className: string): void { + const slot = `${edge}-tint-reset`; + if (document.querySelector(`[data-slot="${slot}"]`)) return; + const strip = document.createElement('div'); + strip.dataset.slot = slot; + strip.setAttribute('aria-hidden', 'true'); + strip.className = className; + document.body.appendChild(strip); + // The first frame paints the strip; remove it on the next. + requestAnimationFrame(() => requestAnimationFrame(() => strip.remove())); +} + +/** + * Makes iOS Safari re-colour its bottom bar from the page. + * + * Safari 26 takes the bottom bar's colour from a fixed element that appears on + * the bottom edge (a bottom sheet turns it `bg-card`) and doesn't change it + * back when that element goes away, or when the page changes colour (a theme + * switch). A page-coloured strip that appears briefly on the edge makes Safari + * sample again. The strip is 6px, no taller than the gap under the composer, + * so it covers only background of its own colour. It has to be on top: a + * strip behind the content, transparent or at `opacity-0` isn't sampled. + */ +function resetBottomTint(): void { + flashStrip( + 'bottom', + 'bg-background pointer-events-none fixed inset-x-0 bottom-0 z-50 h-1.5', + ); +} + +/** + * Makes iOS Safari re-colour its top bar from the page, the same way as + * `resetBottomTint`. Needed after a theme switch, when the top bar keeps the + * old theme's colour. The strip is 16px, inside the phone top bar, which has + * the same background. + */ +function resetTopTint(): void { + flashStrip( + 'top', + 'bg-background pointer-events-none fixed inset-x-0 top-0 z-50 h-4', + ); +} + +/** + * Renders nothing; resets Safari's bottom bar when it unmounts. Put it inside + * a bottom sheet's Radix Content, which unmounts after its exit animation. + */ +function BottomTintReset(): null { + React.useEffect(() => resetBottomTint, []); + return null; +} + +export { BottomTintReset, resetBottomTint, resetTopTint }; diff --git a/frontend/src/components/ui/bottom-tint-reset.ts b/frontend/src/components/ui/bottom-tint-reset.ts deleted file mode 100644 index 58ac01f7..00000000 --- a/frontend/src/components/ui/bottom-tint-reset.ts +++ /dev/null @@ -1,39 +0,0 @@ -import * as React from 'react'; - -// Tested on iPhone (Safari 26, Low Power Mode too): in the app 6px works and -// 4px doesn't (4px was enough on a bare test page, 2px never). One painted -// frame is enough; a 16ms timer isn't, because it can fire before a paint -// (Low Power Mode drops the refresh rate), so the strip lives for one frame. - -/** - * Makes iOS Safari re-colour its bottom bar from the page. - * - * Safari 26 takes the bottom bar's colour from a fixed element that appears on - * the bottom edge (a bottom sheet turns it `bg-card`) and doesn't change it - * back when that element goes away, or when an existing element changes - * colour. A page-coloured strip that appears briefly on the edge makes Safari - * sample again. The strip is 6px, no taller than the gap under the composer, - * so it covers only background of its own colour. It has to be on top: a - * strip behind the content, transparent or at `opacity-0` isn't sampled. - */ -function resetBottomTint(): void { - const strip = document.createElement('div'); - strip.dataset.slot = 'bottom-tint-reset'; - strip.setAttribute('aria-hidden', 'true'); - strip.className = - 'bg-background pointer-events-none fixed inset-x-0 bottom-0 z-50 h-1.5'; - document.body.appendChild(strip); - // The first frame paints the strip; remove it on the next. - requestAnimationFrame(() => requestAnimationFrame(() => strip.remove())); -} - -/** - * Renders nothing; resets Safari's bottom bar when it unmounts. Put it inside - * a bottom sheet's Radix Content, which unmounts after its exit animation. - */ -function BottomTintReset(): null { - React.useEffect(() => resetBottomTint, []); - return null; -} - -export { BottomTintReset, resetBottomTint }; diff --git a/frontend/src/components/ui/modal.tsx b/frontend/src/components/ui/modal.tsx index 6500d4f1..5d2b818c 100644 --- a/frontend/src/components/ui/modal.tsx +++ b/frontend/src/components/ui/modal.tsx @@ -2,7 +2,7 @@ import { XIcon } from 'lucide-react'; import { Dialog as DialogPrimitive, VisuallyHidden } from 'radix-ui'; import * as React from 'react'; -import { BottomTintReset } from '@/components/ui/bottom-tint-reset'; +import { BottomTintReset } from '@/components/ui/bar-tint-reset'; import { Button } from '@/components/ui/button'; import { Dialog, diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx index 98567b66..eda968bc 100644 --- a/frontend/src/components/ui/sheet.tsx +++ b/frontend/src/components/ui/sheet.tsx @@ -3,7 +3,7 @@ import { Dialog as SheetPrimitive } from 'radix-ui'; import { XIcon } from 'lucide-react'; import { cva } from 'class-variance-authority'; -import { BottomTintReset } from '@/components/ui/bottom-tint-reset'; +import { BottomTintReset } from '@/components/ui/bar-tint-reset'; import { Button } from '@/components/ui/button'; import { cn, overlayScrim } from '@/lib/utils'; import { useFocusReturn } from '@/components/ui/use-focus-return'; diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index eab5fabe..04911355 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -1,5 +1,7 @@ import { useCallback, useEffect, useRef, useState, RefObject } from 'react'; +import { resetBottomTint, resetTopTint } from '@/components/ui/bar-tint-reset'; + export function useOutsideAlerter( ref: RefObject, handler: () => void, @@ -118,6 +120,8 @@ export function useDarkTheme() { const [isDarkTheme, setIsDarkTheme] = useState(getInitialTheme()); const [componentMounted, setComponentMounted] = useState(false); + // The theme applied by this hook's last run; null before the first one. + const appliedTheme = useRef(null); useEffect(() => { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); @@ -157,6 +161,13 @@ export function useDarkTheme() { m.removeAttribute('media'); m.setAttribute('content', color); }); + // Safari keeps both bars in the old theme's colour until a fixed element + // appears on their edge. Only on a change: on load they already match. + if (appliedTheme.current !== null && appliedTheme.current !== isDarkTheme) { + resetBottomTint(); + resetTopTint(); + } + appliedTheme.current = isDarkTheme; setComponentMounted(true); }, [isDarkTheme]); diff --git a/frontend/src/hooks/useDarkTheme.test.tsx b/frontend/src/hooks/useDarkTheme.test.tsx index 0866b625..3d482fe8 100644 --- a/frontend/src/hooks/useDarkTheme.test.tsx +++ b/frontend/src/hooks/useDarkTheme.test.tsx @@ -60,4 +60,39 @@ describe('useDarkTheme', () => { await act(async () => root.unmount()); }); + + it('resets both Safari bars once per theme change, not on load', async () => { + const strips = (edge: 'top' | 'bottom') => + document.querySelectorAll(`[data-slot="${edge}-tint-reset"]`).length; + let toggle: (() => void) | undefined; + + function Settings() { + const [, toggleTheme] = useDarkTheme(); + toggle = toggleTheme; + return null; + } + function Logo() { + useDarkTheme(); + return null; + } + + const root = createRoot(container); + await act(async () => { + root.render( + <> + + + , + ); + }); + expect(strips('bottom')).toBe(0); + expect(strips('top')).toBe(0); + + // Both consumers apply the new theme; one strip is enough. + await act(async () => toggle?.()); + expect(strips('bottom')).toBe(1); + expect(strips('top')).toBe(1); + + await act(async () => root.unmount()); + }); });