diff --git a/calm-hub-ui/AGENTS.md b/calm-hub-ui/AGENTS.md
index 6b26ff7ce..00862bb8e 100644
--- a/calm-hub-ui/AGENTS.md
+++ b/calm-hub-ui/AGENTS.md
@@ -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 ; // early-return keeps desktop untouched
+}
+return ;
+```
+
+### 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.
diff --git a/calm-hub-ui/index.html b/calm-hub-ui/index.html
index 0ebf5a9ef..6bfeb255e 100644
--- a/calm-hub-ui/index.html
+++ b/calm-hub-ui/index.html
@@ -3,7 +3,7 @@
{renderGroupedResults()}
diff --git a/calm-hub-ui/src/components/navbar/Navbar.css b/calm-hub-ui/src/components/navbar/Navbar.css
index 4184201e1..1680b7eec 100644
--- a/calm-hub-ui/src/components/navbar/Navbar.css
+++ b/calm-hub-ui/src/components/navbar/Navbar.css
@@ -1,4 +1,15 @@
.logo {
- max-width: unset;
+ /* Allow the logo to shrink within the navbar instead of forcing the bar
+ wider than the viewport on narrow screens. */
+ max-width: 100%;
height: inherit;
}
+
+/* Respect device safe areas (notch / rounded corners) and never let the bar
+ exceed the viewport width. */
+.navbar {
+ padding-top: max(0.5rem, env(safe-area-inset-top));
+ padding-left: max(0.5rem, env(safe-area-inset-left));
+ padding-right: max(0.5rem, env(safe-area-inset-right));
+ max-width: 100vw;
+}
diff --git a/calm-hub-ui/src/components/navbar/Navbar.tsx b/calm-hub-ui/src/components/navbar/Navbar.tsx
index 0980ae232..ae771c000 100644
--- a/calm-hub-ui/src/components/navbar/Navbar.tsx
+++ b/calm-hub-ui/src/components/navbar/Navbar.tsx
@@ -1,64 +1,54 @@
import './Navbar.css';
-import { NavLink } from 'react-router-dom';
+import { Link, NavLink } from 'react-router-dom';
+import { IoMenuOutline } from 'react-icons/io5';
import { GlobalSearchBar } from './GlobalSearchBar.js';
-export function Navbar() {
+interface NavbarProps {
+ /**
+ * When provided, renders an "Explore" button in the navbar that toggles the
+ * navigation/explorer (the desktop sidebar, or the mobile drill-down panel).
+ * Pages without an explorer (e.g. the Visualizer) omit this.
+ */
+ onExploreClick?: () => void;
+}
+
+export function Navbar({ onExploreClick }: NavbarProps) {
return (
-
+ {/* Portal target for page-level actions (e.g. the diagram's
+ view-options menu), always visible across breakpoints. */}
+
+ {/* Desktop keeps the Visualizer link and search in the navbar; on
+ mobile both live inside the explorer panel instead, so they are
+ hidden here below the lg breakpoint. */}
+
+
+ Visualizer
+
+
+
);
diff --git a/calm-hub-ui/src/diff/Diff.css b/calm-hub-ui/src/diff/Diff.css
index 20ac5aac2..f36be467e 100644
--- a/calm-hub-ui/src/diff/Diff.css
+++ b/calm-hub-ui/src/diff/Diff.css
@@ -65,6 +65,23 @@
border-right: none;
}
+/* Stack the two comparison graphs vertically on narrow viewports so each one
+ * has enough room to be legible instead of being squeezed side by side. */
+@media (width <= 1023px) {
+ .architectures-container {
+ flex-direction: column;
+ }
+
+ .architecture-panel {
+ border-right: none;
+ border-bottom: 1px solid #e5e7eb;
+ }
+
+ .architecture-panel:last-child {
+ border-bottom: none;
+ }
+}
+
.architecture-header {
padding: 12px 16px;
background: #f9fafb;
diff --git a/calm-hub-ui/src/diff/components/DiffGraph.tsx b/calm-hub-ui/src/diff/components/DiffGraph.tsx
index e35120463..e9b0e0028 100644
--- a/calm-hub-ui/src/diff/components/DiffGraph.tsx
+++ b/calm-hub-ui/src/diff/components/DiffGraph.tsx
@@ -18,6 +18,7 @@ import { DecisionGroupNode } from '../../visualizer/components/reactflow/Decisio
import { THEME } from '../../visualizer/components/reactflow/theme.js';
import { parseCALMDataWithDiff } from './utils/diffTransformer.js';
import { parsePatternDataWithDiff } from './utils/patternDiffTransformer.js';
+import { useIsMobile } from '../../hooks/useMediaQuery.js';
import type { DiffGraphProps } from '../model/diff-ui-types.js';
const edgeTypes = { custom: FloatingEdge };
@@ -30,6 +31,7 @@ function DiffGraphInner({ source, sourceType, diffResult, isFirst }: DiffGraphPr
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const { fitView } = useReactFlow();
const nodesInitialized = useNodesInitialized();
+ const isMobile = useIsMobile();
const containerRef = useRef(null);
const fitFrameRef = useRef();
@@ -86,21 +88,25 @@ function DiffGraphInner({ source, sourceType, diffResult, isFirst }: DiffGraphPr
style={{ background: THEME.colors.background }}
>
-
-
+ {!isMobile && (
+
+ )}
+ {!isMobile && (
+
+ )}
);
diff --git a/calm-hub-ui/src/hooks/useMediaQuery.test.ts b/calm-hub-ui/src/hooks/useMediaQuery.test.ts
new file mode 100644
index 000000000..85a0005a9
--- /dev/null
+++ b/calm-hub-ui/src/hooks/useMediaQuery.test.ts
@@ -0,0 +1,78 @@
+import { renderHook, act } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { useMediaQuery, useIsMobile } from './useMediaQuery.js';
+
+type Listener = () => void;
+
+/**
+ * Install a controllable matchMedia mock and return a helper to flip the
+ * match state and notify subscribers.
+ */
+function installMatchMedia(initialMatches: boolean) {
+ let matches = initialMatches;
+ const listeners = new Set();
+
+ const matchMedia = vi.fn((query: string) => ({
+ get matches() {
+ return matches;
+ },
+ media: query,
+ onchange: null,
+ addEventListener: (_: string, cb: Listener) => listeners.add(cb),
+ removeEventListener: (_: string, cb: Listener) => listeners.delete(cb),
+ addListener: (cb: Listener) => listeners.add(cb),
+ removeListener: (cb: Listener) => listeners.delete(cb),
+ dispatchEvent: () => false,
+ }));
+
+ window.matchMedia = matchMedia as unknown as typeof window.matchMedia;
+
+ return {
+ setMatches(next: boolean) {
+ matches = next;
+ listeners.forEach((cb) => cb());
+ },
+ };
+}
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('useMediaQuery', () => {
+ it('returns the initial match state', () => {
+ installMatchMedia(true);
+ const { result } = renderHook(() => useMediaQuery('(max-width: 1023px)'));
+ expect(result.current).toBe(true);
+ });
+
+ it('updates when the media query changes', () => {
+ const mm = installMatchMedia(false);
+ const { result } = renderHook(() => useMediaQuery('(max-width: 1023px)'));
+ expect(result.current).toBe(false);
+
+ act(() => mm.setMatches(true));
+ expect(result.current).toBe(true);
+ });
+
+ it('returns false when matchMedia is unavailable', () => {
+ // @ts-expect-error - simulate an environment without matchMedia
+ window.matchMedia = undefined;
+ const { result } = renderHook(() => useMediaQuery('(max-width: 1023px)'));
+ expect(result.current).toBe(false);
+ });
+});
+
+describe('useIsMobile', () => {
+ it('is true when the viewport is below the lg breakpoint', () => {
+ installMatchMedia(true);
+ const { result } = renderHook(() => useIsMobile());
+ expect(result.current).toBe(true);
+ });
+
+ it('is false on wide viewports', () => {
+ installMatchMedia(false);
+ const { result } = renderHook(() => useIsMobile());
+ expect(result.current).toBe(false);
+ });
+});
diff --git a/calm-hub-ui/src/hooks/useMediaQuery.ts b/calm-hub-ui/src/hooks/useMediaQuery.ts
new file mode 100644
index 000000000..b02a1747e
--- /dev/null
+++ b/calm-hub-ui/src/hooks/useMediaQuery.ts
@@ -0,0 +1,50 @@
+import { useEffect, useState } from 'react';
+
+/**
+ * Subscribe to a CSS media query and return whether it currently matches.
+ *
+ * Safe to call in environments without `window.matchMedia` (e.g. older jsdom
+ * test setups): it returns `false` and never subscribes, so components fall
+ * back to their desktop layout.
+ */
+export function useMediaQuery(query: string): boolean {
+ const getMatches = (): boolean => {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return false;
+ }
+ return window.matchMedia(query).matches;
+ };
+
+ const [matches, setMatches] = useState(getMatches);
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
+ return;
+ }
+ const mediaQueryList = window.matchMedia(query);
+ const handleChange = () => setMatches(mediaQueryList.matches);
+
+ // Sync immediately in case the query changed between render and effect.
+ handleChange();
+
+ // Older browsers (notably Safari < 14) only implement the deprecated
+ // addListener/removeListener API, so fall back to it when the modern
+ // addEventListener is unavailable.
+ if (typeof mediaQueryList.addEventListener === 'function') {
+ mediaQueryList.addEventListener('change', handleChange);
+ return () => mediaQueryList.removeEventListener('change', handleChange);
+ }
+ mediaQueryList.addListener(handleChange);
+ return () => mediaQueryList.removeListener(handleChange);
+ }, [query]);
+
+ return matches;
+}
+
+/**
+ * Tailwind's `lg` breakpoint is 1024px. Anything narrower (phones and
+ * tablet-portrait) is treated as "mobile" and gets the off-canvas layout.
+ */
+export function useIsMobile(): boolean {
+ return useMediaQuery('(max-width: 1023px)');
+}
diff --git a/calm-hub-ui/src/hub/Hub.test.tsx b/calm-hub-ui/src/hub/Hub.test.tsx
index 11159813e..d25f78ca9 100644
--- a/calm-hub-ui/src/hub/Hub.test.tsx
+++ b/calm-hub-ui/src/hub/Hub.test.tsx
@@ -2,7 +2,28 @@ import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { BrowserRouter } from 'react-router-dom';
import Hub from './Hub.js';
-import { vi, describe, it, expect } from 'vitest';
+import { vi, describe, it, expect, afterEach } from 'vitest';
+
+/**
+ * Force `useIsMobile()` (which reads window.matchMedia) to report a mobile
+ * viewport. Returns a restore function.
+ */
+function mockMobileViewport(isMobile: boolean) {
+ const original = window.matchMedia;
+ window.matchMedia = ((query: string) => ({
+ matches: isMobile,
+ media: query,
+ onchange: null,
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ addListener: () => {},
+ removeListener: () => {},
+ dispatchEvent: () => false,
+ })) as unknown as typeof window.matchMedia;
+ return () => {
+ window.matchMedia = original;
+ };
+}
vi.mock('./components/tree-navigation/TreeNavigation', () => ({
TreeNavigation: ({
@@ -69,6 +90,35 @@ vi.mock('./components/tree-navigation/TreeNavigation', () => ({
),
}));
+vi.mock('./components/tree-navigation/MobileNavMenu', () => ({
+ MobileNavMenu: ({
+ onDataLoad,
+ onClose,
+ }: {
+ onDataLoad: (data: unknown) => void;
+ onClose: () => void;
+ }) => (
+