mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +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
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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