Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
"@effect/atom-react": "catalog:",
"@expo-google-fonts/dm-sans": "^0.4.2",
"@expo/metro-runtime": "~58.0.10",
"@expo/ui": "~58.0.11",
"@expo/ui": "58.0.11",
"@legendapp/list": "catalog:",
"@material/material-color-utilities": "0.3.0",
"@noble/curves": "catalog:",
Expand Down Expand Up @@ -164,6 +164,7 @@
},
"autolinking": {
"buildFromSource": [
"expo-ui",
"expo-notifications",
"react-native-screens",
"react-native-reanimated",
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/components/NativeThemeHost.android.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Host } from "@expo/ui/jetpack-compose";
import type { ComponentProps } from "react";
import { createNativeThemeColors } from "../lib/nativeTheme";
import { useControlTheme } from "./useControlTheme";

/** One native island, using T3's resolved colors or the user's Material You palette. */
export function NativeThemeHost(
props: Omit<ComponentProps<typeof Host>, "colorScheme" | "seedColor" | "colorOverrides">,
) {
const { colors, colorScheme, systemColorsActive } = useControlTheme();
return (
<Host
{...props}
colorScheme={colorScheme}
colorOverrides={systemColorsActive ? undefined : createNativeThemeColors(colors)}
/>
);
}
11 changes: 11 additions & 0 deletions apps/mobile/src/components/NativeThemeHost.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Host } from "@expo/ui/swift-ui";
import type { ComponentProps } from "react";
import { useControlTheme } from "./useControlTheme";

/** SwiftUI shares the app's appearance and action tint. Row fills stay explicit. */
export function NativeThemeHost(
props: Omit<ComponentProps<typeof Host>, "colorScheme" | "seedColor">,
) {
const { colors, colorScheme } = useControlTheme();
return <Host {...props} colorScheme={colorScheme} seedColor={colors["--color-primary"]} />;
}
9 changes: 5 additions & 4 deletions apps/mobile/src/components/NumberField.ios.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, Host, HStack, Picker, Popover, Text, VStack } from "@expo/ui/swift-ui";
import { Button, HStack, Picker, Popover, Text, VStack } from "@expo/ui/swift-ui";
import {
accessibilityLabel,
buttonStyle,
Expand All @@ -13,16 +13,17 @@ import {
} from "@expo/ui/swift-ui/modifiers";
import { useState } from "react";

import { NativeThemeHost } from "./NativeThemeHost";
import { useControlTheme } from "./useControlTheme";
import type { NumberFieldProps } from "./NumberField";

export function NumberField(props: NumberFieldProps) {
const { colorScheme, colors, fontSize } = useControlTheme();
const { colors, fontSize } = useControlTheme();
const values = Array.from({ length: props.max - props.min + 1 }, (_, index) => props.min + index);
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState(props.value);
return (
<Host matchContents colorScheme={colorScheme} seedColor={colors["--color-primary"]}>
<NativeThemeHost matchContents>
<Popover isPresented={open} onIsPresentedChange={setOpen}>
<Popover.Trigger>
<Button
Expand Down Expand Up @@ -86,6 +87,6 @@ export function NumberField(props: NumberFieldProps) {
</VStack>
</Popover.Content>
</Popover>
</Host>
</NativeThemeHost>
);
}
13 changes: 5 additions & 8 deletions apps/mobile/src/components/SliderRow.android.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
import { Host, Slider } from "@expo/ui/jetpack-compose";
import { Slider } from "@expo/ui/jetpack-compose";
import { fillMaxWidth } from "@expo/ui/jetpack-compose/modifiers";
import * as Haptics from "expo-haptics";
import { useRef, type ComponentProps } from "react";
import { View } from "react-native";

import { AppText as Text } from "./AppText";
import { SymbolView } from "./AppSymbol";
import { NativeThemeHost } from "./NativeThemeHost";
import { useControlTheme } from "./useControlTheme";
import type { SliderRow as SharedSliderRow } from "./SliderRow";

export function SliderRow(props: ComponentProps<typeof SharedSliderRow>) {
const { colorScheme, systemColorsActive, colors } = useControlTheme();
const { colors } = useControlTheme();
const draft = useRef(props.value);
const commit = (value: number) => {
if (props.disabled) return;
Expand Down Expand Up @@ -47,11 +48,7 @@ export function SliderRow(props: ComponentProps<typeof SharedSliderRow>) {
}}
>
<View importantForAccessibility="no-hide-descendants">
<Host
colorScheme={colorScheme}
seedColor={systemColorsActive ? undefined : colors["--color-primary"]}
style={{ height: 48, width: "100%" }}
>
<NativeThemeHost style={{ height: 48, width: "100%" }}>
<Slider
min={props.min}
max={props.max}
Expand All @@ -69,7 +66,7 @@ export function SliderRow(props: ComponentProps<typeof SharedSliderRow>) {
}}
onValueChangeFinished={() => commit(draft.current)}
/>
</Host>
</NativeThemeHost>
</View>
</View>
</View>
Expand Down
45 changes: 45 additions & 0 deletions apps/mobile/src/lib/nativeTheme.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vite-plus/test";
import { getMobileThemeVariables, MOBILE_THEME_OPTIONS } from "./mobileTheme";
import { createNativeThemeColors } from "./nativeTheme";

function luminance(hex: string) {
const channels = [1, 3, 5].map((offset) => {
const value = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255;
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
});
return channels[0]! * 0.2126 + channels[1]! * 0.7152 + channels[2]! * 0.0722;
}

function contrast(a: string, b: string) {
const values = [luminance(a), luminance(b)].sort((x, y) => y - x);
return (values[0]! + 0.05) / (values[1]! + 0.05);
}

const cases = MOBILE_THEME_OPTIONS.flatMap((theme) => {
if (theme.id === "material-you") return [];
const themeId = theme.id;
return (["light", "dark"] as const).map((appearance) => ({
label: theme.label,
themeId,
appearance,
}));
});

describe("native theme colors", () => {
it.each(cases)(
"$label $appearance keeps native labels legible on every container",
({ themeId, appearance }) => {
const colors = createNativeThemeColors(getMobileThemeVariables(themeId, appearance));
for (const surface of [
colors.surface,
colors.surfaceContainer,
colors.surfaceContainerHigh,
colors.surfaceContainerHighest,
]) {
expect(contrast(colors.onSurface, surface)).toBeGreaterThanOrEqual(4.5);
}
expect(contrast(colors.onPrimary, colors.primary)).toBeGreaterThanOrEqual(4.5);
for (const color of Object.values(colors)) expect(color).toMatch(/^#[0-9a-f]{6}$/i);
},
);
});
63 changes: 63 additions & 0 deletions apps/mobile/src/lib/nativeTheme.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { MaterialColors } from "@expo/ui/jetpack-compose";
import { flattenThemeColor, type MobileThemeVariables } from "./mobileTheme";

/** Native controls use the same resolved palette as RN, without generating new hues. */
export function createNativeThemeColors(colors: MobileThemeVariables): MaterialColors {
const roles = {
primary: colors["--color-primary"],
onPrimary: colors["--color-primary-foreground"],
primaryContainer: colors["--color-thread-selected"],
onPrimaryContainer: colors["--color-thread-selected-foreground"],
inversePrimary: colors["--color-primary-foreground"],
secondary: colors["--color-secondary"],
onSecondary: colors["--color-secondary-foreground"],
secondaryContainer: colors["--color-subtle-strong"],
onSecondaryContainer: colors["--color-secondary-foreground"],
tertiary: colors["--color-primary"],
onTertiary: colors["--color-primary-foreground"],
tertiaryContainer: colors["--color-thread-selected"],
onTertiaryContainer: colors["--color-thread-selected-foreground"],
background: colors["--color-screen"],
onBackground: colors["--color-foreground"],
surface: colors["--color-screen"],
onSurface: colors["--color-foreground"],
surfaceVariant: colors["--color-card-alt"],
onSurfaceVariant: colors["--color-foreground-muted"],
surfaceTint: colors["--color-primary"],
inverseSurface: colors["--color-foreground"],
inverseOnSurface: colors["--color-screen"],
error: colors["--color-danger-foreground"],
onError: colors["--color-danger"],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
errorContainer: colors["--color-danger"],
onErrorContainer: colors["--color-danger-foreground"],
outline: colors["--color-input-border"],
outlineVariant: colors["--color-separator"],
scrim: "#000000",
surfaceBright: colors["--color-card-alt"],
surfaceDim: colors["--color-input"],
surfaceContainer: colors["--color-grouped-card"],
surfaceContainerHigh: colors["--color-card-alt"],
surfaceContainerHighest: colors["--color-subtle-strong"],
surfaceContainerLow: colors["--color-sheet-solid"],
surfaceContainerLowest: colors["--color-input"],
primaryFixed: colors["--color-primary"],
primaryFixedDim: colors["--color-primary"],
onPrimaryFixed: colors["--color-primary-foreground"],
onPrimaryFixedVariant: colors["--color-primary-foreground"],
secondaryFixed: colors["--color-secondary"],
secondaryFixedDim: colors["--color-secondary"],
onSecondaryFixed: colors["--color-secondary-foreground"],
onSecondaryFixedVariant: colors["--color-secondary-foreground"],
tertiaryFixed: colors["--color-primary"],
tertiaryFixedDim: colors["--color-primary"],
onTertiaryFixed: colors["--color-primary-foreground"],
onTertiaryFixedVariant: colors["--color-primary-foreground"],
} satisfies Record<keyof MaterialColors, string>;
// Material scheme roles are opaque. Composite translucent RN fills onto the canvas.
return Object.fromEntries(
Object.entries(roles).map(([role, color]) => [
role,
flattenThemeColor(color, colors["--color-screen"]),
]),
) as MaterialColors;
}
Loading
Loading