mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
eebe4019af
commit
9d44d1c47f
9 files changed
+366
-108
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user