Fix QA findings: dual panel mount, touch-action, font floor, a11y

- Replace CSS-only panel hiding with isDesktop media query guard
  in Todos, Reminders, People, Locations, ProjectDetail (W-01)
- Add touch-action: manipulation for mobile interactive elements (W-04)
- Bump FullCalendar more-link from 0.55rem to 0.625rem (W-07)
- Add aria-label on admin portal tab NavLinks (S-05)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-11 03:14:38 +08:00
parent 98ad83ae5f
commit 89f72895c1
7 changed files with 86 additions and 65 deletions

View File

@ -35,6 +35,7 @@ export default function AdminPortal() {
key={path}
to={path}
title={label}
aria-label={label}
className={cn(
'flex items-center justify-center gap-1.5 px-2.5 md:px-4 h-full text-sm font-medium transition-colors duration-150 border-b-2 -mb-px whitespace-nowrap',
isActive

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { useMediaQuery } 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';
@ -19,6 +20,7 @@ import LocationForm from './LocationForm';
export default function LocationsPage() {
const queryClient = useQueryClient();
const isDesktop = useMediaQuery('(min-width: 1024px)');
const [selectedLocationId, setSelectedLocationId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false);
@ -373,20 +375,20 @@ export default function LocationsPage() {
</div>
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
{renderPanel()}
</div>
{panelOpen && isDesktop && (
<div
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
>
{renderPanel()}
</div>
)}
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && selectedLocation && (
{panelOpen && selectedLocation && !isDesktop && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setSelectedLocationId(null)}
>
<div

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useRef, useEffect } from 'react';
import { useMediaQuery } 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';
@ -214,6 +215,7 @@ const panelFields: PanelField[] = [
export default function PeoplePage() {
const queryClient = useQueryClient();
const tableContainerRef = useRef<HTMLDivElement>(null);
const isDesktop = useMediaQuery('(min-width: 1024px)');
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null);
const [showForm, setShowForm] = useState(false);
@ -762,20 +764,20 @@ export default function PeoplePage() {
</div>
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
{renderPanel()}
</div>
{panelOpen && isDesktop && (
<div
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
>
{renderPanel()}
</div>
)}
</div>
</div>
{/* Mobile detail panel overlay */}
{panelOpen && selectedPerson && (
{panelOpen && selectedPerson && !isDesktop && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={() => setSelectedPersonId(null)}
>
<div

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useCallback } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
@ -257,6 +258,8 @@ export default function ProjectDetail() {
}
}, [topLevelTasks, sortMode, sortSubtasks]);
const isDesktop = useMediaQuery('(min-width: 1024px)');
const selectedTask = useMemo(() => {
if (!selectedTaskId) return null;
// Search top-level and subtasks
@ -628,29 +631,29 @@ export default function ProjectDetail() {
</div>
</div>
{/* Right panel: task detail (hidden on small screens) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card ${
selectedTaskId ? 'hidden lg:flex lg:w-[45%]' : 'w-0 opacity-0 border-l-0'
}`}
>
<div className="flex-1 overflow-hidden min-w-[360px]">
<TaskDetailPanel
task={selectedTask}
projectId={parseInt(id!)}
onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)}
onSelectTask={setSelectedTaskId}
/>
{/* Right panel: task detail (desktop only) */}
{selectedTaskId && isDesktop && (
<div
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] border-l border-border bg-card flex w-[45%]"
>
<div className="flex-1 overflow-hidden min-w-[360px]">
<TaskDetailPanel
task={selectedTask}
projectId={parseInt(id!)}
onDelete={handleDeleteTask}
onAddSubtask={(parentId) => openTaskForm(null, parentId)}
onClose={() => setSelectedTaskId(null)}
onSelectTask={setSelectedTaskId}
/>
</div>
</div>
</div>
)}
</div>
</div>
{/* Mobile: show detail panel as overlay when task selected on small screens */}
{selectedTaskId && selectedTask && (
<div className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
{selectedTaskId && selectedTask && !isDesktop && (
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm">
<div className="fixed inset-y-0 right-0 w-full sm:w-[400px] bg-card border-l border-border shadow-lg">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-medium text-muted-foreground">Task Details</span>

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom';
import { Plus, Bell, BellOff, AlertCircle, Search } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@ -24,6 +25,8 @@ type StatusFilter = (typeof statusFilters)[number]['value'];
export default function RemindersPage() {
const location = useLocation();
const isDesktop = useMediaQuery('(min-width: 1024px)');
// Panel state
const [selectedReminderId, setSelectedReminderId] = useState<number | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
@ -216,24 +219,24 @@ export default function RemindersPage() {
</div>
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<ReminderDetailPanel
reminder={panelMode === 'view' ? selectedReminder : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
{panelOpen && isDesktop && (
<div
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
>
<ReminderDetailPanel
reminder={panelMode === 'view' ? selectedReminder : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
)}
</div>
{/* Mobile detail panel overlay */}
{panelOpen && (
{panelOpen && !isDesktop && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div

View File

@ -1,4 +1,5 @@
import { useState, useMemo, useEffect } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useLocation } from 'react-router-dom';
import { Plus, CheckSquare, CheckCircle2, AlertCircle } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
@ -25,6 +26,8 @@ const priorityFilters = [
export default function TodosPage() {
const location = useLocation();
const isDesktop = useMediaQuery('(min-width: 1024px)');
// Panel state
const [selectedTodoId, setSelectedTodoId] = useState<number | null>(null);
const [panelMode, setPanelMode] = useState<'closed' | 'view' | 'create'>('closed');
@ -251,24 +254,24 @@ export default function TodosPage() {
</div>
{/* Detail panel (desktop) */}
<div
className={`overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] ${
panelOpen ? 'hidden lg:block lg:w-[45%]' : 'w-0 opacity-0'
}`}
>
<TodoDetailPanel
todo={panelMode === 'view' ? selectedTodo : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
{panelOpen && isDesktop && (
<div
className="overflow-hidden transition-all duration-300 ease-[cubic-bezier(0.16,1,0.3,1)] w-[45%]"
>
<TodoDetailPanel
todo={panelMode === 'view' ? selectedTodo : null}
isCreating={panelMode === 'create'}
onClose={handlePanelClose}
onSaved={panelMode === 'create' ? handlePanelClose : undefined}
/>
</div>
)}
</div>
{/* Mobile detail panel overlay */}
{panelOpen && (
{panelOpen && !isDesktop && (
<div
className="lg:hidden fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm"
onClick={handlePanelClose}
>
<div

View File

@ -325,6 +325,13 @@ form[data-submitted] input:invalid + button {
}
}
/* ── Mobile touch optimisation ── */
@media (max-width: 767px) {
button, a, [role="button"], input, select, textarea {
touch-action: manipulation;
}
}
/* ── FullCalendar mobile overrides ── */
@media (max-width: 767px) {
.fc .fc-daygrid-event {
@ -361,7 +368,7 @@ form[data-submitted] input:invalid + button {
}
.fc .fc-daygrid-more-link {
font-size: 0.55rem;
font-size: 0.625rem;
}
}
/* ── Ambient background animations ── */