From 9d44d1c47f43c4d1593bd9941ebf6eee8ff1610c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 20 Oct 2026 13:37:00 +0200 Subject: [PATCH] feat(lint): no-raw-card-shell, and the card shells it found MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../__tests__/no-raw-card-shell.test.mjs | 86 +++++++++++ eslint-plugins/healthlog/index.js | 4 + eslint-plugins/healthlog/no-raw-card-shell.js | 107 +++++++++++++ eslint.config.mjs | 47 ++++++ src/app/achievements/page.tsx | 13 +- src/app/insights/loading.tsx | 31 ++-- src/app/notifications/page.tsx | 30 ++-- src/components/cycle/cycle-view.tsx | 16 +- .../gamification/recent-achievements-card.tsx | 140 +++++++++--------- 9 files changed, 366 insertions(+), 108 deletions(-) create mode 100644 eslint-plugins/healthlog/__tests__/no-raw-card-shell.test.mjs create mode 100644 eslint-plugins/healthlog/no-raw-card-shell.js diff --git a/eslint-plugins/healthlog/__tests__/no-raw-card-shell.test.mjs b/eslint-plugins/healthlog/__tests__/no-raw-card-shell.test.mjs new file mode 100644 index 000000000..37821cfdf --- /dev/null +++ b/eslint-plugins/healthlog/__tests__/no-raw-card-shell.test.mjs @@ -0,0 +1,86 @@ +/** + * Unit tests for the `healthlog/no-raw-card-shell` rule — a card surface is a + * 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: '', filename: APP_FILE }, + // An inner tile: another radius. + { + code: '
', + filename: APP_FILE, + }, + // The translucent hero glass and inner washes are not the card colour. + { + code: '
', + filename: APP_FILE, + }, + // No border: not a shell. + { code: '
', filename: APP_FILE }, + // The primitives under src/components/ui own the shell. + { + code: '
', + filename: "/repo/src/components/ui/trend-tile.tsx", + }, + // An allowed file. + { + code: '
', + 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: '
', + filename: "/repo/src/components/__tests__/x.test.tsx", + }, + ], + invalid: [ + { + code: '
', + 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: '
', + filename: APP_FILE, + options: [{ allow: ["src/components/charts/health-chart.tsx"] }], + errors: [{ messageId: "rawShell" }], + }, + ], +}); diff --git a/eslint-plugins/healthlog/index.js b/eslint-plugins/healthlog/index.js index cf7c16ea5..ef0b03df6 100644 --- a/eslint-plugins/healthlog/index.js +++ b/eslint-plugins/healthlog/index.js @@ -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 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, }, }; diff --git a/eslint-plugins/healthlog/no-raw-card-shell.js b/eslint-plugins/healthlog/no-raw-card-shell.js new file mode 100644 index 000000000..5a772c8c8 --- /dev/null +++ b/eslint-plugins/healthlog/no-raw-card-shell.js @@ -0,0 +1,107 @@ +/** + * @fileoverview ESLint rule — a card surface is a `` (or one of the + * shell primitives), not a hand-rolled `bg-card` + `border` + `rounded-xl`. + * + * UI standards §1: "New surfaces compose `` (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 (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 (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; diff --git a/eslint.config.mjs b/eslint.config.mjs index b4e514c25..c6aabb847 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -128,6 +128,53 @@ const eslintConfig = defineConfig([ // machine-read strings carry `eslint-disable-next-line ... -- `; // the rule refuses a directive without one. See the rule header. "healthlog/no-unlocalised-decimal": "error", + // A card surface is a (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 diff --git a/src/app/achievements/page.tsx b/src/app/achievements/page.tsx index b9716739b..55009852b 100644 --- a/src/app/achievements/page.tsx +++ b/src/app/achievements/page.tsx @@ -43,6 +43,7 @@ import { type AchievementCategory, type AchievementProgress, } from "@/lib/gamification/achievements"; +import { TrendTile } from "@/components/ui/trend-tile"; const iconMap: Record = { Activity, @@ -351,7 +352,7 @@ export default function AchievementsPage() { />
-
+

{t("achievements.points")}

@@ -370,8 +371,8 @@ export default function AchievementsPage() { className="h-1.5" aria-label={t("achievements.points")} /> -
-
+ +

{t("achievements.unlocked")}

@@ -384,8 +385,8 @@ export default function AchievementsPage() {

{t("achievements.remainingUnlocks", { count: remainingUnlocks })}

-
-
+ +

{t("achievements.nextGoal")}

@@ -428,7 +429,7 @@ export default function AchievementsPage() { className="mt-3 h-1.5" aria-label={t("achievements.nextProgressLabel")} /> -
+
{grouped.length === 0 ? ( diff --git a/src/app/insights/loading.tsx b/src/app/insights/loading.tsx index 1131ba611..fd7fc9251 100644 --- a/src/app/insights/loading.tsx +++ b/src/app/insights/loading.tsx @@ -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 (
@@ -18,21 +19,25 @@ export default function InsightsLoading() { {/* Chart card */} -
- - -
+ + + + + + {/* Assessment card */} -
-
- - -
- - - -
+ + +
+ + +
+ + + +
+
); } diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index 7ce11570b..4c14bd9e3 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -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 ( -
-
-
{t(nameKey)}
-
+ + + + {t(nameKey)} + + {t(descKey)} -
-
-
+ + + {activeChannels.map((ch) => { const enabled = isEnabled(ch.id, eventType); const disabled = !ch.enabled; @@ -355,8 +361,8 @@ export default function NotificationsPage() {
); })} -
-
+ + ); })}
diff --git a/src/components/cycle/cycle-view.tsx b/src/components/cycle/cycle-view.tsx index 276aa37c1..2243644bb 100644 --- a/src/components/cycle/cycle-view.tsx +++ b/src/components/cycle/cycle-view.tsx @@ -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: () => ( -
-
+ + -
-
- + + + -
-
+ + ), }, ); diff --git a/src/components/gamification/recent-achievements-card.tsx b/src/components/gamification/recent-achievements-card.tsx index e3ba79fd6..42582f521 100644 --- a/src/components/gamification/recent-achievements-card.tsx +++ b/src/components/gamification/recent-achievements-card.tsx @@ -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 = { Activity, @@ -127,84 +129,84 @@ export function RecentAchievementsCard() { } return ( -
+ {/* 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). */} - - {t("achievements.dashboardCard.viewAll")} - - } - /> - - {showSkeleton ? ( - - ) : recent.length === 0 ? ( - + + + {t("achievements.dashboardCard.viewAll")} + + + } /> - ) : ( -
    - {recent.map((achievement) => { - const Icon = iconMap[achievement.icon] ?? Star; - return ( + + + + {showSkeleton ? ( + - )} -
+ ))} + + ) : recent.length === 0 ? ( + + ) : ( +
    + {recent.map((achievement) => { + const Icon = iconMap[achievement.icon] ?? Star; + return ( +
  • +
    + +
    +
    +

    + {t(achievement.titleKey)} +

    +

    + {t(achievement.descriptionKey)} +

    +
    + {achievement.completedAt && ( + + {formatDate(achievement.completedAt)} + + )} +
  • + ); + })} +
+ )} + + ); }