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

+7 -6
View File
@@ -43,6 +43,7 @@ import {
type AchievementCategory,
type AchievementProgress,
} from "@/lib/gamification/achievements";
import { TrendTile } from "@/components/ui/trend-tile";
const iconMap: Record<string, LucideIcon> = {
Activity,
@@ -351,7 +352,7 @@ export default function AchievementsPage() {
/>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
<TrendTile className="min-h-34 justify-between">
<p className="text-muted-foreground text-sm">
{t("achievements.points")}
</p>
@@ -370,8 +371,8 @@ export default function AchievementsPage() {
className="h-1.5"
aria-label={t("achievements.points")}
/>
</div>
<div className="bg-card border-border flex min-h-34 flex-col justify-between rounded-xl border p-4 md:p-6">
</TrendTile>
<TrendTile className="min-h-34 justify-between">
<p className="text-muted-foreground text-sm">
{t("achievements.unlocked")}
</p>
@@ -384,8 +385,8 @@ export default function AchievementsPage() {
<p className="text-muted-foreground text-xs">
{t("achievements.remainingUnlocks", { count: remainingUnlocks })}
</p>
</div>
<div className="bg-card border-border min-h-34 rounded-xl border p-4 sm:col-span-2 md:p-6 lg:col-span-1">
</TrendTile>
<TrendTile className="min-h-34 sm:col-span-2 lg:col-span-1">
<p className="text-muted-foreground text-sm">
{t("achievements.nextGoal")}
</p>
@@ -428,7 +429,7 @@ export default function AchievementsPage() {
className="mt-3 h-1.5"
aria-label={t("achievements.nextProgressLabel")}
/>
</div>
</TrendTile>
</div>
{grouped.length === 0 ? (
+18 -13
View File
@@ -9,6 +9,7 @@
* fetch; Next.js renders it instantly while the segment streams.
*/
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
export default function InsightsLoading() {
return (
<div data-slot="insights-subpage-loading" className="space-y-6">
@@ -18,21 +19,25 @@ export default function InsightsLoading() {
</header>
{/* Chart card */}
<div className="bg-card rounded-xl border p-4">
<Skeleton className="bg-muted mb-4 h-4 w-32 rounded" />
<Skeleton className="h-56 w-full rounded-lg" />
</div>
<Card>
<CardContent className="space-y-4">
<Skeleton className="bg-muted h-4 w-32 rounded" />
<Skeleton className="h-56 w-full rounded-lg" />
</CardContent>
</Card>
{/* Assessment card */}
<div className="bg-card space-y-2 rounded-xl border p-4">
<div className="flex items-center gap-2">
<Skeleton className="bg-muted h-5 w-5 rounded" />
<Skeleton className="bg-muted h-4 w-40 rounded" />
</div>
<Skeleton className="bg-muted h-3.5 w-full rounded" />
<Skeleton className="bg-muted h-3.5 w-11/12 rounded" />
<Skeleton className="bg-muted h-3.5 w-9/12 rounded" />
</div>
<Card>
<CardContent className="space-y-2">
<div className="flex items-center gap-2">
<Skeleton className="bg-muted h-5 w-5 rounded" />
<Skeleton className="bg-muted h-4 w-40 rounded" />
</div>
<Skeleton className="bg-muted h-3.5 w-full rounded" />
<Skeleton className="bg-muted h-3.5 w-11/12 rounded" />
<Skeleton className="bg-muted h-3.5 w-9/12 rounded" />
</CardContent>
</Card>
</div>
);
}
+18 -12
View File
@@ -22,6 +22,13 @@ import {
} from "@/lib/notifications/event-labels";
import { queryKeys } from "@/lib/query-keys";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
interface ChannelInfo {
id: string;
@@ -309,17 +316,16 @@ export default function NotificationsPage() {
const nameKey = eventTranslationKey(eventType);
const descKey = eventDescriptionTranslationKey(eventType);
return (
<div
key={eventType}
className="bg-card border-border rounded-xl border p-4"
>
<div className="mb-3">
<div className="text-sm font-medium">{t(nameKey)}</div>
<div className="text-muted-foreground text-xs">
<Card key={eventType} className="gap-3">
<CardHeader className="gap-1">
<CardTitle className="text-sm font-medium">
{t(nameKey)}
</CardTitle>
<CardDescription className="text-xs">
{t(descKey)}
</div>
</div>
<div className="space-y-2">
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{activeChannels.map((ch) => {
const enabled = isEnabled(ch.id, eventType);
const disabled = !ch.enabled;
@@ -355,8 +361,8 @@ export default function NotificationsPage() {
</div>
);
})}
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
+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>
);
}