Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
031f938
feat(calm-hub-ui): make Hub and Visualizer layouts mobile responsive
rocketstack-matt Jun 20, 2026
e396d1d
feat(calm-hub-ui): make the architecture/diagram view usable on mobile
rocketstack-matt Jun 20, 2026
8d1490f
feat(calm-hub-ui): make mobile menu and detail panels full-screen
rocketstack-matt Jun 20, 2026
a211f0d
feat(calm-hub-ui): iOS-style drill-down navigation on mobile
rocketstack-matt Jun 20, 2026
fa52e9c
feat(calm-hub-ui): move explorer into the navbar; drop nav links and …
rocketstack-matt Jun 20, 2026
8102290
feat(calm-hub-ui): full-bleed render pane, view-modes menu, mobile ex…
rocketstack-matt Jun 20, 2026
e11fa75
feat(calm-hub-ui): full-screen view-options menu on mobile
rocketstack-matt Jun 20, 2026
6ab0d26
feat(calm-hub-ui): move the diagram view-options menu into the navbar
rocketstack-matt Jun 20, 2026
8f8969a
fix(calm-hub-ui): keep the diagram redesign mobile-only; show active …
rocketstack-matt Jun 20, 2026
c6ef8d9
feat(calm-hub-ui): move component (node) search into the mobile view …
rocketstack-matt Jun 20, 2026
2f4ddf0
feat(calm-hub-ui): mobile navbar polish and timeline in view menu
rocketstack-matt Jun 20, 2026
248ce36
feat(calm-hub-ui): make mobile timeline a full-screen view
rocketstack-matt Jun 20, 2026
af4a21a
feat(calm-hub-ui): tabbed mobile layout for control detail view
rocketstack-matt Jun 20, 2026
d426d5e
Merge remote-tracking branch 'upstream/main' into claude/calm-hub-ui-…
rocketstack-matt Jun 20, 2026
9198277
docs(calm-hub-ui): document responsive design and require testing bot…
rocketstack-matt Jun 20, 2026
ea8da04
fix(calm-hub-ui): remove exhaustive-deps suppression in ExplorerSearch
rocketstack-matt Jun 20, 2026
ec1bed1
fix(calm-hub-ui): address Copilot review feedback on navbar and search
rocketstack-matt Jun 21, 2026
9c2efda
feat(calm-hub-ui): mobile timeline drill-down list
rocketstack-matt Jun 21, 2026
0e73525
Merge branch 'main' into claude/calm-hub-ui-mobile-responsive-vrquce
rocketstack-matt Jun 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions calm-hub-ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,77 @@ Instead, place `HeaderSection` and `BodySection` in their own files and import t
- Use `useCallback` for functions referenced in `useEffect` dependency arrays
- **Never suppress** `react-hooks/exhaustive-deps` — fix the dependency array properly

## Responsive Design

The CALM Hub UI is **mobile responsive** — it must be usable on phones as well as
desktops. Both renders are first-class; a change is not complete until it has been
verified at **both** a desktop and a mobile viewport.

### The `useIsMobile` hook

Layout decisions key off `src/hooks/useMediaQuery.ts`:

- `useMediaQuery(query)` — subscribes to a CSS media query (test-safe: returns
`false` when `window.matchMedia` is absent, so components fall back to desktop).
- `useIsMobile()` — `useMediaQuery('(max-width: 1023px)')`. The breakpoint is
Tailwind's `lg` (1024px): anything narrower is treated as mobile.

```typescript
const isMobile = useIsMobile();
if (!isMobile) {
return <DesktopLayout … />; // early-return keeps desktop untouched
}
return <MobileLayout … />;
```

### Mobile-only changes must not alter desktop

When adding or reworking mobile behaviour, **branch on `isMobile` and keep the
desktop path byte-for-byte unchanged** (an early `return` for the desktop layout is
the clearest way). Reviewers have repeatedly rejected diffs that "accidentally"
restyled desktop while targeting mobile — don't.

### Established mobile patterns

- **Full-screen push overlays**, not float-overs: menus, the view-options menu, the
timeline, and detail panels take over the viewport (`fixed inset-0`,
`animate-slide-in-right`) rather than floating above the canvas where iOS chrome
would hide them.
- **Navbar-hosted actions**: page-level controls (e.g. the diagram view-options
menu) portal into the navbar `#navbar-actions` slot instead of floating in the
render pane. See `components/navbar/Navbar.tsx` and `diagram-section/DiagramSection.tsx`.
- **iOS-style drill-down explorer** on mobile (`tree-navigation/MobileNavMenu.tsx`):
one flat list per level, not a tree.
- **Full-bleed render pane** with the minimap/zoom controls hidden (pinch is the
native gesture) — see `visualizer/components/reactflow/`.
- **Tabbed detail views** where desktop stacks panels (e.g.
`control-detail-section/ControlDetailSection.tsx` shows Requirement/Configuration
as tabs on mobile).
- **Device safe areas**: `viewport-fit=cover` in `index.html` plus
`env(safe-area-inset-*)` so the navbar clears the notch (`navbar/Navbar.css`).

Reference components for the patterns above: `hub/Hub.tsx`, `components/navbar/Navbar.tsx`,
`hub/components/diagram-section/DiagramSection.tsx`,
`hub/components/tree-navigation/MobileNavMenu.tsx`, and
`hub/components/control-detail-section/ControlDetailSection.tsx`.

## Testing

- Unit tests use Vitest + React Testing Library
- All new components and services must have tests
- Mock services at the module level with `vi.mock` and class constructor patterns
- E2E tests are in `cypress/`

### Test both desktop and mobile

Any change that touches layout/rendering **must be verified at both a desktop and a
mobile viewport** — never just one.

- **Unit tests**: components that branch on `useIsMobile()` need cases for both
renders. Drive the breakpoint by mocking `window.matchMedia` (returning
`matches: true` simulates mobile, `false` desktop). `ControlDetailSection.test.tsx`
and `hub/Hub.test.tsx` show the `mockViewport`/`mockMobileViewport` helper pattern.
The default (no mock) reports desktop, so add explicit mobile cases.
- **Manual/visual check**: run `npm run start --workspace calm-hub-ui` and confirm the
change at a desktop width and at a mobile width (≤1023px — e.g. a 390px-wide device
in browser dev-tools). Confirm desktop is unchanged when the work was mobile-only.
2 changes: 1 addition & 1 deletion calm-hub-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/brand/Icon/2025_CALM_Icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
Expand Down
213 changes: 213 additions & 0 deletions calm-hub-ui/src/components/navbar/ExplorerSearch.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { ExplorerSearch } from './ExplorerSearch.js';
import { SearchService } from '../../service/search-service.js';
import { GroupedSearchResults } from '../../model/search.js';

const emptyResults: GroupedSearchResults = {
architectures: [],
patterns: [],
flows: [],
standards: [],
interfaces: [],
controls: [],
adrs: [],
};

const mockResults: GroupedSearchResults = {
architectures: [{ namespace: 'finos', id: 1, name: 'Test Architecture', description: 'A test architecture' }],
patterns: [{ namespace: 'finos', id: 2, name: 'Test Pattern', description: 'A test pattern' }],
flows: [],
standards: [],
interfaces: [],
controls: [],
adrs: [],
};

function createMockSearchService(searchFn: (q: string) => Promise<GroupedSearchResults>) {
return { search: searchFn } as unknown as SearchService;
}

function renderSearch(searchService?: SearchService, onSearchingChange?: (a: boolean) => void) {
return render(
<MemoryRouter>
<ExplorerSearch searchService={searchService} onSearchingChange={onSearchingChange} />
</MemoryRouter>
);
}

describe('ExplorerSearch', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('renders search input', () => {
renderSearch();
expect(screen.getByPlaceholderText('Search CALM Hub...')).toBeInTheDocument();
});

it('debounces API calls', async () => {
const searchFn = vi.fn().mockResolvedValue(emptyResults);
renderSearch(createMockSearchService(searchFn));
const input = screen.getByPlaceholderText('Search CALM Hub...');

await act(async () => {
fireEvent.change(input, { target: { value: 't' } });
fireEvent.change(input, { target: { value: 'te' } });
fireEvent.change(input, { target: { value: 'tes' } });
fireEvent.change(input, { target: { value: 'test' } });
});

expect(searchFn).not.toHaveBeenCalled();

await act(async () => {
vi.advanceTimersByTime(300);
});

expect(searchFn).toHaveBeenCalledTimes(1);
expect(searchFn).toHaveBeenCalledWith('test');
});

it('displays grouped results inline', async () => {
const searchFn = vi.fn().mockResolvedValue(mockResults);
renderSearch(createMockSearchService(searchFn));
const input = screen.getByPlaceholderText('Search CALM Hub...');

await act(async () => {
fireEvent.change(input, { target: { value: 'test' } });
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});

expect(screen.getByText('Test Architecture')).toBeInTheDocument();
expect(screen.getByText('Test Pattern')).toBeInTheDocument();
expect(screen.getByText('Architectures')).toBeInTheDocument();
expect(screen.getByText('Patterns')).toBeInTheDocument();
});

it('notifies the parent when a search becomes active and inactive', async () => {
const onSearchingChange = vi.fn();
const searchFn = vi.fn().mockResolvedValue(mockResults);
renderSearch(createMockSearchService(searchFn), onSearchingChange);
const input = screen.getByPlaceholderText('Search CALM Hub...');

await act(async () => {
fireEvent.change(input, { target: { value: 'test' } });
});
expect(onSearchingChange).toHaveBeenLastCalledWith(true);

await act(async () => {
fireEvent.click(screen.getByLabelText('Clear search'));
});
expect(onSearchingChange).toHaveBeenLastCalledWith(false);
});

it('shows no results message when search returns empty', async () => {
const searchFn = vi.fn().mockResolvedValue(emptyResults);
renderSearch(createMockSearchService(searchFn));
const input = screen.getByPlaceholderText('Search CALM Hub...');

await act(async () => {
fireEvent.change(input, { target: { value: 'test' } });
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});

expect(screen.getByText('No results found')).toBeInTheDocument();
});

it('navigates with keyboard ArrowDown and Enter', async () => {
const searchFn = vi.fn().mockResolvedValue(mockResults);
renderSearch(createMockSearchService(searchFn));
const input = screen.getByPlaceholderText('Search CALM Hub...');

await act(async () => {
fireEvent.change(input, { target: { value: 'test' } });
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
await act(async () => {
fireEvent.keyDown(input, { key: 'ArrowDown' });
});

const firstOption = screen.getAllByRole('option')[0];
expect(firstOption).toHaveAttribute('aria-selected', 'true');
});

it('clears results on Escape', async () => {
const searchFn = vi.fn().mockResolvedValue(mockResults);
renderSearch(createMockSearchService(searchFn));
const input = screen.getByPlaceholderText('Search CALM Hub...');

await act(async () => {
fireEvent.change(input, { target: { value: 'test' } });
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});
await act(async () => {
fireEvent.keyDown(input, { key: 'Escape' });
});

expect(screen.queryByText('Test Architecture')).not.toBeInTheDocument();
});

it('clears search on clear button click', async () => {
const searchFn = vi.fn().mockResolvedValue(mockResults);
renderSearch(createMockSearchService(searchFn));
const input = screen.getByPlaceholderText('Search CALM Hub...');

await act(async () => {
fireEvent.change(input, { target: { value: 'test' } });
});
await act(async () => {
await vi.advanceTimersByTimeAsync(300);
});

await act(async () => {
fireEvent.click(screen.getByLabelText('Clear search'));
});

expect(input).toHaveValue('');
expect(screen.queryByText('Test Architecture')).not.toBeInTheDocument();
});

it('handles API errors gracefully', async () => {
const searchFn = vi.fn().mockRejectedValue(new Error('Network error'));
renderSearch(createMockSearchService(searchFn));
const input = screen.getByPlaceholderText('Search CALM Hub...');

await act(async () => {
fireEvent.change(input, { target: { value: 'test' } });
});
await act(async () => {
vi.advanceTimersByTime(300);
await vi.runAllTimersAsync();
});

expect(screen.getByText('Search failed, please try again')).toBeInTheDocument();
});

it('does not search when input is empty', async () => {
const searchFn = vi.fn().mockResolvedValue(emptyResults);
renderSearch(createMockSearchService(searchFn));
const input = screen.getByPlaceholderText('Search CALM Hub...');

await act(async () => {
fireEvent.change(input, { target: { value: ' ' } });
});
await act(async () => {
vi.advanceTimersByTime(300);
});

expect(searchFn).not.toHaveBeenCalled();
});
});
Loading
Loading