feat(targets): mobile-first responsive grid + card reflow

Per Marc's "mobile is co-equal with desktop" directive, every
breakpoint gets a deliberate layout — no afterthought reflow:

  • default (<640px): grid-cols-1, full-width cards, gap-4. The
    per-card Coach CTA spans full width (justify-center) so the
    touch target is generous; the source link sits beneath
    right-aligned.
  • sm (640-1023px): grid-cols-2, gap-4. Card footer reverts to
    horizontal — Coach CTA left, source link right.
  • lg (1024px+): grid-cols-3, gap-6. Three cards per row matches
    the dashboard / insights rhythm and lets the user see all six
    core targets above the fold on a 1440px viewport.

Card internals also reflow:
  • Headline row: icon + label stack with status pill below on
    mobile (sm:flex-row sm:justify-between), inline on sm+.
  • Footer row: flex-col items-stretch on mobile,
    sm:flex-row sm:items-center sm:justify-between on sm+.

New `targets-responsive.test.tsx` pins the breakpoint classes so a
future refactor can't quietly collapse the grid back to v1.4.22's
sm:grid-cols-2 only layout.

Verified by inspection (Tailwind class audit). A Playwright sweep
across 375 / 768 / 1280 / 1440 viewports is queued for the W3e
verification phase.
This commit is contained in:
Marc-André Bombeck committed 2026-05-14 12:36:21 +02:00
1 parent 9acee2bd81
commit f223b4feae
2 files changed
+151 -13

No files matched your search

@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.25 W3e — mobile-first responsive layout audit.
*
* Marc directive: "mobile layout must be designed alongside desktop,
* not as an afterthought". This test pins the Tailwind responsive
* utilities so a future refactor cannot quietly collapse the grid
* back to the v1.4.22 `sm:grid-cols-2` and remove the lg three-col
* row.
*
* The three breakpoints we care about:
* • default (<640px): one column, full-width cards
* • sm (640-1023px): two columns
* • lg (1024px+): three columns, slightly wider gutters
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/targets",
}));
const sampleData = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 78,
average30: 78,
trend: "stable",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
daysInRange7d: 5,
daysLogged7d: 7,
lastMetGoalAt: null,
streakDays: 0,
insufficientData: false,
consistency7d: ["in", "in", "in", "in", "in", "in", "in"],
},
],
pageSummary: {
targetsMetThisWeek: 1,
totalTargets: 1,
streakHighlight: null,
},
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: { activeProvider: "openai" },
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import TargetsPage from "../targets/page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
describe("/targets page — mobile-first responsive grid", () => {
it("uses one column by default, two at sm, three at lg", () => {
const html = render();
expect(html).toMatch(/grid-cols-1/);
expect(html).toMatch(/sm:grid-cols-2/);
expect(html).toMatch(/lg:grid-cols-3/);
});
it("uses gap-4 at mobile/sm, gap-6 at lg", () => {
const html = render();
expect(html).toMatch(/\bgap-4\b/);
expect(html).toMatch(/lg:gap-6/);
});
it("renders the Coach CTA full-width on mobile when AI is enabled", () => {
const html = render();
// The TargetCoachButton wraps the Sparkles + "Ask Coach" label;
// its container element should carry the mobile full-width class.
expect(html).toContain("w-full");
expect(html).toContain("sm:w-auto");
});
});
+18 -13
View File
@@ -617,29 +617,34 @@ export function TargetCard({
</div>
)}
{/* Row 6: footer — Coach CTA on the left (gated), source link
on the right (when available). Margin-top:auto pins the
footer to the card bottom so the grid rows align. */}
<div className="mt-auto flex flex-wrap items-center justify-between gap-3 pt-1">
<TargetCoachButton
prefill={coachPrefill}
sources={coachSources}
onAskCoach={onAskCoach}
aiEnabled={aiEnabled}
className="-ml-2"
/>
{/* Row 6: footer. Mobile-first reflow:
- default (<640px): stack vertically; the Coach CTA
spans full width so the touch target is generous, the
source link sits beneath it right-aligned.
- sm+ (≥640px): horizontal row, Coach left + source right.
`mt-auto` pins to card bottom so grid cells align. */}
<div className="mt-auto flex flex-col items-stretch gap-2 pt-1 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
{aiEnabled && (
<TargetCoachButton
prefill={coachPrefill}
sources={coachSources}
onAskCoach={onAskCoach}
aiEnabled={aiEnabled}
className="w-full justify-center sm:-ml-2 sm:w-auto sm:justify-start"
/>
)}
{sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground ml-auto inline-flex items-center gap-1 text-xs"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 self-end text-xs sm:ml-auto sm:self-auto"
>
<span>{target.source}</span>
<ExternalLink className="size-3" aria-hidden="true" />
</a>
) : (
<span className="text-muted-foreground ml-auto text-xs">
<span className="text-muted-foreground self-end text-xs sm:ml-auto sm:self-auto">
{target.source}
</span>
)}