Show
diff --git a/ui/client/src/views/Nodes.module.css b/ui/client/src/views/Nodes.module.css
index 2e053d5..e0c5508 100644
--- a/ui/client/src/views/Nodes.module.css
+++ b/ui/client/src/views/Nodes.module.css
@@ -182,3 +182,83 @@
.drawerResize:active {
background: rgba(3, 102, 214, 0.25);
}
+
+/* Mobile-only filter toggle. Hidden on desktop where the sidebar is
+ permanently docked. */
+.mobileFilterToggle { display: none; }
+.mobileBackdrop { display: none; border: none; padding: 0; }
+
+@media (max-width: 768px) {
+ .root { grid-template-columns: 1fr; }
+
+ /* Sidebar transforms into an off-canvas drawer. Anchored below the app
+ header via --header-height. */
+ .sidebar {
+ position: fixed;
+ top: var(--header-height);
+ left: 0;
+ bottom: 0;
+ width: 280px;
+ max-width: 80vw;
+ transform: translateX(-100%);
+ transition: transform 0.2s ease;
+ z-index: 20;
+ box-shadow: 2px 0 12px rgba(0, 0, 0, 0.12);
+ }
+ .sidebar.mobileSidebarOpen { transform: translateX(0); }
+
+ .mobileBackdrop {
+ display: block;
+ position: fixed;
+ top: var(--header-height);
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.32);
+ z-index: 19;
+ }
+
+ .mobileFilterToggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ z-index: 12;
+ padding: 6px 12px;
+ font-size: 12px;
+ font-weight: 500;
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ color: var(--text-primary);
+ cursor: pointer;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
+ }
+
+ /* Larger touch targets for filter chips. */
+ .chip { padding: 6px 12px; font-size: 12px; gap: 6px; }
+ .search { padding: 8px 10px; font-size: 14px; }
+
+ /* Detail drawer becomes a fixed fullscreen sheet — position:fixed is
+ relative to the visual viewport so it renders at 1x regardless of any
+ pinch-zoom the user applied to the graph canvas. */
+ .drawer {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ width: 100% !important;
+ z-index: 30;
+ }
+ .drawerResize { display: none; }
+
+ .canvasOverlay {
+ font-size: 10px;
+ padding: 4px 8px;
+ bottom: 8px;
+ left: 8px;
+ }
+}
diff --git a/ui/client/src/views/Nodes.tsx b/ui/client/src/views/Nodes.tsx
index 5a2bc98..afd6031 100644
--- a/ui/client/src/views/Nodes.tsx
+++ b/ui/client/src/views/Nodes.tsx
@@ -26,6 +26,7 @@ export default function Nodes() {
const navigate = useNavigate();
const containerRef = useRef
(null);
const panZoomRef = useRef(null);
+ const drawerRef = useRef(null);
// navigate's reference may not be perfectly stable across renders (or
// an upstream provider re-render); keep it behind a ref so the SVG mount
// effect doesn't fire just because navigate's identity changed —
@@ -41,6 +42,10 @@ export default function Nodes() {
const [activeChapters, setActiveChapters] = useState>(() => new Set());
const [chaptersInit, setChaptersInit] = useState(false);
const [drawerWidth, setDrawerWidth] = useState(readSavedDrawerWidth);
+ // Mobile-only: filter sidebar collapses into a slide-in drawer behind a
+ // toggle button. Desktop CSS hides the toggle and keeps the sidebar
+ // statically docked, so this state has no visual effect there.
+ const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
// Persist drawer width across page loads.
useEffect(() => {
@@ -92,6 +97,55 @@ export default function Nodes() {
if (next !== selectedId) setSelectedId(next);
}, [routeId, selectedId]);
+ // Close mobile filter drawer whenever a node opens — the detail drawer
+ // takes the full screen on mobile and would otherwise overlap the open
+ // filter panel.
+ useEffect(() => {
+ if (selectedId) setMobileSidebarOpen(false);
+ }, [selectedId]);
+
+ // On mobile, the user may pinch-zoom the page to read node labels. When
+ // the drawer opens, counter-scale it so it renders at 1x regardless of
+ // the current browser zoom level.
+ useEffect(() => {
+ const el = drawerRef.current;
+ const vv = window.visualViewport;
+ if (!el || !vv || !selectedId) return;
+
+ const sync = () => {
+ const scale = vv.scale;
+ if (scale > 1.05) {
+ el.style.transform = `scale(${1 / scale})`;
+ el.style.transformOrigin = 'top left';
+ el.style.width = `${vv.width * scale}px`;
+ el.style.height = `${vv.height * scale}px`;
+ el.style.left = `${vv.offsetLeft}px`;
+ el.style.top = `${vv.offsetTop}px`;
+ } else {
+ el.style.transform = '';
+ el.style.transformOrigin = '';
+ el.style.width = '';
+ el.style.height = '';
+ el.style.left = '';
+ el.style.top = '';
+ }
+ };
+
+ sync();
+ vv.addEventListener('resize', sync);
+ vv.addEventListener('scroll', sync);
+ return () => {
+ vv.removeEventListener('resize', sync);
+ vv.removeEventListener('scroll', sync);
+ el.style.transform = '';
+ el.style.transformOrigin = '';
+ el.style.width = '';
+ el.style.height = '';
+ el.style.left = '';
+ el.style.top = '';
+ };
+ }, [selectedId]);
+
// Chapter chips render in content.tex order (server returns them already
// sorted). Don't re-sort here.
const allChapters = graph?.chapters ?? [];
@@ -263,7 +317,26 @@ export default function Nodes() {
return (
-