diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index c413a556..c009dff3 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -39,6 +39,8 @@ body:
label: Installation Method
options:
- npm (global)
+ - Homebrew
+ - MacPorts
- GitHub Release binary
- Built from source
validations:
diff --git a/pages/src/assets/icons/macports.svg b/pages/src/assets/icons/macports.svg
new file mode 100644
index 00000000..d2f83920
--- /dev/null
+++ b/pages/src/assets/icons/macports.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/pages/src/components/HeroSection.test.tsx b/pages/src/components/HeroSection.test.tsx
new file mode 100644
index 00000000..7a15ee78
--- /dev/null
+++ b/pages/src/components/HeroSection.test.tsx
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copyright 2026 alibaba/open-code-review Contributors
+
+import { describe, it, expect } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { LanguageProvider } from '../i18n';
+import HeroSection from './HeroSection';
+
+function renderHero() {
+ render(
+
+
+
+
+ ,
+ );
+}
+
+// The panel is found through the id that `aria-controls` already points at, so
+// the test leans on the accessibility wiring instead of a test-only hook.
+const panel = () => document.getElementById('install-more-panel');
+const trigger = () => screen.getByRole('button', { name: /More|MacPorts/i });
+
+describe('HeroSection install channels', () => {
+ it('starts on the first channel with the panel closed', () => {
+ renderHero();
+ expect(screen.getByText('npm i -g @alibaba-group/open-code-review')).toBeTruthy();
+ expect(panel()).toBeNull();
+ });
+
+ it('picking an overflow channel swaps the command and closes the panel', async () => {
+ const user = userEvent.setup();
+ renderHero();
+
+ await user.click(trigger());
+ expect(panel()).not.toBeNull();
+
+ await user.click(screen.getByRole('button', { name: /MacPorts/i }));
+
+ expect(screen.getByText('sudo port install open-code-review')).toBeTruthy();
+ expect(panel()).toBeNull();
+ });
+
+ it('closes when a primary tab is clicked', async () => {
+ const user = userEvent.setup();
+ renderHero();
+
+ await user.click(trigger());
+ await user.click(screen.getByRole('button', { name: /Homebrew/i }));
+
+ expect(screen.getByText('brew install open-code-review')).toBeTruthy();
+ expect(panel()).toBeNull();
+ });
+
+ // Keyboard activation dispatches `click` with no preceding `mousedown`, so
+ // this does not go through the same path as the test above.
+ it('closes when a primary tab is activated by keyboard', async () => {
+ const user = userEvent.setup();
+ renderHero();
+
+ await user.click(trigger());
+ screen.getByRole('button', { name: /Homebrew/i }).focus();
+ await user.keyboard('{Enter}');
+
+ expect(screen.getByText('brew install open-code-review')).toBeTruthy();
+ expect(panel()).toBeNull();
+ });
+
+ it('closes on Escape and on an outside click', async () => {
+ const user = userEvent.setup();
+ renderHero();
+
+ await user.click(trigger());
+ await user.keyboard('{Escape}');
+ expect(panel()).toBeNull();
+
+ await user.click(trigger());
+ await user.click(document.body);
+ expect(panel()).toBeNull();
+ });
+
+ it('reflects the selected overflow channel on the trigger', async () => {
+ const user = userEvent.setup();
+ renderHero();
+ expect(screen.getByRole('button', { name: /^More$/i })).toBeTruthy();
+
+ await user.click(trigger());
+ await user.click(screen.getByRole('button', { name: /MacPorts/i }));
+
+ const collapsed = screen.getByRole('button', { name: /MacPorts/i });
+ expect(collapsed.getAttribute('aria-expanded')).toBe('false');
+ expect(screen.queryByRole('button', { name: /^More$/i })).toBeNull();
+ });
+});
diff --git a/pages/src/components/HeroSection.tsx b/pages/src/components/HeroSection.tsx
index c3b96962..fe7fae59 100644
--- a/pages/src/components/HeroSection.tsx
+++ b/pages/src/components/HeroSection.tsx
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors
-import React, { Suspense, useCallback, useState, useEffect } from 'react';
+import React, { Suspense, useCallback, useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { Link } from 'react-router-dom';
import { useTranslation } from '../i18n';
@@ -9,7 +9,9 @@ import { useResponsive } from '../hooks/useResponsive';
import ErrorBoundary from './ErrorBoundary';
import npmIcon from '../assets/icons/npm.svg';
import brewIcon from '../assets/icons/brew.svg';
+import macportsIcon from '../assets/icons/macports.svg';
import copyIcon from '../assets/icons/icon-copy.svg';
+import chevronDownIcon from '../assets/icons/icon-chevron-down.svg';
const ColorBends = React.lazy(() => import(/* webpackChunkName: "color-bends" */ './ColorBends'));
@@ -122,11 +124,23 @@ const terminalLines = [
{ num: 12, content: | }, // allow-non-english: fullwidth bar renders the terminal cursor
];
-const INSTALL_CHANNELS = [
- { key: 'npm', labelKey: 'hero.installNpm', cmd: 'npm i -g @alibaba-group/open-code-review', icons: [npmIcon] },
- { key: 'brew', labelKey: 'hero.installBrew', cmd: 'brew install open-code-review', icons: [brewIcon] },
+interface InstallChannel {
+ key: string;
+ labelKey: string;
+ cmd: string;
+ icons: string[];
+ primary: boolean;
+}
+
+const INSTALL_CHANNELS: InstallChannel[] = [
+ { key: 'npm', labelKey: 'hero.installNpm', cmd: 'npm i -g @alibaba-group/open-code-review', icons: [npmIcon], primary: true },
+ { key: 'brew', labelKey: 'hero.installBrew', cmd: 'brew install open-code-review', icons: [brewIcon], primary: true },
+ { key: 'macports', labelKey: 'hero.installMacPorts', cmd: 'sudo port install open-code-review', icons: [macportsIcon], primary: false },
];
+const PRIMARY_CHANNELS = INSTALL_CHANNELS.filter((ch) => ch.primary);
+const SECONDARY_CHANNELS = INSTALL_CHANNELS.filter((ch) => !ch.primary);
+
const HeroSection: React.FC = () => {
const { t } = useTranslation();
const { isMobile, isTablet, isDesktop } = useResponsive();
@@ -134,7 +148,12 @@ const HeroSection: React.FC = () => {
const [toastVisible, setToastVisible] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [showShaderBackground, setShowShaderBackground] = useState(false);
- const [activeChannel, setActiveChannel] = useState(0);
+ const [activeChannelKey, setActiveChannelKey] = useState(INSTALL_CHANNELS[0].key);
+ const [menuOpen, setMenuOpen] = useState(false);
+ const menuRef = useRef(null);
+
+ const activeChannel = INSTALL_CHANNELS.find((ch) => ch.key === activeChannelKey) ?? INSTALL_CHANNELS[0];
+ const activeIsSecondary = !activeChannel.primary;
const showToast = (message: string) => {
setToastMessage(message);
@@ -176,6 +195,24 @@ const HeroSection: React.FC = () => {
return () => clearTimeout(timer);
}, [toastVisible]);
+ useEffect(() => {
+ if (!menuOpen) return;
+ const handlePointerDown = (e: MouseEvent | TouchEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false);
+ };
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setMenuOpen(false);
+ };
+ document.addEventListener('mousedown', handlePointerDown);
+ document.addEventListener('touchstart', handlePointerDown);
+ document.addEventListener('keydown', handleKeyDown);
+ return () => {
+ document.removeEventListener('mousedown', handlePointerDown);
+ document.removeEventListener('touchstart', handlePointerDown);
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [menuOpen]);
+
useEffect(() => {
// Wait until after the first paint before loading the heavy shader chunk.
let secondFrame: number | undefined;
@@ -325,33 +362,140 @@ const HeroSection: React.FC = () => {
{/* Install channels — tab switcher */}
-
-
- {INSTALL_CHANNELS.map((ch, idx) => (
+
+
+ {PRIMARY_CHANNELS.map((ch) => {
+ const isActive = ch.key === activeChannelKey;
+ return (
+
+ );
+ })}
+
+ {/* Overflow channels live behind a "More" trigger so the row stops
+ growing each time a new channel is added. */}
+