Mobile UI polish: global font scaling, tighter dashboard, cleaner calendar

Scale down all content text on mobile via .mobile-scale CSS class (excludes
navbar/UMBRA title). Hide calendar event times in month view (Google Calendar
style). Restructure CategoryFilterBar so categories display on a separate row
when toggled instead of being hidden behind the search bar. Reduce dashboard
widget density with hidden badges and tighter spacing on small screens.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-11 01:56:53 +08:00
parent ec8f5a9b4e
commit 023fa86b65
8 changed files with 168 additions and 135 deletions

View File

@ -110,7 +110,7 @@ export default function DashboardPage() {
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header — greeting + date + quick add */} {/* Header — greeting + date + quick add */}
<div className="px-4 md:px-6 pt-6 pb-2 flex items-center justify-between"> <div className="px-4 md:px-6 pt-4 sm:pt-6 pb-1 sm:pb-2 flex items-center justify-between">
<div> <div>
<h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in"> <h1 className="font-heading text-3xl font-bold tracking-tight animate-fade-in">
{getGreeting(settings?.preferred_name || undefined)} {getGreeting(settings?.preferred_name || undefined)}
@ -157,7 +157,7 @@ export default function DashboardPage() {
</div> </div>
<div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6"> <div className="flex-1 overflow-y-auto px-4 md:px-6 pb-6">
<div className="space-y-5"> <div className="space-y-3 sm:space-y-5">
{/* Week Timeline */} {/* Week Timeline */}
{upcomingData && ( {upcomingData && (
<div className="animate-slide-up"> <div className="animate-slide-up">
@ -187,7 +187,7 @@ export default function DashboardPage() {
<AlertBanner alerts={alerts} onDismiss={dismissAlert} onSnooze={snoozeAlert} /> <AlertBanner alerts={alerts} onDismiss={dismissAlert} onSnooze={snoozeAlert} />
{/* Main Content — 2 columns */} {/* Main Content — 2 columns */}
<div className="grid gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}> <div className="grid gap-3 sm:gap-5 lg:grid-cols-5 animate-slide-up" style={{ animationDelay: '100ms', animationFillMode: 'backwards' }}>
{/* Left: Upcoming feed (wider) */} {/* Left: Upcoming feed (wider) */}
<div className="lg:col-span-3 flex flex-col"> <div className="lg:col-span-3 flex flex-col">
{upcomingData && upcomingData.items.length > 0 ? ( {upcomingData && upcomingData.items.length > 0 ? (
@ -207,7 +207,7 @@ export default function DashboardPage() {
</div> </div>
{/* Right: Countdown + Today's events + todos stacked */} {/* Right: Countdown + Today's events + todos stacked */}
<div className="lg:col-span-2 flex flex-col gap-5"> <div className="lg:col-span-2 flex flex-col gap-3 sm:gap-5">
{data.starred_events.length > 0 && ( {data.starred_events.length > 0 && (
<CountdownWidget events={data.starred_events} /> <CountdownWidget events={data.starred_events} />
)} )}

View File

@ -42,7 +42,7 @@ export default function StatsWidget({ projectStats, totalIncompleteTodos, weathe
]; ];
return ( return (
<div className="grid gap-2.5 grid-cols-2 lg:grid-cols-4"> <div className="grid gap-1.5 sm:gap-2.5 grid-cols-2 sm:grid-cols-4">
{statCards.map((stat) => ( {statCards.map((stat) => (
<Card <Card
key={stat.label} key={stat.label}

View File

@ -86,7 +86,7 @@ export default function TrackedProjectsWidget() {
<span className="text-[11px] text-muted-foreground truncate block">{task.parent_task_title}</span> <span className="text-[11px] text-muted-foreground truncate block">{task.parent_task_title}</span>
)} )}
</div> </div>
<span className="text-[11px] text-muted-foreground truncate shrink-0 max-w-[5rem]">{task.project_name}</span> <span className="text-[11px] text-muted-foreground truncate shrink-0 max-w-[5rem] hidden sm:block">{task.project_name}</span>
<span className={cn( <span className={cn(
'text-xs shrink-0 whitespace-nowrap tabular-nums', 'text-xs shrink-0 whitespace-nowrap tabular-nums',
overdue ? 'text-red-400' : isToday(dueDate) ? 'text-accent' : 'text-muted-foreground' overdue ? 'text-red-400' : isToday(dueDate) ? 'text-accent' : 'text-muted-foreground'
@ -94,7 +94,7 @@ export default function TrackedProjectsWidget() {
{isToday(dueDate) ? 'Today' : format(dueDate, 'MMM d')} {isToday(dueDate) ? 'Today' : format(dueDate, 'MMM d')}
</span> </span>
<span className={cn( <span className={cn(
'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0', 'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0 hidden sm:block',
statusBadgeColors[task.status] || 'bg-gray-500/10 text-gray-400' statusBadgeColors[task.status] || 'bg-gray-500/10 text-gray-400'
)}> )}>
{statusLabels[task.status] || task.status} {statusLabels[task.status] || task.status}

View File

@ -75,11 +75,11 @@ export default function UpcomingWidget({ items, days = 7 }: UpcomingWidgetProps)
? format(new Date(item.datetime), 'MMM d, h:mm a') ? format(new Date(item.datetime), 'MMM d, h:mm a')
: format(new Date(item.date), 'MMM d')} : format(new Date(item.date), 'MMM d')}
</span> </span>
<span className={cn('text-[9px] font-semibold uppercase tracking-wider shrink-0 w-14 text-right', config.color)}> <span className={cn('text-[9px] font-semibold uppercase tracking-wider shrink-0 w-14 text-right hidden sm:block', config.color)}>
{config.label} {config.label}
</span> </span>
<span className={cn( <span className={cn(
'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0 w-14 text-center', 'text-[9px] font-semibold px-1.5 py-0.5 rounded shrink-0 w-14 text-center hidden sm:block',
item.priority === 'high' ? 'bg-red-500/10 text-red-400' : item.priority === 'high' ? 'bg-red-500/10 text-red-400' :
item.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400' : item.priority === 'medium' ? 'bg-yellow-500/10 text-yellow-400' :
item.priority === 'low' ? 'bg-green-500/10 text-green-400' : item.priority === 'low' ? 'bg-green-500/10 text-green-400' :

View File

@ -39,13 +39,13 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
}, [weekStart, today, items]); }, [weekStart, today, items]);
return ( return (
<div className="flex items-stretch gap-2"> <div className="flex items-stretch gap-1 sm:gap-2">
{days.map((day) => ( {days.map((day) => (
<div <div
key={day.key} key={day.key}
onClick={() => navigate('/calendar', { state: { date: day.key, view: 'timeGridDay' } })} onClick={() => navigate('/calendar', { state: { date: day.key, view: 'timeGridDay' } })}
className={cn( className={cn(
'flex-1 flex flex-col items-center gap-1.5 rounded-lg py-3 px-2 transition-all duration-200 border cursor-pointer', 'flex-1 flex flex-col items-center gap-1 sm:gap-1.5 rounded-lg py-2 sm:py-3 px-1 sm:px-2 transition-all duration-200 border cursor-pointer',
day.isToday day.isToday
? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]' ? 'bg-accent/10 border-accent/30 shadow-[0_0_12px_hsl(var(--accent-color)/0.15)]'
: day.isPast : day.isPast
@ -55,7 +55,7 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
> >
<span <span
className={cn( className={cn(
'text-[11px] font-medium uppercase tracking-wider', 'text-[9px] sm:text-[11px] font-medium uppercase tracking-wider',
day.isToday ? 'text-accent' : 'text-muted-foreground' day.isToday ? 'text-accent' : 'text-muted-foreground'
)} )}
> >
@ -63,7 +63,7 @@ export default function WeekTimeline({ items }: WeekTimelineProps) {
</span> </span>
<span <span
className={cn( className={cn(
'font-heading text-lg font-semibold leading-none', 'font-heading text-sm sm:text-lg font-semibold leading-none',
day.isToday ? 'text-accent' : 'text-foreground' day.isToday ? 'text-accent' : 'text-foreground'
)} )}
> >

View File

@ -41,7 +41,7 @@ export default function AppLayout() {
</Button> </Button>
<h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1> <h1 className="text-lg font-bold text-accent ml-3">UMBRA</h1>
</div> </div>
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto mobile-scale">
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View File

@ -146,56 +146,57 @@ export default function CategoryFilterBar({
}; };
return ( return (
<div className="flex items-center gap-2 overflow-x-auto"> <div className="flex flex-col gap-2 md:flex-row md:items-center md:gap-2">
{/* All pill */} {/* Top row: pills + search */}
<button <div className="flex items-center gap-2 overflow-x-auto min-w-0 flex-1">
type="button" {/* All pill */}
onClick={onToggleAll}
aria-label="Show all"
className={pillBase}
style={isAllActive ? activePillStyle : undefined}
>
<span
className={
isAllActive ? '' : 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}
>
All
</span>
</button>
{/* Pinned pill */}
<button
type="button"
onClick={onTogglePinned}
aria-label={`Toggle ${pinnedLabel}`}
className={pillBase}
style={showPinned ? activePillStyle : undefined}
>
<span className={showPinned ? '' : 'text-muted-foreground hover:text-foreground'}>
{pinnedLabel}
</span>
</button>
{/* Extra pinned filters (e.g. "Umbral") */}
{extraPinnedFilters.map((epf) => (
<button <button
key={epf.label}
type="button" type="button"
onClick={epf.onToggle} onClick={onToggleAll}
aria-label={`Filter by ${epf.label}`} aria-label="Show all"
className={pillBase} className={pillBase}
style={epf.isActive ? activePillStyle : undefined} style={isAllActive ? activePillStyle : undefined}
> >
<span className={epf.isActive ? '' : 'text-muted-foreground hover:text-foreground'}> <span
{epf.label} className={
isAllActive ? '' : 'text-muted-foreground hover:text-foreground hover:bg-card-elevated'
}
>
All
</span> </span>
</button> </button>
))}
{/* Categories pill + expandable chips */} {/* Pinned pill */}
{categories.length > 0 && ( <button
<> type="button"
onClick={onTogglePinned}
aria-label={`Toggle ${pinnedLabel}`}
className={pillBase}
style={showPinned ? activePillStyle : undefined}
>
<span className={showPinned ? '' : 'text-muted-foreground hover:text-foreground'}>
{pinnedLabel}
</span>
</button>
{/* Extra pinned filters (e.g. "Umbral") */}
{extraPinnedFilters.map((epf) => (
<button
key={epf.label}
type="button"
onClick={epf.onToggle}
aria-label={`Filter by ${epf.label}`}
className={pillBase}
style={epf.isActive ? activePillStyle : undefined}
>
<span className={epf.isActive ? '' : 'text-muted-foreground hover:text-foreground'}>
{epf.label}
</span>
</button>
))}
{/* Categories pill */}
{categories.length > 0 && (
<button <button
type="button" type="button"
onClick={() => setOtherOpen((p) => !p)} onClick={() => setOtherOpen((p) => !p)}
@ -207,78 +208,72 @@ export default function CategoryFilterBar({
Categories Categories
</span> </span>
</button> </button>
)}
<div {/* Search */}
className="flex items-center gap-1.5 overflow-x-auto transition-all duration-200 ease-out" <div className="flex-1" />
style={{ <div className="relative shrink-0">
maxWidth: otherOpen ? '100vw' : '0px', <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
opacity: otherOpen ? 1 : 0, <Input
overflow: 'hidden', ref={searchInputRef}
}} type="search"
> placeholder="Search..."
{/* "All" chip inside categories — non-draggable */} value={searchValue}
{onSelectAllCategories && ( onChange={(e) => onSearchChange(e.target.value)}
<button className="w-28 sm:w-52 h-8 pl-8 text-sm ring-inset"
type="button" aria-label="Search"
onClick={onSelectAllCategories} />
aria-label="Select all categories" </div>
aria-pressed={allCategoriesSelected}
className="px-2 py-1 text-xs font-medium rounded transition-colors duration-150 whitespace-nowrap shrink-0"
style={allCategoriesSelected ? activePillStyle : undefined}
>
<span
className={
allCategoriesSelected
? ''
: 'text-muted-foreground hover:text-foreground'
}
>
All
</span>
</button>
)}
{/* Draggable category chips */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={categories}
strategy={horizontalListSortingStrategy}
>
{categories.map((cat) => (
<SortableCategoryChip
key={cat}
id={cat}
isActive={activeFilters.includes(cat)}
onToggle={() => onToggleCategory(cat)}
/>
))}
</SortableContext>
</DndContext>
</div>
</>
)}
{/* Spacer */}
<div className="flex-1" />
{/* Search */}
<div className="relative shrink-0">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
ref={searchInputRef}
type="search"
placeholder="Search..."
value={searchValue}
onChange={(e) => onSearchChange(e.target.value)}
className="w-52 h-8 pl-8 text-sm ring-inset"
aria-label="Search"
/>
</div> </div>
{/* Expanded categories row — shows below on mobile, inline on desktop */}
{categories.length > 0 && otherOpen && (
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 md:pb-0">
{/* "All" chip inside categories — non-draggable */}
{onSelectAllCategories && (
<button
type="button"
onClick={onSelectAllCategories}
aria-label="Select all categories"
aria-pressed={allCategoriesSelected}
className="px-2 py-1 text-xs font-medium rounded transition-colors duration-150 whitespace-nowrap shrink-0"
style={allCategoriesSelected ? activePillStyle : undefined}
>
<span
className={
allCategoriesSelected
? ''
: 'text-muted-foreground hover:text-foreground'
}
>
All
</span>
</button>
)}
{/* Draggable category chips */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToHorizontalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
items={categories}
strategy={horizontalListSortingStrategy}
>
{categories.map((cat) => (
<SortableCategoryChip
key={cat}
id={cat}
isActive={activeFilters.includes(cat)}
onToggle={() => onToggleCategory(cat)}
/>
))}
</SortableContext>
</DndContext>
</div>
)}
</div> </div>
); );
} }

View File

@ -224,16 +224,54 @@ form[data-submitted] input:invalid + button {
} }
/* ── 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. */
@media (max-width: 767px) {
.mobile-scale {
font-size: 0.8125rem; /* 13px base instead of 16px */
}
.mobile-scale h1 {
font-size: 1.375rem !important; /* 22px instead of 30px */
}
.mobile-scale h2 {
font-size: 1.125rem !important;
}
.mobile-scale .text-sm {
font-size: 0.6875rem; /* 11px */
}
.mobile-scale .text-xs {
font-size: 0.625rem; /* 10px — floor for readability */
}
.mobile-scale .text-lg {
font-size: 0.9375rem; /* 15px */
}
.mobile-scale .text-xl {
font-size: 1.0625rem; /* 17px */
}
.mobile-scale .text-2xl {
font-size: 1.1875rem; /* 19px */
}
.mobile-scale .text-3xl {
font-size: 1.375rem; /* 22px */
}
}
/* ── FullCalendar mobile overrides ── */ /* ── FullCalendar mobile overrides ── */
@media (max-width: 767px) { @media (max-width: 767px) {
.fc .fc-daygrid-event { .fc .fc-daygrid-event {
font-size: 0.65rem; font-size: 0.6rem;
padding: 0 2px; padding: 0 2px;
line-height: 1.3; line-height: 1.25;
}
/* Hide event times in month view on mobile — Google Calendar style */
.fc .fc-daygrid-event .fc-event-time {
display: none;
} }
.fc .fc-timegrid-event { .fc .fc-timegrid-event {
font-size: 0.65rem; font-size: 0.6rem;
} }
.fc .fc-timegrid-event .fc-event-main { .fc .fc-timegrid-event .fc-event-main {
@ -241,21 +279,21 @@ form[data-submitted] input:invalid + button {
} }
.fc .fc-daygrid-day-number { .fc .fc-daygrid-day-number {
font-size: 0.75rem; font-size: 0.7rem;
padding: 2px 4px; padding: 2px 4px;
} }
.fc .fc-col-header-cell-cushion { .fc .fc-col-header-cell-cushion {
font-size: 0.7rem; font-size: 0.65rem;
padding: 4px 2px; padding: 3px 2px;
} }
.fc .fc-timegrid-slot-label { .fc .fc-timegrid-slot-label {
font-size: 0.65rem; font-size: 0.6rem;
} }
.fc .fc-daygrid-more-link { .fc .fc-daygrid-more-link {
font-size: 0.6rem; font-size: 0.55rem;
} }
} }
/* ── Ambient background animations ── */ /* ── Ambient background animations ── */