From da0a0eea29300c96f64b52ec4274aa81cee0ba98 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 28 Aug 2026 17:41:05 +0000 Subject: [PATCH 01/10] Point qemu-hisilicon at OpenIPC, and drop a card for a repo that never existed Two dead links on /ecosystem. qemu-hisilicon was still addressed to the personal account it was developed in, github.com/widgetii/qemu-hisilicon. It lives under OpenIPC now, and the old URL 404s. telemetry had a card, a stage badge and copy in three languages for github.com/OpenIPC/telemetry, which does not exist and by all appearances never has. Removed, along with proj_telemetry in en, ru and zh. Both were reported rather than found, so there is now a test: every link on a project card must resolve to a repository under the OpenIPC organisation. It is scoped to the cards rather than to every GitHub URL on the page -- the prose links the wiki, which is not a project -- and it was checked against the old link first, where it fails naming widgetii. I checked the other seventeen repositories the page links while I was in there. Those two were the only broken ones. --- app/views/pages/ecosystem.html.erb | 5 ++--- config/locales/pages.en.yml | 1 - config/locales/pages.ru.yml | 1 - config/locales/pages.zh.yml | 1 - test/controllers/relaunch_pages_test.rb | 17 +++++++++++++++++ 5 files changed, 19 insertions(+), 6 deletions(-) diff --git a/app/views/pages/ecosystem.html.erb b/app/views/pages/ecosystem.html.erb index c9dad13..0b3f4d2 100644 --- a/app/views/pages/ecosystem.html.erb +++ b/app/views/pages/ecosystem.html.erb @@ -24,7 +24,7 @@ projects: [ { name: 'openhisilicon', desc: t('.proj_openhisilicon'), repo: "#{gh}/openhisilicon", stage: 'wip' }, { name: 'openxiongmai', desc: t('.proj_openxiongmai'), repo: "#{gh}/openxiongmai", stage: 'rnd' }, - { name: 'qemu-hisilicon', desc: t('.proj_qemu'), repo: 'https://github.com/widgetii/qemu-hisilicon', stage: 'wip' } + { name: 'qemu-hisilicon', desc: t('.proj_qemu'), repo: "#{gh}/qemu-hisilicon", stage: 'wip' } ] }, { @@ -36,8 +36,7 @@ { name: 'waybeam_venc', desc: t('.proj_waybeam'), repo: "#{gh}/waybeam_venc", stage: 'done' }, { name: 'devourer', desc: t('.proj_devourer'), repo: "#{gh}/devourer", stage: 'done' }, { name: 'PixelPilot_rk', desc: t('.proj_pixelpilot'), repo: "#{gh}/PixelPilot_rk", stage: 'done' }, - { name: 'aviateur', desc: t('.proj_aviateur'), repo: "#{gh}/aviateur", stage: 'done' }, - { name: 'telemetry', desc: t('.proj_telemetry'), repo: "#{gh}/telemetry", stage: 'done' } + { name: 'aviateur', desc: t('.proj_aviateur'), repo: "#{gh}/aviateur", stage: 'done' } ] }, { diff --git a/config/locales/pages.en.yml b/config/locales/pages.en.yml index f3f79af..a0c6859 100644 --- a/config/locales/pages.en.yml +++ b/config/locales/pages.en.yml @@ -99,7 +99,6 @@ en: proj_qemu: A full HiSilicon IP camera emulator on QEMU — develop and test firmware without hardware on your desk. proj_rnd_player: 'A player built for media engineers: inspect streams the way a developer needs to.' proj_smolrtsp: An embeddable RTSP 1.0 server library for cameras and other constrained devices. TCP and UDP, any payload format. - proj_telemetry: Telemetry bridging between flight controllers and the ground. proj_waybeam: 'The TX side of the link: a flexible video encoder for FPV drones and URLLC devices.' proj_yamlcli: A small console tool for editing YAML configs from scripts. section_community_text_html: User-made hardware, mounts, and accessories — like the 3D-printable models tagged openipc on Printables. diff --git a/config/locales/pages.ru.yml b/config/locales/pages.ru.yml index a9bb32c..dcc15ff 100644 --- a/config/locales/pages.ru.yml +++ b/config/locales/pages.ru.yml @@ -99,7 +99,6 @@ ru: proj_qemu: Полный эмулятор IP-камер HiSilicon на QEMU — разрабатывайте и тестируйте прошивку без железа на столе. proj_rnd_player: 'Плеер для медиа-инженеров: разбирайте потоки так, как нужно разработчику.' proj_smolrtsp: Встраиваемая библиотека RTSP 1.0 для камер и других ограниченных устройств. TCP и UDP, любой формат полезной нагрузки. - proj_telemetry: Мост телеметрии между полётными контроллерами и землёй. proj_waybeam: 'Сторона TX: гибкий видеоэнкодер для FPV-дронов и URLLC-устройств.' proj_yamlcli: Маленький консольный инструмент для правки YAML-конфигов из скриптов. section_community_text_html: Самодельное железо, крепления и аксессуары — например, модели для 3D-печати с тегом openipc на Printables. diff --git a/config/locales/pages.zh.yml b/config/locales/pages.zh.yml index cfe4fb9..ff17f5a 100644 --- a/config/locales/pages.zh.yml +++ b/config/locales/pages.zh.yml @@ -99,7 +99,6 @@ zh: proj_qemu: 基于 QEMU 的完整海思 IP 摄像机模拟器——桌面上没有硬件也能开发和测试固件。 proj_rnd_player: '为媒体工程师打造的播放器:以开发者需要的方式检视码流。' proj_smolrtsp: 可嵌入的 RTSP 1.0 服务端库,适用于摄像机等资源受限设备。支持 TCP 与 UDP,任意负载格式。 - proj_telemetry: 在飞控与地面之间桥接遥测数据。 proj_waybeam: '链路的发送端:面向 FPV 无人机与 URLLC 设备的灵活视频编码器。' proj_yamlcli: 一个用于在脚本中编辑 YAML 配置的小型命令行工具。 section_community_text_html: 社区自制的硬件、支架与配件——例如在 Printables 上标记为 openipc 的可 3D 打印模型。 diff --git a/test/controllers/relaunch_pages_test.rb b/test/controllers/relaunch_pages_test.rb index f2582e8..ff77e82 100644 --- a/test/controllers/relaunch_pages_test.rb +++ b/test/controllers/relaunch_pages_test.rb @@ -149,6 +149,23 @@ class RelaunchPagesTest < ActionDispatch::IntegrationTest end end + # /ecosystem is a list of our own projects, so every card links into the + # OpenIPC organisation. qemu-hisilicon was still pointing at the personal + # account it was developed in, and telemetry was a card for a repository that + # has never existed. The selector is the card's own link, not every GitHub + # URL on the page: the prose links the wiki too, and that is not a project. + test 'every ecosystem project links a repository under OpenIPC' do + get '/ecosystem' + + repos = css_select('.project-card a[href*="github.com"]').map { |a| a['href'] } + + assert_not_empty repos + repos.each do |href| + assert_match %r{\Ahttps://github\.com/OpenIPC/[\w.-]+\z}, href, + "#{href} is not a repository under the OpenIPC organisation" + end + end + test 'every partner logo the helper names exists as an asset' do (PagesHelper::INTERNATIONAL_PARTNERS + PagesHelper::RU_INTEGRATORS).each do |logo| assert_path_exists Rails.root.join('app/assets/images', logo[:img]) From 518cff1777b520181b2d8502a944456a064cb477 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 28 Aug 2026 17:46:25 +0000 Subject: [PATCH 02/10] Spell the organisation the way GitHub does, and stop pointing at what is gone Started as a casing inconsistency and turned up two real defects sitting on the same lines. github.com/openipc appeared 24 times against github.com/OpenIPC everywhere else -- in the navbar, the footer, the admin header, the installation note in all three languages, and sixteen route redirects. GitHub does not care about the case, but we should spell our own name one way. Two of those lines were broken rather than untidy. /coupler redirected to github.com/OpenIPC//coupler, with a doubled slash. And /telemetry, in both its plain and its wildcard form, redirected to github.com/OpenIPC/telemetry -- the repository this branch has just removed from /ecosystem, which does not exist and by all appearances never has. /telemetry answers 410 now, next to /binaries and for the same reason recorded there: deleting the route is worse than keeping it, because the catch-all answers unknown paths with a 302 to the homepage, which tells a crawler the page moved there rather than that it is gone. Tests follow each GitHub shortcut, plain and with a deep path, and assert the destination is exactly https://github.com/OpenIPC/. Checked against the old value first, where it fails naming openipc//coupler. --- app/views/layouts/_footer.html.erb | 4 ++-- app/views/layouts/_navbar.html.erb | 4 ++-- app/views/layouts/admin.html.erb | 2 +- config/locales/en.yml | 2 +- config/locales/ru.yml | 2 +- config/locales/zh.yml | 2 +- config/routes.rb | 38 +++++++++++++++++------------- test/controllers/redirects_test.rb | 30 +++++++++++++++++++++++ 8 files changed, 60 insertions(+), 24 deletions(-) diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index 6f88a26..96e1062 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -36,7 +36,7 @@
  • <%= t('nav.low_latency') %>
  • <%= t('nav.openwall') %>
  • <%= t('nav.utilities') %>
  • -
  • <%= t('nav.wiki') %>
  • +
  • <%= t('nav.wiki') %>
  • @@ -55,7 +55,7 @@
  • <%= t('nav.community_chat') %>
  • - " alt="Image: GitHub logo"> + " alt="Image: GitHub logo"> " alt="Image: OpenCollective logo"> " alt="Image: Telegram logo"> " alt="Image: WeChat logo"> diff --git a/config/locales/en.yml b/config/locales/en.yml index 03ab583..a96e98c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -70,7 +70,7 @@ en: attention: Attention! backup: info: OpenIPC U-Boot overwrites the original crypto partition making it impossible to revert to the factory firmware unless you have a full flash backup for this particular camera! To create a full firmware backup start a TFTP server and run these commands in bootloader shell. - more_info_html: Please refer to Installation instructions in the project's wiki for more information. + more_info_html: Please refer to Installation instructions in the project's wiki for more information. subtitle: Back up your stock firmware, don't skip this part of the installation process! title: Save the original firmware backup_32: ATTENTION! Before backing up 32M flash ROM, make sure that the camera has at least 64M of RAM. diff --git a/config/locales/ru.yml b/config/locales/ru.yml index a9aa161..2b4da53 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -72,7 +72,7 @@ ru: attention: Внимание! backup: info: OpenIPC U-Boot перезаписывает исходный криптографический раздел, делая невозможным возврат к заводской прошивке, если у вас нет полной резервной копии флэш-памяти для этой конкретной камеры! Чтобы создать полную резервную копию прошивки, запустите TFTP-сервер и выполните эти команды в оболочке загрузчика. - more_info_html: Пожалуйста, обратитесь к инструкциям по установке в вики проекта для получения дополнительной информации. + more_info_html: Пожалуйста, обратитесь к инструкциям по установке в вики проекта для получения дополнительной информации. subtitle: Сделайте резервную копию стоковой прошивки, не пропускайте эту часть процесса установки! title: Сохраните оригинальную прошивку backup_32: ВНИМАНИЕ! Перед резервным копированием 32-мегабайтной флэш-памяти убедитесь, что камера имеет не менее 64 МБ оперативной памяти. diff --git a/config/locales/zh.yml b/config/locales/zh.yml index 2a1294b..f6c562f 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -70,7 +70,7 @@ zh: attention: 注意! backup: info: OpenIPC U-Boot 会覆盖原始的加密分区,因此无法恢复为出厂固件,除非您拥有此特定摄像机的完整闪存备份!要创建完整的固件备份,请启动 TFTP 服务器并在引导加载程序 shell 中执行这些命令。 - more_info_html: 有关详细信息,请参阅项目 wiki 中的安装说明。 + more_info_html: 有关详细信息,请参阅项目 wiki 中的安装说明。 subtitle: 备份您的原始固件,请不要跳过这个步骤! title: 保存原始固件 backup_32: 请注意!在备份 32M 闪存之前,请确保摄像机至少有 64M 的 RAM。 diff --git a/config/routes.rb b/config/routes.rb index 653d89d..43db04f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,14 +42,13 @@ } get '/majestic-endpoints', to: 'pages#majestic_endpoints' - get '/coupler', to: redirect('https://github.com/openipc//coupler/') - get '/firmware', to: redirect('https://github.com/openipc//firmware/') - get '/ipctool', to: redirect('https://github.com/openipc/ipctool/') - get '/microbe-web', to: redirect('https://github.com/openipc/microbe-web/') - get '/smolrtsp', to: redirect('https://github.com/openipc/smolrtsp/') - get '/telemetry', to: redirect('https://github.com/openipc/telemetry/') - get '/yaml-cli', to: redirect('https://github.com/openipc/yaml-cli/') - get '/wiki', to: redirect('https://github.com/openipc/wiki/') + get '/coupler', to: redirect('https://github.com/OpenIPC/coupler/') + get '/firmware', to: redirect('https://github.com/OpenIPC/firmware/') + get '/ipctool', to: redirect('https://github.com/OpenIPC/ipctool/') + get '/microbe-web', to: redirect('https://github.com/OpenIPC/microbe-web/') + get '/smolrtsp', to: redirect('https://github.com/OpenIPC/smolrtsp/') + get '/yaml-cli', to: redirect('https://github.com/OpenIPC/yaml-cli/') + get '/wiki', to: redirect('https://github.com/OpenIPC/wiki/') get '/hardware', to: redirect('/supported-hardware/featured') get '/ru/installation.md', to: redirect('https://github.com/OpenIPC/wiki/blob/master/ru/installation.md') @@ -64,14 +63,13 @@ get '/install_switcam_hs303', to: redirect('https://github.com/OpenIPC/wiki/blob/master/ru/hardware-hs303.md') # FIXME: combine with above - get '/coupler(/*any)', to: redirect('https://github.com/openipc//coupler') - get '/firmware(/*any)', to: redirect('https://github.com/openipc/firmware') - get '/ipctool(/*any)', to: redirect('https://github.com/openipc/ipctool') - get '/microbe-web(/*any)', to: redirect('https://github.com/openipc/microbe-web') - get '/smolrtsp(/*any)', to: redirect('https://github.com/openipc/smolrtsp') - get '/telemetry(/*any)', to: redirect('https://github.com/openipc/telemetry') - get '/yaml-cli(/*any)', to: redirect('https://github.com/openipc/yaml-cli') - get '/wiki(/*any)', to: redirect('https://github.com/openipc/wiki') + get '/coupler(/*any)', to: redirect('https://github.com/OpenIPC/coupler') + get '/firmware(/*any)', to: redirect('https://github.com/OpenIPC/firmware') + get '/ipctool(/*any)', to: redirect('https://github.com/OpenIPC/ipctool') + get '/microbe-web(/*any)', to: redirect('https://github.com/OpenIPC/microbe-web') + get '/smolrtsp(/*any)', to: redirect('https://github.com/OpenIPC/smolrtsp') + get '/yaml-cli(/*any)', to: redirect('https://github.com/OpenIPC/yaml-cli') + get '/wiki(/*any)', to: redirect('https://github.com/OpenIPC/wiki') get '/SDK', to: redirect('/supported-hardware') get '/sponsor', to: redirect('/donate') @@ -146,6 +144,14 @@ match "/binaries", to: proc { [410, { "Content-Type" => "text/plain" }, ["Gone\n"]] }, via: :all, as: :retired_binaries + # Same treatment, same reason. /telemetry was a shortcut to + # github.com/OpenIPC/telemetry, which does not exist and by all appearances + # never has, so it had been bouncing visitors to GitHub's own 404. Deleting + # the route is worse rather than better: the catch-all answers unknown paths + # with a 302 to the homepage, which tells a crawler the page moved there. + match "/telemetry(/*any)", to: proc { [410, { "Content-Type" => "text/plain" }, ["Gone\n"]] }, + via: :all, as: :retired_telemetry + match "*unmatched", to: "application#route_not_found", constraints: lambda { |req| req.path.exclude? 'rails/active_storage' }, via: :all diff --git a/test/controllers/redirects_test.rb b/test/controllers/redirects_test.rb index 4d35159..ba9195a 100644 --- a/test/controllers/redirects_test.rb +++ b/test/controllers/redirects_test.rb @@ -79,4 +79,34 @@ class RedirectsTest < ActionDispatch::IntegrationTest assert_response :success end + + # These send visitors off to GitHub, so a typo here is a dead end nobody on + # this side sees. One of them spelled the organisation `openipc//coupler`, + # with the wrong case and a doubled slash, and `/telemetry` pointed at a + # repository that has never existed. + GITHUB_REDIRECTS = %w[coupler firmware ipctool microbe-web smolrtsp yaml-cli wiki].freeze + + test 'every GitHub shortcut points at a well-formed OpenIPC repository' do + GITHUB_REDIRECTS.each do |repo| + ['', '/some/deep/path'].each do |suffix| + get "/#{repo}#{suffix}" + + assert_response :redirect, "/#{repo}#{suffix} does not redirect" + assert_match %r{\Ahttps://github\.com/OpenIPC/#{Regexp.escape(repo)}/?\z}, + response.location, + "/#{repo}#{suffix} sends visitors to #{response.location}" + end + end + end + + # The repository behind it does not exist, so bouncing anyone to GitHub's own + # 404 was worse than answering here. Deleting the route would be worse still: + # unknown paths 302 to the homepage, which claims the page moved there. + test '/telemetry says it is gone rather than pretending it moved' do + ['/telemetry', '/telemetry/anything'].each do |path| + get path + + assert_response :gone, "#{path} answered #{response.status}" + end + end end From 48f3dc63a6745c1a07d58256c2cef59e57f7fd6a Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 28 Aug 2026 17:53:21 +0000 Subject: [PATCH 03/10] Work through the review of the relaunched pages Homepage - The microline dropped "no cloud required". The lede two lines above already makes that point, and the cloud is not always someone else's -- some of it is ours. - The languages figure is gone from the statistics. Three is not a number worth a tile. - Majestic in the firmware pillar is a link to its wiki page now. The card uses .stretched-link, which covers anything inside it, so the link needed lifting above that overlay; the arrow still takes the whole card. - The partner wall is six labelled rows instead of one block of logos, which is the point of the restructure below. /low-latency - "RunCam and EMAX" becomes "RunCam and other vendors". RunCam is the one actually contributing and the pairing overstated the rest. - RubyFPV and Mario FPV are links, to the same URLs their logos carry. - The dark call-to-action band was butting straight against the white article above it. The gap goes on the page rather than the partial: on the homepage that band follows another dark section, where a top margin shows up as a white stripe. /business - Manufacturers and integrators only. A commercial reader is asking who ships hardware and who installs it, and our code host, our FPV friends and our university teams are not an answer to that. /merchandise - Unlinked from the footer while there is nothing to sell. The route and the page stay: the shop is expected back, plausibly through Open Collective, and deleting them would mean writing it all again. The wall itself - INTERNATIONAL_PARTNERS becomes PARTNER_GROUPS, keyed on why each logo is there: global, manufacturers, integrators, fpv, education, research. Pages ask for the groups they want -- partner_groups(:manufacturers, :integrators) -- and the Russian integrator list still appends to :integrators for :ru only, as before. Not done, and needing a decision rather than a guess: Faceter and rapid have no logo asset in the repository, so neither could be added. Really, TUDSaT and the commented-out Expo Electronica were placed by my reading rather than by instruction -- a sponsor, a student team and a trade show respectively. --- app/assets/stylesheets/components/_cards.scss | 8 ++ app/helpers/pages_helper.rb | 101 +++++++++++------- app/views/layouts/_footer.html.erb | 1 - app/views/pages/business.html.erb | 8 +- app/views/pages/home.html.erb | 12 +-- app/views/pages/low_latency.html.erb | 4 +- app/views/shared/_logo_wall.html.erb | 8 +- config/locales/en.yml | 7 ++ config/locales/pages.en.yml | 9 +- config/locales/pages.ru.yml | 9 +- config/locales/pages.zh.yml | 9 +- config/locales/ru.yml | 7 ++ config/locales/zh.yml | 7 ++ config/routes.rb | 3 + test/controllers/relaunch_pages_test.rb | 35 +++++- 15 files changed, 158 insertions(+), 70 deletions(-) diff --git a/app/assets/stylesheets/components/_cards.scss b/app/assets/stylesheets/components/_cards.scss index d985f20..ae29f2f 100644 --- a/app/assets/stylesheets/components/_cards.scss +++ b/app/assets/stylesheets/components/_cards.scss @@ -18,6 +18,14 @@ } } +// A link inside the card body sits under .stretched-link's overlay and cannot +// be clicked. Lifting it above restores it without giving up the whole-card +// click target -- which is what the arrow at the bottom is for. +.pillar-card .card-body p a { + position: relative; + z-index: 2; +} + .pillar-card .icon-chip { align-items: center; background: rgba($indigo, .08); diff --git a/app/helpers/pages_helper.rb b/app/helpers/pages_helper.rb index 2b20578..a1e4f09 100644 --- a/app/helpers/pages_helper.rb +++ b/app/helpers/pages_helper.rb @@ -2,47 +2,61 @@ # Helpers for the static marketing pages: the page title, and the partner wall. module PagesHelper - # The partner wall, in Ruby rather than hardcoded into a template. + # The partner wall, grouped, because "partners" was doing too much work as one + # word. A chip vendor that ships OpenIPC out of the box, a company that + # installs cameras for a living, a university team and the host of our + # finances are all on the wall for different reasons, and a page usually wants + # some of those reasons rather than all of them: /business shows who builds + # and integrates hardware, and nobody else. # - # Both lists were transcribed from the wall on /introduction as it stands - # today, not from an older copy: entries that page has commented out are - # commented out here too, with the same URLs, so nothing appears or disappears - # silently when /introduction is eventually retired. Uncommenting a line here - # is how one comes back. + # Entries commented out here are commented out on the old /introduction wall + # too, with the same URLs, so nothing appears or disappears silently. + # Uncommenting a line is how one comes back. # - # Shown to every visitor. - INTERNATIONAL_PARTNERS = [ - { name: 'Open Source Collective', url: 'https://www.oscollective.org/', img: 'partners/osc_mini.png' }, - { name: 'GitHub', url: 'https://github.com/', img: 'partners/github_mini.png' }, - { name: 'RunCam', url: 'https://runcam.com/', img: 'partners/runcam_mini.png' }, - { name: 'CCDCAM', url: 'https://ccdcam.com/', img: 'partners/ccdcam_mini.png' }, - { name: 'wfb-ng', url: 'https://github.com/svpcom/wfb-ng/', img: 'partners/wfb-ng_mini.png' }, - { name: 'RubyFPV', url: 'https://rubyfpv.com/', img: 'partners/rubyfpv_mini.png' }, - { name: 'Mario FPV', url: 'https://www.youtube.com/@mariofpv', img: 'partners/mariofpv_mini.png' }, - { name: 'Linux Chenxing', url: 'https://linux-chenxing.org/', img: 'partners/linuxchenxing_mini.png' }, - { name: 'TUDSaT', url: 'https://www.tudsat.space/', img: 'partners/tudsat_mini.png' }, - { name: 'WüSpace', url: 'https://wuespace.de/', img: 'partners/wuespace_mini.png' }, - { name: 'Really', url: 'https://opencollective.com/really-541ee976', img: 'partners/really_mini.png' } - # Commented out on /introduction, so commented out here: - # { name: 'EMAX', url: 'https://emaxmodel.com/', img: 'partners/emax_mini.png' }, - # { name: 'GoodCam', url: 'https://www.goodcam.io/', img: 'partners/goodcam_mini.png' }, - # - # partners/baresip_mini.png exists as an asset but has never been on the - # wall. Left off rather than introduced by a refactor. - ].freeze + # partners/baresip_mini.png exists as an asset but has never been on the wall. + # Left off rather than introduced by a refactor. + PARTNER_GROUPS = { + # Who hosts our money and our code. + global: [ + { name: 'Open Source Collective', url: 'https://www.oscollective.org/', img: 'partners/osc_mini.png' }, + { name: 'GitHub', url: 'https://github.com/', img: 'partners/github_mini.png' }, + { name: 'Really', url: 'https://opencollective.com/really-541ee976', img: 'partners/really_mini.png' } + ], + # Ships hardware with OpenIPC on it. + manufacturers: [ + { name: 'RunCam', url: 'https://runcam.com/', img: 'partners/runcam_mini.png' }, + { name: 'CCDCAM', url: 'https://ccdcam.com/', img: 'partners/ccdcam_mini.png' } + # Commented out on /introduction, so commented out here: + # { name: 'EMAX', url: 'https://emaxmodel.com/', img: 'partners/emax_mini.png' }, + ], + # Builds systems on it for other people. RU_INTEGRATORS is appended to this + # group for Russian-speaking visitors only -- see partner_groups. + integrators: [ + { name: 'GoodCam', url: 'https://www.goodcam.io/', img: 'partners/goodcam_mini.png' } + ], + # The FPV projects we grew up alongside. + fpv: [ + { name: 'wfb-ng', url: 'https://github.com/svpcom/wfb-ng/', img: 'partners/wfb-ng_mini.png' }, + { name: 'RubyFPV', url: 'https://rubyfpv.com/', img: 'partners/rubyfpv_mini.png' }, + { name: 'Mario FPV', url: 'https://www.youtube.com/@mariofpv', img: 'partners/mariofpv_mini.png' } + ], + # Student and university teams flying or teaching on OpenIPC. + education: [ + { name: 'TUDSaT', url: 'https://www.tudsat.space/', img: 'partners/tudsat_mini.png' }, + { name: 'WuSpace', url: 'https://wuespace.de/', img: 'partners/wuespace_mini.png' } + ], + # Reverse engineering and silicon research we build on. + research: [ + { name: 'Linux Chenxing', url: 'https://linux-chenxing.org/', img: 'partners/linuxchenxing_mini.png' } + ] + }.freeze # Integrators are territory-specific: these serve Russia and are shown only to # Russian-language visitors. Showing them to everyone was explicitly not - # wanted, and the reverse -- gating them in CSS on /introduction with - # `html:not([lang="ru"])` -- never worked, because no logo on that page ever - # carried the `ru` class the rule selects on. - # - # NOTE FOR REVIEW: this whole block is currently commented out on - # /introduction, so no visitor sees any of it today. Rendering it for :ru - # brings it back. That is what the relaunch plan asks for, but it is a content - # decision rather than a technical one -- say so if it should stay hidden. + # wanted, and the reverse -- gating them in CSS with `html:not([lang="ru"])` + # -- never worked, because no logo ever carried the `ru` class the rule + # selects on. RU_INTEGRATORS = [ - { name: 'GoodCam', url: 'https://www.goodcam.io/', img: 'partners/goodcam_mini.png' }, { name: 'SkyCam', url: 'https://skycam.cam/', img: 'partners/skycam_mini.png' }, { name: 'Vixand', url: 'https://vixand.ru/', img: 'partners/vixand_mini.png' }, { name: 'Improve IT', url: 'https://3it.ru/', img: 'partners/improve_mini.png' }, @@ -108,9 +122,18 @@ def page_title [@page_title, 'OpenIPC'].join(' - ') end - def partner_logos - logos = INTERNATIONAL_PARTNERS - logos += RU_INTEGRATORS if I18n.locale.eql?(:ru) - logos + # The named groups, in the order asked for, each as [key, logos]. Groups that + # would render empty are dropped rather than left as a heading over nothing. + def partner_groups(*keys) + keys = PARTNER_GROUPS.keys if keys.empty? + keys.filter_map do |key| + logos = PARTNER_GROUPS.fetch(key) + logos += RU_INTEGRATORS if key == :integrators && I18n.locale.eql?(:ru) + [key, logos] if logos.any? + end + end + + def partner_logos(*keys) + partner_groups(*keys).flat_map(&:last) end end diff --git a/app/views/layouts/_footer.html.erb b/app/views/layouts/_footer.html.erb index 6f88a26..48f61d1 100644 --- a/app/views/layouts/_footer.html.erb +++ b/app/views/layouts/_footer.html.erb @@ -46,7 +46,6 @@
  • <%= t('nav.donate') %>
  • <%= t('nav.team') %>
  • <%= t('nav.green_life') %>
  • -
  • <%= t('pages.merchandise.title') %>
  • diff --git a/app/views/pages/business.html.erb b/app/views/pages/business.html.erb index ef9f9f9..c1dd2f5 100644 --- a/app/views/pages/business.html.erb +++ b/app/views/pages/business.html.erb @@ -30,9 +30,13 @@ <% end %>
    - <%# Who builds on it (territory-specific integrator set) %> + <%# Who builds on it. Manufacturers and integrators only: a commercial + reader is asking who ships and who installs, and our code host and + our university teams are not an answer to that. %>

    <%= t('.partners_title') %>

    - <%= render 'shared/logo_wall', logos: partner_logos %> + <% partner_groups(:manufacturers, :integrators).each do |key, logos| %> + <%= render 'shared/logo_wall', logos: logos, title: t("site.partners.#{key}") %> + <% end %> <%# Engagement steps %>

    <%= t('.how_title') %>

    diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index c5a3fa8..7812f20 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -87,7 +87,7 @@

    <%= t('.pillars_lede') %>

    -
    <%= render 'shared/pillar_card', icon: 'bi-cpu', title: t('.pillar_firmware_title'), text: t('.pillar_firmware_text'), href: '/get-started' %>
    +
    <%= render 'shared/pillar_card', icon: 'bi-cpu', title: t('.pillar_firmware_title'), text: t('.pillar_firmware_text_html'), href: '/get-started' %>
    <%= render 'shared/pillar_card', icon: 'bi-broadcast', title: t('.pillar_low_latency_title'), text: t('.pillar_low_latency_text'), href: '/low-latency' %>
    <%= render 'shared/pillar_card', icon: 'bi-wrench-adjustable', title: t('.pillar_tools_title'), text: t('.pillar_tools_text'), href: '/ecosystem' %>
    <%= render 'shared/pillar_card', icon: 'bi-shield-check', title: t('.pillar_longevity_title'), text: t('.pillar_longevity_text'), href: '/ecosystem', accent: true %>
    @@ -98,7 +98,7 @@ <%# ---- Stats band (live numbers from the database; evergreen floors elsewhere) ---- %>
    -
    +
    <% if @soc_count.positive? %>
    <%= @soc_count %>
    @@ -113,10 +113,6 @@
    140+
    <%= t('.stats_repos') %>
    -
    -
    <%= I18n.available_locales.size %>
    -
    <%= t('.stats_languages') %>
    -
    2019
    <%= t('.stats_since') %>
    @@ -129,7 +125,9 @@

    <%= t('.partners_title') %>

    - <%= render 'shared/logo_wall', logos: partner_logos %> + <% partner_groups.each do |key, logos| %> + <%= render 'shared/logo_wall', logos: logos, title: t("site.partners.#{key}") %> + <% end %>
    diff --git a/app/views/pages/low_latency.html.erb b/app/views/pages/low_latency.html.erb index 593e33e..c19257d 100644 --- a/app/views/pages/low_latency.html.erb +++ b/app/views/pages/low_latency.html.erb @@ -86,14 +86,14 @@
    <%# Hardware + credits %> -
    +

    <%= t('.hardware_title') %>

    <%= t('.hardware_text_html') %>

    <%= t('.credits_title') %>

    -

    <%= t('.credits_text') %>

    +

    <%= t('.credits_text_html') %>

    diff --git a/app/views/shared/_logo_wall.html.erb b/app/views/shared/_logo_wall.html.erb index e4eddba..1b4f6e4 100644 --- a/app/views/shared/_logo_wall.html.erb +++ b/app/views/shared/_logo_wall.html.erb @@ -1,5 +1,9 @@ -<%# Partner/integrator logo wall. Locals: logos (array of {name:, url:, img:}). %> -
    +<%# Partner/integrator logo wall. Locals: logos (array of {name:, url:, img:}), + title (optional -- the group heading above the row). %> +<% if local_assigns[:title].present? %> +

    <%= title %>

    +<% end %> +
    <% logos.each do |logo| %>
    diff --git a/config/locales/en.yml b/config/locales/en.yml index 03ab583..80fbff0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -177,6 +177,13 @@ en: nosd: Camera does not have an SD card slot sd: Camera has an SD card slot site: + partners: + education: Educational projects + fpv: Friendly FPV projects + global: Global partners + integrators: Integrators + manufacturers: Manufacturers + research: Research projects default_meta_description: 'OpenIPC is the open platform for embedded video: open-source firmware for IP cameras, ultra-low-latency video links for drones and robots, and free tools for camera engineers.' disclaimer: The information contained on this site is for general informational purposes only and is not intended to be relied upon by the visitor. All software and firmware available on this site is provided AS IS and for research purposes only. OpenIPC shall not be liable for any loss or damage caused by the use of these files or the use of, or reliance upon, any information contained in this site. snapshot: diff --git a/config/locales/pages.en.yml b/config/locales/pages.en.yml index f3f79af..7a2b270 100644 --- a/config/locales/pages.en.yml +++ b/config/locales/pages.en.yml @@ -167,10 +167,10 @@ en: hero_cta_low_latency: Low-latency video hero_cta_start: Get started hero_lede: Open firmware and tools for IP cameras, FPV drones, and the devices in between. Your hardware should answer to you — not to a vendor's cloud. - hero_microline: 140+ repositories · dozens of SoC families · no cloud required + hero_microline: 140+ repositories · dozens of SoC families · since 2019 hero_title: The open platform for embedded video. partners_title: Trusted by manufacturers, integrators, and research teams - pillar_firmware_text: An open, Buildroot-based OS for IP cameras across dozens of SoC families, with the Majestic streamer at its core. + pillar_firmware_text_html: An open, Buildroot-based OS for IP cameras across dozens of SoC families, with the Majestic streamer at its core. pillar_firmware_title: Camera firmware pillar_longevity_text: 'We keep vendor SDKs alive on mainline kernels: CVE fixes and modern features long after the vendor walks away.' pillar_longevity_title: Longevity & security @@ -181,7 +181,6 @@ en: pillars_lede: OpenIPC is more than firmware — it is an ecosystem built by people who run it on their own hardware. pillars_title: One platform, four fronts silicon_title: Runs on silicon by - stats_languages: languages stats_repos: open repositories stats_since: building openly since stats_socs: supported SoCs @@ -200,13 +199,13 @@ en: wall_snapshot_alt: 'Image: live snapshot from a community camera' low_latency: business_bridge_html: Building a product on this link? Talk to us — we do custom development and OEM work. - credits_text: The radio link exists thanks to wfb-ng and devourer, and the ecosystem grows with friends like RubyFPV and Mario FPV. Open source is a team sport. + credits_text_html: The radio link exists thanks to wfb-ng and devourer, and the ecosystem grows with friends like RubyFPV and Mario FPV. Open source is a team sport. credits_title: Standing on open shoulders cta_chat: Join the FPV chat cta_guide: Read the build guide fpv_text: 'A fully open digital FPV system: no locked ecosystem, no paired hardware, no firmware ceiling. Build the air unit from a supported camera, pick your ground station, and tune every stage of the pipeline.' fpv_title: 'FPV: from lens to goggles' - hardware_text_html: Air units are built from supported cameras and AIO boards — start from the supported hardware list. RunCam and EMAX ship OpenIPC-based FPV hardware out of the box. + hardware_text_html: Air units are built from supported cameras and AIO boards — start from the supported hardware list. RunCam and other vendors ship OpenIPC-based FPV hardware out of the box. hardware_title: Hardware hero_lede: 'FPV drones, robots, teleoperation: an open ultra-low-latency video stack from camera to screen. Glass-to-glass from about 30 ms on supported hardware.' hero_title: Open video links for machines that can't wait. diff --git a/config/locales/pages.ru.yml b/config/locales/pages.ru.yml index a9bb32c..967271c 100644 --- a/config/locales/pages.ru.yml +++ b/config/locales/pages.ru.yml @@ -167,10 +167,10 @@ ru: hero_cta_low_latency: Видео с низкой задержкой hero_cta_start: Начать hero_lede: Открытая прошивка и инструменты для IP-камер, FPV-дронов и всего, что между ними. Ваше железо должно подчиняться вам, а не облаку производителя. - hero_microline: 140+ репозиториев · десятки семейств SoC · облако не требуется + hero_microline: 140+ репозиториев · десятки семейств SoC · с 2019 года hero_title: Открытая платформа для встраиваемого видео. partners_title: Нам доверяют производители, интеграторы и исследователи - pillar_firmware_text: Открытая ОС на базе Buildroot для IP-камер на десятках семейств SoC, со стримером Majestic в основе. + pillar_firmware_text_html: Открытая ОС на базе Buildroot для IP-камер на десятках семейств SoC, со стримером Majestic в основе. pillar_firmware_title: Прошивка для камер pillar_longevity_text: 'Мы поддерживаем вендорские SDK на mainline-ядрах: исправления CVE и новые возможности спустя годы после ухода производителя.' pillar_longevity_title: Долголетие и безопасность @@ -181,7 +181,6 @@ ru: pillars_lede: OpenIPC — больше, чем прошивка. Это экосистема, которую делают люди, использующие её на собственном железе. pillars_title: Одна платформа, четыре направления silicon_title: Работает на чипах - stats_languages: языков stats_repos: открытых репозиториев stats_since: открытая разработка с stats_socs: поддерживаемых SoC @@ -200,13 +199,13 @@ ru: wall_snapshot_alt: 'Изображение: живой снимок с камеры сообщества' low_latency: business_bridge_html: Строите продукт на этом линке? Напишите нам — мы делаем заказную разработку и OEM. - credits_text: Радиолинк существует благодаря wfb-ng и devourer, а экосистема растёт вместе с друзьями — RubyFPV и Mario FPV. Open source — командный спорт. + credits_text_html: Радиолинк существует благодаря wfb-ng и devourer, а экосистема растёт вместе с друзьями — RubyFPV и Mario FPV. Open source — командный спорт. credits_title: Стоим на открытых плечах cta_chat: FPV-чат cta_guide: Гайд по сборке fpv_text: 'Полностью открытая цифровая FPV-система: никакой закрытой экосистемы, никакого спаренного железа, никакого потолка прошивки. Соберите борт из поддерживаемой камеры, выберите наземную станцию и настройте каждый этап конвейера.' fpv_title: 'FPV: от объектива до очков' - hardware_text_html: Борт собирается из поддерживаемых камер и AIO-плат — начните со списка поддерживаемого оборудования. RunCam и EMAX выпускают FPV-железо на OpenIPC из коробки. + hardware_text_html: Борт собирается из поддерживаемых камер и AIO-плат — начните со списка поддерживаемого оборудования. RunCam и другие вендоры выпускают FPV-железо на OpenIPC из коробки. hardware_title: Железо hero_lede: 'FPV-дроны, роботы, телеуправление: открытый видеотракт со сверхнизкой задержкой от камеры до экрана. От ~30 мс «от стекла до стекла» на поддерживаемом железе.' hero_title: Открытые видеолинки для машин, которые не умеют ждать. diff --git a/config/locales/pages.zh.yml b/config/locales/pages.zh.yml index cfe4fb9..8e928cc 100644 --- a/config/locales/pages.zh.yml +++ b/config/locales/pages.zh.yml @@ -167,10 +167,10 @@ zh: hero_cta_low_latency: 低延迟视频 hero_cta_start: 快速上手 hero_lede: 面向 IP 摄像机、FPV 无人机以及介于两者之间的设备的开放固件与工具。你的硬件应当听你的,而不是听某个厂商云端的。 - hero_microline: 140+ 个仓库 · 数十个 SoC 系列 · 无需云端 + hero_microline: 140+ 个仓库 · 数十个 SoC 系列 · 自 2019 年 hero_title: 面向嵌入式视频的开放平台。 partners_title: 制造商、集成商与研究团队的共同选择 - pillar_firmware_text: 面向数十个 SoC 系列的 IP 摄像机开放固件,基于 Buildroot,核心是 Majestic 推流器。 + pillar_firmware_text_html: 面向数十个 SoC 系列的 IP 摄像机开放固件,基于 Buildroot,核心是 Majestic 推流器。 pillar_firmware_title: 摄像机固件 pillar_longevity_text: '我们让原厂 SDK 在主线内核上继续存活:在厂商撒手很久之后,依然提供 CVE 修复与现代特性。' pillar_longevity_title: 长期维护与安全 @@ -181,7 +181,6 @@ zh: pillars_lede: OpenIPC 不只是固件——它是一个由亲自在自己硬件上运行它的人们构建的生态。 pillars_title: 一个平台,四条战线 silicon_title: 支持的芯片厂商 - stats_languages: 种语言 stats_repos: 个开放仓库 stats_since: 年起公开开发 stats_socs: 款支持的 SoC @@ -200,13 +199,13 @@ zh: wall_snapshot_alt: '图片:来自社区摄像机的实时快照' low_latency: business_bridge_html: 想基于这条链路做产品?和我们谈谈——我们承接定制开发与 OEM 合作。 - credits_text: 这条无线链路得益于 wfb-ng 与 devourer,整个生态也在 RubyFPV、Mario FPV 等伙伴的参与下不断成长。开源是一项团队运动。 + credits_text_html: 这条无线链路得益于 wfb-ng 与 devourer,整个生态也在 RubyFPVMario FPV 等伙伴的参与下不断成长。开源是一项团队运动。 credits_title: 站在开放的肩膀上 cta_chat: 加入 FPV 聊天群 cta_guide: 阅读搭建指南 fpv_text: '一套完全开放的数字 FPV 系统:没有封闭生态,没有配对绑定的硬件,也没有固件上限。用受支持的摄像机搭建天空端,挑选你的地面站,并可调节管线的每一个环节。' fpv_title: 'FPV:从镜头到眼镜' - hardware_text_html: 天空端由受支持的摄像机和 AIO 板搭建——请从支持硬件列表开始。RunCam 与 EMAX 出厂即提供基于 OpenIPC 的 FPV 硬件。 + hardware_text_html: 天空端由受支持的摄像机和 AIO 板搭建——请从支持硬件列表开始。RunCam 等厂商出厂即提供基于 OpenIPC 的 FPV 硬件。 hardware_title: 硬件 hero_lede: 'FPV 无人机、机器人、遥操作:一套从摄像机到屏幕的开放超低延迟视频方案。在受支持的硬件上,端到端延迟低至约 30 毫秒。' hero_title: 为等不起的机器提供开放视频链路。 diff --git a/config/locales/ru.yml b/config/locales/ru.yml index a9aa161..bb9cef3 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -179,6 +179,13 @@ ru: nosd: Камера не имеет слота для SD-карты sd: Камера имеет слот для SD-карты site: + partners: + education: Образовательные проекты + fpv: Дружественные FPV-проекты + global: Глобальные партнёры + integrators: Интеграторы + manufacturers: Производители + research: Исследовательские проекты default_meta_description: 'OpenIPC — открытая платформа для встраиваемого видео: открытая прошивка для IP-камер, видеолинки со сверхнизкой задержкой для дронов и роботов, бесплатные инструменты для инженеров.' disclaimer: Информация, содержащаяся на этом сайте, предназначена только для общих информационных целей и не предназначена для того, чтобы посетитель полагался исключительно на нее. Все программное обеспечение и прошивки, доступные на этом сайте, предоставляются КАК ЕСТЬ и только для исследовательских целей. OpenIPC не несет ответственности за какие-либо убытки или ущерб, вызванные использованием этих файлов или не использованием их или безусловным доверием к любой информации, содержащейся на этом сайте. snapshot: diff --git a/config/locales/zh.yml b/config/locales/zh.yml index 2a1294b..d43774f 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -177,6 +177,13 @@ zh: nosd: 摄像机没有 SD 卡槽 sd: 摄像机有一个 SD 卡槽 site: + partners: + education: 教育项目 + fpv: 友好的 FPV 项目 + global: 全球合作伙伴 + integrators: 集成商 + manufacturers: 硬件厂商 + research: 研究项目 default_meta_description: 'OpenIPC 是面向嵌入式视频的开放平台:适用于 IP 摄像机的开源固件、面向无人机与机器人的超低延迟视频链路,以及为摄像机工程师提供的免费工具。' disclaimer: 本网站包含的信息仅供一般参考之用,访问者不应依赖这些信息。本网站上提供的所有软件和固件均按原样提供,仅供研究使用。对于因使用这些文件或依赖本网站中包含的任何信息而造成的任何损失或损害,OpenIPC 概不负责。 snapshot: diff --git a/config/routes.rb b/config/routes.rb index 653d89d..c31672e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -77,6 +77,9 @@ get '/sponsor', to: redirect('/donate') get '/green_life', to:'pages#green_life' + # Not linked from anywhere while there is nothing to sell -- see the footer. + # The route and the page stay: the shop is expected back, plausibly through + # Open Collective, and deleting them would mean writing it all again. get '/merchandise', to: 'pages#merchandise' get '/our-team', to: 'pages#our_team' get '/stages-of-firmware-development', to: 'pages#stages_of_firmware_development' diff --git a/test/controllers/relaunch_pages_test.rb b/test/controllers/relaunch_pages_test.rb index f2582e8..eb83ccb 100644 --- a/test/controllers/relaunch_pages_test.rb +++ b/test/controllers/relaunch_pages_test.rb @@ -150,11 +150,42 @@ class RelaunchPagesTest < ActionDispatch::IntegrationTest end test 'every partner logo the helper names exists as an asset' do - (PagesHelper::INTERNATIONAL_PARTNERS + PagesHelper::RU_INTEGRATORS).each do |logo| + logos = PagesHelper::PARTNER_GROUPS.values.flatten + PagesHelper::RU_INTEGRATORS + + assert_operator logos.size, :>, 10 + logos.each do |logo| assert_path_exists Rails.root.join('app/assets/images', logo[:img]) end end + # A commercial reader is asking who ships hardware and who installs it. Our + # code host, our FPV friends and our university teams are on the homepage + # wall for good reasons, none of which are an answer to that question. + test '/business shows only manufacturers and integrators' do + get '/business' + + shown = PagesHelper.const_get(:PARTNER_GROUPS) + %i[manufacturers integrators].each do |key| + shown[key].each { |l| assert_includes response.body, l[:img].sub('.png', '') } + assert_includes response.body, I18n.t("site.partners.#{key}") + end + %i[global fpv education research].each do |key| + shown[key].each do |logo| + assert_not_includes response.body, logo[:img].sub('.png', ''), + "#{logo[:name]} is on /business, which is not the audience for it" + end + end + end + + # Six rows rather than one undifferentiated block of logos. + test 'the homepage groups the wall instead of pouring it into one row' do + get '/' + + PagesHelper::PARTNER_GROUPS.each_key do |key| + assert_includes response.body, I18n.t("site.partners.#{key}"), "the #{key} row is missing" + end + end + # The radio-link card names devourer, OpenIPC's own userspace Realtek driver, # rather than wfb-ng. wfb-ng keeps its place in the credits -- the link exists # because of both -- so the test checks the card, not the whole page. @@ -165,7 +196,7 @@ class RelaunchPagesTest < ActionDispatch::IntegrationTest assert_not_nil card, 'no card on the page names devourer' assert_not_empty card.css('a[href="https://github.com/OpenIPC/devourer"]'), 'devourer is named but not linked' - assert_includes response.body, I18n.t('pages.low_latency.credits_text'), + assert_includes response.body, I18n.t('pages.low_latency.credits_text_html'), 'the credits no longer thank both projects' end From 5fc65a9fecd50aab73ac8a5452558f57d1b45dfd Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 28 Aug 2026 18:10:26 +0000 Subject: [PATCH 04/10] Show the wall as three rows, not six At six, half the rows were a single logo under a heading, which reads as a gap rather than a group: research is one logo, and integrators is one until the visitor is Russian. Three rows now, in the order the wall is meant to be read -- who ships and installs the hardware, who hosts us, who we work alongside. English lands at 3/3/6, Russian at 19/3/6 because the integrator list is territory specific. The six groups are untouched. HOME_PARTNER_ROWS only says how the homepage composes them, /business still asks for :manufacturers and :integrators directly, and splitting a row back out is one line. The three labels that now have no row of their own went with it, so the unused-translation count stays where it was. --- app/helpers/pages_helper.rb | 22 ++++++++++++++++++++++ app/views/pages/home.html.erb | 4 ++-- config/locales/en.yml | 6 ++---- config/locales/ru.yml | 6 ++---- config/locales/zh.yml | 6 ++---- test/controllers/relaunch_pages_test.rb | 15 ++++++++++++--- 6 files changed, 42 insertions(+), 17 deletions(-) diff --git a/app/helpers/pages_helper.rb b/app/helpers/pages_helper.rb index a1e4f09..4a78721 100644 --- a/app/helpers/pages_helper.rb +++ b/app/helpers/pages_helper.rb @@ -122,6 +122,28 @@ def page_title [@page_title, 'OpenIPC'].join(' - ') end + # How the homepage arranges the six groups. At six rows half of them were a + # single logo under a heading, which reads as a gap rather than a group -- + # research is one logo, and integrators is one until the visitor is Russian. + # Three rows, in the order the wall is meant to be read: who ships and + # installs the hardware, who hosts us, and who we work alongside. + # + # /business still asks for its two groups directly. Splitting rows out again + # is a line here, not a rewrite. + HOME_PARTNER_ROWS = { + trade: %i[manufacturers integrators], + global: %i[global], + friends: %i[fpv education research] + }.freeze + + # Rows of [label, logos], skipping any that would render empty. + def partner_rows(rows) + rows.filter_map do |label, keys| + logos = partner_logos(*keys) + [label, logos] if logos.any? + end + end + # The named groups, in the order asked for, each as [key, logos]. Groups that # would render empty are dropped rather than left as a heading over nothing. def partner_groups(*keys) diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index 7812f20..ff5a551 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -125,8 +125,8 @@

    <%= t('.partners_title') %>

    - <% partner_groups.each do |key, logos| %> - <%= render 'shared/logo_wall', logos: logos, title: t("site.partners.#{key}") %> + <% partner_rows(PagesHelper::HOME_PARTNER_ROWS).each do |label, logos| %> + <%= render 'shared/logo_wall', logos: logos, title: t("site.partners.#{label}") %> <% end %>
    diff --git a/config/locales/en.yml b/config/locales/en.yml index cb21709..aa2580e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -107,7 +107,6 @@ en: info: If you need to restore the original firmware, use the backup file you have created using the commands above. Place the backup file into the root directory of your TFTP server and run these commands in bootloader shell. title: Restore camera stock firmware version: - fpv: FPV lite: Lite nand: NAND neo: Neo @@ -178,12 +177,11 @@ en: sd: Camera has an SD card slot site: partners: - education: Educational projects - fpv: Friendly FPV projects + friends: Friendly projects global: Global partners integrators: Integrators manufacturers: Manufacturers - research: Research projects + trade: Manufacturers and integrators default_meta_description: 'OpenIPC is the open platform for embedded video: open-source firmware for IP cameras, ultra-low-latency video links for drones and robots, and free tools for camera engineers.' disclaimer: The information contained on this site is for general informational purposes only and is not intended to be relied upon by the visitor. All software and firmware available on this site is provided AS IS and for research purposes only. OpenIPC shall not be liable for any loss or damage caused by the use of these files or the use of, or reliance upon, any information contained in this site. snapshot: diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 9e960aa..6b43bde 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -109,7 +109,6 @@ ru: info: Если вам нужно восстановить исходную прошивку, используйте файл резервной копии, который вы создали с помощью приведенных выше команд. Поместите файл резервной копии в корневой каталог вашего TFTP-сервера и выполните эти команды в оболочке загрузчика. title: Восстановить стоковую прошивку камеры version: - fpv: FPV lite: Lite nand: NAND neo: Neo @@ -180,12 +179,11 @@ ru: sd: Камера имеет слот для SD-карты site: partners: - education: Образовательные проекты - fpv: Дружественные FPV-проекты + friends: Дружественные проекты global: Глобальные партнёры integrators: Интеграторы manufacturers: Производители - research: Исследовательские проекты + trade: Производители и интеграторы default_meta_description: 'OpenIPC — открытая платформа для встраиваемого видео: открытая прошивка для IP-камер, видеолинки со сверхнизкой задержкой для дронов и роботов, бесплатные инструменты для инженеров.' disclaimer: Информация, содержащаяся на этом сайте, предназначена только для общих информационных целей и не предназначена для того, чтобы посетитель полагался исключительно на нее. Все программное обеспечение и прошивки, доступные на этом сайте, предоставляются КАК ЕСТЬ и только для исследовательских целей. OpenIPC не несет ответственности за какие-либо убытки или ущерб, вызванные использованием этих файлов или не использованием их или безусловным доверием к любой информации, содержащейся на этом сайте. snapshot: diff --git a/config/locales/zh.yml b/config/locales/zh.yml index 530413a..399d525 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -107,7 +107,6 @@ zh: info: 如果您需要恢复原始固件,请使用您使用上述命令创建的备份文件。将备份文件放入 TFTP 服务器的根目录,并在引导加载程序 shell 中执行这些命令。 title: 恢复摄像机原始固件 version: - fpv: FPV lite: Lite nand: Nand neo: Neo @@ -178,12 +177,11 @@ zh: sd: 摄像机有一个 SD 卡槽 site: partners: - education: 教育项目 - fpv: 友好的 FPV 项目 + friends: 友好项目 global: 全球合作伙伴 integrators: 集成商 manufacturers: 硬件厂商 - research: 研究项目 + trade: 厂商与集成商 default_meta_description: 'OpenIPC 是面向嵌入式视频的开放平台:适用于 IP 摄像机的开源固件、面向无人机与机器人的超低延迟视频链路,以及为摄像机工程师提供的免费工具。' disclaimer: 本网站包含的信息仅供一般参考之用,访问者不应依赖这些信息。本网站上提供的所有软件和固件均按原样提供,仅供研究使用。对于因使用这些文件或依赖本网站中包含的任何信息而造成的任何损失或损害,OpenIPC 概不负责。 snapshot: diff --git a/test/controllers/relaunch_pages_test.rb b/test/controllers/relaunch_pages_test.rb index 8f70b5d..73c2475 100644 --- a/test/controllers/relaunch_pages_test.rb +++ b/test/controllers/relaunch_pages_test.rb @@ -194,12 +194,21 @@ class RelaunchPagesTest < ActionDispatch::IntegrationTest end end - # Six rows rather than one undifferentiated block of logos. + # Three labelled rows rather than one undifferentiated block of logos -- and + # rather than six, half of which were a single logo under a heading. test 'the homepage groups the wall instead of pouring it into one row' do get '/' - PagesHelper::PARTNER_GROUPS.each_key do |key| - assert_includes response.body, I18n.t("site.partners.#{key}"), "the #{key} row is missing" + rows = css_select('.logo-wall').size + labels = PagesHelper::HOME_PARTNER_ROWS.keys + + assert_equal labels.size, rows + labels.each do |label| + assert_includes response.body, I18n.t("site.partners.#{label}"), "the #{label} row is missing" + end + # Every group still reaches the page through one row or another. + PagesHelper::PARTNER_GROUPS.each_value do |logos| + logos.each { |l| assert_includes response.body, l[:img].sub('.png', '') } end end From c879995ffb3568bd0c2d94a327895ceb94247b65 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 28 Aug 2026 18:57:13 +0000 Subject: [PATCH 05/10] Put Faceter on the wall A universal integrator, so it goes in the :integrators group proper, which every visitor sees, rather than in RU_INTEGRATORS, which is appended only for Russian. The logo is cut from the supplied seeklogo PNG. That file already carries a correct alpha channel with the mark sitting in a band across a square canvas, so the work was cropping to those alpha bounds -- (16,120)-(304,200) -- and scaling the 288x80 result to 580px wide, the width the rest of the wall uses. Nothing was keyed out; an earlier attempt that flattened white to transparent destroyed the alpha the file already had. The supplied JPEG is the same wordmark with the "Know the people around you" tagline underneath, on white. Not used: the tile is 4.5rem tall, where that second line would be unreadable, and every other logo on the wall is a wordmark without one. --- app/assets/images/partners/faceter_mini.png | Bin 0 -> 23206 bytes app/helpers/pages_helper.rb | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 app/assets/images/partners/faceter_mini.png diff --git a/app/assets/images/partners/faceter_mini.png b/app/assets/images/partners/faceter_mini.png new file mode 100644 index 0000000000000000000000000000000000000000..a9b938bc5c6971b8a19feeb6281d21f5256c2470 GIT binary patch literal 23206 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%0Drit4Hq0#j%me{0Vr}=jgftJ`4)9+En zR(wp-RuDl}zhr^H{~0tyq7g7T)A*MJrHyWl9A+HZ%}=bQ0DaSi1jB^C16zEj7-o!Q zJtvS|9WQ*fjDEoY4+G2lLE`2Wlv|p|U4jMd?B>RAH096;N39&hqK{4jNEI zWHgov3zNn{RFPU3^F?DXN(ylzLTKk+?EdImLqK3b*T+k9{@2ItZ`Hq|Q$76g*3o%7 z4eu6z_2LQ)uX6EHrTUlNA@(bU zrN07l8yU*>r;i_(mv6kolo?-6xCZQtsMhTGGR2p8IvlMZ9v<2xr+F+F*fQ_{V|J|0 zdYMnl2%7r6i0c|==<_kDrOu1bJ?p;|Wh1g1vSZNzgk|>%3!6U-&}XQ}_r8!5eniKM6pEKC z7T6N`7K3c`93Rw$kXn#Y{P~U2eJna^`6(3b|JTf!ctiF7@&ArN_O-GT%}^-26tazF z2-(U~mJk|@r9Re}?0XE7eaXHrQL>eNY=c6UC}d}>W6$rV@9+0F{N|iFbIzQ5-uJ%W z_kG{5_kBH|uZRAOXeZ30c7$pPvDQ&fxg_|Rz#e!hdBj&^X*1eD1!R)y+<1wo*{xb*}3Aa+26g|iqx3L6F+XOakWJ?oj95>eV8qn44X3) zq+DOg#=Y}>I8`&M6L<0vjLf};!E6 zouS~KtA)kPyWrqpDNciv39<2*YEcP5RI0Z2w#?E?@?3v@G3a;U{A5}QLhT? zIEhkZMcarG!>VGSk2x#R)JzY`e!l?Do-YsQW#aq#`Z5(OgKF z^@*XQ2vpE2Kk|rofep@96906^ELgei?h3Q}FV9G0RJ2%U zJGr#jTwxak>RtgmvnKgVRk9fyi9^h!=n>&;U?kC*mO1y4xy2w z<~#Inr@wzE1QLm>?&FS|@NhQ8F_?4z_y>`%83AVVpY8saYRN};$;S^0W2^fGyC~v< z3cB}Ypi2f!`>@lT`}W?XoD(C&se9v{tPttjA4Bm)>U?)MmPyG?xVdL2kyQN%WSg($oZu8uvBpR&k0bvl+W&ry1{Q3uUvP4&i+ z0v_TiJtEg@)&V!hz{B(M^ZAeK8r58z(}~yEvVFhFhzR>RX|fCK*%ld^DttK}`E{Hn zEnS(YxhJ>xlJH85o!mt#u2Agok8svyyvko!1+Xn5_RZ=ErfRDks~Zw>=R=v3aY7}0 zxxva_M73tS=Ur-Bz+%=ttob`o(cTlZSfkaS^?AN&PK`1W4fzWAX{WT%x3!|#IUW`) zEZo+56{ z_P2UwbHHdobg?hIg}&hdm(bo`#g=Qai+0+Ss6d#xc~a9(-|XqSB@4UWFc`VpQ)8_$ z0hLsrjSJG%I33+t>ynTtk&y9~#8EeyZ|yI_oMTOs?hB%qE6UZH(jAQ8K%FlOEMob& zajA;zRlrvKM}1S%??>Nwl1rIWS#!15UQH$$dYX@qeu36puncZz$7Hwj_TDq!ULP^4 zuN{V3%F~n0&_{}73?dH8a@-7KceA*Oo|G6(*tV5Db(c)Npst)ISOq$neD91Wx=J3X zX8P00S_hYoO*p0j4qzmdhdZ;p+n*XjKi#^eHC8$jCiaQ$dt^ozKPs-ghSkcZ@DhnA zQ#H^4(EhwX1*PG%8{a7e_ZL6Fq=V}9G{}3$vi0z3mBi?z+ROTqpM$MF8WK61`{GUL zb&Ck04IMe)pe<@RYOcu0)kR{75P9rwGJ)l(izlTq`dumLHHu2vc}uTS3hvo{qdWm~$z%EX`nM^jDJx4$#^`6yj)qNT>6V~OGYMe9;3RUmqDsA$ z^RlaYvfTBpiU?o~BFBGt0ofwmd1iw~qn|oCIeiHEu$uQmOiC(|u)3NG*(A#XYhO~s zkLgODb_@J?-2>$>9l4uj0=PF@OtmX$!$TgcjMqL4v@ zfdRgKg@0~CQD?C`Qiy4>bR<^Hv5Z)(IYBO}^(}9T=n2zi z2=Wnph`NwNu_S0g#$k^7Lm`j3czj}PWD|)QW)yZ)ZQq#(d!(-^|288dqe9S~K(Uuq z{EB!TlNtXk_I?CGHr4ZK6PcMIw`And&fF^-zc`(_t#nz`F+De@S!#)QU8b@1O?-&e z@>R@Zre-#tvw-foLOzTDOaZ&b6WKBQEc#3H%~N*=hwBQWgHkuExI`TkXTnHRhr_)U zmR+yvXi|}MBOghH@R&uumJ@j!9q`8D(L)1+YsCH3F2IjAOLaIM(JZ3T1M$_Euz(y) z?KC!7XJFtGCKpOdN(@dZ)|#I z=BITp&?eiAE>qJm5Bt!a@_|>aM{Rihlfpy8N6lZqe(g=V`Ql8F#z0jc6y52v)Rh?r z1u?qOzra_KyqSv9Qeg$VK?TOnE11K^_4XNdr=tDmn%27r%G?gNRu7}Ze7;*os3!+7+ zNB6O}736XsH+x|u^H!N_&UjTJ^GhcsK=qT0vw<1@h9nqfT1HxQ{_0nkqQy z$2)aC0V}GX1mMggSlN|-XkleILo;vqiD0lr5HDpRZ0jXXx}%8cG7{7z6oRY{w!rn~ zfzh$d1~Jpo{nymg3OG(at=D40A~8*u!zuDN9TiWI-eELZH!$?TOPhNRYqvN^}Db2t}&M8S^?K7Rk0FGpdNJS1MDv@+U<1N^Pol^Rv8M)KYONm_PFSx zGh{&*K9!IVO{cfOiOXc0JFUqrutPF%i4XrJA=K2=j3NKPF+A*TlNg7)G8|hl>28=^ zkdBlunVc%XFwj?eZ-0NE1zw6JHn-DZAz8FIIdV7pq@=MV9b6O3%O3^-RLOye2iB$TSYw@+U z%eT_4wk;V7Ilr5xr9CqJGxWNWcsyhAxUx=^F@(s6aYhZ@_!Y2NWTedOz|H{Im)sh5 zptzP`Nb**oo+VFmkUVRzTPVq#F2K^k+uJ!>u5p#0Nca6ALoPn7@Go*BJKLFqv`EVO zn{LWe#p~7EO=#Q5`dUG(F?nOSsAH;y>Cy8R9jEfy$0o+c-wA7thu7lAX-hjo#Ew|? zdR_&z!*SC|eRh8h(1rdhZ&nDx+PW>WFSlj`2lt!2K3lp|m7&92jTP1QSg~f* zJKo)0sMm8#|0p~2mUM=rloah$lCij&(C93cg9Jd$v;h1?jySryx~@|MEJSmYVIG^9 zjIS>(apHfU$j>a6=JG0<49~+GU|yN?JKCTi-|2zJamHeL?-Wx*EcueI&d zDf&_efowF3Hh(|utRQgcA4gQ!+okcverH<$wa(eSRLEMK9fBHbB)1f~d#}>?ZQ4}# zRa15ur?MfCOPBg%#?HFvqIX9Qk1@*K^`zgyS)E+*X#02XxbNLe{vxoR?WDcE9hnDm z_$0(h&`xEyRIBy$4emteS6`_f1vR8FzP3SD^3e&9xWvHqs#0CtuB;zFF!07)Z?h5i z(g$?h_!$_)TL@p)zLQ>u?k^1H1pzwi!^8RvK5c81BaiE&43b1FocXDpm^kF|q*hA= zFfm;QzPX+tebA_pk)lRg1R1EFxvW(99 z^YzoqzlLrJ=RWe9HuIBie)Y*CVCfrm_Eom9_!oNI_|uiBH#IR^DYoc1^U)SCM|O6w zWm#+}$cA!y|F~n{3G{x+h>9}uKe=gstjpMX%5j>5IRmO!glHdzuj(%F0a3{_RDOT`RYDntK^oe zB*-LJBc+OOZ;z{BY0h(#)q3HpKjS}aYX_#4@(li)AqqQ^RmA0LYBPDmACnGZ0^4|6 ziUTX=<|lKN=JR&w%{@*JJQfnd-R}BSBqo6*pjR@610Dpm3g$*a@#@UW5tM@aSvY>I zCfcw(_2Yt>f0NoFm&frwlP$W~Z^6uY?vI0a$NGYHPbX(~-%LL3Zq6Uq{rf(0)ZC%f zW@XigZ!;Pp2AI6t-RGfDC@i-!+q7M*c@e_?bnbBp)* zsiW{$Z{sr(+KI+*fDOpG*X0xcs!Lh~(sbLl*t~ShEp6bFdzxc%X1PJ)tewtN@g;4c|1=F1f!-r0hTE z4-Q)NYUp%f9DI`I^uF=YgBbWQo0DYX*IGt0gGG2LkkY%@FFmbK$9PV<8<4frXRsAd zmRF325#k>u7^2wL!D<>PW^{tdA%kQ=ugc!;Z!)oXrLX__Iap4Hzft0OMdHP8e^DgT zlNYg_0xuXX6>uR-q}x60e@qx@LWA!6DvQE}<3L)^0vM`WPr>I&w-N6Zao)f|1Qj2Q z>9&BL zeUMYSpa;@oYN!7SfrJnRITH6tk{ z)qgc2T3n`KOYfYQm-ft4jZn`=b@#whwBe(@h#9KohSyBoZgVX=Y>4j4CF*xy57H8+ zkW6ma|DwL++H^(@iO7qb;=UH>E;6RWp=o83g!6_#K%=rDX#go5{XYF0O-}&|mq0@W z%uT=q+x%QGGaf+ns0qAArSImKO3mpne*wL$n$g~F)BoUpX;-|f6u|_$W~kM7R#K%% z!f_ckB}(j%gRc$}RwPOT#U3j$ID*ExAK$-!lobLo4;}x%Vc6uvjhUzcrOc}T?niS(&PEB* z@!wr$Z*;%H+EcN!pe^=SK?nbIbqkmkp7Bv7jgE=QUszn61nC#tq;+tLw^gG@c1pr* z)&Yi8w^2<_NDgpIkSb#=eU82aafn0i!Li7&kPr~UCscG1eF5SOsmny9!p?-{-nH{N z)Is_@Gd5VLitr!|Y@w^GE7E9h{>i_o?h-&z$fUdk#(uHRDalUX$Zxj*I<2};8wr4Z z(1Za22aaJ2NO>BSKdf2J08?E@J&GX+Vo( ztXQAsCenM$N4JTa zxL-Gbvxs?mjh*I2F|8EClSg*QH8!RYn;zD$6j?m?uYS&{omc|j%O)o#sEUKK&nHui z^+h(jb@TPt{xr~^5$y`lU4h(k4)>>Rjv0*3FvV^D@D}pTf{evI&fo6;&T0O-W`VaF zsBDOIygPH`g!HG~G|RA?0794o4&-e`dQ7~>4=#}~;K-3TFwzhmFmERUhNa2kZI_IH z5X%_Ineg;(r<#ETrUXF8uEhe1I9qXda-P+}I4+pdc7V-=vKzFQB9G&NH!chc^Kyvu z6LT`#>B(54E9#NTESPfRZ^1HQB6muZd6O-NxhZ(+G*`$@h&84b|1MJLxtuaYLzvzx zBMBQz7R&445<7Azm)`$WgG8~D^{Ylo$k349;-zzh=A4jVSVpKzv$g$fo&<}){P`os zjJS|fFv~^RR*B%FzanxSVRX{ZR=~u-{i?Bcg3Qs+(XlW5>iHVmoD)!Z*$4IjQVOMF z#=DL-sUf#mM4-SW~WI6~)_?T=q;ZsU+SwV7lU< zCGKt&ww#}z_<}R{B8FN0yc51sWO}MgCx@A5UXV&=%w2NPz-#E&>N#n7N5cAaxkQb8 z7)PwHO;kO)ES-Z#eM>mf`22!&BR#KV)uYK z5p6Nd44fIYDgd4z8yd_sDfn2l&U@YU(2#+Ps(gB8Mg(-(5#q1K37b%B*b>5-_#%E= zq*W(jw{%GElf1VpdjNllTxr1`INag#yDL%iF}%2D>e1I1f4e$Yay=~eA_ANs4XK2s zrAGqmf*4N9tGp_$N%I2jA@aWiu3brnimJ~tD=I+O6o6wMUG1_8S>FFU?s#SdIpzl_ zovkl*Z-nxS=Pr{BB6HXj@Aw@%D74XlrF*v(cQA*Xl`S_DcgI^>j|A!Le*U#g{*A_W bHqW4g!TmI{6K8rLe;Pol>LALMEd%}!0yldh literal 0 HcmV?d00001 diff --git a/app/helpers/pages_helper.rb b/app/helpers/pages_helper.rb index 4a78721..de7985a 100644 --- a/app/helpers/pages_helper.rb +++ b/app/helpers/pages_helper.rb @@ -32,7 +32,8 @@ module PagesHelper # Builds systems on it for other people. RU_INTEGRATORS is appended to this # group for Russian-speaking visitors only -- see partner_groups. integrators: [ - { name: 'GoodCam', url: 'https://www.goodcam.io/', img: 'partners/goodcam_mini.png' } + { name: 'GoodCam', url: 'https://www.goodcam.io/', img: 'partners/goodcam_mini.png' }, + { name: 'Faceter', url: 'https://faceter.cam/', img: 'partners/faceter_mini.png' } ], # The FPV projects we grew up alongside. fpv: [ From 5416af4cdb8efcc127ebca92b57adcbdfb7d7496 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 28 Aug 2026 19:05:06 +0000 Subject: [PATCH 06/10] Put four logos where they belong Really is an integrator, not a global partner -- it was in :global because its link goes to Open Collective, which said more about how it pays us than about what it does. AnyCam serves more than Russia, so it leaves the territory-gated list for :integrators proper, next to GoodCam, and is now shown to everyone rather than only to Russian-speaking visitors. TUDSaT stays in :education, which was the reading rather than the instruction until now. Expo Electronica is a trade show and belongs in neither. It gets a group of its own, :exhibitions, which no page composes: kept in the system with its logo and link intact, rendered nowhere, ready for the exhibitions page that may come. That is the same treatment /merchandise got, and like that one it needs holding in place -- adding a group to a row is a word. A test asserts it appears on none of the seven pages that carry a wall. RU_INTEGRATORS is down to fifteen and the general integrator row is up to four, so English finally has a row that is not the same three logos. --- app/helpers/pages_helper.rb | 16 ++++++++----- test/controllers/relaunch_pages_test.rb | 30 ++++++++++++++++++++++--- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/app/helpers/pages_helper.rb b/app/helpers/pages_helper.rb index de7985a..2f0d77d 100644 --- a/app/helpers/pages_helper.rb +++ b/app/helpers/pages_helper.rb @@ -19,8 +19,7 @@ module PagesHelper # Who hosts our money and our code. global: [ { name: 'Open Source Collective', url: 'https://www.oscollective.org/', img: 'partners/osc_mini.png' }, - { name: 'GitHub', url: 'https://github.com/', img: 'partners/github_mini.png' }, - { name: 'Really', url: 'https://opencollective.com/really-541ee976', img: 'partners/really_mini.png' } + { name: 'GitHub', url: 'https://github.com/', img: 'partners/github_mini.png' } ], # Ships hardware with OpenIPC on it. manufacturers: [ @@ -33,7 +32,9 @@ module PagesHelper # group for Russian-speaking visitors only -- see partner_groups. integrators: [ { name: 'GoodCam', url: 'https://www.goodcam.io/', img: 'partners/goodcam_mini.png' }, - { name: 'Faceter', url: 'https://faceter.cam/', img: 'partners/faceter_mini.png' } + { name: 'AnyCam', url: 'https://anycam.io/', img: 'partners/anycam_mini.png' }, + { name: 'Faceter', url: 'https://faceter.cam/', img: 'partners/faceter_mini.png' }, + { name: 'Really', url: 'https://opencollective.com/really-541ee976', img: 'partners/really_mini.png' } ], # The FPV projects we grew up alongside. fpv: [ @@ -46,6 +47,13 @@ module PagesHelper { name: 'TUDSaT', url: 'https://www.tudsat.space/', img: 'partners/tudsat_mini.png' }, { name: 'WuSpace', url: 'https://wuespace.de/', img: 'partners/wuespace_mini.png' } ], + # Kept in the system, rendered nowhere. There is no page for trade shows and + # exhibitions yet; when there is, it asks for :exhibitions and both the logo + # and the link are already here. Deliberately absent from HOME_PARTNER_ROWS + # and from every partner_groups call -- a test holds it that way. + exhibitions: [ + { name: 'Expo Electronica', url: 'https://expoelectronica.ru/en/', img: 'partners/expo-electronica_mini.png' } + ], # Reverse engineering and silicon research we build on. research: [ { name: 'Linux Chenxing', url: 'https://linux-chenxing.org/', img: 'partners/linuxchenxing_mini.png' } @@ -66,7 +74,6 @@ module PagesHelper { name: 'Sputnik', url: 'https://sputnik.systems/', img: 'partners/sputnik_mini.png' }, { name: 'Techno-Shield', url: 'https://msvoko.ru/', img: 'partners/techno-shield_mini.png' }, { name: 'KeyTelecom', url: 'https://keytele.com/', img: 'partners/keytelecom_mini.png' }, - { name: 'AnyCam', url: 'https://anycam.io/', img: 'partners/anycam_mini.png' }, { name: 'WebGlazok', url: 'https://webglazok.com/', img: 'partners/webglazok_mini.png' }, { name: 'Yucca', url: 'https://yucca.app/en', img: 'partners/yucca_mini.png' }, { name: 'IPEYE', url: 'https://ipeye.ru/', img: 'partners/ipeye_mini.png' }, @@ -83,7 +90,6 @@ module PagesHelper # { name: 'Flagman', url: 'https://flagman.org/', img: 'partners/flagman_mini.png' }, # { name: 'Meldana', url: 'https://meldana.com/', img: 'partners/meldana_mini.png' }, # { name: 'Binary Machines', url: 'https://bmachines.ru/', img: 'partners/binary-machines_mini.png' }, - # { name: 'Expo Electronica', url: 'https://expoelectronica.ru/en/', img: 'partners/expo-electronica_mini.png' }, # { name: 'GAINS', url: 'https://gains.company/', img: 'partners/gain_mini.png' } ].freeze diff --git a/test/controllers/relaunch_pages_test.rb b/test/controllers/relaunch_pages_test.rb index 73c2475..823e7e8 100644 --- a/test/controllers/relaunch_pages_test.rb +++ b/test/controllers/relaunch_pages_test.rb @@ -206,9 +206,33 @@ class RelaunchPagesTest < ActionDispatch::IntegrationTest labels.each do |label| assert_includes response.body, I18n.t("site.partners.#{label}"), "the #{label} row is missing" end - # Every group still reaches the page through one row or another. - PagesHelper::PARTNER_GROUPS.each_value do |logos| - logos.each { |l| assert_includes response.body, l[:img].sub('.png', '') } + # Every group the rows name still reaches the page through one of them. + PagesHelper::HOME_PARTNER_ROWS.each_value do |keys| + keys.each do |key| + PagesHelper::PARTNER_GROUPS.fetch(key).each do |logo| + assert_includes response.body, logo[:img].sub('.png', '') + end + end + end + end + + # :exhibitions is held in the system on purpose and shown on no page. There + # is no trade-show page yet; when there is, it asks for the group and the + # logo and link are already there. Until then this is what keeps it off the + # site -- a group is easy to add to a row by accident. + test 'the exhibitions group is kept but rendered nowhere' do + held = PagesHelper::PARTNER_GROUPS.fetch(:exhibitions) + + assert_not_empty held + assert_not_includes PagesHelper::HOME_PARTNER_ROWS.values.flatten, :exhibitions + + %w[/ /business /ecosystem /community /donate /low-latency /get-started].each do |path| + get path + + held.each do |logo| + assert_not_includes response.body, logo[:img].sub('.png', ''), + "#{logo[:name]} is being rendered on #{path}" + end end end From fb5fb22a6deef1f2262b0e5a2753c0ae2fd2afb3 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 28 Aug 2026 19:11:13 +0000 Subject: [PATCH 07/10] Order the wall the way it should be read Homepage: global partners, then manufacturers and integrators, then friendly projects -- and inside that last row, research before FPV before education. HOME_PARTNER_ROWS is a Hash, so both orders are just the order it is written in, and a test asserts the page matches rather than merely contains them. /business already asked for its two groups by name and so already listed manufacturers before integrators. It has a test now saying so, because that order was an accident of the argument list rather than anything stated. An empty row takes its heading down with it. That was already true, but only by luck: partner_rows delegated to partner_logos, where no arguments means every group, so a row naming no groups would have rendered all of them instead of nothing. Both now go through one place that applies the territory rule, and a row with nothing in it returns nothing. --- app/helpers/pages_helper.rb | 28 +++++++++++++++---- test/controllers/relaunch_pages_test.rb | 37 +++++++++++++++++++++++-- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/app/helpers/pages_helper.rb b/app/helpers/pages_helper.rb index 2f0d77d..d0ab84f 100644 --- a/app/helpers/pages_helper.rb +++ b/app/helpers/pages_helper.rb @@ -125,6 +125,14 @@ def latency_bar_style(path) "left: #{left.round(1)}%; width: #{width.round(1)}%" end + # The Russian integrator list is appended here and nowhere else, so every + # caller gets the same territory rule whether it asks for a group or a row. + def logos_in(key) + logos = PARTNER_GROUPS.fetch(key) + logos += RU_INTEGRATORS if key == :integrators && I18n.locale.eql?(:ru) + logos + end + def page_title [@page_title, 'OpenIPC'].join(' - ') end @@ -137,27 +145,35 @@ def page_title # # /business still asks for its two groups directly. Splitting rows out again # is a line here, not a rewrite. + # Order matters twice over: the rows appear in the order written here, and + # within a row the groups are laid out in the order they are listed. HOME_PARTNER_ROWS = { - trade: %i[manufacturers integrators], global: %i[global], - friends: %i[fpv education research] + trade: %i[manufacturers integrators], + friends: %i[research fpv education] }.freeze - # Rows of [label, logos], skipping any that would render empty. + # Rows of [label, logos], in the order HOME_PARTNER_ROWS declares. A row whose + # groups are all empty is dropped rather than rendered as a heading over + # nothing -- which is what a territory-specific group looks like in a locale + # that has no entries for it yet. + # + # Note this does not go through partner_logos: there, no arguments means + # every group, so a row that named no groups would quietly render all of them. def partner_rows(rows) rows.filter_map do |label, keys| - logos = partner_logos(*keys) + logos = keys.flat_map { |key| logos_in(key) } [label, logos] if logos.any? end end # The named groups, in the order asked for, each as [key, logos]. Groups that # would render empty are dropped rather than left as a heading over nothing. + # With no arguments, every group. def partner_groups(*keys) keys = PARTNER_GROUPS.keys if keys.empty? keys.filter_map do |key| - logos = PARTNER_GROUPS.fetch(key) - logos += RU_INTEGRATORS if key == :integrators && I18n.locale.eql?(:ru) + logos = logos_in(key) [key, logos] if logos.any? end end diff --git a/test/controllers/relaunch_pages_test.rb b/test/controllers/relaunch_pages_test.rb index 823e7e8..e329ccf 100644 --- a/test/controllers/relaunch_pages_test.rb +++ b/test/controllers/relaunch_pages_test.rb @@ -203,8 +203,18 @@ class RelaunchPagesTest < ActionDispatch::IntegrationTest labels = PagesHelper::HOME_PARTNER_ROWS.keys assert_equal labels.size, rows - labels.each do |label| - assert_includes response.body, I18n.t("site.partners.#{label}"), "the #{label} row is missing" + # In the declared order, not merely present. + shown = css_select('h3.text-uppercase').map { |h| h.text.strip } + + assert_equal labels.map { |l| I18n.t("site.partners.#{l}") }, shown + + # And within a row, the groups are laid out in the order they are listed. + PagesHelper::HOME_PARTNER_ROWS.each_value do |keys| + next if keys.size < 2 + + positions = keys.map { |k| response.body.index(PagesHelper::PARTNER_GROUPS.fetch(k).first[:img].sub('.png', '')) } + + assert_equal positions.sort, positions, "#{keys.join(', ')} are out of order" end # Every group the rows name still reaches the page through one of them. PagesHelper::HOME_PARTNER_ROWS.each_value do |keys| @@ -216,6 +226,29 @@ class RelaunchPagesTest < ActionDispatch::IntegrationTest end end + # A group with nothing in it must take its heading down with it. Territory + # gating means a group can be empty in one locale and not another, and a bare + # heading over no logos reads as a broken page rather than an empty category. + test 'a row with nothing in it renders neither logos nor a heading' do + get '/' + + # partner_rows needs no view context, so it can be exercised directly. + helpers = Object.new.extend(PagesHelper) + + assert_empty helpers.partner_rows(empty: []) + assert_equal %i[global], helpers.partner_rows(global: %i[global]).map(&:first) + # Every heading on the page has a wall under it. + assert_equal css_select('.logo-wall').size, css_select('h3.text-uppercase').size + end + + # /business asks for its two groups by name, each its own labelled row. + test '/business lists manufacturers first, then integrators' do + get '/business' + + assert_equal [I18n.t('site.partners.manufacturers'), I18n.t('site.partners.integrators')], + css_select('h3.text-uppercase').map { |h| h.text.strip } + end + # :exhibitions is held in the system on purpose and shown on no page. There # is no trade-show page yet; when there is, it asks for the group and the # logo and link are already there. Until then this is what keeps it off the From ec263b1fd6081c398bbf443bd0cfadb541c87e4c Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 28 Aug 2026 19:15:06 +0000 Subject: [PATCH 08/10] Say nothing about retention on the Open Wall The intro claimed images are removed a couple of days after they arrive. Out, in all three languages, along with the comment that explained how the wording had been softened -- there is no claim left to soften. Nothing about the purge changed: PurgeImagesJob::RETENTION is still two days and the nightly sweep still runs. The page simply stops promising a visitor something it is not in a position to guarantee. --- app/views/snapshots/index.html.erb | 11 +++++------ config/locales/en.yml | 2 +- config/locales/ru.yml | 2 +- config/locales/zh.yml | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/views/snapshots/index.html.erb b/app/views/snapshots/index.html.erb index fab8e2d..2691ced 100644 --- a/app/views/snapshots/index.html.erb +++ b/app/views/snapshots/index.html.erb @@ -13,12 +13,11 @@ have not seen it before and cannot tell from a grid of stills whether these were collected or volunteered. - The copy says "a couple of days" rather than naming two, and does not - claim the uploads are verified. PurgeImagesJob::RETENTION is two days, - but it is a cutoff for a sweep that runs once a night, so an image can - outlive it by most of a day; and the upload endpoint authenticates - nobody, so we cannot promise every image came from a camera whose owner - opted in. Both were promised here before, and neither was true. %> + The copy makes no claim about how long an image stays. It used to say + two days, which PurgeImagesJob::RETENTION cannot promise -- the constant + is a cutoff for a sweep that runs once a night, so an image can outlive + it by most of a day. Nor does it claim the uploads are verified: the + endpoint authenticates nobody. %>

    <%= t('.intro_html') %>

    diff --git a/config/locales/en.yml b/config/locales/en.yml index aa2580e..73ef22e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -193,7 +193,7 @@ en: icon: snapshot_alt: 'Image: Snapshot' index: - intro_html: Cameras send these themselves — nobody goes out and collects them. Each image is removed a couple of days after it arrives. Want yours here? Turn on the Open Wall option in your camera's web interface. + intro_html: Cameras send these themselves — nobody goes out and collects them. Want yours here? Turn on the Open Wall option in your camera's web interface. no_signal: No signal snapshot_alt: 'Image: Snapshot' stay_tuned: stay tuned diff --git a/config/locales/ru.yml b/config/locales/ru.yml index 6b43bde..3c10dc2 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -195,7 +195,7 @@ ru: icon: snapshot_alt: 'Изображение: снимок' index: - intro_html: Камеры присылают снимки сами — никто их не собирает. Каждый снимок удаляется через пару дней после загрузки. Хотите, чтобы здесь был ваш? Включите опцию Open Wall в веб-интерфейсе камеры. + intro_html: Камеры присылают снимки сами — никто их не собирает. Хотите, чтобы здесь был ваш? Включите опцию Open Wall в веб-интерфейсе камеры. no_signal: Нет сигнала snapshot_alt: 'Изображение: снимок' stay_tuned: Следите за обновлениями diff --git a/config/locales/zh.yml b/config/locales/zh.yml index 399d525..73cc1b0 100644 --- a/config/locales/zh.yml +++ b/config/locales/zh.yml @@ -193,7 +193,7 @@ zh: icon: snapshot_alt: 图片:快照 index: - intro_html: 这些图片是摄像机自己上传的——没有人去采集。每张图片会在上传几天后被删除。想让你的画面出现在这里?在摄像机的 Web 界面中开启 Open Wall 选项即可。 + intro_html: 这些图片是摄像机自己上传的——没有人去采集。想让你的画面出现在这里?在摄像机的 Web 界面中开启 Open Wall 选项即可。 no_signal: 没信号 snapshot_alt: 图片:快照 stay_tuned: 敬请关注 From ca64e19adf0fad9702f40069b56d5e693da3952d Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 28 Aug 2026 19:19:03 +0000 Subject: [PATCH 09/10] Stop saying "cloud" three times on the way down the homepage The hero lede says your hardware should not answer to a vendor's cloud, the story heading says cameras should not die when their cloud does, and step 2 was called "The cloud goes dark" -- the same idea three times in the first screen and a half. Step 2 is the one to change: it is about the servers behind the cloud being switched off, which its own body text already says, so the heading can say it too. Two mentions left across the hero and the story, both carrying the argument rather than repeating it. --- config/locales/pages.en.yml | 2 +- config/locales/pages.ru.yml | 2 +- config/locales/pages.zh.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/locales/pages.en.yml b/config/locales/pages.en.yml index c6ea7e9..f30306b 100644 --- a/config/locales/pages.en.yml +++ b/config/locales/pages.en.yml @@ -188,7 +188,7 @@ en: story_step1_text: Firmware updates stop. Security holes stay. The app disappears from the store. story_step1_title: The vendor moves on story_step2_text: Servers get switched off, and a working camera turns into plastic and silicon with nothing to do. - story_step2_title: The cloud goes dark + story_step2_title: The servers go quiet story_step3_text: The camera boots an open, maintained system — and answers to you. No backdoors. No subscriptions. Less e-waste. story_step3_title: You flash OpenIPC story_title: Cameras shouldn't die when their cloud does. diff --git a/config/locales/pages.ru.yml b/config/locales/pages.ru.yml index e1f4d81..7f046f7 100644 --- a/config/locales/pages.ru.yml +++ b/config/locales/pages.ru.yml @@ -188,7 +188,7 @@ ru: story_step1_text: Обновления прекращаются. Дыры в безопасности остаются. Приложение исчезает из магазина. story_step1_title: Производитель уходит story_step2_text: Серверы выключают, и исправная камера превращается в кусок пластика и кремния без дела. - story_step2_title: Облако гаснет + story_step2_title: Серверы замолкают story_step3_text: Камера загружает открытую, поддерживаемую систему — и подчиняется вам. Без бэкдоров. Без подписок. Меньше электронного мусора. story_step3_title: Вы прошиваете OpenIPC story_title: Камера не должна умирать вместе со своим облаком. diff --git a/config/locales/pages.zh.yml b/config/locales/pages.zh.yml index 6f6117c..46f327f 100644 --- a/config/locales/pages.zh.yml +++ b/config/locales/pages.zh.yml @@ -188,7 +188,7 @@ zh: story_step1_text: 固件更新停了。安全漏洞还在。App 从应用商店里消失了。 story_step1_title: 厂商转身离开 story_step2_text: 服务器被关掉,一台还能用的摄像机就变成了无事可做的塑料和硅片。 - story_step2_title: 云端熄灯 + story_step2_title: 服务器悄然关停 story_step3_text: 摄像机启动了一套开放且持续维护的系统——并且听你的。没有后门,没有订阅,也少了一件电子垃圾。 story_step3_title: 你刷入 OpenIPC story_title: 摄像机不该随着它的云一起死去。 From c026c6408d0bb482a1f79a80f66f41d1398d90e1 Mon Sep 17 00:00:00 2001 From: AI Dev Date: Fri, 28 Aug 2026 19:24:41 +0000 Subject: [PATCH 10/10] Give the closing band room, and take crypto off the donation page The dark band ran straight into the text above it on /get-started, the same way it did on /low-latency. Four of the five pages that render it end with an article on white, so the gap is now the band's default rather than something each page has to remember; the fix I put on /low-latency last time is removed, since one mechanism is enough. The homepage is the exception and says so: its band sits directly under a tinted full-bleed section, where a gap is a stripe of white between two coloured bands. It passes flush: true. /donate loses the cryptocurrency card and its copy in all three languages. Open Collective is the whole page now. Both are held by tests -- the band's spacing on the four pages that need it and its absence on the one that does not, and the donation page against the crypto markup and copy coming back. --- app/views/pages/donate.html.erb | 8 +------ app/views/pages/home.html.erb | 2 +- app/views/pages/low_latency.html.erb | 2 +- app/views/shared/_cta_band.html.erb | 12 ++++++++-- config/locales/pages.en.yml | 2 -- config/locales/pages.ru.yml | 2 -- config/locales/pages.zh.yml | 2 -- test/controllers/relaunch_pages_test.rb | 32 +++++++++++++++++++++++++ 8 files changed, 45 insertions(+), 17 deletions(-) diff --git a/app/views/pages/donate.html.erb b/app/views/pages/donate.html.erb index 21ca429..1423115 100644 --- a/app/views/pages/donate.html.erb +++ b/app/views/pages/donate.html.erb @@ -11,19 +11,13 @@

    <%= t('.accounting') %>

    -
    +

    <%= t('.oc_title') %>

    <%= t('.oc_text_html') %>

    <%= t('.oc_button') %>
    -
    -
    -

    <%= t('.crypto_title') %>

    -

    <%= t('.crypto_text_html') %>

    -
    -
    diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index ff5a551..03ee352 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -141,7 +141,7 @@
    <%# ---- Closing CTA ---- %> -<%= render 'shared/cta_band', +<%= render 'shared/cta_band', flush: true, title: t('.cta_title'), primary_label: t('.cta_primary'), primary_href: '/get-started', secondary_label: t('.cta_secondary'), secondary_href: 'https://t.me/openipc', diff --git a/app/views/pages/low_latency.html.erb b/app/views/pages/low_latency.html.erb index c19257d..c9fc9ee 100644 --- a/app/views/pages/low_latency.html.erb +++ b/app/views/pages/low_latency.html.erb @@ -86,7 +86,7 @@
    <%# Hardware + credits %> -
    +

    <%= t('.hardware_title') %>

    <%= t('.hardware_text_html') %>

    diff --git a/app/views/shared/_cta_band.html.erb b/app/views/shared/_cta_band.html.erb index 19469f0..459bb40 100644 --- a/app/views/shared/_cta_band.html.erb +++ b/app/views/shared/_cta_band.html.erb @@ -1,5 +1,13 @@ -<%# Dark call-to-action band. Locals: title, primary_label, primary_href, secondary_label/secondary_href (optional), note_html (optional). %> -
    +<%# Dark call-to-action band. Locals: title, primary_label, primary_href, + secondary_label/secondary_href (optional), note_html (optional), + flush (optional). + + Most pages end with an article on white, where the band arriving with no + gap reads as the text falling off a cliff. So there is one by default. + flush: true removes it, for the page that puts the band directly under a + full-bleed section -- there the gap is a stripe of white between two + coloured bands, which is worse than no gap at all. %> +

    <%= title %>

    diff --git a/config/locales/pages.en.yml b/config/locales/pages.en.yml index f30306b..269ebf0 100644 --- a/config/locales/pages.en.yml +++ b/config/locales/pages.en.yml @@ -67,8 +67,6 @@ en: title: Community donate: business_band_html: If your company depends on OpenIPC, the sustainable way to support it is a commercial relationship. See what we offer. - crypto_text_html: TON donations work right inside Telegram via @wallet. - crypto_title: Cryptocurrency accounting: We're committed to open and transparent accounting, and are grateful for any support you can provide to our efforts to advance OpenIPC and our mission toward a sustainable future. hero_lede: OpenIPC is free to use and expensive to make. Donations buy cameras for new platforms, pay maintainers, and keep the lights on. hero_title: Fund the work. diff --git a/config/locales/pages.ru.yml b/config/locales/pages.ru.yml index 7f046f7..9abe867 100644 --- a/config/locales/pages.ru.yml +++ b/config/locales/pages.ru.yml @@ -67,8 +67,6 @@ ru: title: Сообщество donate: business_band_html: Если ваша компания зависит от OpenIPC, самый устойчивый способ поддержать проект — коммерческое сотрудничество. Что мы предлагаем. - crypto_text_html: Пожертвования в TON работают прямо в Telegram через @wallet. - crypto_title: Криптовалюта accounting: Мы стремимся к открытому и прозрачному учету и благодарны за любую поддержку, которую вы можете оказать нашим усилиям по продвижению OpenIPC и нашей миссии по обеспечению устойчивого будущего. hero_lede: OpenIPC бесплатен в использовании и дорог в производстве. Пожертвования покупают камеры для новых платформ, оплачивают работу мейнтейнеров и держат свет включённым. hero_title: Поддержите работу. diff --git a/config/locales/pages.zh.yml b/config/locales/pages.zh.yml index 46f327f..622a4cc 100644 --- a/config/locales/pages.zh.yml +++ b/config/locales/pages.zh.yml @@ -67,8 +67,6 @@ zh: title: 社区 donate: business_band_html: 如果贵公司依赖 OpenIPC,更可持续的支持方式是建立商业合作关系。看看我们能提供什么。 - crypto_text_html: TON 捐赠可以直接在 Telegram 中通过 @wallet 完成。 - crypto_title: 加密货币 accounting: 我们致力于共建一个开放和透明的数字环境,并由衷地感谢你对我们的努力提供的任何支持,以推动OpenIPC和我们的使命走向一个可持续的未来。 hero_lede: OpenIPC 使用免费,制作昂贵。捐款用于购买新平台的摄像机、支付维护者的报酬,并维持项目运转。 hero_title: 资助这项工作。 diff --git a/test/controllers/relaunch_pages_test.rb b/test/controllers/relaunch_pages_test.rb index e329ccf..b3bb700 100644 --- a/test/controllers/relaunch_pages_test.rb +++ b/test/controllers/relaunch_pages_test.rb @@ -249,6 +249,38 @@ class RelaunchPagesTest < ActionDispatch::IntegrationTest css_select('h3.text-uppercase').map { |h| h.text.strip } end + # The dark band arriving with no gap under an article on white reads as the + # text falling off a cliff. The homepage is the exception: its band follows a + # tinted full-bleed section, where the gap is a white stripe between two + # coloured bands. + test 'the closing band keeps its distance from text on white' do + %w[/get-started /low-latency /community /ecosystem].each do |path| + get path + + band = css_select('section.section--ink').last + + assert_not_nil band, "#{path} has no closing band" + assert_includes band['class'], 'mt-6', "#{path} runs its text straight into the band" + end + + get '/' + assert_not_includes css_select('section.section--ink').last['class'], 'mt-6', + 'the homepage band is separated from the section above it by a white stripe' + end + + # Cryptocurrency is off the donation page; Open Collective stays. + test '/donate offers Open Collective and nothing crypto' do + get '/donate' + + assert_includes response.body, 'opencollective.com/openipc' + assert_not_includes response.body, 'bi-currency-bitcoin' + assert_not_includes response.body, 't.me/wallet' + %w[en ru zh].each do |locale| + assert_nil I18n.t('pages.donate.crypto_title', locale: locale, default: nil), + "the crypto copy is still defined in #{locale}" + end + end + # :exhibitions is held in the system on purpose and shown on no page. There # is no trade-show page yet; when there is, it asks for the group and the # logo and link are already there. Until then this is what keeps it off the