diff --git a/frontend/src/components/SubjectSidebar.jsx b/frontend/src/components/SubjectSidebar.jsx
new file mode 100644
index 0000000..2d836c5
--- /dev/null
+++ b/frontend/src/components/SubjectSidebar.jsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { groupLines, groupVisibilityState } from '../lib/crossSubjectChart';
+
+// Presentational panel listing subjects grouped by their group, with per-subject
+// and per-group visibility checkboxes. All state lives in the parent; this component
+// only renders and calls handlers.
+export function SubjectSidebar({
+ lines, hiddenSubjects, onToggleSubject, onToggleGroup, onShowAll, onHighlight,
+}) {
+ const groups = groupLines(lines);
+ const anyHidden = lines.some((l) => hiddenSubjects.has(l.id));
+
+ return (
+
+
+ Subjects
+ {anyHidden && (
+
+ )}
+
+
+ {groups.map(({ group, subjects }) => {
+ const ids = subjects.map((s) => s.id);
+ const state = groupVisibilityState(ids, hiddenSubjects);
+ return (
+
+ );
+ })}
+
+
+ );
+}
diff --git a/frontend/tests/SubjectSidebar.test.jsx b/frontend/tests/SubjectSidebar.test.jsx
new file mode 100644
index 0000000..069e123
--- /dev/null
+++ b/frontend/tests/SubjectSidebar.test.jsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import { render, screen, fireEvent, within } from '@testing-library/react';
+import { SubjectSidebar } from '../src/components/SubjectSidebar';
+
+const LINES = [
+ { id: 'a1', name: 'Mouse-A1', group: 'Group A', color: '#111' },
+ { id: 'a2', name: 'Mouse-A2', group: 'Group A', color: '#222' },
+ { id: 'b1', name: 'Mouse-B1', group: 'Group B', color: '#333' },
+];
+
+function setup(overrides = {}) {
+ const props = {
+ lines: LINES,
+ hiddenSubjects: new Set(),
+ onToggleSubject: jest.fn(),
+ onToggleGroup: jest.fn(),
+ onShowAll: jest.fn(),
+ onHighlight: jest.fn(),
+ ...overrides,
+ };
+ render();
+ return props;
+}
+
+it('renders each group header with its subject count', () => {
+ setup();
+ expect(screen.getByText('Group A')).toBeInTheDocument();
+ expect(screen.getByText('Group B')).toBeInTheDocument();
+ expect(screen.getByText('(2)')).toBeInTheDocument();
+ expect(screen.getByText('(1)')).toBeInTheDocument();
+});
+
+it('renders a subject checkbox per subject, checked when visible', () => {
+ setup({ hiddenSubjects: new Set(['a2']) });
+ expect(screen.getByLabelText('Mouse-A1').checked).toBe(true);
+ expect(screen.getByLabelText('Mouse-A2').checked).toBe(false);
+});
+
+it('calls onToggleSubject with the id when a subject checkbox is clicked', () => {
+ const { onToggleSubject } = setup();
+ fireEvent.click(screen.getByLabelText('Mouse-A1'));
+ expect(onToggleSubject).toHaveBeenCalledWith('a1');
+});
+
+it('shows the group checkbox as indeterminate when the group is mixed', () => {
+ setup({ hiddenSubjects: new Set(['a1']) });
+ const groupCheckbox = screen.getByLabelText(/Group A/);
+ expect(groupCheckbox.indeterminate).toBe(true);
+ expect(groupCheckbox.checked).toBe(false);
+});
+
+it('checks the group checkbox when all members are visible', () => {
+ setup();
+ expect(screen.getByLabelText(/Group A/).checked).toBe(true);
+});
+
+it('calls onToggleGroup with the group ids and hidden=true when an all-visible group is clicked', () => {
+ const { onToggleGroup } = setup();
+ fireEvent.click(screen.getByLabelText(/Group A/));
+ expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], true);
+});
+
+it('shows the group checkbox unchecked and not indeterminate when all members are hidden', () => {
+ setup({ hiddenSubjects: new Set(['a1', 'a2']) });
+ const groupCheckbox = screen.getByLabelText(/Group A/);
+ expect(groupCheckbox.checked).toBe(false);
+ expect(groupCheckbox.indeterminate).toBe(false);
+});
+
+it('calls onToggleGroup with hidden=false when a fully-hidden group is clicked', () => {
+ const { onToggleGroup } = setup({ hiddenSubjects: new Set(['a1', 'a2']) });
+ fireEvent.click(screen.getByLabelText(/Group A/));
+ expect(onToggleGroup).toHaveBeenCalledWith(['a1', 'a2'], false);
+});
+
+it('hides the Show all link when nothing is hidden, shows it otherwise', () => {
+ const { rerender } = render(
+ {}} onToggleGroup={() => {}}
+ onShowAll={() => {}} onHighlight={() => {}} />,
+ );
+ expect(screen.queryByText('Show all')).toBeNull();
+ rerender(
+ {}} onToggleGroup={() => {}}
+ onShowAll={() => {}} onHighlight={() => {}} />,
+ );
+ expect(screen.getByText('Show all')).toBeInTheDocument();
+});
+
+it('calls onShowAll when the Show all link is clicked', () => {
+ const { onShowAll } = setup({ hiddenSubjects: new Set(['a1']) });
+ fireEvent.click(screen.getByText('Show all'));
+ expect(onShowAll).toHaveBeenCalled();
+});
+
+it('calls onHighlight on subject row hover enter/leave', () => {
+ const { onHighlight } = setup();
+ const row = screen.getByLabelText('Mouse-A1').closest('label');
+ fireEvent.mouseEnter(row);
+ expect(onHighlight).toHaveBeenCalledWith('a1');
+ fireEvent.mouseLeave(row);
+ expect(onHighlight).toHaveBeenCalledWith(null);
+});