From a737f06e85c7e85755b9a064e995882e067109b2 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 11 Mar 2026 03:43:25 +0800 Subject: [PATCH 1/5] Action deferred QA items: shared overlay, sort, touch, a11y MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - S-01/W-06/S-02/S-04: Extract MobileDetailOverlay shared component with Escape key, body scroll lock, and ARIA dialog attributes. Refactored Todos, Reminders, People, Locations, ProjectDetail. - W-02: Add specificity contract comment to mobile-scale CSS - W-03: Enforce 10px floor for text-[9px] on mobile - W-05: Add sort dropdown to EntityTable mobile card view - S-03: Export MOBILE/DESKTOP breakpoint constants from useMediaQuery, updated all 8 consumer files to use constants - S-06: Bump KanbanBoard TouchSensor tolerance from 5 to 8 - S-07: Hover state audit — no action needed, hoverOnlyWhenSupported in Tailwind config already handles touch devices correctly Co-Authored-By: Claude Opus 4.6 --- .../src/components/calendar/CalendarPage.tsx | 4 +- .../components/locations/LocationsPage.tsx | 19 ++---- frontend/src/components/people/PeoplePage.tsx | 19 ++---- .../src/components/projects/KanbanBoard.tsx | 6 +- .../src/components/projects/ProjectDetail.tsx | 49 +++++++-------- .../components/reminders/RemindersPage.tsx | 29 ++++----- .../src/components/shared/EntityTable.tsx | 34 +++++++++- .../components/shared/MobileDetailOverlay.tsx | 63 +++++++++++++++++++ frontend/src/components/todos/TodosPage.tsx | 29 ++++----- frontend/src/components/ui/date-picker.tsx | 4 +- frontend/src/hooks/useMediaQuery.ts | 3 + frontend/src/index.css | 10 +++ 12 files changed, 172 insertions(+), 97 deletions(-) create mode 100644 frontend/src/components/shared/MobileDetailOverlay.tsx diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index d94f514..794c8d8 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -1,5 +1,5 @@ import { useState, useRef, useEffect, useMemo, useCallback } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { useLocation } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -164,7 +164,7 @@ export default function CalendarPage() { const panelOpen = panelMode !== 'closed'; // Track desktop breakpoint to prevent dual EventDetailPanel mount - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); // Continuously resize calendar during panel open/close CSS transition diff --git a/frontend/src/components/locations/LocationsPage.tsx b/frontend/src/components/locations/LocationsPage.tsx index e0f643c..3174292 100644 --- a/frontend/src/components/locations/LocationsPage.tsx +++ b/frontend/src/components/locations/LocationsPage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef, useEffect } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { Plus, MapPin, Phone, Mail, Tag, AlignLeft } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -17,10 +17,11 @@ import { import { useTableVisibility } from '@/hooks/useTableVisibility'; import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import LocationForm from './LocationForm'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; export default function LocationsPage() { const queryClient = useQueryClient(); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); const [selectedLocationId, setSelectedLocationId] = useState(null); const [showForm, setShowForm] = useState(false); @@ -387,17 +388,9 @@ export default function LocationsPage() { {/* Mobile detail panel overlay */} {panelOpen && selectedLocation && !isDesktop && ( -
setSelectedLocationId(null)} - > -
e.stopPropagation()} - > - {renderPanel()} -
-
+ setSelectedLocationId(null)}> + {renderPanel()} + )} {showForm && ( diff --git a/frontend/src/components/people/PeoplePage.tsx b/frontend/src/components/people/PeoplePage.tsx index 59328d4..9ab6e46 100644 --- a/frontend/src/components/people/PeoplePage.tsx +++ b/frontend/src/components/people/PeoplePage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef, useEffect } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { Plus, Users, Star, Cake, Phone, Mail, MapPin, Tag, Building2, Briefcase, AlignLeft, Ghost, ChevronDown, Unlink, Link2, User2 } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; @@ -27,6 +27,7 @@ import PersonForm from './PersonForm'; import ConnectionSearch from '@/components/connections/ConnectionSearch'; import ConnectionRequestCard from '@/components/connections/ConnectionRequestCard'; import { useConnections } from '@/hooks/useConnections'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; // --------------------------------------------------------------------------- // StatCounter — inline helper @@ -215,7 +216,7 @@ const panelFields: PanelField[] = [ export default function PeoplePage() { const queryClient = useQueryClient(); const tableContainerRef = useRef(null); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); const [selectedPersonId, setSelectedPersonId] = useState(null); const [showForm, setShowForm] = useState(false); @@ -776,17 +777,9 @@ export default function PeoplePage() { {/* Mobile detail panel overlay */} {panelOpen && selectedPerson && !isDesktop && ( -
setSelectedPersonId(null)} - > -
e.stopPropagation()} - > - {renderPanel()} -
-
+ setSelectedPersonId(null)}> + {renderPanel()} + )} {showForm && ( diff --git a/frontend/src/components/projects/KanbanBoard.tsx b/frontend/src/components/projects/KanbanBoard.tsx index a27a3df..04452da 100644 --- a/frontend/src/components/projects/KanbanBoard.tsx +++ b/frontend/src/components/projects/KanbanBoard.tsx @@ -1,7 +1,7 @@ import { DndContext, closestCorners, - PointerSensor, + PointerSensor, TouchSensor, useSensor, useSensors, @@ -153,8 +153,8 @@ export default function KanbanBoard({ onBackToAllTasks, }: KanbanBoardProps) { const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) , - useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 5 } }) + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }) , + useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 8 } }) ); // Subtask view is driven by kanbanParentTask (decoupled from selected task) diff --git a/frontend/src/components/projects/ProjectDetail.tsx b/frontend/src/components/projects/ProjectDetail.tsx index 867bd54..b85a841 100644 --- a/frontend/src/components/projects/ProjectDetail.tsx +++ b/frontend/src/components/projects/ProjectDetail.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useCallback } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { useParams, useNavigate } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; @@ -39,6 +39,7 @@ import KanbanBoard from './KanbanBoard'; import TaskForm from './TaskForm'; import ProjectForm from './ProjectForm'; import { statusColors, statusLabels } from './constants'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; type SortMode = 'manual' | 'priority' | 'due_date'; type ViewMode = 'list' | 'kanban'; @@ -258,7 +259,7 @@ export default function ProjectDetail() { } }, [topLevelTasks, sortMode, sortSubtasks]); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); const selectedTask = useMemo(() => { if (!selectedTaskId) return null; @@ -653,30 +654,28 @@ export default function ProjectDetail() { {/* Mobile: show detail panel as overlay when task selected on small screens */} {selectedTaskId && selectedTask && !isDesktop && ( -
-
-
- Task Details - -
-
- openTaskForm(null, parentId)} - onClose={() => setSelectedTaskId(null)} - onSelectTask={setSelectedTaskId} - /> -
+ setSelectedTaskId(null)}> +
+ Task Details +
-
+
+ openTaskForm(null, parentId)} + onClose={() => setSelectedTaskId(null)} + onSelectTask={setSelectedTaskId} + /> +
+ )} {showTaskForm && ( diff --git a/frontend/src/components/reminders/RemindersPage.tsx b/frontend/src/components/reminders/RemindersPage.tsx index 328f1ca..c3b4933 100644 --- a/frontend/src/components/reminders/RemindersPage.tsx +++ b/frontend/src/components/reminders/RemindersPage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { useLocation } from 'react-router-dom'; import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; @@ -13,6 +13,7 @@ import { Card, CardContent } from '@/components/ui/card'; import { ListSkeleton } from '@/components/ui/skeleton'; import ReminderList from './ReminderList'; import ReminderDetailPanel from './ReminderDetailPanel'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; const statusFilters = [ { value: 'active', label: 'Active' }, @@ -25,7 +26,7 @@ type StatusFilter = (typeof statusFilters)[number]['value']; export default function RemindersPage() { const location = useLocation(); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); // Panel state const [selectedReminderId, setSelectedReminderId] = useState(null); @@ -235,22 +236,14 @@ export default function RemindersPage() { {/* Mobile detail panel overlay */} {panelOpen && !isDesktop && ( -
-
e.stopPropagation()} - > - -
-
+ + + )}
); diff --git a/frontend/src/components/shared/EntityTable.tsx b/frontend/src/components/shared/EntityTable.tsx index 529493d..6204150 100644 --- a/frontend/src/components/shared/EntityTable.tsx +++ b/frontend/src/components/shared/EntityTable.tsx @@ -1,7 +1,7 @@ import React from 'react'; -import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; +import { ArrowUpDown, ArrowUp, ArrowDown, ChevronsUpDown } from 'lucide-react'; import type { VisibilityMode } from '@/hooks/useTableVisibility'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, MOBILE } from '@/hooks/useMediaQuery'; export interface ColumnDef { key: string; @@ -134,11 +134,39 @@ export function EntityTable({ const visibleColumns = columns.filter((col) => isVisible(col.visibilityLevel, visibilityMode)); const colCount = visibleColumns.length; const showPinnedSection = showPinned && pinnedRows.length > 0; - const isMobile = useMediaQuery('(max-width: 767px)'); + const isMobile = useMediaQuery(MOBILE); + + const sortableColumns = columns.filter((col) => col.sortable); if (isMobile && mobileCardRender) { return (
+ {sortableColumns.length > 0 && !loading && ( +
+ + +
+ )} {loading ? ( Array.from({ length: 6 }).map((_, i) => (
diff --git a/frontend/src/components/shared/MobileDetailOverlay.tsx b/frontend/src/components/shared/MobileDetailOverlay.tsx new file mode 100644 index 0000000..81cc7a6 --- /dev/null +++ b/frontend/src/components/shared/MobileDetailOverlay.tsx @@ -0,0 +1,63 @@ +import { useEffect } from 'react'; +import { cn } from '@/lib/utils'; + +interface MobileDetailOverlayProps { + open: boolean; + onClose: () => void; + children: React.ReactNode; + className?: string; +} + +/** + * Full-screen overlay for mobile detail panels. + * - Backdrop click closes the overlay + * - Escape key closes the overlay + * - Body scroll is locked while open + */ +export default function MobileDetailOverlay({ + open, + onClose, + children, + className, +}: MobileDetailOverlayProps) { + // Escape key handler + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, onClose]); + + // Body scroll lock + useEffect(() => { + if (!open) return; + const previous = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previous; + }; + }, [open]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {children} +
+
+ ); +} diff --git a/frontend/src/components/todos/TodosPage.tsx b/frontend/src/components/todos/TodosPage.tsx index 1090915..699fc6d 100644 --- a/frontend/src/components/todos/TodosPage.tsx +++ b/frontend/src/components/todos/TodosPage.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect } from 'react'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, DESKTOP } from '@/hooks/useMediaQuery'; import { useLocation } from 'react-router-dom'; import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; @@ -11,6 +11,7 @@ import { Select } from '@/components/ui/select'; import { Card, CardContent } from '@/components/ui/card'; import { ListSkeleton } from '@/components/ui/skeleton'; import { CategoryFilterBar } from '@/components/shared'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; import { useCategoryOrder } from '@/hooks/useCategoryOrder'; import TodoList from './TodoList'; import TodoDetailPanel from './TodoDetailPanel'; @@ -26,7 +27,7 @@ const priorityFilters = [ export default function TodosPage() { const location = useLocation(); - const isDesktop = useMediaQuery('(min-width: 1024px)'); + const isDesktop = useMediaQuery(DESKTOP); // Panel state const [selectedTodoId, setSelectedTodoId] = useState(null); @@ -270,22 +271,14 @@ export default function TodosPage() { {/* Mobile detail panel overlay */} {panelOpen && !isDesktop && ( -
-
e.stopPropagation()} - > - -
-
+ + + )}
); diff --git a/frontend/src/components/ui/date-picker.tsx b/frontend/src/components/ui/date-picker.tsx index 4f9f7bb..5ed33ae 100644 --- a/frontend/src/components/ui/date-picker.tsx +++ b/frontend/src/components/ui/date-picker.tsx @@ -2,7 +2,7 @@ import * as React from 'react'; import { createPortal } from 'react-dom'; import { Calendar, ChevronLeft, ChevronRight, Clock } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useMediaQuery, MOBILE } from '@/hooks/useMediaQuery'; // ── Browser detection (stable — checked once at module load) ── @@ -128,7 +128,7 @@ const DatePicker = React.forwardRef( const blurTimeoutRef = React.useRef>(); const [pos, setPos] = React.useState<{ top: number; left: number }>({ top: 0, left: 0 }); - const isMobile = useMediaQuery('(max-width: 767px)'); + const isMobile = useMediaQuery(MOBILE); React.useImperativeHandle(ref, () => triggerRef.current!); diff --git a/frontend/src/hooks/useMediaQuery.ts b/frontend/src/hooks/useMediaQuery.ts index 6fc4f90..ff8515c 100644 --- a/frontend/src/hooks/useMediaQuery.ts +++ b/frontend/src/hooks/useMediaQuery.ts @@ -1,5 +1,8 @@ import { useState, useEffect } from 'react'; +export const MOBILE = '(max-width: 767px)'; +export const DESKTOP = '(min-width: 1024px)'; + export function useMediaQuery(query: string): boolean { const [matches, setMatches] = useState(() => typeof window !== 'undefined' ? window.matchMedia(query).matches : false diff --git a/frontend/src/index.css b/frontend/src/index.css index 267310c..8e6005e 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -292,6 +292,12 @@ form[data-submitted] input:invalid + button { } +/* + * Mobile font scaling — overrides Tailwind text utilities below 768px. + * Applied via .mobile-scale class on
in AppLayout.tsx. + * These selectors rely on cascade order (loaded after Tailwind utilities). + * Arbitrary sizes like text-[9px] are NOT captured — see W-03 override below. + */ /* ── Global mobile content scaling ── */ /* Scales down all page content text on mobile. Navbar and UMBRA title are excluded because they live outside .mobile-scale and use their own sizing. */ @@ -323,6 +329,10 @@ form[data-submitted] input:invalid + button { .mobile-scale .text-3xl { font-size: 1.375rem; /* 22px */ } + /* W-03: Enforce 10px floor for arbitrary small sizes */ + .mobile-scale [class*="text-\[9px\]"] { + font-size: 10px !important; + } } /* ── Mobile touch optimisation ── */ From 4e9194495662d4dec611c64ccf1694e17a9e1305 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 11 Mar 2026 03:46:40 +0800 Subject: [PATCH 2/5] Fix code review findings: sort dropdown, overlay ref, CalendarPage - C-01: Simplify EntityTable sort dropdown to toggle-based (select column, re-select to flip direction), add aria-label - W-01: Convert CalendarPage mobile overlay to MobileDetailOverlay - W-02: Use ref for onClose in MobileDetailOverlay to prevent listener churn from inline arrow functions Co-Authored-By: Claude Opus 4.6 --- .../src/components/calendar/CalendarPage.tsx | 33 ++++++++----------- .../src/components/shared/EntityTable.tsx | 21 ++++-------- .../components/shared/MobileDetailOverlay.tsx | 10 ++++-- 3 files changed, 26 insertions(+), 38 deletions(-) diff --git a/frontend/src/components/calendar/CalendarPage.tsx b/frontend/src/components/calendar/CalendarPage.tsx index 794c8d8..e9d490c 100644 --- a/frontend/src/components/calendar/CalendarPage.tsx +++ b/frontend/src/components/calendar/CalendarPage.tsx @@ -20,6 +20,7 @@ import { Select } from '@/components/ui/select'; import { Sheet, SheetContent, SheetClose } from '@/components/ui/sheet'; import CalendarSidebar from './CalendarSidebar'; import EventDetailPanel from './EventDetailPanel'; +import MobileDetailOverlay from '@/components/shared/MobileDetailOverlay'; import type { CreateDefaults } from './EventDetailPanel'; type CalendarView = 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; @@ -639,26 +640,18 @@ export default function CalendarPage() { {/* Mobile detail panel overlay */} {panelOpen && !isDesktop && ( -
-
e.stopPropagation()} - > - -
-
+ + + )}
); diff --git a/frontend/src/components/shared/EntityTable.tsx b/frontend/src/components/shared/EntityTable.tsx index 6204150..22f5823 100644 --- a/frontend/src/components/shared/EntityTable.tsx +++ b/frontend/src/components/shared/EntityTable.tsx @@ -145,24 +145,15 @@ export function EntityTable({
diff --git a/frontend/src/components/shared/MobileDetailOverlay.tsx b/frontend/src/components/shared/MobileDetailOverlay.tsx index 81cc7a6..93019ff 100644 --- a/frontend/src/components/shared/MobileDetailOverlay.tsx +++ b/frontend/src/components/shared/MobileDetailOverlay.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; import { cn } from '@/lib/utils'; interface MobileDetailOverlayProps { @@ -20,15 +20,19 @@ export default function MobileDetailOverlay({ children, className, }: MobileDetailOverlayProps) { + // Stable ref to avoid re-registering listener on every render + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + // Escape key handler useEffect(() => { if (!open) return; const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); + if (e.key === 'Escape') onCloseRef.current(); }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); - }, [open, onClose]); + }, [open]); // Body scroll lock useEffect(() => { From e935dc08f1799ba20d77d976a9babe53897a4411 Mon Sep 17 00:00:00 2001 From: Kyle Pope Date: Wed, 11 Mar 2026 08:06:28 +0800 Subject: [PATCH 3/5] Fix admin portal: restore desktop tab layout, mobile-only changes - Nav: justify-evenly on mobile, justify-start on desktop - Title: "Admin Portal" on desktop, "Admin" on mobile - Restore mr-6 spacing on title group for desktop - Tab labels: icon-only on mobile, icon+label on sm+ Co-Authored-By: Claude Opus 4.6 --- frontend/src/components/admin/AdminPortal.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/admin/AdminPortal.tsx b/frontend/src/components/admin/AdminPortal.tsx index 2f8e5ad..5d72911 100644 --- a/frontend/src/components/admin/AdminPortal.tsx +++ b/frontend/src/components/admin/AdminPortal.tsx @@ -19,15 +19,18 @@ export default function AdminPortal() { {/* Portal header with tab navigation */}
-
+
-

Admin

+

+ Admin Portal + Admin +

- {/* Horizontal tab navigation */} -
{/* Horizontal tab navigation — evenly spaced on mobile, left-aligned on desktop */} -
{/* Horizontal tab navigation — evenly spaced on mobile, left-aligned on desktop */} -