diff --git a/package.json b/package.json index 0a47830..cda0831 100644 --- a/package.json +++ b/package.json @@ -87,7 +87,7 @@ "test:settings-search": "node --experimental-strip-types --test src/components/search/settingsSearchRoutes.test.mts", "test:now-playing-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/player/nowPlayingLayout.test.mts src/components/player/nowPlayingPreferences.test.mts src/components/player/nowPlayingDismiss.test.mts src/playback/playbackTargetPresentation.test.mts", "test:memory-lifecycle": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/components/delayedPresence.test.mts scripts/android-memory-profile.test.mjs", - "test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/navigation/shellLayout.test.mts src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts src/navigation/tabStackReset.test.mts src/navigation/tabReselect.test.mts src/navigation/scrollToTopBehavior.test.mts src/library/libraryWindowTop.test.mts src/components/selectionSlideMath.test.mts", + "test:ui-navigation": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/navigation/shellLayout.test.mts src/stores/playerPresence.test.mts src/navigation/tabsAnchor.test.mts src/navigation/libraryDetailBack.test.mts src/navigation/homeLibraryNavigation.test.mts src/navigation/tabTransition.test.mts src/navigation/statsTabState.test.mts src/navigation/tabStackReset.test.mts src/navigation/tabReselect.test.mts src/navigation/scrollToTopBehavior.test.mts src/library/libraryWindowTop.test.mts src/components/selectionSlideMath.test.mts src/components/topFadeMath.test.mts", "test:library-layout": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/library/libraryLayout.test.mts src/components/library/detailHeroLayout.test.mts", "test:haptics": "node --experimental-strip-types --experimental-specifier-resolution=node --test src/lib/haptics.test.mts", "test:app-dialog": "node --experimental-strip-types --test src/components/dialogs/dialogQueue.test.mts", diff --git a/src/app/(tabs)/index.tsx b/src/app/(tabs)/index.tsx index d57c138..02f16ac 100644 --- a/src/app/(tabs)/index.tsx +++ b/src/app/(tabs)/index.tsx @@ -15,6 +15,7 @@ import { Image } from 'expo-image'; import { Ionicons } from '@expo/vector-icons'; import { useFocusEffect, useRouter } from 'expo-router'; import { Screen } from '@/components/Screen'; +import { useTopBleedInset } from '@/components/screenTopBleed'; import { Text } from '@/components/Text'; import { AstraLogo } from '@/components/AstraLogo'; import { TrackRow } from '@/components/library/TrackRow'; @@ -580,6 +581,11 @@ function HomeBand({ export default function HomeScreen() { const sceneBottomInset = useSceneBottomInset(); + // `` hands this back to the content: the scroll frame runs + // to the top of the window so the masthead can travel behind the status bar, + // and the content container re-pays the inset so it still starts below it. + // Zero in a window too short to bleed, where `Screen` keeps paying it. + const topBleed = useTopBleedInset(); const styles = useStyles(); const router = useRouter(); const openLibrary = useHomeLibraryNavigation(); @@ -841,13 +847,13 @@ export default function HomeScreen() { ) : null; return ( - + ` stopped paying it, and + // zero in a window too short to bleed — see `screenTopBleed`. + const topBleed = useTopBleedInset(); const styles = useStyles(); const colors = useColors(); const router = useRouter(); @@ -89,8 +93,8 @@ export default function SettingsScreen() { : desktopRemoteSubtitle; return ( - - + + {/* The rail names this destination itself; repeating it would just spend a landscape window's scarce height on the same word. */} {showScreenTitle ? ( diff --git a/src/components/Screen.tsx b/src/components/Screen.tsx index 4606e60..29ee1b5 100644 --- a/src/components/Screen.tsx +++ b/src/components/Screen.tsx @@ -5,12 +5,26 @@ import { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useShellRailPresent } from '@/navigation/shellRailContext'; import { useShellLayout } from '@/navigation/useShellLayout'; +import { ScreenTopBleedContext, useTopFadeBand } from '@/components/screenTopBleed'; +import { TopFadeScrim } from '@/components/TopFadeScrim'; import { spacing } from '@/theme'; import { createThemedStyles } from '@/theme/themed'; interface ScreenProps extends ViewProps { /** Apply default horizontal padding. */ padded?: boolean; + /** + * Let content travel behind the status bar instead of stopping below it. + * + * Only for screens whose scroll surface starts at the very top — Home and + * Settings. On a screen with fixed chrome above its list (Library's switcher, + * a back row) this would just push the chrome under the cutout, since the + * list below it can never reach the status bar anyway. + * + * The scroll surface must re-pay the inset as `contentContainerStyle` + * padding; `useScreenTopBleed` is how it learns the amount. + */ + bleedTop?: boolean; } /** @@ -24,29 +38,47 @@ interface ScreenProps extends ViewProps { * * Insets sit on the root and the content gutter on the inner view, so the * gutter is measured from the safe edge rather than from the cutout. + * + * The top inset is the exception a screen can buy out of. Paying it here is + * what makes content stop dead at the status bar — the frame ends there, so a + * heading scrolling up is clipped mid-glyph. `bleedTop` hands the inset to the + * content instead, and `TopFadeScrim` washes the strip so the crossing reads as + * a dissolve rather than a cut. + * + * The library detail screens bleed too, but by overriding `paddingTop` in their + * own style rather than through this prop: they bleed so *artwork* runs behind + * the status bar, and `CollapsingDetail` brings its own scrim and collapsed + * bar. A `bgPrimary` wash on top of that would mute the art it exists to show. */ -export function Screen({ children, style, padded = true, ...rest }: ScreenProps) { +export function Screen({ children, style, padded = true, bleedTop = false, ...rest }: ScreenProps) { const styles = useStyles(); const insets = useSafeAreaInsets(); const railPresent = useShellRailPresent(); // The dock claims the trailing edge, so it pays that inset — the mirror of // the rail taking the leading one. `sceneInsetRight` is the shell's answer. const sceneInsetRight = useShellLayout().sceneInsetRight; + // A window too short to spend room on the fade doesn't bleed at all: content + // stops below the bar as it always did, rather than bleeding into a bare clip. + const fade = useTopFadeBand(); + const topBleed = bleedTop && fade ? insets.top : 0; return ( - - {children} - + + + {children} + {bleedTop && fade ? : null} + + ); } diff --git a/src/components/TopFadeScrim.tsx b/src/components/TopFadeScrim.tsx new file mode 100644 index 0000000..156f389 --- /dev/null +++ b/src/components/TopFadeScrim.tsx @@ -0,0 +1,65 @@ +import { useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; +import { Canvas, Fill, LinearGradient, vec } from '@shopify/react-native-skia'; +import { type TopFadeBand } from '@/components/topFadeMath'; +import { useColors } from '@/theme/themed'; + +/** + * The gradient that lands scrolling content into the status bar. + * + * The mirror of `MiniPlayerScrim` at the other end of the screen, and it has + * the same job: soften the seam where content runs into chrome. On a screen + * that bleeds under the status bar the scroll frame reaches y=0, so a heading + * riding up is clipped mid-glyph against the top of the window. This washes it + * to the screen background over the strip instead, so it ends rather than + * stops. + * + * Nothing here can affect layout — it is absolutely positioned and + * `pointerEvents="none"`. On a screen already sitting on `bgPrimary` with + * nothing scrolled up there it is invisible, which is the intended resting + * state: this should be doing its work without being noticed. + * + * The ramp itself lives in `topFadeMath` — it depends on the device's status + * bar height, so it is worth testing away from React. + */ + +function withAlpha(hex: string, alpha: number): string { + return `${hex}${Math.round(alpha * 255).toString(16).padStart(2, '0')}`; +} + +export function TopFadeScrim({ band }: { band: TopFadeBand }) { + const colors = useColors(); + + // `bgPrimary` is guaranteed 6-digit hex by the palette invariant, so the + // 8-digit alpha suffix is safe (same idiom as MiniPlayerScrim). + const gradient = useMemo(() => ({ + colors: band.stops.map((stop) => withAlpha(colors.bgPrimary, stop.alpha)), + positions: band.stops.map((stop) => stop.at), + }), [band, colors.bgPrimary]); + + return ( + + + + + + + + ); +} + +const styles = StyleSheet.create({ + band: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + }, +}); + +export default TopFadeScrim; diff --git a/src/components/screenTopBleed.ts b/src/components/screenTopBleed.ts new file mode 100644 index 0000000..ca519ec --- /dev/null +++ b/src/components/screenTopBleed.ts @@ -0,0 +1,56 @@ +import { createContext, useContext, useMemo } from 'react'; +import { useWindowDimensions } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { topFadeBand, type TopFadeBand } from '@/components/topFadeMath'; + +/** + * Whether this window lets content travel behind the status bar, and what that + * costs whoever has to pay for it. + * + * Three parties need the same answer and none of them can see each other: + * `Screen`, which stops paying its top inset; the scroll surface inside it, + * which starts; and `TopFadeScrim`, which draws the fade that makes the + * crossing legible. They agree by all deriving from `topFadeBand` rather than + * by passing the answer around — a screen that decided for itself would + * double-pay the inset the moment the window got too short to bleed. + */ + +/** + * The top safe-area inset the surrounding `Screen` did *not* pay, and which its + * content therefore owes. + * + * For descendants that pin themselves to the top of a screen without knowing + * which screen they are on — `PullSearchGesture`'s chip is the live case, and + * it is shared between a screen that bleeds (Home) and one that doesn't + * (Library). Zero by default, so everything that has not opted in is untouched. + */ +export const ScreenTopBleedContext = createContext(0); + +/** Zero on a screen whose container pays its own top inset. */ +export function useScreenTopBleed(): number { + return useContext(ScreenTopBleedContext); +} + +/** + * The fade this window gets, or null if it should not bleed at all. + * + * Asked *before* the inset is dropped, because bleeding without a fade is the + * bare clip the whole feature exists to remove. + */ +export function useTopFadeBand(): TopFadeBand | null { + const insetTop = useSafeAreaInsets().top; + const windowHeight = useWindowDimensions().height; + return useMemo(() => topFadeBand(insetTop, windowHeight), [insetTop, windowHeight]); +} + +/** + * What a bleeding screen's scroll surface must re-pay as `paddingTop`. + * + * Zero when the window declined to bleed, which is what keeps a screen that + * passes `bleedTop` correct in landscape: `Screen` goes on paying the inset and + * the content must not pay it again. + */ +export function useTopBleedInset(): number { + const insetTop = useSafeAreaInsets().top; + return useTopFadeBand() ? insetTop : 0; +} diff --git a/src/components/search/PullSearchGesture.tsx b/src/components/search/PullSearchGesture.tsx index 90520f6..a5fcd40 100644 --- a/src/components/search/PullSearchGesture.tsx +++ b/src/components/search/PullSearchGesture.tsx @@ -31,6 +31,7 @@ import { useSharedValue } from 'react-native-reanimated'; import { Text } from '@/components/Text'; +import { useScreenTopBleed } from '@/components/screenTopBleed'; import { radius, spacing, @@ -133,6 +134,7 @@ export function PullSearchGesture({ }) { const styles = useStyles(); const colors = useColors(); + const topBleed = useScreenTopBleed(); const [pull, setPull] = useState(0); const [armed, setArmed] = useState(false); const [dragging, setDragging] = useState(false); @@ -242,6 +244,10 @@ export function PullSearchGesture({ const progress = clamp(pull / OPEN_THRESHOLD, 0, 1); const indicatorStyle = { + // The chip pins to the top of whatever screen it's on, and on a screen that + // bleeds under the status bar (Home) that top is the top of the *window*. + // Zero on Library, which doesn't bleed, so nothing moves there. + top: topBleed + spacing.xs, opacity: pull <= 0 ? 0 : Math.max(0.72, progress), transform: [ { translateY: -18 + (clamp(pull, 0, MAX_PULL) / MAX_PULL) * 38 }, @@ -273,8 +279,9 @@ const useStyles = createThemedStyles((colors) => ({ flex: 1, }, indicator: { + // `top` is applied inline — it depends on whether the screen bleeds under + // the status bar. position: 'absolute', - top: spacing.xs, alignSelf: 'center', zIndex: 20, flexDirection: 'row', diff --git a/src/components/topFadeMath.test.mts b/src/components/topFadeMath.test.mts new file mode 100644 index 0000000..22f50c8 --- /dev/null +++ b/src/components/topFadeMath.test.mts @@ -0,0 +1,230 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { topFadeBand } from './topFadeMath.ts'; + +/** A tall punch-hole phone (S22-ish) and a short-strip one, in dp. */ +const TALL_INSET = 48; +const SHORT_INSET = 24; + +/** Status bar insets across the real Android range, in dp. */ +const REAL_INSETS = [28, 32, 36, 40, 44, 48, 52]; + +/** Phone in portrait, phone in landscape, tablet — window heights in dp. */ +const TALL_WINDOW = 780; +const SHORT_WINDOW = 360; +const TABLET_WINDOW = 1180; + +/** Alpha at an arbitrary depth into the band, the way Skia will interpolate it. */ +function alphaAt(inset: number, depth: number): number { + const band = topFadeBand(inset, TALL_WINDOW); + assert.ok(band); + const at = depth / band.height; + const next = band.stops.findIndex((stop) => stop.at >= at); + if (next <= 0) return band.stops[0]!.alpha; + const from = band.stops[next - 1]!; + const to = band.stops[next]!; + return from.alpha + ((at - from.at) / (to.at - from.at)) * (to.alpha - from.alpha); +} + +/** Ratio of the largest value to the smallest — how much a device changes it. */ +function spread(values: number[]): number { + return Math.max(...values) / Math.min(...values); +} + +/** Slope in alpha-per-dp between each adjacent pair of stops. */ +function slopes(inset: number): number[] { + const band = topFadeBand(inset, TALL_WINDOW); + assert.ok(band); + const out: number[] = []; + for (let i = 1; i < band.stops.length; i += 1) { + const from = band.stops[i - 1]!; + const to = band.stops[i]!; + out.push((from.alpha - to.alpha) / ((to.at - from.at) * band.height)); + } + return out; +} + +test('does not mount a band when there is no strip to cover', () => { + // No top inset means content is not disappearing into anything, so a + // zero-height scrim would be pure cost. + assert.equal(topFadeBand(0, TALL_WINDOW), null); + assert.equal(topFadeBand(-1, TALL_WINDOW), null); +}); + +test('refuses a window with no height to spend on the fade', () => { + // Android keeps the status bar up in landscape — `insets.top` is not zero + // there, which `shellLayout` already budgets for — so without this the band + // would wash a quarter of a phone's landscape window. Refusing beats + // compressing: a squeezed curve re-steepens into the hard line the shape + // exists to avoid, and the caller answers by not bleeding at all. + assert.equal(topFadeBand(TALL_INSET, SHORT_WINDOW), null); + assert.equal(topFadeBand(SHORT_INSET, SHORT_WINDOW), null); +}); + +test('bleeds on a phone in portrait and on a tablet either way', () => { + // The cutoff has to fall between a phone's two orientations, not inside the + // set of devices that should all behave the same. + assert.ok(topFadeBand(TALL_INSET, TALL_WINDOW)); + assert.ok(topFadeBand(TALL_INSET, TABLET_WINDOW)); + // A tablet in landscape is still tall enough to be worth it. + assert.ok(topFadeBand(SHORT_INSET, 800)); +}); + +test('gives a window that does bleed the same band whatever its height', () => { + // Window height is a gate, not a scale: once a window qualifies, the fade is + // the same physical size on a phone and on a tablet. + const phone = topFadeBand(TALL_INSET, TALL_WINDOW); + const tablet = topFadeBand(TALL_INSET, TABLET_WINDOW); + assert.ok(phone); + assert.ok(tablet); + assert.equal(phone.height, tablet.height); + assert.deepEqual(phone.stops, tablet.stops); +}); + +test('runs well past the status bar', () => { + // The first attempt ended ~12dp below the bar and still read as content + // meeting a line — the dissolve needs length to happen over. + const band = topFadeBand(TALL_INSET, TALL_WINDOW); + assert.ok(band); + assert.ok(band.height - TALL_INSET >= TALL_INSET); +}); + +test('anchors the curve to the status bar edge, not to a fixed height', () => { + // The whole point of computing this: the strip is a different height on every + // device, and the solid part has to cover exactly it, whatever it measures. + for (const inset of [SHORT_INSET, TALL_INSET]) { + const band = topFadeBand(inset, TALL_WINDOW); + assert.ok(band); + assert.equal(band.barAt, inset / band.height); + } +}); + +test('washes to the background at the top and to nothing at the bottom', () => { + const band = topFadeBand(TALL_INSET, TALL_WINDOW); + assert.ok(band); + const first = band.stops[0]!; + const last = band.stops[band.stops.length - 1]!; + + assert.equal(first.at, 0); + assert.equal(last.at, 1); + // Short of fully opaque on purpose — a whisper of content behind the clock is + // what makes it read as passing behind rather than stopping at a slab. + assert.ok(first.alpha > 0.85 && first.alpha < 1); + assert.equal(last.alpha, 0); +}); + +test('fades in one direction only, with stops Skia will accept', () => { + // Skia requires ascending positions; a non-monotonic alpha would read as a + // band rather than a dissolve, and the monotone limiter exists to guarantee + // the cubic never overshoots into one. + for (const inset of REAL_INSETS) { + const band = topFadeBand(inset, TALL_WINDOW); + assert.ok(band); + for (let i = 1; i < band.stops.length; i += 1) { + const previous = band.stops[i - 1]!; + const current = band.stops[i]!; + assert.ok(current.at > previous.at, `positions ascend at inset ${inset}`); + assert.ok(current.alpha <= previous.alpha, `alpha never rises at inset ${inset}`); + assert.ok(current.alpha >= 0 && current.alpha <= 1, `alpha stays in range at ${inset}`); + } + } +}); + +test('has no crease anywhere along the ramp', () => { + // The regression this pins, and the reason the curve is a sampled cubic + // rather than a handful of stops: straight lines between control points meet + // at a derivative discontinuity, and the eye reads that as a Mach band — a + // visible "gradient line" across the middle of the fade. An earlier pass + // kinked by ~0.026 alpha/dp at a single point, which was plainly visible. + for (const inset of REAL_INSETS) { + const ramp = slopes(inset); + for (let i = 1; i < ramp.length; i += 1) { + assert.ok( + Math.abs(ramp[i]! - ramp[i - 1]!) < 0.01, + `kink of ${Math.abs(ramp[i]! - ramp[i - 1]!).toFixed(4)} alpha/dp at inset ${inset}` + ); + } + } +}); + +test('never falls faster than the eye reads as an edge', () => { + // Smooth is not enough on its own — a steep enough ramp reads as a line even + // with no crease in it. This is what `SOLID_THROUGH` is really buying. + for (const inset of REAL_INSETS) { + assert.ok(Math.max(...slopes(inset)) < 0.025, `peak slope at inset ${inset}`); + } +}); + +test('keeps content behind the clock past the point of being legible', () => { + // Without a blur to destroy the letterforms, readable text behind the status + // bar reads as two things overlapping rather than as depth. Solid through the + // icon row's centre, and still a ghost at its lower edge. + for (const inset of REAL_INSETS) { + assert.ok(alphaAt(inset, inset * 0.5) >= 0.85, `clock centre at inset ${inset}`); + assert.ok(alphaAt(inset, inset * 0.65) >= 0.75, `icon lower edge at inset ${inset}`); + } +}); + +test('is still visibly fading where content clears the bar', () => { + // Half gone at the bar's bottom edge. A wash that finished inside the strip + // would let glyphs re-sharpen just before they disappeared. + assert.ok(alphaAt(TALL_INSET, TALL_INSET) > 0.35); + assert.ok(alphaAt(TALL_INSET, TALL_INSET) < 0.65); +}); + +test('lays a deliberate light wash over content resting below the bar', () => { + // A dissolve this long cannot also leave the top of a resting heading + // untouched, and that is the accepted trade: a soft vignette the content + // emerges from, not a dimmed strip. Pinned so it can't drift into one. + const restingTitle = alphaAt(TALL_INSET, TALL_INSET + 24); + assert.ok(restingTitle > 0.05 && restingTitle < 0.2, `resting wash ${restingTitle.toFixed(3)}`); +}); + +test('spends its last fifth effectively invisible', () => { + const band = topFadeBand(TALL_INSET, TALL_WINDOW); + assert.ok(band); + assert.ok(alphaAt(TALL_INSET, band.height * 0.8) <= 0.06); + assert.ok(alphaAt(TALL_INSET, band.height - 1) < 0.01); +}); + +test('looks the same on every phone, in both the ways that show', () => { + // Two things vary with the strip and both are visible, so both are pinned. + // + // The slope is how gradual the dissolve looks while scrolling. Hanging the + // interior points off the bar's bottom edge made a segment proportional to + // the strip and doubled the slope on a short one — a hard line again. + // + // The resting wash is how dimmed a heading looks when nothing is scrolled. + // Content sits at `insets.top` plus a fixed margin on every device, so a + // fixed-length ramp anchored only at its top slid content along the curve: + // 23% washed on a short-strip phone against 13% on a tall-strip one. + // + // Anchoring both ends to the strip and placing the interior points along the + // span between them is what holds both within a fraction of each other. + const peaks = REAL_INSETS.map((inset) => Math.max(...slopes(inset))); + const resting = REAL_INSETS.map((inset) => alphaAt(inset, inset + 24)); + + assert.ok(spread(peaks) < 1.2, `peak slope spread ${spread(peaks).toFixed(2)}`); + + // Measured as a difference, not a ratio. What the eye compares between two + // phones is how much darker one heading looks than the other, and a ratio + // exaggerates that at small alphas — 10% against 15% is a fifth of a stop, + // not "50% worse". Anchoring only the top of the ramp gave 13% against 23%, + // which is the gap that showed. + const restingGap = Math.max(...resting) - Math.min(...resting); + assert.ok(restingGap < 0.06, `resting wash gap ${restingGap.toFixed(3)}`); + + // And bounded in absolute terms on the worst device, not just the best. + assert.ok(Math.max(...peaks) < 0.025, `worst peak slope ${Math.max(...peaks).toFixed(4)}`); + assert.ok(Math.max(...resting) < 0.17, `worst resting wash ${Math.max(...resting).toFixed(3)}`); +}); + +test('puts the same landmarks at close to the same alpha on every phone', () => { + // The bar's bottom edge is where a heading finishes crossing into the strip. + // It cannot be identical everywhere — the strip is the thing that varies — + // but it must not swing far enough that one phone hides content the next one + // shows. + const barEdge = REAL_INSETS.map((inset) => alphaAt(inset, inset)); + assert.ok(Math.min(...barEdge) > 0.4, `weakest bar edge ${Math.min(...barEdge).toFixed(2)}`); + assert.ok(Math.max(...barEdge) < 0.7, `strongest bar edge ${Math.max(...barEdge).toFixed(2)}`); +}); diff --git a/src/components/topFadeMath.ts b/src/components/topFadeMath.ts new file mode 100644 index 0000000..0ab127d --- /dev/null +++ b/src/components/topFadeMath.ts @@ -0,0 +1,253 @@ +/** + * The gradient band that dissolves content on its way behind the status bar. + * + * A screen that bleeds under the status bar has its scroll frame reach y=0, so + * a heading riding up gets clipped mid-glyph at the top of the window. Clipping + * is what "cut off in the void" was: the letters do not end, they stop. + * + * The band's job is to make them *end*, and three things have to be true at + * once: + * + * 1. Nothing legible survives where the clock and icons are. Without a blur to + * destroy the letterforms, readable text up there reads as two things + * overlapping rather than as depth — so the strip stays effectively solid + * through `SOLID_THROUGH`, and the visible dissolve happens below it. + * 2. The dissolve gets real length to happen over. A short band was the first + * attempt and it failed the same way the clipping did: the fade happened + * over so little distance that content still read as meeting a line. + * 3. It has no crease anywhere. This is the subtle one. The alphas below are + * *control points*, and joining them with straight lines puts a derivative + * discontinuity at every one of them — which the eye picks up as a Mach + * band, a visible "gradient line" across the middle of the fade. So the + * curve through them is a monotone cubic, sampled densely enough that what + * Skia finally interpolates has no corner left in it. + * + * The control points are anchored to the status bar's bottom edge rather than + * set in dp, because the strip is 24dp on one device and 48dp on the next: the + * solid part always covers exactly the bar, whatever it measures, while the + * tail stays a constant length in dp because the content it fades is a constant + * size. + */ + +/** One gradient stop: `at` is a 0..1 fraction of the band's height. */ +export interface TopFadeStop { + at: number; + alpha: number; +} + +export interface TopFadeBand { + height: number; + /** Where the status bar's bottom edge falls, as a fraction of `height`. */ + barAt: number; + stops: TopFadeStop[]; +} + +/** + * Strength at the very top of the window. Deliberately short of 1 — a whisper + * of content still shows through behind the clock, which is the difference + * between content passing behind the status bar and content ending at a slab. + * Same reasoning, and nearly the same figure, as `MiniPlayerScrim`'s. + */ +const MAX_ALPHA = 0.95; + +/** + * How far below the status bar the band ends, in dp. + * + * Both ends of the fade track the strip, and it took two wrong answers to get + * here. Every screen positions its content at `insets.top` plus its own margin, + * so a heading's resting distance below the bar is the same on every phone + * while the bar itself is anywhere from ~28dp to ~52dp. Anchoring only the + * *start* to the strip and giving the ramp a fixed length held the slope + * perfectly constant but slid content along the curve: the top of a resting + * title was washed 23% on a short-strip phone and 13% on a tall-strip one, + * which is the difference between looking dimmed and looking clean. + * + * The other wrong answer was hanging the interior points off the bar's bottom + * edge, which made a single segment proportional to the strip and compressed + * the whole ramp into ~11dp on a short one — a hard line again. So the interior + * points are fractions of the *span* between the two anchors, and the span + * varies by about a sixth across the range instead of doubling. + * + * This is the number to turn if the fade feels abrupt (raise) or if resting + * content near the top looks dimmed (lower). + */ +const TAIL = 58; + +/** + * How far down the strip the wash stays effectively solid, as a fraction of it + * — about the middle of the clock and icon row. + * + * This is the smoothness knob, and it trades against the icons. Holding solid + * all the way to the bottom of the icons (0.7) forces the entire visible + * dissolve into the ~14dp below them, and that steepness alone reads as a line + * even with no crease left in it. Releasing at the icons' centre instead buys + * the ramp half again as much room; the cost is that content is a ~20% ghost at + * their lower edge, which is well short of legible. + */ +const SOLID_THROUGH = 0.55; + +/** + * Alpha at `SOLID_THROUGH`. Past roughly this figure the wash is + * indistinguishable from solid `bgPrimary`, so this is the point where the + * gradient stops being background and starts being visible. + */ +const SOLID_ALPHA = 0.88; + +/** + * The dissolve's shape, as fractions of the span between the two anchors. + * + * Convex: it sheds most of its strength in the first half and then spends the + * rest of its length creeping to zero, which is what keeps a band this long + * from reading as a deliberate dimmed strip (the mistake `MiniPlayerScrim` + * records making at the other end of the screen). + * + * The first point is placed so that on a typical phone the status bar's bottom + * edge lands at about half alpha — content clearing the bar is visibly still + * dissolving rather than snapping back to full strength. + */ +const DISSOLVE_POINTS: readonly (readonly [of: number, alpha: number])[] = [ + [0.27, 0.52], + [0.52, 0.15], + [0.72, 0.05], +]; + +/** + * How many stops the curve is flattened into. + * + * Skia interpolates linearly between stops, so this is what decides whether the + * cubic survives as a smooth ramp or comes back as a polyline. Forty across a + * ~110dp band puts the remaining corners under 3dp apart, well below where a + * Mach band forms. + */ +const SAMPLES = 40; + +/** + * The largest share of the window the band may occupy before the screen should + * stop bleeding altogether. + * + * `insets.top` is not zero in landscape — Android keeps the status bar up, and + * `shellLayout` already budgets for it — so without this a ~95dp band lands in + * a ~360dp-tall window and washes a quarter of the screen. + * + * Refusing beats compressing. Squeezing the curve into a short window + * re-steepens it into exactly the hard line the shape exists to avoid, and the + * honest answer for a window with no height to spare is not to bleed at all: + * `Screen` keeps paying its own inset and content stops below the bar, the same + * as on every screen that never opted in. There is no cut to soften, because + * there is no bleed. + * + * At 0.18 the cutoff lands near a 590dp window, which is within a few dp of + * `RAIL_MAX_WINDOW_HEIGHT` — the point where the shell independently decided + * height was scarce. Phones bleed in portrait and not in landscape; tablets + * bleed either way. + */ +const MAX_WINDOW_SHARE = 0.18; + +/** + * Tangents for a monotone cubic (Fritsch–Carlson) through the control points. + * + * Plain Catmull-Rom would be smooth but can overshoot, and an alpha that dips + * below zero or bulges above the point above it would show as a bright or dark + * ring inside the fade. This limiter is what rules that out. + */ +function monotoneTangents(xs: number[], ys: number[]): number[] { + const n = xs.length; + const secants: number[] = []; + for (let i = 0; i < n - 1; i += 1) { + secants.push((ys[i + 1]! - ys[i]!) / (xs[i + 1]! - xs[i]!)); + } + + const tangents: number[] = [secants[0]!]; + for (let i = 1; i < n - 1; i += 1) { + const previous = secants[i - 1]!; + const next = secants[i]!; + tangents.push(previous * next <= 0 ? 0 : (previous + next) / 2); + } + tangents.push(secants[n - 2]!); + + for (let i = 0; i < n - 1; i += 1) { + const secant = secants[i]!; + if (secant === 0) { + tangents[i] = 0; + tangents[i + 1] = 0; + continue; + } + const a = tangents[i]! / secant; + const b = tangents[i + 1]! / secant; + const magnitude = a * a + b * b; + if (magnitude > 9) { + const scale = 3 / Math.sqrt(magnitude); + tangents[i] = scale * a * secant; + tangents[i + 1] = scale * b * secant; + } + } + + return tangents; +} + +/** Cubic Hermite evaluation on the span containing `x`. */ +function interpolate(xs: number[], ys: number[], tangents: number[], x: number): number { + let span = xs.length - 2; + for (let i = 0; i < xs.length - 1; i += 1) { + if (x <= xs[i + 1]!) { + span = i; + break; + } + } + + const x0 = xs[span]!; + const x1 = xs[span + 1]!; + const h = x1 - x0; + const t = (x - x0) / h; + const t2 = t * t; + const t3 = t2 * t; + + return ( + ys[span]! * (2 * t3 - 3 * t2 + 1) + + h * tangents[span]! * (t3 - 2 * t2 + t) + + ys[span + 1]! * (-2 * t3 + 3 * t2) + + h * tangents[span + 1]! * (t3 - t2) + ); +} + +/** + * The band for a given window, or null when this window should not bleed. + * + * Null is a decision, not just a rendering skip: a screen must consult this + * before dropping its top inset, because bleeding without a fade is the hard + * clip the whole thing exists to remove. Two windows get it — one with no top + * inset at all (nothing for content to disappear into) and one too short to + * spend `MAX_WINDOW_SHARE` of itself on the fade. + */ +export function topFadeBand(insetTop: number, windowHeight: number): TopFadeBand | null { + if (insetTop <= 0) return null; + + // The two anchors, both measured from the strip: the wash releases part-way + // down the icons, and the band ends a fixed distance below the bar — where + // resting content sits, on every device. + const solidEnd = insetTop * SOLID_THROUGH; + const height = insetTop + TAIL; + const span = height - solidEnd; + if (height > windowHeight * MAX_WINDOW_SHARE) return null; + + const control: [at: number, alpha: number][] = [ + [0, MAX_ALPHA], + [solidEnd, SOLID_ALPHA], + ...DISSOLVE_POINTS.map(([of, alpha]): [number, number] => [solidEnd + span * of, alpha]), + [height, 0], + ]; + + const xs = control.map(([at]) => at / height); + const ys = control.map(([, alpha]) => alpha); + const tangents = monotoneTangents(xs, ys); + + const stops: TopFadeStop[] = []; + for (let i = 0; i <= SAMPLES; i += 1) { + const at = i / SAMPLES; + // Clamped because the limiter bounds overshoot but the endpoints still have + // to land exactly on 1 and 0 for the band to be seamless at both edges. + stops.push({ at, alpha: Math.min(1, Math.max(0, interpolate(xs, ys, tangents, at))) }); + } + + return { height, barAt: insetTop / height, stops }; +}