theme switch fox

This commit is contained in:
Pavel committed 2026-09-27 12:54:51 +04:00
1 parent 4e03bc0388
commit a63234e7d9
6 files changed
+114 -41

No files matched your search

@@ -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 };
@@ -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 };
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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';
+11
View File
@@ -1,5 +1,7 @@
import { useCallback, useEffect, useRef, useState, RefObject } from 'react';
import { resetBottomTint, resetTopTint } from '@/components/ui/bar-tint-reset';
export function useOutsideAlerter<T extends HTMLElement>(
ref: RefObject<T | null>,
handler: () => void,
@@ -118,6 +120,8 @@ export function useDarkTheme() {
const [isDarkTheme, setIsDarkTheme] = useState<boolean>(getInitialTheme());
const [componentMounted, setComponentMounted] = useState(false);
// The theme applied by this hook's last run; null before the first one.
const appliedTheme = useRef<boolean | null>(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]);
+35
View File
@@ -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(
<>
<Settings />
<Logo />
</>,
);
});
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());
});
});