style: one spacing, title and skeleton language across the app

Icons inside buttons drop their manual margins in favour of the
button's built-in gap (two hundred call sites), form field stacks settle
on one spacing step, card titles on exactly two sizes, inline field
errors on one text size, and a dozen hand-rolled loading pulses move
onto the shared skeleton primitive while keeping their silhouettes. The
intake dialog wording joins the rest of the app on 'übersprungen', and
the UI guidelines now document the two deliberate footer and
touch-target systems.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 16:54:15 +02:00
1 parent 4ca3de5ff3
commit 04765ea8ac
92 files changed
+1433 -1445

No files matched your search

+5 -5
View File
@@ -151,9 +151,9 @@ export default function LoginPage() {
disabled={loading}
>
{loading && mode === "passkey" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="mr-2 h-4 w-4" />
<KeyRound className="h-4 w-4" />
)}
{t("auth.loginWithPasskey")}
</Button>
@@ -173,7 +173,7 @@ export default function LoginPage() {
size="lg"
onClick={() => setMode("password")}
>
<Lock className="mr-2 h-4 w-4" />
<Lock className="h-4 w-4" />
{t("auth.loginWithPassword")}
</Button>
) : (
@@ -220,9 +220,9 @@ export default function LoginPage() {
disabled={loading}
>
{loading && mode === "password" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Lock className="mr-2 h-4 w-4" />
<Lock className="h-4 w-4" />
)}
{t("auth.login")}
</Button>
+2 -2
View File
@@ -191,9 +191,9 @@ export default function RegisterPage() {
disabled={loading}
>
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<UserPlus className="mr-2 h-4 w-4" />
<UserPlus className="h-4 w-4" />
)}
{t("auth.register")}
</Button>
+2 -2
View File
@@ -243,9 +243,9 @@ export default function BugReportPage() {
`div.space-y-2 > Button > Loader-Circle`).
*/}
{loading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<Bug className="mr-2 h-4 w-4" />
<Bug className="h-4 w-4" />
)}
{t("bugreport.submit")}
</Button>
+10 -9
View File
@@ -8,29 +8,30 @@
* by less than a row. Pure server component — no client hooks, no data
* fetch; Next.js renders it instantly while the segment streams.
*/
import { Skeleton } from "@/components/ui/skeleton";
export default function InsightsLoading() {
return (
<div data-slot="insights-subpage-loading" className="space-y-6">
<header className="space-y-2">
<div className="bg-muted h-7 w-48 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/70 h-4 w-72 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-7 w-48 rounded" />
<Skeleton className="bg-muted/70 h-4 w-72 rounded" />
</header>
{/* Chart card */}
<div className="bg-card rounded-xl border p-4">
<div className="bg-muted mb-4 h-4 w-32 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-56 w-full animate-pulse rounded-lg motion-reduce:animate-none" />
<Skeleton className="bg-muted mb-4 h-4 w-32 rounded" />
<Skeleton className="h-56 w-full rounded-lg" />
</div>
{/* Assessment card */}
<div className="bg-card space-y-2 rounded-xl border p-4">
<div className="flex items-center gap-2">
<div className="bg-muted h-5 w-5 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-4 w-40 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-5 w-5 rounded" />
<Skeleton className="bg-muted h-4 w-40 rounded" />
</div>
<div className="bg-muted h-3.5 w-full animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-11/12 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted h-3.5 w-9/12 animate-pulse rounded motion-reduce:animate-none" />
<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>
</div>
);
+1 -1
View File
@@ -96,7 +96,7 @@ export default function CompositeScorePage() {
className="-ml-2 w-fit"
>
<Link href="/insights">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
<ArrowLeft className="size-4" aria-hidden="true" />
{t("insights.subPage.scoresBack")}
</Link>
</Button>
+4 -2
View File
@@ -71,9 +71,11 @@ export default function InsightsMetricValuesPage({
className="-ml-2 w-fit"
>
<Link href={backHref}>
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
<ArrowLeft className="size-4" aria-hidden="true" />
{backToOrigin
? t("insights.subPage.valuesBackToMetric", { metric: metricLabel })
? t("insights.subPage.valuesBackToMetric", {
metric: metricLabel,
})
: t("insights.subPage.valuesBack")}
</Link>
</Button>
+1 -1
View File
@@ -49,7 +49,7 @@ export default function InsightsWorkoutDetailPage({
className="-ml-2 w-fit"
>
<Link href="/insights/workouts">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
<ArrowLeft className="size-4" aria-hidden="true" />
{t("insights.workouts.detail.backToList")}
</Link>
</Button>
+4 -3
View File
@@ -7,15 +7,16 @@
* component — no client hooks, no data fetch. More specific segments (e.g.
* `/insights`) provide their own skeleton and take precedence.
*/
import { Skeleton } from "@/components/ui/skeleton";
export default function RootLoading() {
return (
<div data-slot="dashboard-loading" className="space-y-6 p-4">
<div className="bg-muted h-7 w-44 animate-pulse rounded motion-reduce:animate-none" />
<Skeleton className="bg-muted h-7 w-44 rounded" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="bg-card rounded-xl border p-4">
<div className="bg-muted mb-3 h-4 w-24 animate-pulse rounded motion-reduce:animate-none" />
<div className="bg-muted/60 h-24 w-full animate-pulse rounded-lg motion-reduce:animate-none" />
<Skeleton className="bg-muted mb-3 h-4 w-24 rounded" />
<Skeleton className="h-24 w-full rounded-lg" />
</div>
))}
</div>
+2 -4
View File
@@ -28,9 +28,7 @@ export default function MeasurementsPage() {
// effect, so the open-on-param work is render-driven instead.
const addParam = searchParams.get("add");
const [dialogOpen, setDialogOpen] = useState(false);
const [defaultType, setDefaultType] = useState<string | undefined>(
undefined,
);
const [defaultType, setDefaultType] = useState<string | undefined>(undefined);
const [consumedAddParam, setConsumedAddParam] = useState<string | null>(null);
// v1.4.27 R4 RC2 — DOM handle the form portals its action row into so
// the Sheet branch can sticky-pin Save / Cancel.
@@ -81,7 +79,7 @@ export default function MeasurementsPage() {
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("measurements.addMeasurement")}
</Button>
</div>
+1 -1
View File
@@ -90,7 +90,7 @@ export default function MedicationDetailPage({
<div className="space-y-6">
<Button variant="ghost" size="sm" className="-ml-2 w-fit" asChild>
<Link href="/medications">
<ArrowLeft className="mr-1 size-4" aria-hidden="true" />
<ArrowLeft className="size-4" aria-hidden="true" />
{t("medications.back")}
</Link>
</Button>
+2 -2
View File
@@ -245,7 +245,7 @@ export default function MedicationsPage() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="min-h-11 sm:min-h-9">
<Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("medications.addMedication")}
</Button>
</DropdownMenuTrigger>
@@ -305,7 +305,7 @@ export default function MedicationsPage() {
description={t("medications.emptyDescription")}
action={
<Button size="sm" onClick={openCreate}>
<Plus className="mr-1 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("medications.firstMedication")}
</Button>
}
+1 -1
View File
@@ -49,7 +49,7 @@ export default function MoodPage() {
</p>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("mood.addEntry")}
</Button>
</div>
+9 -7
View File
@@ -7,7 +7,13 @@ import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Bell, ChevronRight, Loader2, Settings, AlertCircle } from "lucide-react";
import {
Bell,
ChevronRight,
Loader2,
Settings,
AlertCircle,
} from "lucide-react";
import {
EVENT_DEFAULT_ENABLED,
type EventType,
@@ -43,11 +49,7 @@ interface PreferencesData {
* unauthenticated, error, empty, populated) so the user always sees the
* disambiguation, even when the body content is a fallback state.
*/
function NotificationsBreadcrumb({
t,
}: {
t: (key: string) => string;
}) {
function NotificationsBreadcrumb({ t }: { t: (key: string) => string }) {
return (
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
<ol className="flex items-center gap-1">
@@ -231,7 +233,7 @@ export default function NotificationsPage() {
</p>
<Button asChild variant="outline">
<Link href="/settings/notifications">
<Settings className="mr-2 h-4 w-4" />
<Settings className="h-4 w-4" />
{t("notifications.goToSettings")}
</Link>
</Button>
+13 -21
View File
@@ -101,9 +101,9 @@ export function sheetBodyHasUnsavedInput(): boolean {
'[data-slot="responsive-sheet-body"]',
);
if (!body) return false;
const fields = body.querySelectorAll<
HTMLInputElement | HTMLTextAreaElement
>("input, textarea");
const fields = body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>(
"input, textarea",
);
for (const field of fields) {
if (field.disabled || field.type === "hidden") continue;
// Skip pickers the form prefills with "now" — an untouched default
@@ -124,9 +124,7 @@ export function sheetBodyHasUnsavedInput(): boolean {
//
// Mood radios start with nothing selected (`mood = ""`), so ANY
// `aria-checked="true"` is a deliberate user choice — flag it.
if (
body.querySelector('[role="radio"][aria-checked="true"]') !== null
) {
if (body.querySelector('[role="radio"][aria-checked="true"]') !== null) {
return true;
}
// Radix Select trigger renders `role="combobox"`. A trigger with
@@ -135,9 +133,7 @@ export function sheetBodyHasUnsavedInput(): boolean {
// We deliberately do NOT treat a merely non-placeholder value as dirty: the
// type / medication selects mount with a non-placeholder DEFAULT, and
// flagging that would over-trigger the confirm on a pristine sheet.
if (
body.querySelector('[role="combobox"][data-state="open"]') !== null
) {
if (body.querySelector('[role="combobox"][data-state="open"]') !== null) {
return true;
}
return false;
@@ -290,9 +286,7 @@ function getRangeHint(
return (
<>
<p>
<span className="text-success font-bold">
{t("charts.colorGreen")}
</span>{" "}
<span className="text-success font-bold">{t("charts.colorGreen")}</span>{" "}
{format(range.greenMin)}-{format(range.greenMax)} {unit}
</p>
<p>
@@ -572,9 +566,7 @@ export default function DashboardPage() {
* undefined-safe and tiles with recent data paint byte-identical
* with the pre-v1.4.34 contract.
*/
const tileStaleDays = (
type: string | null | undefined,
): number | null => {
const tileStaleDays = (type: string | null | undefined): number | null => {
if (!type) return null;
const entry = data?.lastSeenByType?.[type];
if (!entry) return null;
@@ -892,7 +884,7 @@ export default function DashboardPage() {
className="min-h-11 sm:min-h-9"
data-tour-id="dashboard-quick-add"
>
<Plus className="mr-1 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("common.add")}
</Button>
</DropdownMenuTrigger>
@@ -954,9 +946,7 @@ export default function DashboardPage() {
open={quickEntryDialog === "measurement"}
onOpenChange={handleQuickEntryOpenChange}
title={t("measurements.addMeasurement")}
footer={
<div ref={setMeasurementFooterEl} className="flex w-full" />
}
footer={<div ref={setMeasurementFooterEl} className="flex w-full" />}
>
<MeasurementForm
onSuccess={() => setQuickEntryDialog(null)}
@@ -1196,7 +1186,9 @@ export default function DashboardPage() {
icon={TrendingUp}
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(pulseTileSummary)}
staleDays={tileStaleDays(hasRestingHr ? "RESTING_HEART_RATE" : "PULSE")}
staleDays={tileStaleDays(
hasRestingHr ? "RESTING_HEART_RATE" : "PULSE",
)}
/>
),
});
@@ -1736,7 +1728,7 @@ export default function DashboardPage() {
size="sm"
onClick={() => setQuickEntryDialog("measurement")}
>
<Plus className="mr-1 h-4 w-4" />
<Plus className="h-4 w-4" />
{t("dashboard.emptyAddMeasurement")}
</Button>
}