From 4097ba2041a369acfbf43019ddd8d033b887c0e7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 30 Jul 2026 21:12:55 -0400 Subject: [PATCH 1/5] Use official OpenHCS brand across desktop and web --- .github/workflows/website-pages.yml | 2 + README.md | 11 +--- openhcs/gui_startup.py | 4 ++ openhcs/pyqt_gui/app.py | 8 +-- openhcs/pyqt_gui/branding.py | 16 +++++ openhcs/resources/__init__.py | 1 + openhcs/resources/assets/README.md | 11 ++++ openhcs/resources/assets/openhcs-mark.png | Bin 0 -> 5452 bytes openhcs/resources/assets/openhcs-mark.svg | 8 +++ openhcs/resources/assets/openhcs.icns | Bin 0 -> 16318 bytes openhcs/resources/assets/openhcs.ico | Bin 0 -> 6594 bytes openhcs/resources/brand.py | 45 ++++++++++++++ packaging/installers/macos/build-installer.sh | 8 +++ packaging/installers/macos/install-openhcs.sh | 41 +++++++++---- .../windows/Build-InstallerLauncher.ps1 | 18 ++++++ .../windows/InstallerLauncher.csproj | 1 + pyproject.toml | 4 ++ scripts/build_website.py | 5 +- scripts/render_brand_assets.sh | 36 ++++++++++++ .../installer/test_macos_simple_installer.py | 7 +++ .../test_windows_simple_installer.py | 3 + tests/unit/pyqt_gui/test_branding.py | 10 ++++ tests/unit/test_brand_assets.py | 55 ++++++++++++++++++ tests/unit/test_build_website.py | 24 +++++++- website/index.html | 6 +- website/privacy.html | 6 +- website/styles.css | 6 +- website/support.html | 6 +- website/terms.html | 6 +- 29 files changed, 303 insertions(+), 45 deletions(-) create mode 100644 openhcs/pyqt_gui/branding.py create mode 100644 openhcs/resources/__init__.py create mode 100644 openhcs/resources/assets/README.md create mode 100644 openhcs/resources/assets/openhcs-mark.png create mode 100644 openhcs/resources/assets/openhcs-mark.svg create mode 100644 openhcs/resources/assets/openhcs.icns create mode 100644 openhcs/resources/assets/openhcs.ico create mode 100644 openhcs/resources/brand.py create mode 100755 scripts/render_brand_assets.sh create mode 100644 tests/unit/pyqt_gui/test_branding.py create mode 100644 tests/unit/test_brand_assets.py diff --git a/.github/workflows/website-pages.yml b/.github/workflows/website-pages.yml index 5fdc30480..1dc8b1028 100644 --- a/.github/workflows/website-pages.yml +++ b/.github/workflows/website-pages.yml @@ -4,6 +4,7 @@ on: pull_request: paths: - "website/**" + - "openhcs/resources/assets/openhcs-mark.svg" - "docs/source/_static/ui.png" - "scripts/build_website.py" - "tests/unit/test_build_website.py" @@ -13,6 +14,7 @@ on: branches: [main] paths: - "website/**" + - "openhcs/resources/assets/openhcs-mark.svg" - "docs/source/_static/ui.png" - "scripts/build_website.py" - "tests/unit/test_build_website.py" diff --git a/README.md b/README.md index 62a6fd799..8d0d72795 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,9 @@
-
-  ___                    _   _  ___  _____
- / _ \ _ __  ___  _ ___ | | | |/ __\/ ___/
-| | | | '_ \/ _ \| '_  \| |_| | |   \___ \
-| |_| | |_||| __/| | | ||  _  | |__  __/ |
- \___/| .__/\___||_| |_||_| |_|\___/\____/
-      |_|           High-Content Screening
-
+OpenHCS logo + +

OpenHCS

**Bioimage analysis platform for high-content screening**\ **Compile-time validation · Bidirectional GUI↔Code · Multi-GPU · LLM pipeline generation · Extensible function registry** diff --git a/openhcs/gui_startup.py b/openhcs/gui_startup.py index 8ba16421b..8ef5f8a07 100644 --- a/openhcs/gui_startup.py +++ b/openhcs/gui_startup.py @@ -309,6 +309,7 @@ def _run_startup_window_child() -> int: QPushButton, QVBoxLayout, ) + from openhcs.pyqt_gui.branding import openhcs_application_icon from pyqt_reactive.theming import ColorScheme, ThemeManager class _EventBridge(QObject): @@ -435,10 +436,13 @@ def _append_detail(self, message: str) -> None: app = QApplication([sys.argv[0]]) app.setApplicationName("OpenHCS Startup") + application_icon = openhcs_application_icon() + app.setWindowIcon(application_icon) color_scheme = ColorScheme() theme_manager = ThemeManager(color_scheme) theme_manager.apply_color_scheme(color_scheme) window = _StartupWindow(color_scheme, theme_manager.style_generator) + window.setWindowIcon(application_icon) bridge = _EventBridge() bridge.event_received.connect(window.apply_event) diff --git a/openhcs/pyqt_gui/app.py b/openhcs/pyqt_gui/app.py index 829c69eb3..0eb79fbd0 100644 --- a/openhcs/pyqt_gui/app.py +++ b/openhcs/pyqt_gui/app.py @@ -8,13 +8,12 @@ import sys import logging from typing import Callable, Optional, TYPE_CHECKING -from pathlib import Path from PyQt6.QtWidgets import QApplication, QMessageBox from PyQt6.QtCore import qInstallMessageHandler -from PyQt6.QtGui import QIcon from openhcs.core.config import GlobalPipelineConfig +from openhcs.pyqt_gui.branding import openhcs_application_icon from polystore.base import storage_registry from polystore.filemanager import FileManager from objectstate import spawn_thread_with_context @@ -168,10 +167,7 @@ def init_function_registry_background(): register_reactor_providers(lambda: self.runtime_context.ui_config.zmq) - # Set application icon (if available) - icon_path = Path(__file__).parent / "resources" / "openhcs_icon.png" - if icon_path.exists(): - self.setWindowIcon(QIcon(str(icon_path))) + self.setWindowIcon(openhcs_application_icon()) # Setup exception handling sys.excepthook = self.handle_exception diff --git a/openhcs/pyqt_gui/branding.py b/openhcs/pyqt_gui/branding.py new file mode 100644 index 000000000..2dfd0316a --- /dev/null +++ b/openhcs/pyqt_gui/branding.py @@ -0,0 +1,16 @@ +"""Qt projection of the package-owned OpenHCS brand mark.""" + +from __future__ import annotations + +from PyQt6.QtGui import QIcon, QPixmap + +from openhcs.resources.brand import BrandAsset, brand_asset_bytes + + +def openhcs_application_icon() -> QIcon: + """Build the OpenHCS application icon from its packaged raster asset.""" + + pixmap = QPixmap() + if not pixmap.loadFromData(brand_asset_bytes(BrandAsset.RASTER)): + raise RuntimeError("Packaged OpenHCS application icon could not be decoded.") + return QIcon(pixmap) diff --git a/openhcs/resources/__init__.py b/openhcs/resources/__init__.py new file mode 100644 index 000000000..b718f1ecb --- /dev/null +++ b/openhcs/resources/__init__.py @@ -0,0 +1 @@ +"""Packaged, implementation-independent OpenHCS resources.""" diff --git a/openhcs/resources/assets/README.md b/openhcs/resources/assets/README.md new file mode 100644 index 000000000..883ffd492 --- /dev/null +++ b/openhcs/resources/assets/README.md @@ -0,0 +1,11 @@ +# OpenHCS brand assets + +`openhcs-mark.svg` is the canonical OpenHCS project mark. It preserves the +geometry and colors of the OpenHCSDev organization avatar. + +`openhcs-mark.png`, `openhcs.ico`, and `openhcs.icns` are mechanically rendered +platform encodings of that SVG. Regenerate them with: + +```bash +scripts/render_brand_assets.sh +``` diff --git a/openhcs/resources/assets/openhcs-mark.png b/openhcs/resources/assets/openhcs-mark.png new file mode 100644 index 0000000000000000000000000000000000000000..18d3f26188e1a66178a87df196d5be5904a6e749 GIT binary patch literal 5452 zcmeAS@N?(olHy`uVBq!ia0y~yU;#2&7&w@K)Q9>#SAY~-lDE4H!+#K5uy^@n1_n_^ zPZ!6KiaBp@Irg>&2)G_R&%)l}#JMV^#-;tE-qk_kEr< zkKw@bJq#bX_cQDOW;aNeV8Snb2ZzXSKe!!casvfN`wpW8(P%%By1nDyUl?Y52X*xr z8v4N*4^jap*ZgMJ09SuJ1;B!Tv~xZ>Br)3gr|me^?WpW3X5f&U;Ao$3@He+V@w2dL Wb^QEqFa=m8GkCiCxvX + OpenHCS + + + diff --git a/openhcs/resources/assets/openhcs.icns b/openhcs/resources/assets/openhcs.icns new file mode 100644 index 0000000000000000000000000000000000000000..b9cf481c3f9ecd5f658bd8fd1231b31ce40b0d6e GIT binary patch literal 16318 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 literal 0 HcmV?d00001 diff --git a/openhcs/resources/assets/openhcs.ico b/openhcs/resources/assets/openhcs.ico new file mode 100644 index 0000000000000000000000000000000000000000..9810f1557dd6169b6dd97f5bbb53f6b9ef2b486b GIT binary patch literal 6594 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 Znf~Ro Path: + """Return the filesystem path for one installed OpenHCS brand asset.""" + + return Path(str(files("openhcs.resources") / "assets" / asset.value)) + + +def brand_asset_bytes(asset: BrandAsset) -> bytes: + """Read one packaged OpenHCS brand asset.""" + + return brand_asset_path(asset).read_bytes() + + +def main() -> int: + """Print an installed brand asset path for native launcher integration.""" + + parser = argparse.ArgumentParser(description=__doc__) + choices = tuple(asset.name.lower() for asset in BrandAsset) + parser.add_argument("asset", choices=choices) + arguments = parser.parse_args() + asset = BrandAsset[arguments.asset.upper()] + print(brand_asset_path(asset)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/packaging/installers/macos/build-installer.sh b/packaging/installers/macos/build-installer.sh index 70eceb6c8..01c6b054a 100755 --- a/packaging/installers/macos/build-installer.sh +++ b/packaging/installers/macos/build-installer.sh @@ -5,11 +5,16 @@ set -euo pipefail script_directory=$(cd "$(dirname "$0")" && pwd) contract_path=${1:-"$script_directory/../installer_contract.json"} output_path=${2:-"$script_directory/dist/OpenHCS Installer.app"} +brand_icon_path="$script_directory/../../../openhcs/resources/assets/openhcs.icns" if [[ ! -f "$contract_path" ]]; then printf 'Installer contract not found: %s\n' "$contract_path" >&2 exit 2 fi +if [[ ! -f "$brand_icon_path" ]]; then + printf 'OpenHCS brand icon not found: %s\n' "$brand_icon_path" >&2 + exit 2 +fi if ! command -v xcrun >/dev/null 2>&1; then printf 'Xcode command-line tools are required to build the macOS installer app.\n' >&2 exit 2 @@ -57,6 +62,7 @@ done CFBundleDisplayNameOpenHCS Installer CFBundleExecutableOpenHCSInstaller CFBundleIdentifierorg.openhcs.installer + CFBundleIconFileOpenHCS CFBundleNameOpenHCS Installer CFBundlePackageTypeAPPL LSMinimumSystemVersion12.0 @@ -69,6 +75,8 @@ PLIST "$temporary_app/Contents/Resources/install-openhcs.sh" /bin/cp "$contract_path" \ "$temporary_app/Contents/Resources/installer_contract.json" +/bin/cp "$brand_icon_path" \ + "$temporary_app/Contents/Resources/OpenHCS.icns" /bin/chmod 755 "$temporary_app/Contents/MacOS/OpenHCSInstaller" /bin/chmod 755 "$temporary_app/Contents/Resources/install-openhcs.sh" /usr/bin/plutil -lint "$temporary_app/Contents/Info.plist" diff --git a/packaging/installers/macos/install-openhcs.sh b/packaging/installers/macos/install-openhcs.sh index 8e06fed05..9b6966dc5 100755 --- a/packaging/installers/macos/install-openhcs.sh +++ b/packaging/installers/macos/install-openhcs.sh @@ -114,9 +114,10 @@ temporary_uv_installer=$( /usr/bin/mktemp "${TMPDIR:-/tmp}/openhcs-uv-installer.XXXXXX" ) new_launcher_app="$applications_root/.$product_name.app.new.$$" +launcher_backup="$applications_root/.$product_name.app.backup.$$" agent_registration_report="$application_root/agent-registration.json" agent_registration_candidate="$application_root/.agent-registration.new.$$" -launcher_created=false +launcher_published=false install_succeeded=false active_child_pid= @@ -143,13 +144,17 @@ cleanup() { install_succeeded=true fi if [[ "$install_succeeded" == true ]]; then + /bin/rm -rf "$launcher_backup" write_installer_state launcher-path "$launcher_app" fi if [[ "$install_succeeded" != true ]]; then /bin/rm -rf "$new_environment" - if [[ "$launcher_created" == true ]]; then + if [[ "$launcher_published" == true ]]; then /bin/rm -rf "$launcher_app" fi + if [[ -d "$launcher_backup" ]]; then + /bin/mv "$launcher_backup" "$launcher_app" + fi fi } @@ -281,10 +286,21 @@ if [[ -e "$launcher_app" && ! -d "$launcher_app" ]]; then fi report_progress 'Preparing Applications and Desktop shortcuts…' -if [[ ! -e "$launcher_app" ]]; then - /bin/rm -rf "$new_launcher_app" - /bin/mkdir -p "$new_launcher_app/Contents/MacOS" - /bin/cat >"$new_launcher_app/Contents/Info.plist" <&2 + exit 1 +fi +/bin/cp "$brand_icon_path" \ + "$new_launcher_app/Contents/Resources/OpenHCS.icns" +/bin/cat >"$new_launcher_app/Contents/Info.plist" < @@ -292,21 +308,24 @@ if [[ ! -e "$launcher_app" ]]; then CFBundleDisplayName$product_name CFBundleExecutablelaunch-openhcs CFBundleIdentifierorg.openhcs.desktop + CFBundleIconFileOpenHCS CFBundleName$product_name CFBundlePackageTypeAPPL CFBundleVersion1 PLIST - /bin/cat >"$new_launcher_app/Contents/MacOS/launch-openhcs" <"$new_launcher_app/Contents/MacOS/launch-openhcs" <net48 OpenHCS-Windows-Installer OpenHCS.Installer + OpenHCS.ico latest false diff --git a/pyproject.toml b/pyproject.toml index 8eb1cc377..4eef1c362 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -323,6 +323,10 @@ openhcs = [ "**/*.yaml", "**/*.yml", "**/*.json", + "resources/assets/*.svg", + "resources/assets/*.png", + "resources/assets/*.ico", + "resources/assets/*.icns", "agent/resources/knowledge/**/*.md", "agent/resources/knowledge/**/*.rst", ] diff --git a/scripts/build_website.py b/scripts/build_website.py index 9b51cc17c..3e644527d 100644 --- a/scripts/build_website.py +++ b/scripts/build_website.py @@ -31,7 +31,10 @@ "assets/logos/pytorch.svg", "assets/logos/tensorflow.svg", ) -ASSET_SOURCES = {"assets/ui.png": "docs/source/_static/ui.png"} +ASSET_SOURCES = { + "assets/logos/openhcs.svg": "openhcs/resources/assets/openhcs-mark.svg", + "assets/ui.png": "docs/source/_static/ui.png", +} REQUIRED_COPY = ( "PyPI", ".cppipe", diff --git a/scripts/render_brand_assets.sh b/scripts/render_brand_assets.sh new file mode 100755 index 000000000..011b98cf2 --- /dev/null +++ b/scripts/render_brand_assets.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +set -euo pipefail + +repository_root=$(cd "$(dirname "$0")/.." && pwd) +asset_directory="$repository_root/openhcs/resources/assets" +source_svg="$asset_directory/openhcs-mark.svg" +raster_png="$asset_directory/openhcs-mark.png" +windows_icon="$asset_directory/openhcs.ico" +macos_icon="$asset_directory/openhcs.icns" + +for executable in rsvg-convert python; do + if ! command -v "$executable" >/dev/null 2>&1; then + printf 'Required brand renderer is unavailable: %s\n' "$executable" >&2 + exit 2 + fi +done + +rsvg-convert --width 1024 --height 1024 "$source_svg" --output "$raster_png" +python - "$raster_png" "$windows_icon" "$macos_icon" <<'PY' +from pathlib import Path +import sys + +from PIL import Image + +source = Path(sys.argv[1]) +windows_destination = Path(sys.argv[2]) +macos_destination = Path(sys.argv[3]) +with Image.open(source) as image: + image.save( + windows_destination, + format="ICO", + sizes=((16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)), + ) + image.save(macos_destination, format="ICNS") +PY diff --git a/tests/installer/test_macos_simple_installer.py b/tests/installer/test_macos_simple_installer.py index 6acb87dd7..88bfa8e08 100644 --- a/tests/installer/test_macos_simple_installer.py +++ b/tests/installer/test_macos_simple_installer.py @@ -181,6 +181,8 @@ def test_macos_installer_builds_a_universal_native_app_with_embedded_contract() assert "CFBundleVersion" not in build assert "Contents/Resources/installer_contract.json" in build assert "Contents/Resources/install-openhcs.sh" in build + assert "Contents/Resources/OpenHCS.icns" in build + assert "CFBundleIconFileOpenHCS" in build def test_macos_release_is_one_verified_disk_image() -> None: @@ -245,6 +247,11 @@ def test_macos_shell_owns_live_progress_log_and_launcher_projection() -> None: touch_position < regular_file_position < projection_position < redirect_position ) assert 'if [[ -L "$log_path" ]]' in source + assert '"$environment_python" -m openhcs.resources.brand macos_icon' in source + assert '"$new_launcher_app/Contents/Resources/OpenHCS.icns"' in source + assert "CFBundleIconFileOpenHCS" in source + assert 'mv "$launcher_app" "$launcher_backup"' in source + assert 'mv "$launcher_backup" "$launcher_app"' in source app_source = APP_SOURCE_PATH.read_text(encoding="utf-8") assert 'installerStateValue(named: "progress")' in app_source diff --git a/tests/installer/test_windows_simple_installer.py b/tests/installer/test_windows_simple_installer.py index af6f1d13f..897db749f 100644 --- a/tests/installer/test_windows_simple_installer.py +++ b/tests/installer/test_windows_simple_installer.py @@ -47,6 +47,7 @@ def test_windows_installer_has_stable_double_click_entrypoint() -> None: assert "" not in project assert "" not in project assert "OpenHCS-Windows-Installer" in project + assert "OpenHCS.ico" in project assert 'EmbeddedResource Include="Install-OpenHCS.ps1"' in project assert 'EmbeddedResource Include="..\\installer_contract.json"' in project assert "OpenHCS.Installer.Install-OpenHCS.ps1" in project @@ -57,6 +58,8 @@ def test_windows_installer_has_stable_double_click_entrypoint() -> None: assert "[string]$ContractPath" in build assert '"OpenHCS-Windows-Installer.exe"' in build assert '"installer_contract.json"' in build + assert '"openhcs.ico"' in build + assert '"OpenHCS.ico"' in build assert "Assembly.GetExecutingAssembly()" in launcher assert "Environment.Is64BitOperatingSystem" in launcher diff --git a/tests/unit/pyqt_gui/test_branding.py b/tests/unit/pyqt_gui/test_branding.py new file mode 100644 index 000000000..5ab49b79c --- /dev/null +++ b/tests/unit/pyqt_gui/test_branding.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from openhcs.pyqt_gui.branding import openhcs_application_icon + + +def test_packaged_application_icon_decodes_for_qt(qapp) -> None: + icon = openhcs_application_icon() + + assert not icon.isNull() + assert icon.pixmap(128, 128).size().width() == 128 diff --git a/tests/unit/test_brand_assets.py b/tests/unit/test_brand_assets.py new file mode 100644 index 000000000..48fac35de --- /dev/null +++ b/tests/unit/test_brand_assets.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +import struct +from xml.etree import ElementTree + +from openhcs.resources.brand import ( + BrandAsset, + brand_asset_bytes, + brand_asset_path, +) + + +def test_brand_assets_are_one_complete_packaged_family() -> None: + assert {asset.name for asset in BrandAsset} == { + "SCALABLE", + "RASTER", + "WINDOWS_ICON", + "MACOS_ICON", + } + for asset in BrandAsset: + path = brand_asset_path(asset) + assert path.is_file() + assert path.read_bytes() == brand_asset_bytes(asset) + + +def test_canonical_brand_svg_preserves_official_geometry_and_colors() -> None: + root = ElementTree.fromstring(brand_asset_bytes(BrandAsset.SCALABLE)) + namespace = {"svg": "http://www.w3.org/2000/svg"} + + assert root.attrib["viewBox"] == "0 0 420 420" + background = root.find("svg:rect", namespace) + mark = root.find("svg:path", namespace) + assert background is not None + assert mark is not None + assert background.attrib == { + "width": "420", + "height": "420", + "fill": "#f0f0f0", + } + assert mark.attrib["fill"] == "#dda98b" + assert mark.attrib["d"].startswith("M35 35h141") + + +def test_platform_brand_encodings_have_native_container_headers() -> None: + png = brand_asset_bytes(BrandAsset.RASTER) + assert png[:8] == b"\x89PNG\r\n\x1a\n" + assert struct.unpack(">II", png[16:24]) == (1024, 1024) + + windows_icon = brand_asset_bytes(BrandAsset.WINDOWS_ICON) + reserved, image_type, image_count = struct.unpack("I", macos_icon[4:8])[0] == len(macos_icon) diff --git a/tests/unit/test_build_website.py b/tests/unit/test_build_website.py index c37c6491d..3074b18fa 100644 --- a/tests/unit/test_build_website.py +++ b/tests/unit/test_build_website.py @@ -29,6 +29,7 @@ def test_build_site_stages_authoritative_screenshot_and_valid_references( REPO_ROOT / "docs/source/_static/ui.png" ).read_bytes() for logo_name in ( + "openhcs.svg", "bioformats.svg", "cellprofiler.png", "cupy.svg", @@ -39,9 +40,15 @@ def test_build_site_stages_authoritative_screenshot_and_valid_references( "pytorch.svg", "tensorflow.svg", ): + if logo_name == "openhcs.svg": + authority = ( + REPO_ROOT / "openhcs/resources/assets/openhcs-mark.svg" + ) + else: + authority = REPO_ROOT / "website/assets/logos" / logo_name assert (site_dir / "assets/logos" / logo_name).read_bytes() == ( - REPO_ROOT / "website/assets/logos" / logo_name - ).read_bytes() + authority.read_bytes() + ) assert local_targets == ( "assets/logos/bioformats.svg", "assets/logos/cellprofiler.png", @@ -49,6 +56,7 @@ def test_build_site_stages_authoritative_screenshot_and_valid_references( "assets/logos/fiji.svg", "assets/logos/jax.png", "assets/logos/napari.svg", + "assets/logos/openhcs.svg", "assets/logos/pyclesperanto.png", "assets/logos/pytorch.svg", "assets/logos/tensorflow.svg", @@ -176,6 +184,9 @@ def test_landing_page_uses_factual_copy_and_readable_proportions(): styles = (REPO_ROOT / "website/styles.css").read_text(encoding="utf-8") assert "OpenHCS defines and runs microscopy workflows." in html + assert html.count('src="assets/logos/openhcs.svg"') == 2 + assert 'href="assets/logos/openhcs.svg"' in html + assert '' not in html assert 'class="hero-grid"' in html assert 'class="release-summary"' in html assert "Plate, pipeline, and result management." in html @@ -226,6 +237,8 @@ def test_public_policy_pages_are_staged_with_truthful_hosted_boundaries( assert 'href="privacy.html"' in document assert 'href="support.html"' in document assert 'href="terms.html"' in document + assert document.count('src="assets/logos/openhcs.svg"') == 2 + assert 'href="assets/logos/openhcs.svg"' in document assert "does not currently operate a public hosted MCP endpoint" in privacy_copy assert "does not record bearer tokens or tool arguments" in privacy_copy @@ -270,6 +283,12 @@ def test_website_source_and_workflow_follow_package_metadata_authorities(): assert "0.5.21" not in source_html assert "0.5.22" not in source_html assert workflow.count(' - "openhcs/__init__.py"') == 2 + assert ( + workflow.count( + ' - "openhcs/resources/assets/openhcs-mark.svg"' + ) + == 2 + ) for page_name in ("privacy.html", "support.html", "terms.html"): page_source = (REPO_ROOT / "website" / page_name).read_text(encoding="utf-8") assert CONTACT_EMAIL_TOKEN in page_source @@ -297,6 +316,7 @@ def test_readme_does_not_link_unpublished_coverage_site(): readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") assert "trissim.github.io/openhcs/coverage" not in readme + assert 'src="openhcs/resources/assets/openhcs-mark.svg"' in readme def test_build_site_refuses_to_replace_source_or_repository_root(): diff --git a/website/index.html b/website/index.html index 2af5a5b42..46d9ea3df 100644 --- a/website/index.html +++ b/website/index.html @@ -20,7 +20,7 @@ > OpenHCS | Open-source high-content image analysis - + @@ -32,7 +32,7 @@