feat(native): introduce title-accent CSS variable for per-title color theming

Replace hardcoded `--color-primary` references on the title detail screen
with a dedicated `--color-title-accent` / `--color-title-accent-foreground`
variable pair, allowing the poster-derived palette to style title UI
elements without polluting the global primary color.

- Add `--color-title-accent` and `--color-title-accent-foreground` to
  `global.css` with default warm amber values
- Update `use-title-theme.ts` to set `--color-title-accent` instead of
  `--color-primary`; switch from `useFocusEffect` to `useEffect`
- Replace all `bg-primary`, `text-primary`, `border-primary` etc. in
  `title/[id].tsx` and title components with `title-accent` equivalents
- Add `accentColor` prop to `StarRating` so it can receive the resolved
  CSS variable value at runtime
- Pass `iconColor={titleAccent}` to all `SectionHeader` instances on the
  title detail screen
- Use platform-appropriate store icon (App Store / Google Play) for the
  "Where to Watch" section header
- Switch progress bar in `ContinueWatchingBanner` from `bg-status-watching`
  to `bg-title-accent`
This commit is contained in:
2026-03-13 15:20:43 -04:00
parent 7b941a8739
commit 985030ad26
8 changed files with 77 additions and 67 deletions
+39 -21
View File
@@ -1,4 +1,6 @@
import {
IconBrandAppstore,
IconBrandGooglePlay,
IconCheck,
IconList,
IconMovie,
@@ -15,6 +17,7 @@ import * as WebBrowser from "expo-web-browser";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
FlatList,
Platform,
Pressable,
RefreshControl,
ScrollView,
@@ -45,10 +48,10 @@ export default function TitleDetailScreen() {
const insets = useSafeAreaInsets();
const { back } = useRouter();
const [primary, mutedForeground, primaryForeground] = useCSSVariable([
"--color-primary",
const [titleAccent, mutedForeground, titleAccentForeground] = useCSSVariable([
"--color-title-accent",
"--color-muted-foreground",
"--color-primary-foreground",
"--color-title-accent-foreground",
]) as [string, string, string];
const detail = useQuery(orpc.titles.detail.queryOptions({ input: { id } }));
@@ -246,7 +249,7 @@ export default function TitleDetailScreen() {
<RefreshControl
refreshing={refreshing}
onRefresh={onRefresh}
tintColorClassName="accent-primary"
tintColorClassName="accent-title-accent"
/>
}
>
@@ -305,11 +308,7 @@ export default function TitleDetailScreen() {
)}
{/* Bottom fade to background */}
<LinearGradient
colors={[
"transparent",
"rgba(0,0,0,0.6)",
"rgba(0,0,0,0.95)",
]}
colors={["transparent", "rgba(0,0,0,0.6)", "rgba(0,0,0,0.95)"]}
locations={[0, 0.5, 1]}
style={{
position: "absolute",
@@ -370,8 +369,8 @@ export default function TitleDetailScreen() {
{title.title}
</Text>
<View className="mt-1.5 flex-row flex-wrap items-center gap-2">
<View className="rounded-full bg-primary px-2 py-0.5">
<Text className="font-sans-medium text-[10px] text-primary-foreground">
<View className="rounded-full bg-title-accent px-2 py-0.5">
<Text className="font-sans-medium text-[10px] text-title-accent-foreground">
{title.type === "movie" ? "Movie" : "TV"}
</Text>
</View>
@@ -385,8 +384,8 @@ export default function TitleDetailScreen() {
) : null}
{title.voteAverage != null && title.voteAverage > 0 && (
<View className="flex-row items-center gap-0.5">
<IconStarFilled size={12} color={primary} />
<Text className="text-primary text-xs">
<IconStarFilled size={12} color={titleAccent} />
<Text className="text-title-accent text-xs">
{title.voteAverage.toFixed(1)}
</Text>
</View>
@@ -454,20 +453,21 @@ export default function TitleDetailScreen() {
<StarRating
rating={userInfo.data?.rating ?? 0}
onRate={(stars) => updateRating.mutate({ id, stars })}
accentColor={titleAccent}
/>
{title.type === "movie" && (
<Pressable
onPress={() => watchMovie.mutate({ id })}
disabled={watchMovie.isPending}
className="flex-row items-center gap-1.5 rounded-full bg-primary px-4 py-2"
className="flex-row items-center gap-1.5 rounded-full bg-title-accent px-4 py-2"
>
{watchMovie.isPending ? (
<Spinner size="sm" />
) : (
<>
<IconCheck size={16} color={primaryForeground} />
<Text className="font-sans-medium text-[13px] text-primary-foreground">
<IconCheck size={16} color={titleAccentForeground} />
<Text className="font-sans-medium text-[13px] text-title-accent-foreground">
Mark Watched
</Text>
</>
@@ -493,7 +493,13 @@ export default function TitleDetailScreen() {
className="mt-6"
>
<View className="px-4">
<SectionHeader title="Where to Watch" icon={IconPlayerPlay} />
<SectionHeader
title="Where to Watch"
icon={
Platform.OS === "ios" ? IconBrandAppstore : IconBrandGooglePlay
}
iconColor={titleAccent}
/>
</View>
<ScrollView
horizontal
@@ -540,7 +546,11 @@ export default function TitleDetailScreen() {
entering={FadeInDown.duration(300).delay(400)}
className="mt-6 px-4"
>
<SectionHeader title="Seasons" icon={IconList} />
<SectionHeader
title="Seasons"
icon={IconList}
iconColor={titleAccent}
/>
{seasons.map((season) => (
<SeasonAccordion
key={season.id}
@@ -554,7 +564,7 @@ export default function TitleDetailScreen() {
{hydrateMutation.isPending && (
<View className="items-center py-6">
<Spinner colorClassName="accent-primary" />
<Spinner colorClassName="accent-title-accent" />
<Text className="mt-2 text-[13px] text-muted-foreground">
Loading season data...
</Text>
@@ -568,7 +578,11 @@ export default function TitleDetailScreen() {
className="mt-6"
>
<View className="px-4">
<SectionHeader title="Cast" icon={IconUsers} />
<SectionHeader
title="Cast"
icon={IconUsers}
iconColor={titleAccent}
/>
</View>
<FlatList
horizontal
@@ -590,7 +604,11 @@ export default function TitleDetailScreen() {
className="mt-6"
>
<View className="px-4">
<SectionHeader title="More Like This" icon={IconThumbUp} />
<SectionHeader
title="More Like This"
icon={IconThumbUp}
iconColor={titleAccent}
/>
</View>
<FlatList
horizontal
@@ -4,6 +4,7 @@ import { IconPlayerPlay } from "@tabler/icons-react-native";
import { useMemo } from "react";
import { View } from "react-native";
import Animated, { FadeInDown } from "react-native-reanimated";
import { useCSSVariable } from "uniwind";
import { Image } from "@/components/ui/image";
import { SectionHeader } from "@/components/ui/section-header";
import { Text } from "@/components/ui/text";
@@ -19,6 +20,8 @@ export function ContinueWatchingBanner({
userStatus: string | null;
backdropPath: string | null;
}) {
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
const { nextEpisode, totalEpisodes, watchedEpisodes } = useMemo(
() => getNextEpisode(seasons, watchedEpisodeIds),
[seasons, watchedEpisodeIds],
@@ -35,7 +38,11 @@ export function ContinueWatchingBanner({
entering={FadeInDown.duration(300).delay(350)}
className="mt-6 px-4"
>
<SectionHeader title="Next Episode" icon={IconPlayerPlay} />
<SectionHeader
title="Next Episode"
icon={IconPlayerPlay}
iconColor={titleAccentColor}
/>
<View
className="overflow-hidden rounded-[12px] border bg-card"
style={{
@@ -59,8 +66,8 @@ export function ContinueWatchingBanner({
/>
<View className="absolute right-3 bottom-2.5 left-3">
<View className="flex-row items-center gap-1.5">
<View className="h-1.5 w-1.5 rounded-full bg-primary" />
<Text className="font-sans-medium text-[10px] text-primary uppercase tracking-wider">
<View className="h-1.5 w-1.5 rounded-full bg-title-accent" />
<Text className="font-sans-medium text-[10px] text-title-accent uppercase tracking-wider">
Up next
</Text>
</View>
@@ -80,7 +87,7 @@ export function ContinueWatchingBanner({
style={{ backgroundColor: "rgba(255,255,255,0.1)" }}
>
<View
className="h-full bg-status-watching"
className="h-full bg-title-accent"
style={{ width: `${progress}%` }}
/>
</View>
@@ -20,7 +20,7 @@ export function EpisodeRow({
isWatched: boolean;
onToggle: () => void;
}) {
const completedColor = useCSSVariable("--color-status-completed") as string;
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
return (
@@ -30,7 +30,7 @@ export function EpisodeRow({
style={{ borderBottomWidth: 0.5 }}
>
{isWatched ? (
<IconCircleCheckFilled size={22} color={completedColor} />
<IconCircleCheckFilled size={22} color={titleAccentColor} />
) : (
<IconCircleDashed size={22} color={mutedFgColor} />
)}
@@ -34,8 +34,7 @@ export function SeasonAccordion({
}>;
watchedEpisodeIds: Set<string>;
}) {
const completedColor = useCSSVariable("--color-status-completed") as string;
const watchingColor = useCSSVariable("--color-status-watching") as string;
const titleAccentColor = useCSSVariable("--color-title-accent") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
const [expanded, setExpanded] = useState(false);
@@ -145,7 +144,7 @@ export function SeasonAccordion({
style={{
height: "100%",
width: `${progress * 100}%`,
backgroundColor: progress === 1 ? completedColor : watchingColor,
backgroundColor: titleAccentColor,
}}
/>
</View>
@@ -165,7 +164,7 @@ export function SeasonAccordion({
onPress={() => watchSeason.mutate({ id: season.id })}
className="mx-4 mb-2 flex-row items-center justify-center rounded-lg bg-secondary py-2"
>
<Text className="font-sans-medium text-primary text-xs">
<Text className="font-sans-medium text-title-accent text-xs">
Mark All Watched
</Text>
</Pressable>
@@ -29,7 +29,7 @@ export function StatusActionButton({
{ status: "completed", label: "Completed", Icon: IconCircleCheck },
];
const primaryColor = useCSSVariable("--color-primary") as string;
const primaryColor = useCSSVariable("--color-title-accent") as string;
const mutedFgColor = useCSSVariable("--color-muted-foreground") as string;
return (
@@ -46,13 +46,13 @@ export function StatusActionButton({
disabled={isPending}
className={`flex-row items-center gap-1.5 rounded-full border px-3 py-2 ${
isActive
? "border-primary bg-primary/10"
? "border-title-accent bg-title-accent/10"
: "border-border bg-card"
}`}
>
<Icon size={14} color={isActive ? primaryColor : mutedFgColor} />
<Text
className={`font-sans-medium text-xs ${isActive ? "text-primary" : "text-foreground"}`}
className={`font-sans-medium text-xs ${isActive ? "text-title-accent" : "text-foreground"}`}
>
{label}
</Text>
@@ -8,6 +8,7 @@ interface StarRatingProps {
onRate?: (rating: number) => void;
size?: number;
interactive?: boolean;
accentColor?: string;
}
export function StarRating({
@@ -15,12 +16,15 @@ export function StarRating({
onRate,
size = 22,
interactive = true,
accentColor,
}: StarRatingProps) {
const [primary, mutedForeground] = useCSSVariable([
const [defaultPrimary, mutedForeground] = useCSSVariable([
"--color-primary",
"--color-muted-foreground",
]) as [string, string];
const primary = accentColor ?? defaultPrimary;
return (
<View className="flex-row items-center gap-1">
{[1, 2, 3, 4, 5].map((star) => (
+2
View File
@@ -32,6 +32,8 @@
--color-status-watchlist: oklch(0.72 0.12 220);
--color-status-watching: oklch(0.78 0.14 65);
--color-status-completed: oklch(0.75 0.14 155);
--color-title-accent: oklch(0.8 0.14 65);
--color-title-accent-foreground: oklch(0.13 0.006 55);
--radius: 0.625rem;
}
+12 -32
View File
@@ -1,6 +1,5 @@
import type { ColorPalette } from "@sofa/api/schemas";
import { useFocusEffect } from "expo-router";
import { useCallback } from "react";
import { useEffect } from "react";
import { Uniwind } from "uniwind";
function hexToRelativeLuminance(hex: string): number {
@@ -12,38 +11,19 @@ function hexToRelativeLuminance(hex: string): number {
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
const DEFAULTS: Record<string, string> = {
"--color-primary": "oklch(0.8 0.14 65)",
"--color-ring": "oklch(0.8 0.14 65)",
"--color-primary-foreground": "oklch(0.13 0.006 55)",
"--color-status-watching": "oklch(0.78 0.14 65)",
};
function applyPalette(vibrant: string): void {
const luminance = hexToRelativeLuminance(vibrant);
const foreground =
luminance > 0.3 ? "oklch(0.13 0.006 55)" : "oklch(0.93 0.015 80)";
Uniwind.updateCSSVariables("dark", {
"--color-primary": vibrant,
"--color-ring": vibrant,
"--color-primary-foreground": foreground,
"--color-status-watching": vibrant,
});
}
export function useTitleTheme(palette: ColorPalette | null | undefined): void {
const vibrant = palette?.vibrant ?? null;
useFocusEffect(
useCallback(() => {
if (vibrant) {
applyPalette(vibrant);
}
useEffect(() => {
if (!vibrant) return;
return () => {
Uniwind.updateCSSVariables("dark", DEFAULTS);
};
}, [vibrant]),
);
const luminance = hexToRelativeLuminance(vibrant);
const foreground =
luminance > 0.3 ? "oklch(0.13 0.006 55)" : "oklch(0.93 0.015 80)";
Uniwind.updateCSSVariables("dark", {
"--color-title-accent": vibrant,
"--color-title-accent-foreground": foreground,
});
}, [vibrant]);
}