authorgravatar for me@pixp.ccAlex <me@pixp.cc> 2026-10-11 20:32:23-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 20:32:23-07:00
log52f9046260981270292e1fd9c77c2c3fd942be54
tree6bd12d08325491c5dedccadd5ee55d2af3ee38f7
parentc32ade41390be10783246fbbeed45de7e3add79b
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep PR surfaces and Android tab selection visible across themes (#18479)


9 files changed, 301 insertions(+), 99 deletions(-)

apps/mobile/generated-uniwind-themes.css+58-58
......@@ -337,7 +337,7 @@
337337 --color-composer-surface: rgba(250, 243, 251, 0.94);
338338 --color-composer-border: rgba(238, 225, 237, 0.54);
339339 --color-foreground: #501854;
340 --color-foreground-secondary: #ac1668;
340 --color-foreground-secondary: #ab1668;
341341 --color-foreground-muted: #8d1255;
342342 --color-foreground-tertiary: #ac1668;
343343 --color-border: #eee1ed;
......@@ -351,7 +351,7 @@
351351 --color-inline-skill-foreground: #454554;
352352 --color-primary: #db2777;
353353 --color-primary-foreground: #ffffff;
354 --color-primary-text: #d32572;
354 --color-primary-text: #aa1e5d;
355355 --color-primary-shadow: #000000;
356356 --color-secondary: #f1c4e6;
357357 --color-secondary-foreground: #77347c;
......@@ -371,7 +371,7 @@
371371 --color-input: #faf3fb;
372372 --color-input-border: #e7c1dc;
373373 --color-sidebar-search: #f8f8f7;
374 --color-placeholder: #8b5f90;
374 --color-placeholder: #714e76;
375375 --color-icon: #501854;
376376 --color-icon-muted: #ac1668;
377377 --color-icon-subtle: #ac1668;
......@@ -510,7 +510,7 @@
510510 --color-inline-skill-foreground: #f8f1f5;
511511 --color-primary: #a3004c;
512512 --color-primary-foreground: #fbd0e8;
513 --color-primary-text: #cc729c;
513 --color-primary-text: #d07ea4;
514514 --color-primary-shadow: #000000;
515515 --color-secondary: #362d3d;
516516 --color-secondary-foreground: #d4c7e1;
......@@ -530,7 +530,7 @@
530530 --color-input: #29232d;
531531 --color-input-border: #302029;
532532 --color-sidebar-search: #261922;
533 --color-placeholder: #968d9f;
533 --color-placeholder: #9d94a5;
534534 --color-icon: #f9f8fb;
535535 --color-icon-muted: #d4c7e1;
536536 --color-icon-subtle: #e7d0dd;
......@@ -642,7 +642,7 @@
642642 --color-sheet: rgba(243, 247, 244, 0.98);
643643 --color-sheet-solid: #f3f7f4;
644644 --color-card: #f3f7f4;
645 --color-grouped-card: #f3f7f4;
645 --color-grouped-card: #ecefed;
646646 --color-card-alt: #ecefed;
647647 --color-card-translucent: rgba(243, 247, 244, 0.8);
648648 --color-thread-canvas: #f3f7f4;
......@@ -652,11 +652,11 @@
652652 --color-thread-hover: #cae0d5;
653653 --color-row-hover: #d5e6dd;
654654 --color-composer-panel: rgba(243, 247, 244, 0.88);
655 --color-composer-surface: rgba(243, 247, 244, 0.94);
656 --color-composer-border: rgba(203, 213, 209, 0.54);
655 --color-composer-surface: rgba(236, 239, 237, 0.94);
656 --color-composer-border: rgba(203, 213, 209, 0.8);
657657 --color-foreground: #241523;
658 --color-foreground-secondary: #726a71;
659 --color-foreground-muted: #6e696f;
658 --color-foreground-secondary: #6f686f;
659 --color-foreground-muted: #6d686e;
660660 --color-foreground-tertiary: #746c73;
661661 --color-border: #cbd5d1;
662662 --color-focus: #1b7d50;
......@@ -669,7 +669,7 @@
669669 --color-inline-skill-foreground: #241523;
670670 --color-primary: #8f6410;
671671 --color-primary-foreground: #fffaff;
672 --color-primary-text: #8f6410;
672 --color-primary-text: #8d6210;
673673 --color-primary-shadow: #000000;
674674 --color-secondary: #e2ede7;
675675 --color-secondary-foreground: #241523;
......@@ -689,7 +689,7 @@
689689 --color-input: #f3f7f4;
690690 --color-input-border: #becbc5;
691691 --color-sidebar-search: #d3dcd8;
692 --color-placeholder: #716971;
692 --color-placeholder: #6f686f;
693693 --color-icon: #241523;
694694 --color-icon-muted: #746c73;
695695 --color-icon-subtle: #746c73;
......@@ -697,7 +697,7 @@
697697 --color-header-foreground: #241523;
698698 --color-header-border: #d5e6dd;
699699 --color-glass-surface: rgba(231, 233, 232, 0.74);
700 --color-glass-fallback: rgba(243, 247, 244, 0.94);
700 --color-glass-fallback: rgba(236, 239, 237, 0.94);
701701 --color-glass-tint: rgba(231, 233, 232, 0.22);
702702 --color-status-bar: #f3f7f4;
703703 --color-md-body: #241523;
......@@ -801,7 +801,7 @@
801801 --color-sheet: rgba(27, 40, 33, 0.98);
802802 --color-sheet-solid: #1b2821;
803803 --color-card: #1b2821;
804 --color-grouped-card: #1b2821;
804 --color-grouped-card: #36413c;
805805 --color-card-alt: #36413c;
806806 --color-card-translucent: rgba(27, 40, 33, 0.8);
807807 --color-thread-canvas: #1b2821;
......@@ -811,11 +811,11 @@
811811 --color-thread-hover: #2a4938;
812812 --color-row-hover: #325c46;
813813 --color-composer-panel: rgba(27, 40, 33, 0.92);
814 --color-composer-surface: rgba(27, 40, 33, 0.9);
815 --color-composer-border: rgba(65, 95, 79, 0.46);
814 --color-composer-surface: rgba(54, 65, 60, 0.9);
815 --color-composer-border: rgba(65, 95, 79, 0.8);
816816 --color-foreground: #fffaff;
817 --color-foreground-secondary: #a6aaaa;
818 --color-foreground-muted: #a3aba8;
817 --color-foreground-secondary: #afb1b1;
818 --color-foreground-muted: #acb2b0;
819819 --color-foreground-tertiary: #919595;
820820 --color-border: #415f4f;
821821 --color-focus: #69d69a;
......@@ -848,7 +848,7 @@
848848 --color-input: #1b2821;
849849 --color-input-border: #4f725f;
850850 --color-sidebar-search: #45554d;
851 --color-placeholder: #a9abab;
851 --color-placeholder: #afb1b1;
852852 --color-icon: #fffaff;
853853 --color-icon-muted: #919595;
854854 --color-icon-subtle: #919595;
......@@ -856,7 +856,7 @@
856856 --color-header-foreground: #fffaff;
857857 --color-header-border: #36654c;
858858 --color-glass-surface: rgba(68, 77, 73, 0.74);
859 --color-glass-fallback: rgba(27, 40, 33, 0.9);
859 --color-glass-fallback: rgba(54, 65, 60, 0.9);
860860 --color-glass-tint: rgba(68, 77, 73, 0.22);
861861 --color-status-bar: #1b2821;
862862 --color-md-body: #fffaff;
......@@ -960,7 +960,7 @@
960960 --color-sheet: rgba(245, 247, 248, 0.98);
961961 --color-sheet-solid: #f5f7f8;
962962 --color-card: #f5f7f8;
963 --color-grouped-card: #f5f7f8;
963 --color-grouped-card: #edeff1;
964964 --color-card-alt: #edeff1;
965965 --color-card-translucent: rgba(245, 247, 248, 0.8);
966966 --color-thread-canvas: #f5f7f8;
......@@ -970,10 +970,10 @@
970970 --color-thread-hover: #cdddea;
971971 --color-row-hover: #d8e4ee;
972972 --color-composer-panel: rgba(245, 247, 248, 0.88);
973 --color-composer-surface: rgba(245, 247, 248, 0.94);
974 --color-composer-border: rgba(205, 212, 220, 0.54);
973 --color-composer-surface: rgba(237, 239, 241, 0.94);
974 --color-composer-border: rgba(205, 212, 220, 0.8);
975975 --color-foreground: #241523;
976 --color-foreground-secondary: #726a73;
976 --color-foreground-secondary: #706870;
977977 --color-foreground-muted: #6f6873;
978978 --color-foreground-tertiary: #746c75;
979979 --color-border: #cdd4dc;
......@@ -1007,7 +1007,7 @@
10071007 --color-input: #f5f7f8;
10081008 --color-input-border: #c0c9d4;
10091009 --color-sidebar-search: #d5dbe2;
1010 --color-placeholder: #716972;
1010 --color-placeholder: #706870;
10111011 --color-icon: #241523;
10121012 --color-icon-muted: #746c75;
10131013 --color-icon-subtle: #746c75;
......@@ -1015,7 +1015,7 @@
10151015 --color-header-foreground: #241523;
10161016 --color-header-border: #d8e4ee;
10171017 --color-glass-surface: rgba(232, 233, 235, 0.74);
1018 --color-glass-fallback: rgba(245, 247, 248, 0.94);
1018 --color-glass-fallback: rgba(237, 239, 241, 0.94);
10191019 --color-glass-tint: rgba(232, 233, 235, 0.22);
10201020 --color-status-bar: #f5f7f8;
10211021 --color-md-body: #241523;
......@@ -1119,7 +1119,7 @@
11191119 --color-sheet: rgba(23, 33, 43, 0.98);
11201120 --color-sheet-solid: #17212b;
11211121 --color-card: #17212b;
1122 --color-grouped-card: #17212b;
1122 --color-grouped-card: #333b45;
11231123 --color-card-alt: #333b45;
11241124 --color-card-translucent: rgba(23, 33, 43, 0.8);
11251125 --color-thread-canvas: #17212b;
......@@ -1129,11 +1129,11 @@
11291129 --color-thread-hover: #283e50;
11301130 --color-row-hover: #324e66;
11311131 --color-composer-panel: rgba(23, 33, 43, 0.92);
1132 --color-composer-surface: rgba(23, 33, 43, 0.9);
1133 --color-composer-border: rgba(64, 85, 103, 0.46);
1132 --color-composer-surface: rgba(51, 59, 69, 0.9);
1133 --color-composer-border: rgba(64, 85, 103, 0.8);
11341134 --color-foreground: #fffaff;
1135 --color-foreground-secondary: #a2a3aa;
1136 --color-foreground-muted: #9ea4ad;
1135 --color-foreground-secondary: #a5a7ad;
1136 --color-foreground-muted: #a2a7b0;
11371137 --color-foreground-tertiary: #8d8f97;
11381138 --color-border: #405567;
11391139 --color-focus: #70b9ee;
......@@ -1166,7 +1166,7 @@
11661166 --color-input: #17212b;
11671167 --color-input-border: #4f677b;
11681168 --color-sidebar-search: #424e5a;
1169 --color-placeholder: #a4a4ac;
1169 --color-placeholder: #a6a6ad;
11701170 --color-icon: #fffaff;
11711171 --color-icon-muted: #8d8f97;
11721172 --color-icon-subtle: #8d8f97;
......@@ -1174,7 +1174,7 @@
11741174 --color-header-foreground: #fffaff;
11751175 --color-header-border: #36566f;
11761176 --color-glass-surface: rgba(65, 72, 81, 0.74);
1177 --color-glass-fallback: rgba(23, 33, 43, 0.9);
1177 --color-glass-fallback: rgba(51, 59, 69, 0.9);
11781178 --color-glass-tint: rgba(65, 72, 81, 0.22);
11791179 --color-status-bar: #17212b;
11801180 --color-md-body: #fffaff;
......@@ -1278,7 +1278,7 @@
12781278 --color-sheet: rgba(249, 247, 245, 0.98);
12791279 --color-sheet-solid: #f9f7f5;
12801280 --color-card: #f9f7f5;
1281 --color-grouped-card: #f9f7f5;
1281 --color-grouped-card: #f1efee;
12821282 --color-card-alt: #f1efee;
12831283 --color-card-translucent: rgba(249, 247, 245, 0.8);
12841284 --color-thread-canvas: #f9f7f5;
......@@ -1288,11 +1288,11 @@
12881288 --color-thread-hover: #ead8cf;
12891289 --color-row-hover: #eee0d9;
12901290 --color-composer-panel: rgba(249, 247, 245, 0.88);
1291 --color-composer-surface: rgba(249, 247, 245, 0.94);
1292 --color-composer-border: rgba(221, 210, 206, 0.54);
1291 --color-composer-surface: rgba(241, 239, 238, 0.94);
1292 --color-composer-border: rgba(221, 210, 206, 0.8);
12931293 --color-foreground: #241523;
1294 --color-foreground-secondary: #746a72;
1295 --color-foreground-muted: #74686f;
1294 --color-foreground-secondary: #726870;
1295 --color-foreground-muted: #73686e;
12961296 --color-foreground-tertiary: #766c74;
12971297 --color-border: #ddd2ce;
12981298 --color-focus: #ae552a;
......@@ -1325,7 +1325,7 @@
13251325 --color-input: #f9f7f5;
13261326 --color-input-border: #d4c6c1;
13271327 --color-sidebar-search: #e2d9d6;
1328 --color-placeholder: #736971;
1328 --color-placeholder: #726870;
13291329 --color-icon: #241523;
13301330 --color-icon-muted: #766c74;
13311331 --color-icon-subtle: #766c74;
......@@ -1333,7 +1333,7 @@
13331333 --color-header-foreground: #241523;
13341334 --color-header-border: #eee0d9;
13351335 --color-glass-surface: rgba(236, 233, 233, 0.74);
1336 --color-glass-fallback: rgba(249, 247, 245, 0.94);
1336 --color-glass-fallback: rgba(241, 239, 238, 0.94);
13371337 --color-glass-tint: rgba(236, 233, 233, 0.22);
13381338 --color-status-bar: #f9f7f5;
13391339 --color-md-body: #241523;
......@@ -1437,7 +1437,7 @@
14371437 --color-sheet: rgba(41, 30, 26, 0.98);
14381438 --color-sheet-solid: #291e1a;
14391439 --color-card: #291e1a;
1440 --color-grouped-card: #291e1a;
1440 --color-grouped-card: #433835;
14411441 --color-card-alt: #433835;
14421442 --color-card-translucent: rgba(41, 30, 26, 0.8);
14431443 --color-thread-canvas: #291e1a;
......@@ -1447,11 +1447,11 @@
14471447 --color-thread-hover: #4f3528;
14481448 --color-row-hover: #644330;
14491449 --color-composer-panel: rgba(41, 30, 26, 0.92);
1450 --color-composer-surface: rgba(41, 30, 26, 0.9);
1451 --color-composer-border: rgba(102, 76, 63, 0.46);
1450 --color-composer-surface: rgba(67, 56, 53, 0.9);
1451 --color-composer-border: rgba(102, 76, 63, 0.8);
14521452 --color-foreground: #fffaff;
1453 --color-foreground-secondary: #a8a2a2;
1454 --color-foreground-muted: #aca19f;
1453 --color-foreground-secondary: #aca5a6;
1454 --color-foreground-muted: #afa5a2;
14551455 --color-foreground-tertiary: #968e8f;
14561456 --color-border: #664c3f;
14571457 --color-focus: #f09a64;
......@@ -1484,7 +1484,7 @@
14841484 --color-input: #291e1a;
14851485 --color-input-border: #7a5d4d;
14861486 --color-sidebar-search: #584943;
1487 --color-placeholder: #aba3a5;
1487 --color-placeholder: #aca5a6;
14881488 --color-icon: #fffaff;
14891489 --color-icon-muted: #968e8f;
14901490 --color-icon-subtle: #968e8f;
......@@ -1492,7 +1492,7 @@
14921492 --color-header-foreground: #fffaff;
14931493 --color-header-border: #6e4934;
14941494 --color-glass-surface: rgba(79, 69, 67, 0.74);
1495 --color-glass-fallback: rgba(41, 30, 26, 0.9);
1495 --color-glass-fallback: rgba(67, 56, 53, 0.9);
14961496 --color-glass-tint: rgba(79, 69, 67, 0.22);
14971497 --color-status-bar: #291e1a;
14981498 --color-md-body: #fffaff;
......@@ -1596,7 +1596,7 @@
15961596 --color-sheet: rgba(248, 247, 249, 0.98);
15971597 --color-sheet-solid: #f8f7f9;
15981598 --color-card: #f8f7f9;
1599 --color-grouped-card: #f8f7f9;
1599 --color-grouped-card: #f0eff2;
16001600 --color-card-alt: #f0eff2;
16011601 --color-card-translucent: rgba(248, 247, 249, 0.8);
16021602 --color-thread-canvas: #f8f7f9;
......@@ -1606,11 +1606,11 @@
16061606 --color-thread-hover: #ded8ed;
16071607 --color-row-hover: #e5e0f0;
16081608 --color-composer-panel: rgba(248, 247, 249, 0.88);
1609 --color-composer-surface: rgba(248, 247, 249, 0.94);
1610 --color-composer-border: rgba(214, 209, 222, 0.54);
1609 --color-composer-surface: rgba(240, 239, 242, 0.94);
1610 --color-composer-border: rgba(214, 209, 222, 0.8);
16111611 --color-foreground: #241523;
1612 --color-foreground-secondary: #746a74;
1613 --color-foreground-muted: #726874;
1612 --color-foreground-secondary: #716871;
1613 --color-foreground-muted: #716773;
16141614 --color-foreground-tertiary: #766c76;
16151615 --color-border: #d6d1de;
16161616 --color-focus: #7253b9;
......@@ -1643,7 +1643,7 @@
16431643 --color-input: #f8f7f9;
16441644 --color-input-border: #ccc5d6;
16451645 --color-sidebar-search: #ddd9e3;
1646 --color-placeholder: #736973;
1646 --color-placeholder: #716871;
16471647 --color-icon: #241523;
16481648 --color-icon-muted: #766c76;
16491649 --color-icon-subtle: #766c76;
......@@ -1651,7 +1651,7 @@
16511651 --color-header-foreground: #241523;
16521652 --color-header-border: #e5e0f0;
16531653 --color-glass-surface: rgba(235, 233, 237, 0.74);
1654 --color-glass-fallback: rgba(248, 247, 249, 0.94);
1654 --color-glass-fallback: rgba(240, 239, 242, 0.94);
16551655 --color-glass-tint: rgba(235, 233, 237, 0.22);
16561656 --color-status-bar: #f8f7f9;
16571657 --color-md-body: #241523;
......@@ -1755,7 +1755,7 @@
17551755 --color-sheet: rgba(29, 25, 41, 0.98);
17561756 --color-sheet-solid: #1d1929;
17571757 --color-card: #1d1929;
1758 --color-grouped-card: #1d1929;
1758 --color-grouped-card: #383443;
17591759 --color-card-alt: #383443;
17601760 --color-card-translucent: rgba(29, 25, 41, 0.8);
17611761 --color-thread-canvas: #1d1929;
......@@ -1765,8 +1765,8 @@
17651765 --color-thread-hover: #352c4f;
17661766 --color-row-hover: #433765;
17671767 --color-composer-panel: rgba(29, 25, 41, 0.92);
1768 --color-composer-surface: rgba(29, 25, 41, 0.9);
1769 --color-composer-border: rgba(77, 67, 102, 0.46);
1768 --color-composer-surface: rgba(56, 52, 67, 0.9);
1769 --color-composer-border: rgba(77, 67, 102, 0.8);
17701770 --color-foreground: #fffaff;
17711771 --color-foreground-secondary: #a09da6;
17721772 --color-foreground-muted: #a19cab;
......@@ -1810,7 +1810,7 @@
18101810 --color-header-foreground: #fffaff;
18111811 --color-header-border: #4a3c70;
18121812 --color-glass-surface: rgba(69, 66, 80, 0.74);
1813 --color-glass-fallback: rgba(29, 25, 41, 0.9);
1813 --color-glass-fallback: rgba(56, 52, 67, 0.9);
18141814 --color-glass-tint: rgba(69, 66, 80, 0.22);
18151815 --color-status-bar: #1d1929;
18161816 --color-md-body: #fffaff;
apps/mobile/src/features/pull-requests/MaterialFloatingToolbar.android.tsx+1-1
......@@ -15,7 +15,7 @@ export function MaterialFloatingToolbar(props: { readonly children: ReactElement
1515 >
1616 <HorizontalFloatingToolbar
1717 colors={{
18 toolbarContainerColor: colors["--color-card-alt"],
18 toolbarContainerColor: colors["--color-grouped-card"],
1919 toolbarContentColor: colors["--color-foreground"],
2020 }}
2121 >
apps/mobile/src/features/pull-requests/PullRequestDescription.tsx+7-6
......@@ -6,7 +6,7 @@ import {
66} from "./PullRequestMedia";
77import { PullRequestSectionHeading } from "./PullRequestSectionHeading";
88import { useContext, useMemo, useState } from "react";
9import { Pressable, ScrollView, StyleSheet, View } from "react-native";
9import { Platform, Pressable, ScrollView, StyleSheet, View } from "react-native";
1010import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg";
1111import { pullRequestImageSource } from "./pullRequestMediaSource";
1212import { parseMarkdownDocument } from "../../lib/markdownDocument";
......@@ -30,6 +30,7 @@ export function PullRequestDescription(props: {
3030 const [bodyHeight, setBodyHeight] = useState(0);
3131 const mediaContext = useContext(PullRequestMediaContext);
3232 const { themeVariables: colors } = useAppearancePreferences();
33 const fadeColor = colors[Platform.OS === "android" ? "--color-sheet-solid" : "--color-screen"];
3334 const content = useMemo(() => {
3435 const document = parseMarkdownDocument(props.markdown);
3536 const content = pullRequestDescriptionContent(document, splitPullRequestBody(props.markdown));
......@@ -87,10 +88,10 @@ export function PullRequestDescription(props: {
8788 y2="80"
8889 gradientUnits="userSpaceOnUse"
8990 >
90 <Stop offset="0" stopColor={colors["--color-screen"]} stopOpacity={0} />
91 <Stop offset="0.5" stopColor={colors["--color-screen"]} stopOpacity={0.94} />
92 <Stop offset="0.72" stopColor={colors["--color-screen"]} stopOpacity={1} />
93 <Stop offset="1" stopColor={colors["--color-screen"]} stopOpacity={1} />
91 <Stop offset="0" stopColor={fadeColor} stopOpacity={0} />
92 <Stop offset="0.5" stopColor={fadeColor} stopOpacity={0.94} />
93 <Stop offset="0.72" stopColor={fadeColor} stopOpacity={1} />
94 <Stop offset="1" stopColor={fadeColor} stopOpacity={1} />
9495 </LinearGradient>
9596 </Defs>
9697 <Rect width="100%" height="100%" fill="url(#description-fade)" />
......@@ -102,7 +103,7 @@ export function PullRequestDescription(props: {
102103 left: 0,
103104 right: 0,
104105 height: 44,
105 backgroundColor: colors["--color-screen"],
106 backgroundColor: fadeColor,
106107 }}
107108 />
108109 </View>
apps/mobile/src/features/workspace/AndroidWorkspaceNavigation.android.tsx+6-10
......@@ -12,7 +12,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
1212import { useKeyboardState } from "react-native-keyboard-controller";
1313
1414import { useAndroidControlSizing } from "../../components/useAndroidControlSizing";
15import { flattenThemeColor, themeColorWithAlpha } from "../../lib/mobileTheme";
15import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors";
1616import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
1717import { useWorkspaceTabs } from "./WorkspaceTabsContext";
1818import type { WorkspaceTab } from "./workspace-tabs-state";
......@@ -49,12 +49,8 @@ export function AndroidWorkspaceNavigation(props: {
4949 const { themeAppearance, themeVariables: colors } = useAppearancePreferences();
5050 const { scale } = useAndroidControlSizing();
5151 const rail = Boolean(props.rail);
52 const containerColor = rail
53 ? colors["--color-header"]
54 : flattenThemeColor(
55 themeColorWithAlpha(colors["--color-header-foreground"], 0.05),
56 flattenThemeColor(colors["--color-header"], colors["--color-screen"]),
57 );
52 const { containerColor, indicatorColor, selectedColor, unselectedColor } =
53 androidWorkspaceNavigationColors(colors, rail);
5854 if (!rail && keyboardVisible) return null;
5955
6056 return (
......@@ -77,9 +73,9 @@ export function AndroidWorkspaceNavigation(props: {
7773 dark={themeAppearance === "dark"}
7874 textScale={scale}
7975 containerColor={nativeColor(containerColor)}
80 selectedColor={nativeColor(colors["--color-secondary-foreground"])}
81 indicatorColor={nativeColor(colors["--color-secondary"])}
82 unselectedColor={nativeColor(colors["--color-header-foreground"])}
76 selectedColor={nativeColor(selectedColor)}
77 indicatorColor={nativeColor(indicatorColor)}
78 unselectedColor={nativeColor(unselectedColor)}
8379 onTabSelected={(event) => {
8480 Keyboard.dismiss();
8581 const tab = event.nativeEvent.tab;
apps/mobile/src/features/workspace/android-workspace-navigation-colors.test.ts created+110
......@@ -0,0 +1,110 @@
1import { describe, expect, it } from "vite-plus/test";
2import { Hct, SchemeTonalSpot, argbFromHex, hexFromArgb } from "@material/material-color-utilities";
3
4import { MOBILE_THEME_IDS } from "../../lib/mobileTheme";
5import { getMobileThemeRuntimeVariables } from "../../lib/mobileThemeVariables";
6import { materialYouPaletteToMobileThemeVariables } from "../../lib/materialYouTheme.android";
7import type { MaterialYouPalette } from "../../lib/materialYouPalette";
8import { androidWorkspaceNavigationColors } from "./android-workspace-navigation-colors";
9
10function luminance(hex: string) {
11 const channels = hex
12 .slice(1, 7)
13 .match(/.{2}/g)!
14 .map((channel) => Number.parseInt(channel, 16) / 255)
15 .map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4));
16 return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
17}
18
19function contrast(first: string, second: string) {
20 const firstLuminance = luminance(first);
21 const secondLuminance = luminance(second);
22 return (
23 (Math.max(firstLuminance, secondLuminance) + 0.05) /
24 (Math.min(firstLuminance, secondLuminance) + 0.05)
25 );
26}
27
28function expectVisibleSelection(colors: ReturnType<typeof androidWorkspaceNavigationColors>) {
29 expect(contrast(colors.indicatorColor, colors.containerColor)).toBeGreaterThanOrEqual(1.5);
30 // Native labels sit below the indicator, while their icons sit within it.
31 for (const surface of [colors.containerColor, colors.indicatorColor]) {
32 expect(contrast(colors.selectedColor, surface)).toBeGreaterThanOrEqual(4.5);
33 }
34 expect(contrast(colors.unselectedColor, colors.containerColor)).toBeGreaterThanOrEqual(4.5);
35}
36
37describe("Android workspace navigation colors", () => {
38 it.each(MOBILE_THEME_IDS)(
39 "keeps %s selection visible in both navigation positions",
40 (themeId) => {
41 for (const appearance of ["light", "dark"] as const) {
42 const variables = getMobileThemeRuntimeVariables(themeId, appearance, "android");
43 const snapshot = { ...variables };
44 for (const rail of [false, true]) {
45 expectVisibleSelection(androidWorkspaceNavigationColors(variables, rail));
46 }
47 expect(variables).toEqual(snapshot);
48 }
49 },
50 );
51
52 it.each(["#6750a4", "#006a6a", "#9c4146"])(
53 "keeps Material You selection visible for system seed %s",
54 (seed) => {
55 for (const appearance of ["light", "dark"] as const) {
56 const scheme = new SchemeTonalSpot(
57 Hct.fromInt(argbFromHex(seed)),
58 appearance === "dark",
59 0,
60 );
61 const roles = [
62 "primary",
63 "onPrimary",
64 "primaryContainer",
65 "onPrimaryContainer",
66 "inversePrimary",
67 "secondaryContainer",
68 "onSecondaryContainer",
69 "tertiary",
70 "tertiaryContainer",
71 "onTertiaryContainer",
72 "surface",
73 "onSurface",
74 "onSurfaceVariant",
75 "surfaceContainer",
76 "surfaceContainerHigh",
77 "surfaceContainerHighest",
78 "surfaceContainerLow",
79 "surfaceContainerLowest",
80 "errorContainer",
81 "onErrorContainer",
82 "outline",
83 "outlineVariant",
84 "scrim",
85 ] as const satisfies ReadonlyArray<keyof MaterialYouPalette>;
86 const palette = Object.fromEntries(
87 roles.map((role) => [role, `${hexFromArgb(scheme[role])}ff`]),
88 ) as Record<(typeof roles)[number], string>;
89 const variables = materialYouPaletteToMobileThemeVariables(
90 palette,
91 appearance,
92 getMobileThemeRuntimeVariables("material-you", appearance, "android"),
93 );
94 for (const rail of [false, true]) {
95 expectVisibleSelection(androidWorkspaceNavigationColors(variables, rail));
96 }
97 }
98 },
99 );
100
101 it("preserves an already visible tonal fill", () => {
102 const variables = getMobileThemeRuntimeVariables("t3-chat", "light", "android");
103 const colors = androidWorkspaceNavigationColors(
104 { ...variables, "--color-secondary": "#e091cf" },
105 true,
106 );
107 expect(colors.indicatorColor).toBe("#e091cf");
108 expectVisibleSelection(colors);
109 });
110});
apps/mobile/src/features/workspace/android-workspace-navigation-colors.ts created+28
......@@ -0,0 +1,28 @@
1import {
2 flattenThemeColor,
3 themeColorWithAlpha,
4 themeColorWithContrast,
5 type MobileThemeVariables,
6} from "../../lib/mobileTheme";
7
8/** Both navigation positions keep a visible tonal selection against their actual surface. */
9export function androidWorkspaceNavigationColors(colors: MobileThemeVariables, rail: boolean) {
10 const header = flattenThemeColor(colors["--color-header"], colors["--color-screen"]);
11 const containerColor = rail
12 ? header
13 : flattenThemeColor(themeColorWithAlpha(colors["--color-header-foreground"], 0.05), header);
14 const indicatorColor = themeColorWithContrast(
15 flattenThemeColor(colors["--color-secondary"], containerColor),
16 containerColor,
17 1.5,
18 );
19 return {
20 containerColor,
21 indicatorColor,
22 selectedColor: themeColorWithContrast(
23 flattenThemeColor(colors["--color-secondary-foreground"], indicatorColor),
24 [indicatorColor, containerColor],
25 ),
26 unselectedColor: flattenThemeColor(colors["--color-header-foreground"], containerColor),
27 };
28}
apps/mobile/src/lib/mobileTheme.test-support.ts+6-2
......@@ -9,14 +9,18 @@ import {
99export function readDefaultMobileThemeVariables(
1010 appearance: MobileThemeAppearance,
1111): MobileThemeVariables {
12 return readGeneratedMobileThemeVariables(appearance);
13}
14
15export function readGeneratedMobileThemeVariables(themeName: string): MobileThemeVariables {
1216 const stylesheet = NodeFS.readFileSync(
1317 new URL("../../generated-uniwind-themes.css", import.meta.url),
1418 "utf8",
1519 );
16 const variant = new RegExp(`@variant ${appearance} \\{([\\s\\S]*?)\\n \\}`, "u").exec(
20 const variant = new RegExp(`@variant ${themeName} \\{([\\s\\S]*?)\\n \\}`, "u").exec(
1721 stylesheet,
1822 )?.[1];
19 if (variant === undefined) throw new Error(`Missing generated default ${appearance} theme.`);
23 if (variant === undefined) throw new Error(`Missing generated ${themeName} theme.`);
2024
2125 return Object.fromEntries(
2226 Array.from(variant.matchAll(/(--color-[a-z0-9-]+):\s*([^;]+);/gu), ([, name, value]) => [
apps/mobile/src/lib/mobileTheme.test.ts+48-7
......@@ -8,7 +8,10 @@ import {
88 MOBILE_THEME_IDS,
99 getThemeColorsForAppearance,
1010} from "@t3tools/shared/themePalettes";
11import { readDefaultMobileThemeVariables } from "./mobileTheme.test-support";
11import {
12 readDefaultMobileThemeVariables,
13 readGeneratedMobileThemeVariables,
14} from "./mobileTheme.test-support";
1215import { getMobileThemeRuntimeVariables } from "./mobileThemeVariables";
1316
1417import {
......@@ -96,12 +99,7 @@ describe("mobile themes", () => {
9699 );
97100 expect(variables["--color-card"]).toBe(themeColorToNativeColor(colors.surface));
98101 expect(variables["--color-composer-surface"]).toBe(
99 themeColorWithAlpha(
100 themeId === DEFAULT_MOBILE_THEME_ID
101 ? variables["--color-grouped-card"]
102 : themeColorToNativeColor(colors.surface),
103 appearance === "dark" ? 0.9 : 0.94,
104 ),
102 themeColorWithAlpha(variables["--color-grouped-card"], appearance === "dark" ? 0.9 : 0.94),
105103 );
106104 expect(variables["--color-thread-selected"]).toBe(
107105 themeColorToNativeColor(colors.sidebarRowActive),
......@@ -182,6 +180,49 @@ describe("mobile themes", () => {
182180 expect(variables["--color-screen"]).toMatch(/^#/);
183181 });
184182
183 it.each(MOBILE_THEME_IDS)("separates %s filled groups from their sheets", (themeId) => {
184 for (const appearance of ["light", "dark"] as const) {
185 const generated = readGeneratedMobileThemeVariables(
186 themeId === DEFAULT_MOBILE_THEME_ID ? appearance : `${themeId}-${appearance}`,
187 );
188 expect(generated).toEqual(getMobileThemeVariables(themeId, appearance));
189 for (const platform of ["ios", "android"]) {
190 const variables = getMobileThemeRuntimeVariables(themeId, appearance, platform);
191 const sheet = variables["--color-sheet-solid"];
192 const group = variables["--color-grouped-card"];
193 expect(
194 contrastRatio(group, sheet),
195 `${platform} ${appearance}: grouped surface on sheet`,
196 ).toBeGreaterThanOrEqual(1.025);
197 for (const role of [
198 "--color-foreground",
199 "--color-foreground-secondary",
200 "--color-foreground-muted",
201 "--color-placeholder",
202 "--color-primary-text",
203 ] as const) {
204 expect(
205 contrastRatio(variables[role], group),
206 `${platform} ${appearance}: ${role} on grouped surface`,
207 ).toBeGreaterThanOrEqual(4.5);
208 }
209 if (platform === "android") {
210 const header = flattenThemeColor(variables["--color-header"], sheet);
211 for (const role of ["--color-header-foreground", "--color-foreground-muted"] as const) {
212 expect(
213 contrastRatio(variables[role], header),
214 `${appearance}: ${role} on Android header`,
215 ).toBeGreaterThanOrEqual(4.5);
216 }
217 }
218 expect(
219 contrastRatio(variables["--color-foreground-muted"], variables["--color-secondary"]),
220 `${platform} ${appearance}: muted label on secondary surface`,
221 ).toBeGreaterThanOrEqual(4.5);
222 }
223 }
224 });
225
185226 it.each(["light", "dark"] as const)(
186227 "separates default settings groups from their %s background",
187228 (appearance) => {
apps/mobile/src/lib/mobileTheme.ts+37-15
......@@ -191,15 +191,23 @@ function contrastRatio(
191191 );
192192}
193193
194/** Preserve the color's hue while giving text 4.5:1 contrast on every supplied surface. */
195function readableTextColor(accent: string, surface: string | ReadonlyArray<string>): string {
194/** Keep the theme tint while adjusting lightness to contrast with every supplied surface. */
195export function themeColorWithContrast(
196 accent: string,
197 surface: string | ReadonlyArray<string>,
198 minimum = 4.5,
199): string {
196200 const accentChannels = rgbChannels(accent);
197201 const surfaceChannels = (typeof surface === "string" ? [surface] : surface)
198202 .map(rgbChannels)
199203 .filter((channels) => channels !== null);
200204 const minimumContrast = (channels: readonly [number, number, number]) =>
201205 Math.min(...surfaceChannels.map((surface) => contrastRatio(channels, surface)));
202 if (!accentChannels || surfaceChannels.length === 0 || minimumContrast(accentChannels) >= 4.5) {
206 if (
207 !accentChannels ||
208 surfaceChannels.length === 0 ||
209 minimumContrast(accentChannels) >= minimum
210 ) {
203211 return accent;
204212 }
205213
......@@ -216,7 +224,7 @@ function readableTextColor(accent: string, surface: string | ReadonlyArray<strin
216224 Math.round(accentChannels[1] + (target[1] - accentChannels[1]) * amount),
217225 Math.round(accentChannels[2] + (target[2] - accentChannels[2]) * amount),
218226 ];
219 if (minimumContrast(candidate) >= 4.5) {
227 if (minimumContrast(candidate) >= minimum) {
220228 readable = candidate;
221229 upperAmount = amount;
222230 } else {
......@@ -242,7 +250,15 @@ export function createMobileThemeVariables(
242250) {
243251 const c = nativeColors(colors);
244252 const groupedCard = themeColorToNativeColor(groupedCardColor);
245 const textSurfaces = [c.canvas, c.surface, c.surfaceRaised, c.chrome, groupedCard];
253 const textSurfaces = [
254 c.canvas,
255 c.surface,
256 c.surfaceRaised,
257 c.chrome,
258 c.sidebar,
259 c.secondary,
260 groupedCard,
261 ];
246262 return {
247263 "--color-screen": c.canvas,
248264 "--color-sheet": withAlpha(c.chrome, 0.98),
......@@ -254,7 +270,7 @@ export function createMobileThemeVariables(
254270 "--color-thread-canvas": c.canvas,
255271 "--color-thread-selected": c.sidebarRowActive,
256272 "--color-thread-selected-foreground": c.sidebarForeground,
257 "--color-thread-selected-foreground-muted": readableTextColor(
273 "--color-thread-selected-foreground-muted": themeColorWithContrast(
258274 c.sidebarMutedForeground,
259275 c.sidebarRowActive,
260276 ),
......@@ -270,8 +286,8 @@ export function createMobileThemeVariables(
270286 groupedCard === c.surface ? (appearance === "dark" ? 0.46 : 0.54) : 0.8,
271287 ),
272288 "--color-foreground": c.text,
273 "--color-foreground-secondary": readableTextColor(c.textMuted, textSurfaces),
274 "--color-foreground-muted": readableTextColor(c.mutedForeground, textSurfaces),
289 "--color-foreground-secondary": themeColorWithContrast(c.textMuted, textSurfaces),
290 "--color-foreground-muted": themeColorWithContrast(c.mutedForeground, textSurfaces),
275291 "--color-foreground-tertiary": c.secondaryLabel,
276292 "--color-border": c.border,
277293 "--color-focus": c.focus,
......@@ -284,7 +300,7 @@ export function createMobileThemeVariables(
284300 "--color-inline-skill-foreground": c.accentSurfaceForeground,
285301 "--color-primary": c.messageAction,
286302 "--color-primary-foreground": c.messageActionForeground,
287 "--color-primary-text": readableTextColor(c.messageAction, textSurfaces),
303 "--color-primary-text": themeColorWithContrast(c.messageAction, textSurfaces),
288304 "--color-primary-shadow": "#000000",
289305 "--color-secondary": c.secondary,
290306 "--color-secondary-foreground": c.secondaryForeground,
......@@ -304,7 +320,7 @@ export function createMobileThemeVariables(
304320 "--color-input": c.surface,
305321 "--color-input-border": c.input,
306322 "--color-sidebar-search": c.sidebarControlSurface,
307 "--color-placeholder": readableTextColor(c.placeholder, textSurfaces),
323 "--color-placeholder": themeColorWithContrast(c.placeholder, textSurfaces),
308324 "--color-icon": c.text,
309325 "--color-icon-muted": c.iconMuted,
310326 "--color-icon-subtle": c.secondaryLabel,
......@@ -317,7 +333,7 @@ export function createMobileThemeVariables(
317333 "--color-status-bar": c.canvas,
318334 "--color-md-body": c.text,
319335 "--color-md-strong": c.text,
320 "--color-md-link": readableTextColor(c.messageAction, c.canvas),
336 "--color-md-link": themeColorWithContrast(c.messageAction, c.canvas),
321337 "--color-md-blockquote-border": c.border,
322338 "--color-md-blockquote-bg": c.muted,
323339 "--color-md-code-bg": c.codeBackground,
......@@ -330,11 +346,14 @@ export function createMobileThemeVariables(
330346 "--color-user-bubble": c.messageSurface,
331347 "--color-user-bubble-foreground": c.messageForeground,
332348 "--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78),
333 "--color-user-bubble-skill-foreground": readableTextColor(c.messageAction, c.messageSurface),
349 "--color-user-bubble-skill-foreground": themeColorWithContrast(
350 c.messageAction,
351 c.messageSurface,
352 ),
334353 "--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22),
335354 "--color-drawer": c.sidebar,
336355 "--color-drawer-foreground": c.sidebarForeground,
337 "--color-drawer-foreground-muted": readableTextColor(c.sidebarMutedForeground, [
356 "--color-drawer-foreground-muted": themeColorWithContrast(c.sidebarMutedForeground, [
338357 c.sidebar,
339358 c.sidebarRowHover,
340359 ]),
......@@ -368,13 +387,16 @@ export function getMobileThemeVariables(
368387): MobileThemeVariables {
369388 const colors = getMobileThemeColors(themeId, appearance);
370389 // Mobile settings groups and fallback materials use tonal fills where desktop
371 // uses outlined cards. Regular cards retain their shared desktop surface.
390 // uses outlined cards. Raise flat desktop surfaces above the sheet so grouped
391 // content keeps a visible boundary. Regular cards retain their desktop surface.
372392 const groupedCard =
373393 themeId === DEFAULT_MOBILE_THEME_ID
374394 ? appearance === "light"
375395 ? colors.toolbarControlHover
376396 : colors.sidebarRowActive
377 : colors.surface;
397 : colors.surface === colors.chrome
398 ? colors.surfaceRaised
399 : colors.surface;
378400 const mobileColors =
379401 themeId === DEFAULT_MOBILE_THEME_ID
380402 ? {