Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,34 @@
body {
@apply bg-background text-foreground;
font-family: var(--font-sans), Arial, Helvetica, sans-serif;
-webkit-tap-highlight-color: transparent;
overscroll-behavior: none;
}

/* Eliminate 300ms tap delay on all interactive elements */
a, button, [role="button"], input, select, textarea, label, summary {
touch-action: manipulation;
}

/* Respect user's motion preferences */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

/* Smooth scrolling for in-app navigation */
html {
scroll-behavior: smooth;
}

@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
}
}

Expand Down
7 changes: 6 additions & 1 deletion apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,18 @@ const geistMono = Geist_Mono({
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
viewportFit: "cover",
themeColor: "#000000",
};

export const metadata: Metadata = {
title: "March Fitness - Challenge Yourself",
description: "Join fitness challenges, track your progress, and compete with friends",
appleWebApp: {
capable: true,
statusBarStyle: "black-translucent",
title: "March Fitness",
},
};

export default async function RootLayout({
Expand Down
21 changes: 21 additions & 0 deletions apps/web/app/manifest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
return {
name: "March Fitness",
short_name: "March Fit",
description:
"Join fitness challenges, track your progress, and compete with friends",
start_url: "/challenges",
display: "standalone",
background_color: "#000000",
theme_color: "#000000",
icons: [
{
src: "/favicon.ico",
sizes: "48x48",
type: "image/x-icon",
},
],
};
}
14 changes: 9 additions & 5 deletions apps/web/components/dashboard/activity-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -536,7 +536,7 @@ export function ActivityFeed({
<button
onClick={() => setFeedFilter("for_you")}
className={cn(
"relative flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50",
"relative min-h-[44px] flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50 active:bg-zinc-800/50",
feedFilter === "for_you" ? "text-white" : "text-zinc-500",
)}
>
Expand All @@ -548,7 +548,7 @@ export function ActivityFeed({
<button
onClick={() => setFeedFilter("all")}
className={cn(
"relative flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50",
"relative min-h-[44px] flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50 active:bg-zinc-800/50",
feedFilter === "all" ? "text-white" : "text-zinc-500",
)}
>
Expand All @@ -560,7 +560,7 @@ export function ActivityFeed({
<button
onClick={() => setFeedFilter("following")}
className={cn(
"relative flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50",
"relative min-h-[44px] flex-1 py-4 text-center text-sm font-medium transition-colors hover:bg-zinc-900/50 active:bg-zinc-800/50",
feedFilter === "following" ? "text-white" : "text-zinc-500",
)}
>
Expand Down Expand Up @@ -1113,14 +1113,18 @@ const ActivityCard = memo(function ActivityCard({
) : null;

return (
<div className="cursor-pointer" onClick={handleCardClick}>
<article
className="cursor-pointer transition-colors active:bg-zinc-900/50"
style={{ contentVisibility: "auto", containIntrinsicSize: "auto 200px" }}
onClick={handleCardClick}
>
<div className="px-4 pt-3 pb-1" onClick={(e) => e.stopPropagation()}>{headerContent}</div>
<div className="space-y-2 px-4">{bodyContent}</div>
{likesDisplay && <div className="px-4 pt-2">{likesDisplay}</div>}
<div className="px-4 py-2">{actionBar}</div>
<div className="px-4 pb-3">{commentsSection}</div>
<div className="border-b border-zinc-800" />
</div>
</article>
);
});

Expand Down
10 changes: 5 additions & 5 deletions apps/web/components/dashboard/mobile-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function MobileNav({ challengeId, currentUserId, challengeStartDate }: Mo
className="fixed inset-x-0 bottom-0 z-50 border-t border-white/10 bg-zinc-950 lg:hidden"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="grid grid-cols-5 items-center py-2">
<div className="grid grid-cols-5 items-center">
{leftItems.map((item) => {
const href = item.href(challengeId, currentUserId);
const isActive = pathname === href ||
Expand All @@ -44,7 +44,7 @@ export function MobileNav({ challengeId, currentUserId, challengeStartDate }: Mo
key={item.label}
href={href}
className={cn(
"relative flex flex-col items-center gap-1 py-2 transition-colors",
"relative flex flex-col items-center gap-1 py-3 transition-colors active:opacity-70",
isActive
? "text-white"
: "text-zinc-500 hover:text-zinc-300"
Expand All @@ -67,7 +67,7 @@ export function MobileNav({ challengeId, currentUserId, challengeStartDate }: Mo
challengeId={challengeId}
challengeStartDate={challengeStartDate}
trigger={
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-white/15 bg-transparent text-zinc-100 transition hover:bg-white/10 hover:text-white">
<button className="flex h-12 w-12 items-center justify-center rounded-full border border-white/15 bg-transparent text-zinc-100 transition hover:bg-white/10 hover:text-white active:scale-95">
<Plus className="h-6 w-6" />
</button>
}
Expand All @@ -83,7 +83,7 @@ export function MobileNav({ challengeId, currentUserId, challengeStartDate }: Mo
key={item.label}
href={href}
className={cn(
"flex flex-col items-center gap-1 py-2 transition-colors",
"flex flex-col items-center gap-1 py-3 transition-colors active:opacity-70",
isActive
? "text-white"
: "text-zinc-500 hover:text-zinc-300"
Expand All @@ -99,7 +99,7 @@ export function MobileNav({ challengeId, currentUserId, challengeStartDate }: Mo
<DropdownMenuTrigger asChild>
<button
className={cn(
"flex flex-col items-center gap-1 py-2 transition-colors w-full",
"flex flex-col items-center gap-1 py-3 transition-colors w-full active:opacity-70",
menuActive ? "text-white" : "text-zinc-500 hover:text-zinc-300"
)}
aria-label="More navigation"
Expand Down
21 changes: 19 additions & 2 deletions apps/web/hooks/use-media-query.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { useEffect, useState } from "react";
import { useEffect, useState, useSyncExternalStore } from "react";

export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
Expand All @@ -26,6 +26,23 @@ export function useMediaQuery(query: string): boolean {
return matches;
}

/**
* Returns `true` on mobile viewports (<=767px).
*
* Uses `useSyncExternalStore` with a server snapshot of `false` so that the
* first client render matches the server (no hydration mismatch), and then
* immediately syncs to the real value on the client. Components that need to
* avoid a layout flash should hide mobile-only UI with CSS (`lg:hidden`)
* rather than conditionally rendering based on this hook.
*/
export function useIsMobile(): boolean {
return useMediaQuery("(max-width: 767px)");
return useSyncExternalStore(
(callback) => {
const mql = window.matchMedia("(max-width: 767px)");
mql.addEventListener("change", callback);
return () => mql.removeEventListener("change", callback);
},
() => window.matchMedia("(max-width: 767px)").matches,
() => false, // server snapshot
);
}
29 changes: 29 additions & 0 deletions tasks/web-best-practices-polish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Web Best Practices Polish
**Date:** 2026-03-07

Mobile-first polish pass covering browser compatibility, usability, UX, and design.

## Priority 1: Mobile Interaction Quality
- [x] Add `-webkit-tap-highlight-color: transparent` to suppress blue flash on tap (iOS/Android)
- [x] Add `touch-action: manipulation` on interactive elements to eliminate 300ms tap delay
- [x] Add `overscroll-behavior: none` on app shell to prevent pull-to-refresh interference
- [x] Increase mobile nav touch targets to meet 44px minimum guideline

## Priority 2: Accessibility & Viewport
- [x] Remove `maximumScale: 1` from viewport config (blocks pinch-to-zoom, WCAG violation)
- [x] Add `prefers-reduced-motion` media query to disable/reduce animations for motion-sensitive users
- [x] Add `theme-color` meta tag for mobile browser chrome coloring

## Priority 3: PWA & Home Screen
- [x] Add web app manifest (`manifest.ts`) for Add to Home Screen support
- [x] Add apple-touch-icon and PWA icon references
- [x] Set `apple-mobile-web-app-capable` and status bar style

## Priority 4: Performance & Hydration
- [x] Fix `useIsMobile` SSR flash (initializes `false`, then flips to `true` on mobile causing layout shift)
- [x] Add CSS `content-visibility: auto` on feed cards for render performance

## Priority 5: Feed & Card Usability
- [x] Make activity card click target use `<article>` for semantics
- [x] Add active/pressed state visual feedback on tappable cards
- [x] Smooth scroll behavior for in-app navigation
Loading