diff --git a/ThirdParty/container-maker b/ThirdParty/container-maker
index f854e10b2ae9e69b46464476bd84131de6aa4fd6..20cc58ea0f939c6b2f5dbea8981c91b49d4ed4f9 160000
--- a/ThirdParty/container-maker
+++ b/ThirdParty/container-maker
@@ -1 +1 @@
-Subproject commit f854e10b2ae9e69b46464476bd84131de6aa4fd6
+Subproject commit 20cc58ea0f939c6b2f5dbea8981c91b49d4ed4f9
diff --git a/scripts/ci/jobs/code-coverage.yml b/scripts/ci/jobs/code-coverage.yml
index fc95559ae5d3ec245be445e9ba989ea616a291c0..ac68e457ea9e42821953606ecb735be0d10d8b8f 100644
--- a/scripts/ci/jobs/code-coverage.yml
+++ b/scripts/ci/jobs/code-coverage.yml
@@ -21,13 +21,14 @@ code coverage:
     - genhtml --demangle-cpp -o coverage_report coverage.info
     - wget https://raw.github.com/eriwen/lcov-to-cobertura-xml/master/lcov_cobertura/lcov_cobertura.py
     - poetry run python lcov_cobertura.py coverage.info --base-dir $CI_PROJECT_DIR
+    - poetry run ./generate_coverage_vis_data.py
   artifacts:
-    expose_as: 'Coverage'
     paths:
       - Coverage.html
       - build/coverage/coverage_report
       - build/coverage/coverage.json
       - build/coverage/coverage.xml
+      - build/coverage/coverage_reports/*.json
     expire_in: 1 week
     reports:
       cobertura: build/coverage/coverage.xml
diff --git a/scripts/cmake/CMakeSetup.cmake b/scripts/cmake/CMakeSetup.cmake
index a549b6247c72f4d76327c5c2b5b5f19832965efe..763b6f4bc0dcec982c889c9a225736ac2853ace4 100644
--- a/scripts/cmake/CMakeSetup.cmake
+++ b/scripts/cmake/CMakeSetup.cmake
@@ -13,7 +13,7 @@ endif()
 CPMAddPackage(
     NAME cmake-modules
     GITHUB_REPOSITORY bilke/cmake-modules
-    GIT_TAG d6d1a778e41cb114e5cdf279b8a659fa0ce9a0d4
+    GIT_TAG d98828f54f6974717798e63195cfbf08fe2daad0
     DOWNLOAD_ONLY YES
 )
 set(CMAKE_MODULE_PATH
diff --git a/scripts/cmake/Coverage.cmake b/scripts/cmake/Coverage.cmake
index e68763b14a9c5a3b3369473f078d661e337626d4..0fb66a367f453eda3881fecc93114de26eae18c8 100644
--- a/scripts/cmake/Coverage.cmake
+++ b/scripts/cmake/Coverage.cmake
@@ -3,6 +3,16 @@ if(NOT FASTCOV_PATH AND NOT POETRY)
     message(FATAL_ERROR "Code coverage requires either fastcov or poetry.")
 endif()
 
+# https://github.com/linux-test-project/lcov/pull/125
+if(APPLE)
+    file(
+        DOWNLOAD
+        https://raw.githubusercontent.com/linux-test-project/lcov/41d8655951d6898511f98be2a2dbcfbe662f0b17/bin/genhtml
+        ${PROJECT_BINARY_DIR}/bin/genhtml
+    )
+    set(GENHTML_PATH ${PROJECT_BINARY_DIR}/bin/genhtml)
+endif()
+
 include(CodeCoverage)
 append_coverage_compiler_flags()
 set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Og")
@@ -28,6 +38,8 @@ endif()
 setup_target_for_coverage_fastcov(
     NAME
     testrunner_coverage
+    BASE_DIRECTORY
+    ${PROJECT_BINARY_DIR}
     EXECUTABLE
     $<TARGET_FILE:testrunner>
     -l
@@ -36,6 +48,7 @@ setup_target_for_coverage_fastcov(
     DEPENDENCIES
     testrunner
     FASTCOV_ARGS
+    --branch-coverage
     --include
     ${PROJECT_SOURCE_DIR}
     ${COVERAGE_ADDITIONAL_ARGS}
@@ -49,6 +62,8 @@ setup_target_for_coverage_fastcov(
 setup_target_for_coverage_fastcov(
     NAME
     ctest_coverage
+    BASE_DIRECTORY
+    ${PROJECT_BINARY_DIR}
     EXECUTABLE
     ctest
     -E
@@ -56,14 +71,27 @@ setup_target_for_coverage_fastcov(
     DEPENDENCIES
     all
     FASTCOV_ARGS
+    --branch-coverage
     --include
     ${PROJECT_SOURCE_DIR}
     ${COVERAGE_ADDITIONAL_ARGS}
     EXCLUDE
     Applications/CLI/
     Tests/
+    POST_CMD
+    perl
+    -i
+    -pe
+    s!${PROJECT_SOURCE_DIR}/!!g
+    ctest_coverage.json
+    NO_DEMANGLE
 )
 
 if(UNIX)
     add_custom_target(clean_coverage find . -name '*.gcda' -delete)
 endif()
+
+configure_file(
+    ${PROJECT_SOURCE_DIR}/scripts/test/generate_coverage_vis_data.in.py
+    ${PROJECT_BINARY_DIR}/generate_coverage_vis_data.py @ONLY
+)
diff --git a/scripts/test/generate_coverage_vis_data.in.py b/scripts/test/generate_coverage_vis_data.in.py
new file mode 100755
index 0000000000000000000000000000000000000000..1944db4f2974a5b2c7d318bbafb20308f3bc0513
--- /dev/null
+++ b/scripts/test/generate_coverage_vis_data.in.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python
+
+import os
+
+# need to increase OGS_CTEST_MAX_RUNTIME to enable these:
+# ctests = ["SurfaceComplexation", "EquilibriumPhase", "KineticReactant"]
+ctests = ["SteadyState", "ComponentTransport", "ThermoHydroMechanics"]
+report_path = "./coverage_reports"
+
+os.makedirs(report_path, exist_ok=True)
+
+for t in ctests:
+    os.system("cmake --build . -t clean_coverage")
+    os.system(f"ctest -R {t}")
+    report = f"{report_path}/{t}_coverage.json"
+    os.system(
+        f"@FASTCOV_PATH@ --branch-coverage --include @PROJECT_SOURCE_DIR@ --gcov @GCOV_PATH@ --search-directory @PROJECT_BINARY_DIR@ --process-gcno --output {report} --exclude Applications/CLI/ ProcessLib/ Tests/ --exclude Applications/CLI/ ProcessLib/ Tests/"
+    )
+    os.system(f"perl -i -pe s!@PROJECT_SOURCE_DIR@/!!g {report}")
+    if "CI" in os.environ:
+        print(
+            f"Coverage visualization: https://ogs.ogs.xyz/code-coverage-vis/?report={os.environ.get('CI_JOB_URL')}/artifacts/raw/build/coverage/{report}"
+        )