Merge wave-mobile-rest: phone tap targets, shared list-row, scroll-touch

Raise primary touch controls across the non-Settings surfaces to a 44px
floor that condenses at sm+, add a shared ListRow primitive and migrate
the three bespoke list-row mini-cards onto it, and add a scroll-touch
utility for the horizontal scrollers.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 21:19:32 +02:00
commit b10d978bce
13 files changed
+128 -30

No files matched your search

+15
View File
@@ -448,6 +448,21 @@
display: none;
}
/*
* `.scroll-touch` — momentum scrolling for horizontal scrollers on iOS
* WebKit. `-webkit-overflow-scrolling: touch` restores the inertial
* flick the default `overflow-x-auto` row loses on touch; `overscroll-
* behavior: contain` keeps a flick that reaches the end of the row from
* chaining into the document scroll (the "rubber-band drags the whole
* page" paper-cut). Applied to the in-app horizontal pill / tab strips
* (insights tab strip, cycle tab list) so the gesture matches the
* native feel the bottom-sheet scrollers already get for free.
*/
.scroll-touch {
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* ── Insight card animations ─────────────────────── */
@keyframes insight-fade-in {
from {
+1 -1
View File
@@ -255,7 +255,7 @@ export function CycleView() {
triggers size to their content (`text-xs`, no `flex-1`) and the
strip scrolls cleanly if needed; from `sm` up they reclaim the
equal-width `flex-1` layout. */}
<TabsList className="w-full max-w-full overflow-x-auto">
<TabsList className="scroll-touch w-full max-w-full overflow-x-auto">
<TabsTrigger
value="calendar"
className="text-xs sm:flex-1 sm:text-sm"
@@ -258,15 +258,17 @@ export function DashboardHero({
cta.kind === "link" ? (
<Button
asChild
size="sm"
size="default"
variant="outline"
className="min-h-11 sm:min-h-9"
data-slot="dashboard-hero-cta"
>
<Link href={cta.href}>{t(ctaLabelKey)}</Link>
</Button>
) : (
<Button
size="sm"
size="default"
className="min-h-11 sm:min-h-9"
data-slot="dashboard-hero-cta"
onClick={() => onQuickEntry(cta.target)}
>
+1 -1
View File
@@ -109,7 +109,7 @@ function EpisodeCard({
<Button
variant="outline"
size="sm"
className="min-h-9"
className="min-h-11 sm:min-h-9"
onClick={() => onLogDay(episode.id)}
>
{t("illness.logDay")}
@@ -175,6 +175,39 @@ describe("<CoachInput>", () => {
expect(html).toContain("Stopp");
});
it("sizes the send control to the 44px tap-target floor on phones", () => {
// v1.18.7 — the composer controls (send / stop / mic) must clear the
// 44px WCAG 2.5.5 floor on phones and condense at `sm:` upward. Guard
// the `size-11 sm:size-9` pattern so a future restyle can't quietly
// drop the send button back to 36px.
const html = render(
<CoachInput value="Hi" onChange={() => {}} onSubmit={() => {}} />,
);
const sendTag = html.match(
/<button[^>]*data-slot="coach-input-send"[^>]*>/,
);
expect(sendTag?.[0]).toMatch(/\bsize-11\b/);
expect(sendTag?.[0]).toMatch(/\bsm:size-9\b/);
});
it("sizes the stop control to the 44px tap-target floor on phones", () => {
const html = render(
<CoachInput
value="Hi"
onChange={() => {}}
onSubmit={() => {}}
onCancel={() => {}}
disabled
isStreaming
/>,
);
const stopTag = html.match(
/<button[^>]*data-slot="coach-input-stop"[^>]*>/,
);
expect(stopTag?.[0]).toMatch(/\bsize-11\b/);
expect(stopTag?.[0]).toMatch(/\bsm:size-9\b/);
});
it("invokes onChange when the parent passes a controlled handler", () => {
// SSR can't fire DOM events; smoke-check the contract by calling
// the supplied handler directly.
@@ -352,7 +352,7 @@ export function CoachInput({
: t("insights.coach.dictate")
}
className={cn(
"size-9 shrink-0 rounded-xl transition-colors",
"size-11 shrink-0 rounded-xl transition-colors sm:size-9",
listening
? "text-dracula-pink bg-dracula-pink/10 hover:text-dracula-pink"
: "text-muted-foreground hover:text-foreground",
@@ -396,8 +396,8 @@ export function CoachInput({
// Desktop shrinks back to `text-sm` for the compact
// composer.
"min-w-0 flex-1 resize-none bg-transparent text-base leading-relaxed outline-none sm:text-sm",
// Centre the single-line state against the 36 px send
// button so the placeholder and the icon share a baseline.
// Centre the single-line state against the send button so
// the placeholder and the icon share a baseline.
"px-2 py-1.5",
"max-h-[9.5rem] overflow-auto",
// v1.18.7 — calm, thin scrollbar inside the composer when
@@ -420,7 +420,7 @@ export function CoachInput({
data-slot="coach-input-stop"
aria-label={t("insights.coach.stop")}
title={t("insights.coach.stop")}
className="size-9 shrink-0 rounded-xl"
className="size-11 shrink-0 rounded-xl sm:size-9"
>
<Square className="size-3.5 fill-current" aria-hidden="true" />
</Button>
@@ -432,7 +432,7 @@ export function CoachInput({
data-slot="coach-input-send"
aria-label={t("insights.coach.send")}
title={t("insights.coach.send")}
className="size-9 shrink-0 rounded-xl"
className="size-11 shrink-0 rounded-xl sm:size-9"
>
{isStreaming ? (
<Loader2
+9 -8
View File
@@ -22,6 +22,7 @@ import {
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import { Skeleton } from "@/components/ui/skeleton";
import { SectionHeading } from "@/components/insights/section-heading";
import { useTranslations } from "@/lib/i18n/context";
@@ -230,28 +231,28 @@ function BriefingRow({
);
if (href) {
return (
<Link
href={href}
<ListRow
asChild
data-slot={dataSlot}
data-metric={sourceMetric}
className={cn(
"border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3",
"border-border/60 bg-card/40 relative flex items-start gap-3",
"hover:bg-accent/40 transition-colors",
"focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none",
)}
>
{rowContent}
</Link>
<Link href={href}>{rowContent}</Link>
</ListRow>
);
}
return (
<div
<ListRow
data-slot={dataSlot}
data-metric={sourceMetric}
className="border-border/60 bg-card/40 relative flex items-start gap-3 rounded-md border p-3"
className="border-border/60 bg-card/40 relative flex items-start gap-3"
>
{rowContent}
</div>
</ListRow>
);
}
@@ -709,7 +709,7 @@ function InsightsTabStripImpl({
// gives that room back to the layout so the strip height is
// unchanged. `px-1 -mx-1` does the same horizontally so the
// first/last pill's focus ring isn't clipped at the row edges.
className="-mx-1 -my-1 flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto px-1 py-1 [&::-webkit-scrollbar]:hidden"
className="scroll-touch -mx-1 -my-1 flex min-w-0 flex-1 [scrollbar-width:none] gap-2 overflow-x-auto px-1 py-1 [&::-webkit-scrollbar]:hidden"
>
{tabs.map((tab) => {
if (tab.kind === "link") {
@@ -22,6 +22,7 @@ import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ListRow } from "@/components/ui/list-row";
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { MeasurementForm } from "@/components/measurements/measurement-form";
@@ -119,10 +120,12 @@ export function VorsorgeDashboardCard() {
const due = relativeDueKey(reminder.nextDueAt, now);
const isLinked = reminder.measurementType != null;
return (
<li
<ListRow
key={reminder.id}
className="flex items-center justify-between gap-3 rounded-md border p-2.5"
asChild
className="flex items-center justify-between gap-3"
>
<li>
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-medium">
{resolveLabel(reminder, t)}
@@ -139,8 +142,8 @@ export function VorsorgeDashboardCard() {
</div>
<Button
type="button"
size="sm"
className="shrink-0"
size="default"
className="min-h-11 shrink-0 sm:min-h-9"
onClick={() => onPrimaryAction(reminder)}
disabled={satisfy.isPending}
>
@@ -156,7 +159,8 @@ export function VorsorgeDashboardCard() {
</>
)}
</Button>
</li>
</li>
</ListRow>
);
})}
</ul>
@@ -794,8 +794,8 @@ function VorsorgeCard({
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="min-h-11 min-w-11"
size="icon-lg"
className="sm:size-10"
aria-label={t("common.moreOptions")}
>
<MoreVertical className="h-4 w-4" />
@@ -909,7 +909,7 @@ function VorsorgeCard({
type="button"
size="sm"
className={cn(
"min-h-9",
"min-h-11 sm:min-h-9",
isDue && "bg-success text-success-foreground hover:bg-success/90",
)}
onClick={onPrimaryAction}
@@ -5,6 +5,7 @@ import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import {
FilterBar,
FilterBarDateRange,
@@ -1080,10 +1081,10 @@ export function MeasurementList({
const drilldownId = `drilldown-mobile-${m.dayKey ?? m.id}`;
const isSelected = selectedIds.has(m.id);
return (
<div
<ListRow
key={m.id}
data-state={isSelected ? "selected" : undefined}
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5 rounded-lg border p-3"
className="bg-card border-border data-[state=selected]:border-dracula-purple/60 data-[state=selected]:bg-dracula-purple/5"
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 overflow-hidden">
@@ -1243,7 +1244,7 @@ export function MeasurementList({
/>
</div>
)}
</div>
</ListRow>
);
})}
</div>
@@ -243,7 +243,7 @@ export function Step3Dose({ payload, applyPartial }: StepProps) {
<Button
type="button"
variant="ghost"
className="h-9 w-full justify-between px-2"
className="min-h-11 w-full justify-between px-2 sm:min-h-9"
aria-expanded={allowedOpen}
onClick={() => setAllowedOpen((prev) => !prev)}
>
+42
View File
@@ -0,0 +1,42 @@
import * as React from "react";
import { Slot } from "radix-ui";
import { cn } from "@/lib/utils";
/**
* v1.18.7 — shared list-row primitive.
*
* Before this, every list-row "mini-card" (a bordered row sitting inside
* a section or a `<Card>`) invented its own padding + radius: the
* Vorsorge dashboard card used `rounded-md p-2.5`, the daily-briefing
* rows `rounded-md p-3`, the measurement-list mobile row a bespoke
* `rounded-lg p-3` div. Three surfaces, three shapes for one visual
* concept. `ListRow` pins the canonical shape — `rounded-lg border p-3`
* — so the rows read as one language across the app.
*
* It owns ONLY the box shape (radius + border + padding). Layout
* (flex direction, alignment, gap), colour overrides, and interaction
* state stay with the caller via `className`, so migrating a bespoke row
* is purely dropping the radius/border/padding literals and keeping the
* rest.
*
* `asChild` renders the row as its single child (e.g. a `<Link>` or
* `<li>`) while still applying the shared classes, matching the shadcn
* primitive convention used elsewhere in `ui/`.
*/
function ListRow({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "div";
return (
<Comp
data-slot="list-row"
className={cn("rounded-lg border p-3", className)}
{...props}
/>
);
}
export { ListRow };