fix(ui): unclip the score-ring glow, reachable insight pills, consistent destructive actions

The score ring's bloom was clipped by the SVG's own viewport — the
drop-shadow sits on a child of the viewBox-filling arc — so the glow
never extended past the circle; the SVG now overflows visibly on every
host. The insight pill strip gains chevron buttons and wheel scrolling
so an overflowing row is reachable with a mouse. Safari favourites
rendered the transparent icon on a white plate; a flattened
apple-touch-icon and a maskable manifest icon fix both. The
time-of-day mood chart adopts the damped fill the weekday chart already
uses, eighteen delete confirmations move onto the destructive button
variant (one of them had the wrong text colour), five confirmation
dialogs gain pending states, the sources dialog stacks its primary
action on top on mobile, and the token form aligns input and button
heights.
This commit is contained in:
Marc-André Bombeck committed 2026-06-10 12:56:06 +02:00
1 parent 298e4c34b4
commit 6f45d72a84
28 files changed
+221 -49

No files matched your search

+3 -1
View File
@@ -834,7 +834,7 @@
"title": "Medikament beschreiben",
"description": "Beschreibe das Medikament in eigenen Worten — das Formular wird automatisch vorausgefüllt.",
"placeholder": "z. B. Mounjaro 5 mg wöchentlich am Mittwochmorgen ab nächstem Montag",
"submit": "Übernehmen",
"submit": "Extrahieren",
"submitBusy": "Wird verarbeitet …",
"cancel": "Abbrechen",
"examples": {
@@ -2099,6 +2099,8 @@
"navSteps": "Schritte",
"navActiveEnergy": "Aktive Energie",
"navAriaLabel": "Zu Abschnitt springen",
"pillScrollLeft": "Tabs nach links scrollen",
"pillScrollRight": "Tabs nach rechts scrollen",
"tabStrip": {
"vitalsParent": {
"label": "Vitalwerte",
+2
View File
@@ -2099,6 +2099,8 @@
"navSteps": "Steps",
"navActiveEnergy": "Active energy",
"navAriaLabel": "Skip to section",
"pillScrollLeft": "Scroll tabs left",
"pillScrollRight": "Scroll tabs right",
"tabStrip": {
"vitalsParent": {
"label": "Vitals",
+2
View File
@@ -2099,6 +2099,8 @@
"navSteps": "Pasos",
"navActiveEnergy": "Energía activa",
"navAriaLabel": "Saltar a sección",
"pillScrollLeft": "Desplazar las pestañas a la izquierda",
"pillScrollRight": "Desplazar las pestañas a la derecha",
"tabStrip": {
"vitalsParent": {
"label": "Vitales",
+2
View File
@@ -2099,6 +2099,8 @@
"navSteps": "Pas",
"navActiveEnergy": "Énergie active",
"navAriaLabel": "Aller à la section",
"pillScrollLeft": "Faire défiler les onglets vers la gauche",
"pillScrollRight": "Faire défiler les onglets vers la droite",
"tabStrip": {
"vitalsParent": {
"label": "Constantes",
+2
View File
@@ -2099,6 +2099,8 @@
"navSteps": "Passi",
"navActiveEnergy": "Energia attiva",
"navAriaLabel": "Vai alla sezione",
"pillScrollLeft": "Scorri le schede a sinistra",
"pillScrollRight": "Scorri le schede a destra",
"tabStrip": {
"vitalsParent": {
"label": "Vitali",
+2
View File
@@ -2099,6 +2099,8 @@
"navSteps": "Kroki",
"navActiveEnergy": "Energia czynna",
"navAriaLabel": "Przejdź do sekcji",
"pillScrollLeft": "Przewiń karty w lewo",
"pillScrollRight": "Przewiń karty w prawo",
"tabStrip": {
"vitalsParent": {
"label": "Witalne",
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+10 -2
View File
@@ -13,12 +13,20 @@
{
"src": "/logo-192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/logo-512.png",
"sizes": "512x512",
"type": "image/png"
"type": "image/png",
"purpose": "any"
},
{
"src": "/logo-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+4 -1
View File
@@ -39,7 +39,10 @@ export const metadata: Metadata = {
manifest: "/manifest.json",
icons: {
icon: "/favicon.svg",
apple: "/logo-192.png",
// Pre-flattened 180×180 on the app background — Safari ignores PNG
// alpha in favourites/home-screen tiles and would render the
// transparent logo on a white slab otherwise.
apple: "/apple-touch-icon.png",
},
appleWebApp: {
capable: true,
+1 -1
View File
@@ -127,7 +127,7 @@ function RestoreRowDialog({
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
disabled={!matched || pending}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={() => {
if (!matched) return;
setOpen(false);
+6 -1
View File
@@ -104,9 +104,14 @@ export function DangerZoneSection() {
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
disabled={wipeAllData.isPending}
aria-busy={wipeAllData.isPending || undefined}
onClick={() => wipeAllData.mutate()}
>
{wipeAllData.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.finalDelete")}
</AlertDialogAction>
</AlertDialogFooter>
@@ -398,8 +398,13 @@ function FeedbackDetailDialog({
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => archive.mutate()}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
disabled={archive.isPending}
aria-busy={archive.isPending || undefined}
>
{archive.isPending && (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("admin.feedback.actionArchive")}
</AlertDialogAction>
</AlertDialogFooter>
@@ -560,7 +560,9 @@ export function UserManagementSection() {
forceLogout.mutate({ id: logoutTarget.id });
}
}}
className="bg-destructive hover:bg-destructive/90"
variant="destructive"
disabled={forceLogout.isPending}
aria-busy={forceLogout.isPending || undefined}
>
{forceLogout.isPending ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
+1 -1
View File
@@ -64,7 +64,7 @@ export function DeleteButton({
<AlertDialogFooter>
<AlertDialogCancel>{cancelLabel ?? t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={onConfirm}
>
{confirmLabel ?? t("common.delete")}
@@ -105,7 +105,7 @@ export function SelectionActionBar({
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={onConfirmDelete}
disabled={isDeleting}
>
@@ -122,8 +122,7 @@ export function ScoreRing({
const shouldAnimate = animate && hasScore && !reduced;
const dash = (clamped / 100) * C;
const hasBaseline =
baseline != null && Number.isFinite(baseline) && hasScore;
const hasBaseline = baseline != null && Number.isFinite(baseline) && hasScore;
const baselineDash = hasBaseline ? (clampScore(baseline) / 100) * C : 0;
// Arc starts empty (offset C) and sweeps to its fill on the next frame after
@@ -170,9 +169,14 @@ export function ScoreRing({
} as React.CSSProperties
}
>
{/* `overflow-visible`: the arc's drop-shadow bloom (`.wellness-ring-arc`)
paints outside the viewBox — the arc fills the 0 0 100 100 box, so the
default `overflow: hidden` on SVG clipped the glow flat at the circle's
edge. The -90° rotation is unaffected (the bloom is radially symmetric)
and the parent div never clips, so the glow now feathers out fully. */}
<svg
viewBox={`0 0 ${VIEW} ${VIEW}`}
className="h-full w-full -rotate-90"
className="h-full w-full -rotate-90 overflow-visible"
aria-hidden="true"
>
<defs>
+110 -4
View File
@@ -1,9 +1,16 @@
"use client";
import { memo, useEffect, useMemo, useRef } from "react";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronDown, Loader2, RefreshCw, Settings2 } from "lucide-react";
import {
ChevronDown,
ChevronLeft,
ChevronRight,
Loader2,
RefreshCw,
Settings2,
} from "lucide-react";
import { toast } from "sonner";
import {
@@ -13,6 +20,7 @@ import {
} from "@/components/ui/popover";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import {
INSIGHTS_OVERVIEW_PATH,
SUB_PAGE_GROUP,
@@ -226,7 +234,10 @@ export const SUB_PAGE_TABS: Record<
metric: "WALKING_SPEED",
},
// v1.10.0 — cardio fitness (VO2 max) + Apple-Health Mobility additions.
"cardio-fitness": { labelKey: "insights.navCardioFitness", metric: "VO2_MAX" },
"cardio-fitness": {
labelKey: "insights.navCardioFitness",
metric: "VO2_MAX",
},
falls: { labelKey: "insights.navFalls", metric: "FALL_COUNT" },
"six-minute-walk": {
labelKey: "insights.navSixMinuteWalk",
@@ -498,6 +509,63 @@ function InsightsTabStripImpl({
const regenerateLabel = t("insights.regenerateAnalysis");
const customizeLabel = t("insights.customize");
// Overflow affordance for the pill row. The row hides its scrollbar
// (deliberately — see the container classes below), which left desktop
// users with no signal and no obvious gesture once the pills overflow:
// mouse wheels scroll vertically and there is nothing to grab. Three
// complements close that gap:
// • chevron buttons (sm+ only — touch keeps native pan) that page the
// row ±200 px; they render whenever the row overflows and disable
// per-direction so focus never lands on a vanished control;
// • a native non-passive wheel listener that turns a dominant vertical
// wheel delta into horizontal scroll (React's synthetic onWheel is
// passive at the root, so `preventDefault()` must bind natively);
// • the existing `<sm` right-edge fade stays as the mobile signal.
const scrollerRef = useRef<HTMLDivElement | null>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const updateScrollAffordance = useCallback(() => {
const el = scrollerRef.current;
if (!el) return;
const maxScroll = el.scrollWidth - el.clientWidth;
setCanScrollLeft(el.scrollLeft > 4);
setCanScrollRight(el.scrollLeft < maxScroll - 4);
}, []);
useEffect(() => {
// Re-measure on mount, when the pill set changes, and on any resize of
// the scroll container (sidebar collapse, orientation change).
updateScrollAffordance();
const el = scrollerRef.current;
if (!el) return;
const observer = new ResizeObserver(updateScrollAffordance);
observer.observe(el);
return () => observer.disconnect();
}, [updateScrollAffordance, tabs]);
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const onWheel = (event: WheelEvent) => {
if (el.scrollWidth <= el.clientWidth) return;
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
el.scrollLeft += event.deltaY;
event.preventDefault();
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, []);
const scrollPills = useCallback((direction: -1 | 1) => {
scrollerRef.current?.scrollBy({
left: direction * 200,
behavior: prefersReducedMotion() ? "auto" : "smooth",
});
}, []);
const pillsOverflow = canScrollLeft || canScrollRight;
return (
<nav
data-slot="insights-tab-strip"
@@ -523,7 +591,28 @@ function InsightsTabStripImpl({
)}
>
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden">
{pillsOverflow && (
<button
type="button"
onClick={() => scrollPills(-1)}
disabled={!canScrollLeft}
aria-label={t("insights.pillScrollLeft")}
data-slot="insights-tab-strip-scroll-left"
className={cn(
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-30",
)}
>
<ChevronLeft className="h-4 w-4" aria-hidden="true" />
</button>
)}
<div
ref={scrollerRef}
onScroll={updateScrollAffordance}
className="flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto [&::-webkit-scrollbar]:hidden"
>
{tabs.map((tab) => {
if (tab.kind === "link") {
// The overview pill matches the mother page exactly; the
@@ -631,6 +720,23 @@ function InsightsTabStripImpl({
);
})}
</div>
{pillsOverflow && (
<button
type="button"
onClick={() => scrollPills(1)}
disabled={!canScrollRight}
aria-label={t("insights.pillScrollRight")}
data-slot="insights-tab-strip-scroll-right"
className={cn(
"hidden h-11 w-9 shrink-0 items-center justify-center rounded-full sm:inline-flex",
"text-muted-foreground hover:text-foreground hover:bg-accent transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none",
"disabled:cursor-not-allowed disabled:opacity-30",
)}
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
)}
{/* v1.4.27 MB7 / CF-72 — right-edge fade. The gradient
absolute-positions over the rightmost ~24 px of the strip
so the last visible pill softly fades into the background
@@ -25,11 +25,7 @@ import { useTranslations } from "@/lib/i18n/context";
* a muted zero-height bar so the four-column rhythm stays intact.
*/
export type MoodTimeOfDayBucket =
| "morning"
| "afternoon"
| "evening"
| "night";
export type MoodTimeOfDayBucket = "morning" | "afternoon" | "evening" | "night";
export interface MoodTimeOfDayRow {
bucket: MoodTimeOfDayBucket;
@@ -88,23 +84,28 @@ export function MoodTimeOfDayChart({
data={data}
margin={{ top: 8, right: 12, bottom: 8, left: 0 }}
>
{/* Muted chart language — same treatment as the weekday/distribution
siblings: `--muted-foreground` axis ticks, a quiet comment-grey
target guide, and 0.55 bar fill so the level hues inform without
shouting. */}
<XAxis
dataKey="label"
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--dracula-comment)"
interval={0}
/>
<YAxis
domain={[1, 5]}
ticks={[1, 2, 3, 4, 5]}
tick={{ fontSize: 11, fill: "var(--dracula-fg)" }}
tick={{ fontSize: 11, fill: "var(--muted-foreground)" }}
stroke="var(--dracula-comment)"
width={24}
/>
<ReferenceLine
y={3.5}
stroke="var(--dracula-green)"
stroke="var(--dracula-comment)"
strokeDasharray="3 3"
strokeOpacity={0.6}
/>
<Tooltip
cursor={{ fill: "var(--secondary)", opacity: 0.4 }}
@@ -129,7 +130,7 @@ export function MoodTimeOfDayChart({
];
}}
/>
<Bar dataKey="value" radius={[3, 3, 0, 0]}>
<Bar dataKey="value" radius={[3, 3, 0, 0]} fillOpacity={0.55}>
{data.map((row) => (
<Cell
key={row.bucket}
@@ -1443,7 +1443,7 @@ export function MeasurementList({
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={deleteEditingMeasurement}
disabled={deleteMutation.isPending}
>
+1 -1
View File
@@ -498,7 +498,7 @@ export function MoodForm({ onSuccess, onCancel, footerSlot }: MoodFormProps) {
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={resetForm}
>
{t("mood.formResetConfirmAction")}
+1 -1
View File
@@ -1018,7 +1018,7 @@ export function MoodList({ onAddFirst }: MoodListProps = {}) {
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={deleteEditingEntry}
disabled={deleteMutation.isPending}
>
+12 -2
View File
@@ -940,9 +940,14 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
disabled={deletePasskey.isPending}
aria-busy={deletePasskey.isPending || undefined}
onClick={() => deletePasskey.mutate(pk.id)}
>
{deletePasskey.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
@@ -1022,9 +1027,14 @@ function PasskeyListSection({ isAuthenticated }: { isAuthenticated: boolean }) {
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
disabled={deletePasskey.isPending}
aria-busy={deletePasskey.isPending || undefined}
onClick={() => deletePasskey.mutate(pk.id)}
>
{deletePasskey.isPending && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.delete")}
</AlertDialogAction>
</AlertDialogFooter>
+2 -2
View File
@@ -327,7 +327,7 @@ function DataResetCard() {
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAllData();
@@ -471,7 +471,7 @@ function AccountDeleteCard() {
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={(e) => {
e.preventDefault();
void handleDeleteAccount();
+12 -7
View File
@@ -134,13 +134,13 @@ function ApiEndpointsCard() {
<code className="font-mono break-all">{endpoint.path}</code>
</div>
<div>
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
{t("settings.apiEndpointAuth")}
</p>
<code className="font-mono break-all">{endpoint.auth}</code>
</div>
<div>
<p className="text-muted-foreground text-[10px] uppercase tracking-wide">
<p className="text-muted-foreground text-[10px] tracking-wide uppercase">
{t("settings.apiEndpointExample")}
</p>
<code className="block font-mono break-all">
@@ -263,7 +263,10 @@ function ApiTokensCard() {
/>
<div className="mt-4 space-y-4">
<form onSubmit={handleCreate} className="flex gap-2">
{/* The button mirrors the Input's responsive height (h-11 / sm:h-10)
so the row reads as one aligned control group instead of a tall
input next to a shorter `sm` button. */}
<form onSubmit={handleCreate} className="flex items-center gap-2">
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
@@ -274,10 +277,12 @@ function ApiTokensCard() {
<Button
type="submit"
variant="outline"
size="sm"
className="h-11 sm:h-10"
disabled={creating || !newName.trim()}
>
{creating && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />}
{creating && (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("common.create")}
</Button>
</form>
@@ -416,7 +421,7 @@ function ApiTokensCard() {
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevoked")}
@@ -515,7 +520,7 @@ function ApiTokensCard() {
{t("common.cancel")}
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={() => handleRevoke(tok.id)}
>
{t("settings.tokenRevoked")}
@@ -197,8 +197,13 @@ export function CoachMemorySection({
</AlertDialogCancel>
<AlertDialogAction
data-testid="settings-coach-memory-forget-all-confirm"
disabled={forgetAll.isPending}
aria-busy={forgetAll.isPending || undefined}
onClick={() => forgetAll.mutate()}
>
{forgetAll.isPending && (
<Loader2 className="mr-1 size-3.5 animate-spin motion-reduce:animate-none" />
)}
{t("settings.ai.coachMemory.forgetAllConfirmAction")}
</AlertDialogAction>
</AlertDialogFooter>
@@ -208,7 +213,11 @@ export function CoachMemorySection({
</div>
{query.isError && (
<p role="status" aria-live="polite" className="text-destructive text-xs">
<p
role="status"
aria-live="polite"
className="text-destructive text-xs"
>
{t("settings.ai.coachMemory.loadError")}
</p>
)}
@@ -622,7 +622,7 @@ function WithingsCard({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.withingsDisconnect")}
@@ -1035,7 +1035,7 @@ function WhoopCard({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.whoopDisconnect")}
@@ -1481,7 +1481,7 @@ function FitbitCard({
<AlertDialogFooter>
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
variant="destructive"
onClick={() => disconnect.mutate()}
>
{t("settings.fitbitDisconnect")}
+7 -5
View File
@@ -387,7 +387,8 @@ export function SourcesSection() {
className="h-11 w-11"
onClick={() => moveSource(metric, index, 1)}
disabled={
index === list.length - 1 || saveMutation.isPending
index === list.length - 1 ||
saveMutation.isPending
}
aria-label={t("settings.sections.sources.moveDown")}
>
@@ -536,9 +537,7 @@ export function SourcesSection() {
variant="ghost"
size="icon"
className="h-11 w-11"
onClick={() =>
moveDeviceType(null, index, -1)
}
onClick={() => moveDeviceType(null, index, -1)}
disabled={index === 0 || saveMutation.isPending}
aria-label={t("settings.sections.sources.moveUp")}
>
@@ -566,8 +565,11 @@ export function SourcesSection() {
</div>
)}
{/* `flex-col-reverse` mirrors the DialogFooter idiom: the primary
action stacks on top on mobile, row order stays Cancel → Save
on `sm+`. */}
{dirty && priority && (
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="ghost"