diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index 82e7ad85d6cfe9128e25b14c77e25dfc00b2dabb..d7720bc50bd92b14a261ae394f74e0b028289bbd 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -337,7 +337,7 @@ --color-composer-surface: rgba(250, 243, 251, 0.94); --color-composer-border: rgba(238, 225, 237, 0.54); --color-foreground: #501854; - --color-foreground-secondary: #ac1668; + --color-foreground-secondary: #ab1668; --color-foreground-muted: #8d1255; --color-foreground-tertiary: #ac1668; --color-border: #eee1ed; @@ -351,7 +351,7 @@ --color-inline-skill-foreground: #454554; --color-primary: #db2777; --color-primary-foreground: #ffffff; - --color-primary-text: #d32572; + --color-primary-text: #aa1e5d; --color-primary-shadow: #000000; --color-secondary: #f1c4e6; --color-secondary-foreground: #77347c; @@ -371,7 +371,7 @@ --color-input: #faf3fb; --color-input-border: #e7c1dc; --color-sidebar-search: #f8f8f7; - --color-placeholder: #8b5f90; + --color-placeholder: #714e76; --color-icon: #501854; --color-icon-muted: #ac1668; --color-icon-subtle: #ac1668; @@ -510,7 +510,7 @@ --color-inline-skill-foreground: #f8f1f5; --color-primary: #a3004c; --color-primary-foreground: #fbd0e8; - --color-primary-text: #cc729c; + --color-primary-text: #d07ea4; --color-primary-shadow: #000000; --color-secondary: #362d3d; --color-secondary-foreground: #d4c7e1; @@ -530,7 +530,7 @@ --color-input: #29232d; --color-input-border: #302029; --color-sidebar-search: #261922; - --color-placeholder: #968d9f; + --color-placeholder: #9d94a5; --color-icon: #f9f8fb; --color-icon-muted: #d4c7e1; --color-icon-subtle: #e7d0dd; @@ -642,7 +642,7 @@ --color-sheet: rgba(243, 247, 244, 0.98); --color-sheet-solid: #f3f7f4; --color-card: #f3f7f4; - --color-grouped-card: #f3f7f4; + --color-grouped-card: #ecefed; --color-card-alt: #ecefed; --color-card-translucent: rgba(243, 247, 244, 0.8); --color-thread-canvas: #f3f7f4; @@ -652,11 +652,11 @@ --color-thread-hover: #cae0d5; --color-row-hover: #d5e6dd; --color-composer-panel: rgba(243, 247, 244, 0.88); - --color-composer-surface: rgba(243, 247, 244, 0.94); - --color-composer-border: rgba(203, 213, 209, 0.54); + --color-composer-surface: rgba(236, 239, 237, 0.94); + --color-composer-border: rgba(203, 213, 209, 0.8); --color-foreground: #241523; - --color-foreground-secondary: #726a71; - --color-foreground-muted: #6e696f; + --color-foreground-secondary: #6f686f; + --color-foreground-muted: #6d686e; --color-foreground-tertiary: #746c73; --color-border: #cbd5d1; --color-focus: #1b7d50; @@ -669,7 +669,7 @@ --color-inline-skill-foreground: #241523; --color-primary: #8f6410; --color-primary-foreground: #fffaff; - --color-primary-text: #8f6410; + --color-primary-text: #8d6210; --color-primary-shadow: #000000; --color-secondary: #e2ede7; --color-secondary-foreground: #241523; @@ -689,7 +689,7 @@ --color-input: #f3f7f4; --color-input-border: #becbc5; --color-sidebar-search: #d3dcd8; - --color-placeholder: #716971; + --color-placeholder: #6f686f; --color-icon: #241523; --color-icon-muted: #746c73; --color-icon-subtle: #746c73; @@ -697,7 +697,7 @@ --color-header-foreground: #241523; --color-header-border: #d5e6dd; --color-glass-surface: rgba(231, 233, 232, 0.74); - --color-glass-fallback: rgba(243, 247, 244, 0.94); + --color-glass-fallback: rgba(236, 239, 237, 0.94); --color-glass-tint: rgba(231, 233, 232, 0.22); --color-status-bar: #f3f7f4; --color-md-body: #241523; @@ -801,7 +801,7 @@ --color-sheet: rgba(27, 40, 33, 0.98); --color-sheet-solid: #1b2821; --color-card: #1b2821; - --color-grouped-card: #1b2821; + --color-grouped-card: #36413c; --color-card-alt: #36413c; --color-card-translucent: rgba(27, 40, 33, 0.8); --color-thread-canvas: #1b2821; @@ -811,11 +811,11 @@ --color-thread-hover: #2a4938; --color-row-hover: #325c46; --color-composer-panel: rgba(27, 40, 33, 0.92); - --color-composer-surface: rgba(27, 40, 33, 0.9); - --color-composer-border: rgba(65, 95, 79, 0.46); + --color-composer-surface: rgba(54, 65, 60, 0.9); + --color-composer-border: rgba(65, 95, 79, 0.8); --color-foreground: #fffaff; - --color-foreground-secondary: #a6aaaa; - --color-foreground-muted: #a3aba8; + --color-foreground-secondary: #afb1b1; + --color-foreground-muted: #acb2b0; --color-foreground-tertiary: #919595; --color-border: #415f4f; --color-focus: #69d69a; @@ -848,7 +848,7 @@ --color-input: #1b2821; --color-input-border: #4f725f; --color-sidebar-search: #45554d; - --color-placeholder: #a9abab; + --color-placeholder: #afb1b1; --color-icon: #fffaff; --color-icon-muted: #919595; --color-icon-subtle: #919595; @@ -856,7 +856,7 @@ --color-header-foreground: #fffaff; --color-header-border: #36654c; --color-glass-surface: rgba(68, 77, 73, 0.74); - --color-glass-fallback: rgba(27, 40, 33, 0.9); + --color-glass-fallback: rgba(54, 65, 60, 0.9); --color-glass-tint: rgba(68, 77, 73, 0.22); --color-status-bar: #1b2821; --color-md-body: #fffaff; @@ -960,7 +960,7 @@ --color-sheet: rgba(245, 247, 248, 0.98); --color-sheet-solid: #f5f7f8; --color-card: #f5f7f8; - --color-grouped-card: #f5f7f8; + --color-grouped-card: #edeff1; --color-card-alt: #edeff1; --color-card-translucent: rgba(245, 247, 248, 0.8); --color-thread-canvas: #f5f7f8; @@ -970,10 +970,10 @@ --color-thread-hover: #cdddea; --color-row-hover: #d8e4ee; --color-composer-panel: rgba(245, 247, 248, 0.88); - --color-composer-surface: rgba(245, 247, 248, 0.94); - --color-composer-border: rgba(205, 212, 220, 0.54); + --color-composer-surface: rgba(237, 239, 241, 0.94); + --color-composer-border: rgba(205, 212, 220, 0.8); --color-foreground: #241523; - --color-foreground-secondary: #726a73; + --color-foreground-secondary: #706870; --color-foreground-muted: #6f6873; --color-foreground-tertiary: #746c75; --color-border: #cdd4dc; @@ -1007,7 +1007,7 @@ --color-input: #f5f7f8; --color-input-border: #c0c9d4; --color-sidebar-search: #d5dbe2; - --color-placeholder: #716972; + --color-placeholder: #706870; --color-icon: #241523; --color-icon-muted: #746c75; --color-icon-subtle: #746c75; @@ -1015,7 +1015,7 @@ --color-header-foreground: #241523; --color-header-border: #d8e4ee; --color-glass-surface: rgba(232, 233, 235, 0.74); - --color-glass-fallback: rgba(245, 247, 248, 0.94); + --color-glass-fallback: rgba(237, 239, 241, 0.94); --color-glass-tint: rgba(232, 233, 235, 0.22); --color-status-bar: #f5f7f8; --color-md-body: #241523; @@ -1119,7 +1119,7 @@ --color-sheet: rgba(23, 33, 43, 0.98); --color-sheet-solid: #17212b; --color-card: #17212b; - --color-grouped-card: #17212b; + --color-grouped-card: #333b45; --color-card-alt: #333b45; --color-card-translucent: rgba(23, 33, 43, 0.8); --color-thread-canvas: #17212b; @@ -1129,11 +1129,11 @@ --color-thread-hover: #283e50; --color-row-hover: #324e66; --color-composer-panel: rgba(23, 33, 43, 0.92); - --color-composer-surface: rgba(23, 33, 43, 0.9); - --color-composer-border: rgba(64, 85, 103, 0.46); + --color-composer-surface: rgba(51, 59, 69, 0.9); + --color-composer-border: rgba(64, 85, 103, 0.8); --color-foreground: #fffaff; - --color-foreground-secondary: #a2a3aa; - --color-foreground-muted: #9ea4ad; + --color-foreground-secondary: #a5a7ad; + --color-foreground-muted: #a2a7b0; --color-foreground-tertiary: #8d8f97; --color-border: #405567; --color-focus: #70b9ee; @@ -1166,7 +1166,7 @@ --color-input: #17212b; --color-input-border: #4f677b; --color-sidebar-search: #424e5a; - --color-placeholder: #a4a4ac; + --color-placeholder: #a6a6ad; --color-icon: #fffaff; --color-icon-muted: #8d8f97; --color-icon-subtle: #8d8f97; @@ -1174,7 +1174,7 @@ --color-header-foreground: #fffaff; --color-header-border: #36566f; --color-glass-surface: rgba(65, 72, 81, 0.74); - --color-glass-fallback: rgba(23, 33, 43, 0.9); + --color-glass-fallback: rgba(51, 59, 69, 0.9); --color-glass-tint: rgba(65, 72, 81, 0.22); --color-status-bar: #17212b; --color-md-body: #fffaff; @@ -1278,7 +1278,7 @@ --color-sheet: rgba(249, 247, 245, 0.98); --color-sheet-solid: #f9f7f5; --color-card: #f9f7f5; - --color-grouped-card: #f9f7f5; + --color-grouped-card: #f1efee; --color-card-alt: #f1efee; --color-card-translucent: rgba(249, 247, 245, 0.8); --color-thread-canvas: #f9f7f5; @@ -1288,11 +1288,11 @@ --color-thread-hover: #ead8cf; --color-row-hover: #eee0d9; --color-composer-panel: rgba(249, 247, 245, 0.88); - --color-composer-surface: rgba(249, 247, 245, 0.94); - --color-composer-border: rgba(221, 210, 206, 0.54); + --color-composer-surface: rgba(241, 239, 238, 0.94); + --color-composer-border: rgba(221, 210, 206, 0.8); --color-foreground: #241523; - --color-foreground-secondary: #746a72; - --color-foreground-muted: #74686f; + --color-foreground-secondary: #726870; + --color-foreground-muted: #73686e; --color-foreground-tertiary: #766c74; --color-border: #ddd2ce; --color-focus: #ae552a; @@ -1325,7 +1325,7 @@ --color-input: #f9f7f5; --color-input-border: #d4c6c1; --color-sidebar-search: #e2d9d6; - --color-placeholder: #736971; + --color-placeholder: #726870; --color-icon: #241523; --color-icon-muted: #766c74; --color-icon-subtle: #766c74; @@ -1333,7 +1333,7 @@ --color-header-foreground: #241523; --color-header-border: #eee0d9; --color-glass-surface: rgba(236, 233, 233, 0.74); - --color-glass-fallback: rgba(249, 247, 245, 0.94); + --color-glass-fallback: rgba(241, 239, 238, 0.94); --color-glass-tint: rgba(236, 233, 233, 0.22); --color-status-bar: #f9f7f5; --color-md-body: #241523; @@ -1437,7 +1437,7 @@ --color-sheet: rgba(41, 30, 26, 0.98); --color-sheet-solid: #291e1a; --color-card: #291e1a; - --color-grouped-card: #291e1a; + --color-grouped-card: #433835; --color-card-alt: #433835; --color-card-translucent: rgba(41, 30, 26, 0.8); --color-thread-canvas: #291e1a; @@ -1447,11 +1447,11 @@ --color-thread-hover: #4f3528; --color-row-hover: #644330; --color-composer-panel: rgba(41, 30, 26, 0.92); - --color-composer-surface: rgba(41, 30, 26, 0.9); - --color-composer-border: rgba(102, 76, 63, 0.46); + --color-composer-surface: rgba(67, 56, 53, 0.9); + --color-composer-border: rgba(102, 76, 63, 0.8); --color-foreground: #fffaff; - --color-foreground-secondary: #a8a2a2; - --color-foreground-muted: #aca19f; + --color-foreground-secondary: #aca5a6; + --color-foreground-muted: #afa5a2; --color-foreground-tertiary: #968e8f; --color-border: #664c3f; --color-focus: #f09a64; @@ -1484,7 +1484,7 @@ --color-input: #291e1a; --color-input-border: #7a5d4d; --color-sidebar-search: #584943; - --color-placeholder: #aba3a5; + --color-placeholder: #aca5a6; --color-icon: #fffaff; --color-icon-muted: #968e8f; --color-icon-subtle: #968e8f; @@ -1492,7 +1492,7 @@ --color-header-foreground: #fffaff; --color-header-border: #6e4934; --color-glass-surface: rgba(79, 69, 67, 0.74); - --color-glass-fallback: rgba(41, 30, 26, 0.9); + --color-glass-fallback: rgba(67, 56, 53, 0.9); --color-glass-tint: rgba(79, 69, 67, 0.22); --color-status-bar: #291e1a; --color-md-body: #fffaff; @@ -1596,7 +1596,7 @@ --color-sheet: rgba(248, 247, 249, 0.98); --color-sheet-solid: #f8f7f9; --color-card: #f8f7f9; - --color-grouped-card: #f8f7f9; + --color-grouped-card: #f0eff2; --color-card-alt: #f0eff2; --color-card-translucent: rgba(248, 247, 249, 0.8); --color-thread-canvas: #f8f7f9; @@ -1606,11 +1606,11 @@ --color-thread-hover: #ded8ed; --color-row-hover: #e5e0f0; --color-composer-panel: rgba(248, 247, 249, 0.88); - --color-composer-surface: rgba(248, 247, 249, 0.94); - --color-composer-border: rgba(214, 209, 222, 0.54); + --color-composer-surface: rgba(240, 239, 242, 0.94); + --color-composer-border: rgba(214, 209, 222, 0.8); --color-foreground: #241523; - --color-foreground-secondary: #746a74; - --color-foreground-muted: #726874; + --color-foreground-secondary: #716871; + --color-foreground-muted: #716773; --color-foreground-tertiary: #766c76; --color-border: #d6d1de; --color-focus: #7253b9; @@ -1643,7 +1643,7 @@ --color-input: #f8f7f9; --color-input-border: #ccc5d6; --color-sidebar-search: #ddd9e3; - --color-placeholder: #736973; + --color-placeholder: #716871; --color-icon: #241523; --color-icon-muted: #766c76; --color-icon-subtle: #766c76; @@ -1651,7 +1651,7 @@ --color-header-foreground: #241523; --color-header-border: #e5e0f0; --color-glass-surface: rgba(235, 233, 237, 0.74); - --color-glass-fallback: rgba(248, 247, 249, 0.94); + --color-glass-fallback: rgba(240, 239, 242, 0.94); --color-glass-tint: rgba(235, 233, 237, 0.22); --color-status-bar: #f8f7f9; --color-md-body: #241523; @@ -1755,7 +1755,7 @@ --color-sheet: rgba(29, 25, 41, 0.98); --color-sheet-solid: #1d1929; --color-card: #1d1929; - --color-grouped-card: #1d1929; + --color-grouped-card: #383443; --color-card-alt: #383443; --color-card-translucent: rgba(29, 25, 41, 0.8); --color-thread-canvas: #1d1929; @@ -1765,8 +1765,8 @@ --color-thread-hover: #352c4f; --color-row-hover: #433765; --color-composer-panel: rgba(29, 25, 41, 0.92); - --color-composer-surface: rgba(29, 25, 41, 0.9); - --color-composer-border: rgba(77, 67, 102, 0.46); + --color-composer-surface: rgba(56, 52, 67, 0.9); + --color-composer-border: rgba(77, 67, 102, 0.8); --color-foreground: #fffaff; --color-foreground-secondary: #a09da6; --color-foreground-muted: #a19cab; @@ -1810,7 +1810,7 @@ --color-header-foreground: #fffaff; --color-header-border: #4a3c70; --color-glass-surface: rgba(69, 66, 80, 0.74); - --color-glass-fallback: rgba(29, 25, 41, 0.9); + --color-glass-fallback: rgba(56, 52, 67, 0.9); --color-glass-tint: rgba(69, 66, 80, 0.22); --color-status-bar: #1d1929; --color-md-body: #fffaff; diff --git a/apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx b/apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx index 423275a39157db35077507437368df011d1fd274..700660acc5e0e27a78d36b0c4be3afa07d13a513 100644 --- a/apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx +++ b/apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx @@ -15,7 +15,7 @@ export function MaterialFloatingToolbar(props: { readonly children: ReactElement > diff --git a/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx b/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx index 467833ab891a76d50acf36b86bb4dc04c7eed760..d6948dcba826ee073f2a415a602fe0f86dac00a5 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestDescription.tsx @@ -6,7 +6,7 @@ import { } from "./PullRequestMedia"; import { PullRequestSectionHeading } from "./PullRequestSectionHeading"; import { useContext, useMemo, useState } from "react"; -import { Pressable, ScrollView, StyleSheet, View } from "react-native"; +import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; import { pullRequestImageSource } from "./pullRequestMediaSource"; import { parseMarkdownDocument } from "../../lib/markdownDocument"; @@ -30,6 +30,7 @@ export function PullRequestDescription(props: { const [bodyHeight, setBodyHeight] = useState(0); const mediaContext = useContext(PullRequestMediaContext); const { themeVariables: colors } = useAppearancePreferences(); + const fadeColor = colors[Platform.OS === "android" ? "--color-sheet-solid" : "--color-screen"]; const content = useMemo(() => { const document = parseMarkdownDocument(props.markdown); const content = pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown)); @@ -87,10 +88,10 @@ export function PullRequestDescription(props: { y2="80" gradientUnits="userSpaceOnUse" > - - - - + + + + @@ -102,7 +103,7 @@ export function PullRequestDescription(props: { left: 0, right: 0, height: 44, - backgroundColor: colors["--color-screen"], + backgroundColor: fadeColor, }} /> diff --git a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx index 7bcfb936b9feb18d463451262307aed1da8c795a..b6cddfbd463917e7c972f44cf452c1847fc62f42 100644 --- a/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx +++ b/apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx @@ -12,7 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useKeyboardState } from "react-native-keyboard-controller"; import { useAndroidControlSizing } from "../../components/useAndroidControlSizing"; -import { flattenThemeColor, themeColorWithAlpha } from "../../lib/mobileTheme"; +import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useWorkspaceTabs } from "./WorkspaceTabsContext"; import type { WorkspaceTab } from "./workspace-tabs-state"; @@ -49,12 +49,8 @@ export function AndroidWorkspaceNavigation(props: { const { themeAppearance, themeVariables: colors } = useAppearancePreferences(); const { scale } = useAndroidControlSizing(); const rail = Boolean(props.rail); - const containerColor = rail - ? colors["--color-header"] - : flattenThemeColor( - themeColorWithAlpha(colors["--color-header-foreground"], 0.05), - flattenThemeColor(colors["--color-header"], colors["--color-screen"]), - ); + const { containerColor, indicatorColor, selectedColor, unselectedColor } = + androidWorkspaceNavigationColors(colors, rail); if (!rail && keyboardVisible) return null; return ( @@ -77,9 +73,9 @@ export function AndroidWorkspaceNavigation(props: { dark={themeAppearance === "dark"} textScale={scale} containerColor={nativeColor(containerColor)} - selectedColor={nativeColor(colors["--color-secondary-foreground"])} - indicatorColor={nativeColor(colors["--color-secondary"])} - unselectedColor={nativeColor(colors["--color-header-foreground"])} + selectedColor={nativeColor(selectedColor)} + indicatorColor={nativeColor(indicatorColor)} + unselectedColor={nativeColor(unselectedColor)} onTabSelected={(event) => { Keyboard.dismiss(); const tab = event.nativeEvent.tab; diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2216ed6b4d6319598e05a60d67a726277c2ade58 --- /dev/null +++ b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from "vite-plus/test"; +import { Hct, SchemeTonalSpot, argbFromHex, hexFromArgb } from "@material/material-color-utilities"; + +import { MOBILE_THEME_IDS } from "../../lib/mobileTheme"; +import { getMobileThemeRuntimeVariables } from "../../lib/mobileThemeVariables"; +import { materialYouPaletteToMobileThemeVariables } from "../../lib/materialYouTheme.android"; +import type { MaterialYouPalette } from "../../lib/materialYouPalette"; +import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors"; + +function luminance(hex: string) { + const channels = hex + .slice(1, 7) + .match(/.{2}/g)! + .map((channel) => Number.parseInt(channel, 16) / 255) + .map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)); + return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!; +} + +function contrast(first: string, second: string) { + const firstLuminance = luminance(first); + const secondLuminance = luminance(second); + return ( + (Math.max(firstLuminance, secondLuminance) + 0.05) / + (Math.min(firstLuminance, secondLuminance) + 0.05) + ); +} + +function expectVisibleSelection(colors: ReturnType) { + expect(contrast(colors.indicatorColor, colors.containerColor)).toBeGreaterThanOrEqual(1.5); + // Native labels sit below the indicator, while their icons sit within it. + for (const surface of [colors.containerColor, colors.indicatorColor]) { + expect(contrast(colors.selectedColor, surface)).toBeGreaterThanOrEqual(4.5); + } + expect(contrast(colors.unselectedColor, colors.containerColor)).toBeGreaterThanOrEqual(4.5); +} + +describe("Android workspace navigation colors", () => { + it.each(MOBILE_THEME_IDS)( + "keeps %s selection visible in both navigation positions", + (themeId) => { + for (const appearance of ["light", "dark"] as const) { + const variables = getMobileThemeRuntimeVariables(themeId, appearance, "android"); + const snapshot = { ...variables }; + for (const rail of [false, true]) { + expectVisibleSelection(androidWorkspaceNavigationColors(variables, rail)); + } + expect(variables).toEqual(snapshot); + } + }, + ); + + it.each(["#6750a4", "#006a6a", "#9c4146"])( + "keeps Material You selection visible for system seed %s", + (seed) => { + for (const appearance of ["light", "dark"] as const) { + const scheme = new SchemeTonalSpot( + Hct.fromInt(argbFromHex(seed)), + appearance === "dark", + 0, + ); + const roles = [ + "primary", + "onPrimary", + "primaryContainer", + "onPrimaryContainer", + "inversePrimary", + "secondaryContainer", + "onSecondaryContainer", + "tertiary", + "tertiaryContainer", + "onTertiaryContainer", + "surface", + "onSurface", + "onSurfaceVariant", + "surfaceContainer", + "surfaceContainerHigh", + "surfaceContainerHighest", + "surfaceContainerLow", + "surfaceContainerLowest", + "errorContainer", + "onErrorContainer", + "outline", + "outlineVariant", + "scrim", + ] as const satisfies ReadonlyArray; + const palette = Object.fromEntries( + roles.map((role) => [role, `${hexFromArgb(scheme[role])}ff`]), + ) as Record<(typeof roles)[number], string>; + const variables = materialYouPaletteToMobileThemeVariables( + palette, + appearance, + getMobileThemeRuntimeVariables("material-you", appearance, "android"), + ); + for (const rail of [false, true]) { + expectVisibleSelection(androidWorkspaceNavigationColors(variables, rail)); + } + } + }, + ); + + it("preserves an already visible tonal fill", () => { + const variables = getMobileThemeRuntimeVariables("t3-chat", "light", "android"); + const colors = androidWorkspaceNavigationColors( + { ...variables, "--color-secondary": "#e091cf" }, + true, + ); + expect(colors.indicatorColor).toBe("#e091cf"); + expectVisibleSelection(colors); + }); +}); diff --git a/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts new file mode 100644 index 0000000000000000000000000000000000000000..a43f7e574af2cfbefcb1a816b23197d001179385 --- /dev/null +++ b/apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts @@ -0,0 +1,28 @@ +import { + flattenThemeColor, + themeColorWithAlpha, + themeColorWithContrast, + type MobileThemeVariables, +} from "../../lib/mobileTheme"; + +/** Both navigation positions keep a visible tonal selection against their actual surface. */ +export function androidWorkspaceNavigationColors(colors: MobileThemeVariables, rail: boolean) { + const header = flattenThemeColor(colors["--color-header"], colors["--color-screen"]); + const containerColor = rail + ? header + : flattenThemeColor(themeColorWithAlpha(colors["--color-header-foreground"], 0.05), header); + const indicatorColor = themeColorWithContrast( + flattenThemeColor(colors["--color-secondary"], containerColor), + containerColor, + 1.5, + ); + return { + containerColor, + indicatorColor, + selectedColor: themeColorWithContrast( + flattenThemeColor(colors["--color-secondary-foreground"], indicatorColor), + [indicatorColor, containerColor], + ), + unselectedColor: flattenThemeColor(colors["--color-header-foreground"], containerColor), + }; +} diff --git a/apps/mobile/src/lib/mobileTheme.test-support.ts b/apps/mobile/src/lib/mobileTheme.test-support.ts index c363c4a559c6dee94571f91378db089128fa57cd..b40b67b1fd27fbeda40f43be5d1655178a9fd489 100644 --- a/apps/mobile/src/lib/mobileTheme.test-support.ts +++ b/apps/mobile/src/lib/mobileTheme.test-support.ts @@ -9,14 +9,18 @@ import { export function readDefaultMobileThemeVariables( appearance: MobileThemeAppearance, ): MobileThemeVariables { + return readGeneratedMobileThemeVariables(appearance); +} + +export function readGeneratedMobileThemeVariables(themeName: string): MobileThemeVariables { const stylesheet = NodeFS.readFileSync( new URL("../../generated-uniwind-themes.css", import.meta.url), "utf8", ); - const variant = new RegExp(`@variant ${appearance} \\{([\\s\\S]*?)\\n \\}`, "u").exec( + const variant = new RegExp(`@variant ${themeName} \\{([\\s\\S]*?)\\n \\}`, "u").exec( stylesheet, )?.[1]; - if (variant === undefined) throw new Error(`Missing generated default ${appearance} theme.`); + if (variant === undefined) throw new Error(`Missing generated ${themeName} theme.`); return Object.fromEntries( Array.from(variant.matchAll(/(--color-[a-z0-9-]+):\s*([^;]+);/gu), ([, name, value]) => [ diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index c1b4ce553fddd2b7943fdf0eaf4e7f73e6a9b473..0793a6f174360291c593a84e32e7a1ac61f64bd6 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -8,7 +8,10 @@ import { MOBILE_THEME_IDS, getThemeColorsForAppearance, } from "@t3tools/shared/themePalettes"; -import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support"; +import { + readDefaultMobileThemeVariables, + readGeneratedMobileThemeVariables, +} from "./mobileTheme.test-support"; import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables"; import { @@ -96,12 +99,7 @@ describe("mobile themes", () => { ); expect(variables["--color-card"]).toBe(themeColorToNativeColor(colors.surface)); expect(variables["--color-composer-surface"]).toBe( - themeColorWithAlpha( - themeId === DEFAULT_MOBILE_THEME_ID - ? variables["--color-grouped-card"] - : themeColorToNativeColor(colors.surface), - appearance === "dark" ? 0.9 : 0.94, - ), + themeColorWithAlpha(variables["--color-grouped-card"], appearance === "dark" ? 0.9 : 0.94), ); expect(variables["--color-thread-selected"]).toBe( themeColorToNativeColor(colors.sidebarRowActive), @@ -182,6 +180,49 @@ describe("mobile themes", () => { expect(variables["--color-screen"]).toMatch(/^#/); }); + it.each(MOBILE_THEME_IDS)("separates %s filled groups from their sheets", (themeId) => { + for (const appearance of ["light", "dark"] as const) { + const generated = readGeneratedMobileThemeVariables( + themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`, + ); + expect(generated).toEqual(getMobileThemeVariables(themeId, appearance)); + for (const platform of ["ios", "android"]) { + const variables = getMobileThemeRuntimeVariables(themeId, appearance, platform); + const sheet = variables["--color-sheet-solid"]; + const group = variables["--color-grouped-card"]; + expect( + contrastRatio(group, sheet), + `${platform} ${appearance}: grouped surface on sheet`, + ).toBeGreaterThanOrEqual(1.025); + for (const role of [ + "--color-foreground", + "--color-foreground-secondary", + "--color-foreground-muted", + "--color-placeholder", + "--color-primary-text", + ] as const) { + expect( + contrastRatio(variables[role], group), + `${platform} ${appearance}: ${role} on grouped surface`, + ).toBeGreaterThanOrEqual(4.5); + } + if (platform === "android") { + const header = flattenThemeColor(variables["--color-header"], sheet); + for (const role of ["--color-header-foreground", "--color-foreground-muted"] as const) { + expect( + contrastRatio(variables[role], header), + `${appearance}: ${role} on Android header`, + ).toBeGreaterThanOrEqual(4.5); + } + } + expect( + contrastRatio(variables["--color-foreground-muted"], variables["--color-secondary"]), + `${platform} ${appearance}: muted label on secondary surface`, + ).toBeGreaterThanOrEqual(4.5); + } + } + }); + it.each(["light", "dark"] as const)( "separates default settings groups from their %s background", (appearance) => { diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 4159232fe7abf91b3194514027613bc059a799f7..933f214f215c0a7b4acabbcbe610853d19258716 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -191,15 +191,23 @@ function contrastRatio( ); } -/** Preserve the color's hue while giving text 4.5:1 contrast on every supplied surface. */ -function readableTextColor(accent: string, surface: string | ReadonlyArray): string { +/** Keep the theme tint while adjusting lightness to contrast with every supplied surface. */ +export function themeColorWithContrast( + accent: string, + surface: string | ReadonlyArray, + minimum = 4.5, +): string { const accentChannels = rgbChannels(accent); const surfaceChannels = (typeof surface === "string" ? [surface] : surface) .map(rgbChannels) .filter((channels) => channels !== null); const minimumContrast = (channels: readonly [number, number, number]) => Math.min(...surfaceChannels.map((surface) => contrastRatio(channels, surface))); - if (!accentChannels || surfaceChannels.length === 0 || minimumContrast(accentChannels) >= 4.5) { + if ( + !accentChannels || + surfaceChannels.length === 0 || + minimumContrast(accentChannels) >= minimum + ) { return accent; } @@ -216,7 +224,7 @@ function readableTextColor(accent: string, surface: string | ReadonlyArray= 4.5) { + if (minimumContrast(candidate) >= minimum) { readable = candidate; upperAmount = amount; } else { @@ -242,7 +250,15 @@ export function createMobileThemeVariables( ) { const c = nativeColors(colors); const groupedCard = themeColorToNativeColor(groupedCardColor); - const textSurfaces = [c.canvas, c.surface, c.surfaceRaised, c.chrome, groupedCard]; + const textSurfaces = [ + c.canvas, + c.surface, + c.surfaceRaised, + c.chrome, + c.sidebar, + c.secondary, + groupedCard, + ]; return { "--color-screen": c.canvas, "--color-sheet": withAlpha(c.chrome, 0.98), @@ -254,7 +270,7 @@ export function createMobileThemeVariables( "--color-thread-canvas": c.canvas, "--color-thread-selected": c.sidebarRowActive, "--color-thread-selected-foreground": c.sidebarForeground, - "--color-thread-selected-foreground-muted": readableTextColor( + "--color-thread-selected-foreground-muted": themeColorWithContrast( c.sidebarMutedForeground, c.sidebarRowActive, ), @@ -270,8 +286,8 @@ export function createMobileThemeVariables( groupedCard === c.surface ? (appearance === "dark" ? 0.46 : 0.54) : 0.8, ), "--color-foreground": c.text, - "--color-foreground-secondary": readableTextColor(c.textMuted, textSurfaces), - "--color-foreground-muted": readableTextColor(c.mutedForeground, textSurfaces), + "--color-foreground-secondary": themeColorWithContrast(c.textMuted, textSurfaces), + "--color-foreground-muted": themeColorWithContrast(c.mutedForeground, textSurfaces), "--color-foreground-tertiary": c.secondaryLabel, "--color-border": c.border, "--color-focus": c.focus, @@ -284,7 +300,7 @@ export function createMobileThemeVariables( "--color-inline-skill-foreground": c.accentSurfaceForeground, "--color-primary": c.messageAction, "--color-primary-foreground": c.messageActionForeground, - "--color-primary-text": readableTextColor(c.messageAction, textSurfaces), + "--color-primary-text": themeColorWithContrast(c.messageAction, textSurfaces), "--color-primary-shadow": "#000000", "--color-secondary": c.secondary, "--color-secondary-foreground": c.secondaryForeground, @@ -304,7 +320,7 @@ export function createMobileThemeVariables( "--color-input": c.surface, "--color-input-border": c.input, "--color-sidebar-search": c.sidebarControlSurface, - "--color-placeholder": readableTextColor(c.placeholder, textSurfaces), + "--color-placeholder": themeColorWithContrast(c.placeholder, textSurfaces), "--color-icon": c.text, "--color-icon-muted": c.iconMuted, "--color-icon-subtle": c.secondaryLabel, @@ -317,7 +333,7 @@ export function createMobileThemeVariables( "--color-status-bar": c.canvas, "--color-md-body": c.text, "--color-md-strong": c.text, - "--color-md-link": readableTextColor(c.messageAction, c.canvas), + "--color-md-link": themeColorWithContrast(c.messageAction, c.canvas), "--color-md-blockquote-border": c.border, "--color-md-blockquote-bg": c.muted, "--color-md-code-bg": c.codeBackground, @@ -330,11 +346,14 @@ export function createMobileThemeVariables( "--color-user-bubble": c.messageSurface, "--color-user-bubble-foreground": c.messageForeground, "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78), - "--color-user-bubble-skill-foreground": readableTextColor(c.messageAction, c.messageSurface), + "--color-user-bubble-skill-foreground": themeColorWithContrast( + c.messageAction, + c.messageSurface, + ), "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22), "--color-drawer": c.sidebar, "--color-drawer-foreground": c.sidebarForeground, - "--color-drawer-foreground-muted": readableTextColor(c.sidebarMutedForeground, [ + "--color-drawer-foreground-muted": themeColorWithContrast(c.sidebarMutedForeground, [ c.sidebar, c.sidebarRowHover, ]), @@ -368,13 +387,16 @@ export function getMobileThemeVariables( ): MobileThemeVariables { const colors = getMobileThemeColors(themeId, appearance); // Mobile settings groups and fallback materials use tonal fills where desktop - // uses outlined cards. Regular cards retain their shared desktop surface. + // uses outlined cards. Raise flat desktop surfaces above the sheet so grouped + // content keeps a visible boundary. Regular cards retain their desktop surface. const groupedCard = themeId === DEFAULT_MOBILE_THEME_ID ? appearance === "light" ? colors.toolbarControlHover : colors.sidebarRowActive - : colors.surface; + : colors.surface === colors.chrome + ? colors.surfaceRaised + : colors.surface; const mobileColors = themeId === DEFAULT_MOBILE_THEME_ID ? {