From a959db8679118b05eed7cbfe02eb624c158d7459 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?=
Date: Sun, 9 Aug 2026 11:27:18 +0200
Subject: [PATCH] refactor(ui): route settings shells, actions, and empty
states through primitives
Bring the remaining settings and dashboard surfaces onto the shared
anatomy: the API and MCP revoke buttons become the destructive variant
instead of an outline hand-tinted red; the runtime-actions row places
its raw-data setting and status lines above the actions, not under them;
the account section paints its card and header while loading instead of a
lone spinner; the share revoke becomes a right-aligned row action rather
than a full-width alarm strip per link; the recent-workouts tile and the
MCP and notifications empty states compose Card and EmptyState instead of
hand-rolled bg-card shells.
---
src/app/notifications/page.tsx | 20 +--
.../dashboard/recent-workouts-tile.tsx | 169 +++++++++---------
.../settings/account-section/index.tsx | 14 +-
.../settings/ai/runtime-actions-row.tsx | 96 +++++-----
src/components/settings/api-section.tsx | 4 +-
src/components/settings/mcp-section.tsx | 17 +-
src/components/settings/sharing-section.tsx | 5 +-
7 files changed, 168 insertions(+), 157 deletions(-)
diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx
index db199f853..ba17b08c5 100644
--- a/src/app/notifications/page.tsx
+++ b/src/app/notifications/page.tsx
@@ -8,6 +8,7 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { PageHeader } from "@/components/ui/page-header";
import { Skeleton } from "@/components/ui/skeleton";
+import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorCard } from "@/components/ui/query-error-card";
import Link from "next/link";
import { Bell, Settings, AlertCircle } from "lucide-react";
@@ -201,22 +202,21 @@ export default function NotificationsPage() {
description={t("notifications.subtitle")}
actions={headerActions}
/>
-
-
-
-
- {t("notifications.noChannels")}
-
- {/* Channel setup lives in Settings → Integrations since v1.25.7;
- point the CTA at that anchor, not the reminder-types screen. */}
+ }
+ title={t("notifications.noChannels")}
+ /* Channel setup lives in Settings → Integrations since v1.25.7;
+ point the CTA at that anchor, not the reminder-types screen. */
+ action={
-
-
+ }
+ />
);
}
diff --git a/src/components/dashboard/recent-workouts-tile.tsx b/src/components/dashboard/recent-workouts-tile.tsx
index cd4eab1ab..cf77c7eeb 100644
--- a/src/components/dashboard/recent-workouts-tile.tsx
+++ b/src/components/dashboard/recent-workouts-tile.tsx
@@ -6,6 +6,7 @@ import { Activity } from "lucide-react";
import { useTranslations } from "@/lib/i18n/context";
import { useWorkouts, type WorkoutListEntry } from "@/hooks/use-workouts";
import { cn } from "@/lib/utils";
+import { Card, CardContent, CardHeader } from "@/components/ui/card";
import { TileHeader } from "@/components/insights/tile-header";
import { getDateTimeFormat } from "@/lib/intl/formatter-cache";
import { Skeleton } from "@/components/ui/skeleton";
@@ -78,90 +79,90 @@ export function RecentWorkoutsTile() {
const workouts = data?.workouts ?? [];
return (
-
- {/* Canonical tile header (foreground icon + title) — the same
- `TileHeader` contract the Insights tiles use, so the dashboard
- stops speaking a separate header language. `size="sm"` matches the
- compact dashboard tile. */}
-
- {t("dashboard.recentWorkouts.viewAll")}
-
- }
- />
-
- {showLoading ? (
- // v1.4.43 W11-L6 — reserve roughly the loaded tile height
- // (3 workouts × ~3 rem rows + spacing) so the surrounding
- // dashboard layout doesn't reflow once the data lands.
- // v1.29.x — row-shaped skeletons instead of a "loading…" text
- // line, matching the sport-icon + label + day + duration shape
- // the loaded rows render.
-
- {[0, 1, 2].map((i) => (
-
-
-
-
-
-
- ))}
-
- ) : isError ? (
- refetch()}
- className="border-0 bg-transparent shadow-none"
+
+
+ {/* Canonical tile header (foreground icon + title) — the same
+ `TileHeader` contract the Insights tiles use, so the dashboard
+ stops speaking a separate header language. `size="sm"` matches the
+ compact dashboard tile. */}
+
+ {t("dashboard.recentWorkouts.viewAll")}
+
+ }
/>
- ) : workouts.length === 0 ? (
-
+ )}
+
+
);
}
diff --git a/src/components/settings/account-section/index.tsx b/src/components/settings/account-section/index.tsx
index 5d223b5fd..795852dd0 100644
--- a/src/components/settings/account-section/index.tsx
+++ b/src/components/settings/account-section/index.tsx
@@ -285,10 +285,18 @@ export function AccountSection() {
}
if (!mounted || isLoading) {
+ // §13 — a loading section paints its card + header, not a bare centered
+ // spinner (reference: admin/coach-feedback-section).
return (
-
-
-
+
+
+
+
+
+ {t("common.loading")}
+
+
+
);
}
diff --git a/src/components/settings/ai/runtime-actions-row.tsx b/src/components/settings/ai/runtime-actions-row.tsx
index 4bdc3e181..ddc043a4f 100644
--- a/src/components/settings/ai/runtime-actions-row.tsx
+++ b/src/components/settings/ai/runtime-actions-row.tsx
@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
+import { SettingsCardActions } from "@/components/settings/_card-actions";
import { apiFetchRaw, apiPut } from "@/lib/api/api-fetch";
import { formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
@@ -199,8 +200,54 @@ export function RuntimeActionsRow({
void userProvider;
return (
+ // §12 — the action row is the LAST thing in the block. The raw-data
+ // setting and every status line (last-generated, test / regenerate
+ // result) sit ABOVE it, never under it.
);
}
diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx
index af2565527..542d25fbd 100644
--- a/src/components/settings/api-section.tsx
+++ b/src/components/settings/api-section.tsx
@@ -406,9 +406,9 @@ function ApiTokensCard() {
+ {/* §12 — a right-aligned row action, not a full-width
+ destructive strip stacked once per link (which reads as
+ an alarm wall on a list of shares). */}
{t("settings.sharing.revoke")}