feat(lint): no-raw-card-shell, and the card shells it found

UI standards §1 asks every surface to compose Card; the new
healthlog/no-raw-card-shell rule refuses bg-card + border + rounded-xl
on one literal under src/components and src/app. The files where a
shell is the point (shell primitives, the hero, popovers, the chart
frame its skeleton and error state share, table frames, the sign-in and
first-run screens) are listed with their reason in eslint.config.mjs;
there is no inline escape. The shells that were drift now compose a
primitive: the notification matrix's phone cards (Card with a real
header instead of a p-4 box with a hand-rolled one), the achievements
stat tiles (TrendTile), the recent achievements card (Card, its view
all link an InlineAction), the cycle chart and insights loading
skeletons (Card).
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:37:00 +02:00
1 parent eebe4019af
commit 9d44d1c47f
9 files changed
+366 -108

No files matched your search

+8 -8
View File
@@ -6,7 +6,7 @@ import { useSearchParams } from "next/navigation";
import { Loader2, Plus, RefreshCw, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { PageHeader } from "@/components/ui/page-header";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import { QueryErrorRow } from "@/components/ui/query-error-row";
@@ -73,15 +73,15 @@ const BbtChart = dynamic(
{
ssr: false,
loading: () => (
<div className="bg-card border-border rounded-xl border py-6">
<div className="px-6">
<Card>
<CardHeader>
<Skeleton className="h-5 w-44" />
</div>
<div className="mt-6 px-6">
<Skeleton className="mb-2 h-4 w-64" />
</CardHeader>
<CardContent className="space-y-2">
<Skeleton className="h-4 w-64" />
<Skeleton className="h-[210px] w-full rounded-md" />
</div>
</div>
</CardContent>
</Card>
),
},
);
@@ -28,6 +28,8 @@ import type { AchievementProgress } from "@/lib/gamification/achievements";
import { Skeleton } from "@/components/ui/skeleton";
import { TileHeader } from "@/components/insights/tile-header";
import { EmptyState } from "@/components/ui/empty-state";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { InlineAction } from "@/components/ui/inline-action";
const iconMap: Record<string, LucideIcon> = {
Activity,
@@ -127,84 +129,84 @@ export function RecentAchievementsCard() {
}
return (
<div
data-slot="recent-achievements-card"
className="bg-card border-border space-y-3 rounded-xl border p-4 md:p-6"
>
<Card data-slot="recent-achievements-card" className="gap-3 md:gap-3">
{/* Canonical tile header (foreground h-5 icon, text-base h2) so the
dashboard card matches its sibling tiles instead of the old muted
h-4 / text-sm row. The "view all" link rides the header's right slot
(§11 inline-action-row). */}
<TileHeader
icon={Trophy}
titleAs="h2"
title={t("achievements.dashboardCard.title")}
right={
<Link
href="/achievements"
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 items-center text-xs"
>
{t("achievements.dashboardCard.viewAll")}
</Link>
}
/>
{showSkeleton ? (
<ul
data-slot="recent-achievements-skeleton"
aria-hidden="true"
className="space-y-2 motion-reduce:animate-none"
>
{Array.from({ length: RECENT_LIMIT }).map((_, idx) => (
<li
key={`achievement-skeleton-${idx}`}
className="border-border bg-background/40 flex items-center gap-3 rounded-md border p-2"
>
<Skeleton className="h-7 w-7 shrink-0" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3 w-1/2 rounded" />
<Skeleton className="h-2.5 w-3/4 rounded" />
</div>
</li>
))}
</ul>
) : recent.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("achievements.dashboardCard.empty")}
<CardHeader>
<TileHeader
icon={Trophy}
titleAs="h2"
title={t("achievements.dashboardCard.title")}
right={
<InlineAction asChild tone="muted">
<Link href="/achievements">
{t("achievements.dashboardCard.viewAll")}
</Link>
</InlineAction>
}
/>
) : (
<ul className="space-y-2">
{recent.map((achievement) => {
const Icon = iconMap[achievement.icon] ?? Star;
return (
</CardHeader>
<CardContent>
{showSkeleton ? (
<ul
data-slot="recent-achievements-skeleton"
aria-hidden="true"
className="space-y-2 motion-reduce:animate-none"
>
{Array.from({ length: RECENT_LIMIT }).map((_, idx) => (
<li
key={achievement.id}
data-slot="recent-achievement-item"
key={`achievement-skeleton-${idx}`}
className="border-border bg-background/40 flex items-center gap-3 rounded-md border p-2"
>
<div className="bg-primary/15 text-primary rounded-md p-1.5">
<Icon className="h-3.5 w-3.5" />
<Skeleton className="h-7 w-7 shrink-0" />
<div className="min-w-0 flex-1 space-y-1.5">
<Skeleton className="h-3 w-1/2 rounded" />
<Skeleton className="h-2.5 w-3/4 rounded" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{t(achievement.titleKey)}
</p>
<p className="text-muted-foreground line-clamp-1 text-xs">
{t(achievement.descriptionKey)}
</p>
</div>
{achievement.completedAt && (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDate(achievement.completedAt)}
</span>
)}
</li>
);
})}
</ul>
)}
</div>
))}
</ul>
) : recent.length === 0 ? (
<EmptyState
variant="plain"
size="compact"
title={t("achievements.dashboardCard.empty")}
/>
) : (
<ul className="space-y-2">
{recent.map((achievement) => {
const Icon = iconMap[achievement.icon] ?? Star;
return (
<li
key={achievement.id}
data-slot="recent-achievement-item"
className="border-border bg-background/40 flex items-center gap-3 rounded-md border p-2"
>
<div className="bg-primary/15 text-primary rounded-md p-1.5">
<Icon className="h-3.5 w-3.5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{t(achievement.titleKey)}
</p>
<p className="text-muted-foreground line-clamp-1 text-xs">
{t(achievement.descriptionKey)}
</p>
</div>
{achievement.completedAt && (
<span className="text-muted-foreground shrink-0 text-xs tabular-nums">
{formatDate(achievement.completedAt)}
</span>
)}
</li>
);
})}
</ul>
)}
</CardContent>
</Card>
);
}