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

@@ -0,0 +1,86 @@
/**
* Unit tests for the `healthlog/no-raw-card-shell` rule — a card surface is a
* <Card> or a shell primitive. Covers the three-part match, the translucent
* and inner-tile radii that stay legal, cn()/template strings, the allow
* option, and the scope gating.
*/
import { describe, it } from "vitest";
import { RuleTester } from "eslint";
import rule from "../no-raw-card-shell.js";
RuleTester.describe = describe;
RuleTester.it = it;
RuleTester.itOnly = it.only;
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2023,
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const APP_FILE = "/repo/src/components/insights/example.tsx";
ruleTester.run("no-raw-card-shell", rule, {
valid: [
{ code: '<Card className="gap-3" />', filename: APP_FILE },
// An inner tile: another radius.
{
code: '<div className="bg-card border-border rounded-lg border p-3" />',
filename: APP_FILE,
},
// The translucent hero glass and inner washes are not the card colour.
{
code: '<div className="bg-card/65 rounded-xl border p-4 backdrop-blur-sm" />',
filename: APP_FILE,
},
// No border: not a shell.
{ code: '<div className="bg-card rounded-xl p-4" />', filename: APP_FILE },
// The primitives under src/components/ui own the shell.
{
code: '<div className="bg-card border rounded-xl p-4" />',
filename: "/repo/src/components/ui/trend-tile.tsx",
},
// An allowed file.
{
code: '<div className="bg-card border rounded-xl p-4" />',
filename: "/repo/src/components/charts/health-chart.tsx",
options: [{ allow: ["src/components/charts/health-chart.tsx"] }],
},
// Outside the UI roots and in tests.
{
code: 'const c = "bg-card border rounded-xl";',
filename: "/repo/src/lib/x.ts",
},
{
code: '<div className="bg-card border rounded-xl" />',
filename: "/repo/src/components/__tests__/x.test.tsx",
},
],
invalid: [
{
code: '<div className="bg-card border-border rounded-xl border p-4 md:p-6" />',
filename: APP_FILE,
errors: [{ messageId: "rawShell" }],
},
{
code: 'cn("bg-card hover:bg-accent/40 block rounded-xl border p-4", className)',
filename: "/repo/src/app/insights/pulse/page.tsx",
errors: [{ messageId: "rawShell" }],
},
{
code: "const c = `bg-card rounded-xl border-dashed ${x}`;",
filename: APP_FILE,
errors: [{ messageId: "rawShell" }],
},
// An allow entry for another file does not cover this one.
{
code: '<div className="bg-card border rounded-xl" />',
filename: APP_FILE,
options: [{ allow: ["src/components/charts/health-chart.tsx"] }],
errors: [{ messageId: "rawShell" }],
},
],
});
+4
View File
@@ -19,6 +19,8 @@
* - `no-unlocalised-decimal` — a displayed number goes through the app's
* formatters, not toFixed() or a locale-less
* toLocaleString().
* - `no-raw-card-shell` — a card surface is a <Card> or a shell
* primitive, not bg-card + border + rounded-xl.
*/
"use strict";
@@ -33,6 +35,7 @@ const noDefaultZoneLiteral = require("./no-default-zone-literal.js");
const noUtcDayKey = require("./no-utc-day-key.js");
const noPlanningTags = require("./no-planning-tags.js");
const noUnlocalisedDecimal = require("./no-unlocalised-decimal.js");
const noRawCardShell = require("./no-raw-card-shell.js");
module.exports = {
rules: {
@@ -51,5 +54,6 @@ module.exports = {
"no-utc-day-key": noUtcDayKey,
"no-planning-tags": noPlanningTags,
"no-unlocalised-decimal": noUnlocalisedDecimal,
"no-raw-card-shell": noRawCardShell,
},
};
@@ -0,0 +1,107 @@
/**
* @fileoverview ESLint rule — a card surface is a `<Card>` (or one of the
* shell primitives), not a hand-rolled `bg-card` + `border` + `rounded-xl`.
*
* UI standards §1: "New surfaces compose `<Card>` (or `SettingsCard` in
* Settings)". A hand-rolled shell drifts on its own: one had `p-4` with no
* `md:p-6`, one `py-6` with no horizontal inset, one a header row of its own
* instead of `TileHeader`, and every one of them sat beside real cards whose
* padding, gap and shadow it did not share.
*
* FLAGGED, under `src/components/` and `src/app/` outside
* `src/components/ui/` and test files: one string literal whose class list
* carries `bg-card` (the opaque card colour; a translucent `bg-card/NN` is an
* inner tile or the sanctioned hero glass), a `border` utility, and
* `rounded-xl` (the card radius; `rounded-lg` / `rounded-md` are inner tiles
* and controls, §1).
*
* The files where a shell is the point (the shell primitives themselves, the
* hero identity surface, popovers, the sign-in card that renders outside the
* app shell, the chart frame its skeleton and error state share) are passed
* in the `allow` option, each with its reason in the flat config. There is no
* inline escape: a new exception is a reviewed change to that list.
*
* WHAT THIS RULE DOES NOT CATCH
*
* A shell assembled from separate literals (`cn("bg-card", "border",
* "rounded-xl")`), one painted through a CSS class, or one in another radius.
* It holds the idiom the sweep removed, not every way to draw a box.
*/
"use strict";
const ENFORCED_ROOTS = ["src/components/", "src/app/"];
const EXEMPT_ROOTS = ["src/components/ui/"];
const BG_CARD_RE = /(?:^|\s)bg-card(?=\s|$)/;
const BORDER_RE = /(?:^|\s)border(?:-[a-z0-9]|(?=\s|$))/;
const RADIUS_RE = /(?:^|\s)rounded-xl(?=\s|$)/;
function toPosix(filename) {
return filename.replace(/\\/g, "/");
}
function isTestFile(posix) {
return (
/\.test\.[cm]?[jt]sx?$/.test(posix) ||
/\.spec\.[cm]?[jt]sx?$/.test(posix) ||
posix.includes("/__tests__/") ||
posix.includes("/__mocks__/")
);
}
function isShell(value) {
return (
typeof value === "string" &&
BG_CARD_RE.test(value) &&
BORDER_RE.test(value) &&
RADIUS_RE.test(value)
);
}
/** @type {import("eslint").Rule.RuleModule} */
const noRawCardShellRule = {
meta: {
type: "problem",
docs: {
description:
"Compose <Card> (or a shell primitive) instead of a hand-rolled bg-card + border + rounded-xl shell.",
},
schema: [
{
type: "object",
properties: {
allow: { type: "array", items: { type: "string" } },
},
additionalProperties: false,
},
],
messages: {
rawShell:
"Hand-rolled card shell (bg-card + border + rounded-xl). Compose <Card> (with TileHeader inside), SettingsCard in Settings, or TrendTile for a trend tile (UI standards §1). A deliberate exception goes into the rule's `allow` list in eslint.config.mjs with its reason.",
},
},
create(context) {
const filename = toPosix(context.filename ?? context.getFilename?.() ?? "");
if (!ENFORCED_ROOTS.some((root) => filename.includes(root))) return {};
if (EXEMPT_ROOTS.some((root) => filename.includes(root))) return {};
if (isTestFile(filename)) return {};
const allow = (context.options[0] && context.options[0].allow) || [];
if (allow.some((suffix) => filename.endsWith(suffix))) return {};
function check(node, value) {
if (isShell(value)) context.report({ node, messageId: "rawShell" });
}
return {
Literal(node) {
check(node, node.value);
},
TemplateElement(node) {
check(node, node.value.cooked ?? node.value.raw);
},
};
},
};
module.exports = noRawCardShellRule;
+47
View File
@@ -128,6 +128,53 @@ const eslintConfig = defineConfig([
// machine-read strings carry `eslint-disable-next-line ... -- <reason>`;
// the rule refuses a directive without one. See the rule header.
"healthlog/no-unlocalised-decimal": "error",
// A card surface is a <Card> (or SettingsCard / TrendTile), never a
// hand-rolled bg-card + border + rounded-xl (UI standards §1). The
// files below are where a shell is the point; each says why. A new
// exception is a reviewed change to this list, there is no inline
// escape. See the rule header.
"healthlog/no-raw-card-shell": [
"error",
{
allow: [
// The shell primitives themselves.
"src/components/settings/settings-card.tsx",
"src/components/medications/medication-detail-section.tsx",
// The sanctioned identity surface of the dashboard (§1).
"src/components/daily/today-hero.tsx",
"src/components/daily/today-hero-skeleton.tsx",
// Floating surfaces: a chart tooltip, the tour popovers, the
// wizard's icon tile.
"src/components/charts/chart-tooltip.tsx",
"src/components/onboarding/tour.tsx",
"src/components/medications/wizard/medication-wizard-dialog.tsx",
// The chart frame: chart, skeleton and error state paint the
// same frame so a chart never moves when it loads or fails.
"src/components/charts/health-chart.tsx",
"src/components/charts/chart-skeleton.tsx",
"src/components/charts/chart-error-state.tsx",
"src/components/charts/medication-compliance-chart.tsx",
"src/components/insights/derived/score-trend-chart.tsx",
"src/components/insights/intraday-pulse-chart.tsx",
// Table frames: a clipped, unpadded border around a table.
"src/components/medications/medication-table.tsx",
"src/app/notifications/page.tsx",
// Surfaces that render outside the app shell: sign-in, register,
// claim, the key-mismatch gate.
"src/app/auth/login/page.tsx",
"src/app/auth/register/page.tsx",
"src/components/auth/claim-profile.tsx",
"src/app/key-mismatch-page.tsx",
// The first-run flow: full-screen steps with their own rhythm.
"src/components/onboarding/anamnesis-card.tsx",
"src/components/onboarding/baseline-fields.tsx",
"src/components/onboarding/confirm-screen.tsx",
"src/components/onboarding/done-screen.tsx",
"src/components/onboarding/first-result-screen.tsx",
"src/components/onboarding/getting-started-checklist.tsx",
],
},
],
},
},
// v1.28.17 — every recharts-rendering component funnels through the
+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>
);
}