fix(ui): mobile cutoff polish — onboarding banner + yacht owner truncate (R1/R2)
All checks were successful
Build & Push Docker Images / lint (push) Successful in 3m2s
Build & Push Docker Images / build-and-push (push) Successful in 8m28s

Responsive-overflow sweep findings (tests/e2e/matrix/responsive-overflow.spec.ts):

- R1: the onboarding banner's verbose "N of M steps done. Next: <link>" was
  clipped on mobile (extended ~160px past a 390px viewport) and duplicated the
  always-visible "View checklist" button. Now hidden below sm:; mobile shows
  just "Setup X% complete" + the checklist button.
- R2: yacht card owner subtitle used inline-flex + truncate, so a long owner
  name overflowed ~11px on the narrowest widths. Switched to flex min-w-0 so it
  truncates within the card.
- Detector: skip SVG internals (icons / the react-grab dev overlay) and elements
  inside overflow-x scroll containers (data tables scroll on purpose) to drop
  false positives. Sweep now confirms mobile/tablet clean + no real desktop
  overflow (berths wide table is the DataTable's intended horizontal scroll).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 16:23:56 +02:00
parent 459c68a2c3
commit 352b2420b7
3 changed files with 51 additions and 18 deletions

View File

@@ -50,20 +50,25 @@ export function OnboardingBanner() {
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<Sparkles className="size-4 shrink-0" aria-hidden /> <Sparkles className="size-4 shrink-0" aria-hidden />
<span className="truncate"> <span className="truncate">
<strong>Setup is {data.percent}% complete</strong>. {data.completed} of {data.total} steps <strong>Setup is {data.percent}% complete</strong>
done.{' '} {/* Verbose progress + the "Next:" deep-link are hidden on mobile,
{next ? ( where they get clipped (R1) and duplicate the always-visible
<> "View checklist" button. Shown from sm: up. */}
Next:{' '} <span className="hidden sm:inline">
<Link . {data.completed} of {data.total} steps done.{' '}
// eslint-disable-next-line @typescript-eslint/no-explicit-any {next ? (
href={`/${portSlug}/admin/${next.href}` as any} <>
className="font-medium underline-offset-2 hover:underline" Next:{' '}
> <Link
{next.label} // eslint-disable-next-line @typescript-eslint/no-explicit-any
</Link> href={`/${portSlug}/admin/${next.href}` as any}
</> className="font-medium underline-offset-2 hover:underline"
) : null} >
{next.label}
</Link>
</>
) : null}
</span>
</span> </span>
</div> </div>
<div className="flex shrink-0 items-center gap-1"> <div className="flex shrink-0 items-center gap-1">

View File

@@ -102,9 +102,11 @@ export function YachtCard({ yacht, portSlug, onEdit, onArchive }: YachtCardProps
<span aria-hidden className="block h-9 w-9 shrink-0" /> <span aria-hidden className="block h-9 w-9 shrink-0" />
</div> </div>
{/* Owner subtitle */} {/* Owner subtitle. `flex min-w-0` (not inline-flex) so a long owner
name truncates within the card instead of overflowing ~11px on
the narrowest mobile widths (R2). */}
{yacht.currentOwnerName ? ( {yacht.currentOwnerName ? (
<p className="mt-0.5 inline-flex items-center gap-1 truncate text-sm text-muted-foreground"> <p className="mt-0.5 flex min-w-0 items-center gap-1 text-sm text-muted-foreground">
<OwnerIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden /> <OwnerIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" aria-hidden />
<span className="truncate">{yacht.currentOwnerName}</span> <span className="truncate">{yacht.currentOwnerName}</span>
</p> </p>

View File

@@ -82,13 +82,39 @@ test.describe('Responsive overflow sweep', () => {
// Elements whose right edge runs past the viewport by > 2px and are // Elements whose right edge runs past the viewport by > 2px and are
// actually visible (have size, not display:none). // actually visible (have size, not display:none).
const offscreen: { tag: string; cls: string; right: number; text: string }[] = []; const offscreen: { tag: string; cls: string; right: number; text: string }[] = [];
const SVG_INTERNAL = new Set([
'svg',
'g',
'ellipse',
'path',
'circle',
'rect',
'line',
'polyline',
'polygon',
]);
const els = document.querySelectorAll('body *'); const els = document.querySelectorAll('body *');
for (const el of els) { for (const el of els) {
const tag = el.tagName.toLowerCase();
// Skip SVG internals (icons, the react-grab dev overlay, chart guts)
// — not layout-cutoff signal.
if (SVG_INTERNAL.has(tag)) continue;
const r = (el as HTMLElement).getBoundingClientRect(); const r = (el as HTMLElement).getBoundingClientRect();
if (r.width === 0 || r.height === 0) continue; if (r.width === 0 || r.height === 0) continue;
if (r.right > vpWidth + 2 && r.left < vpWidth) { if (r.right > vpWidth + 2 && r.left < vpWidth) {
// overflowing the right edge (partially clipped) // Skip elements inside a horizontal-scroll container (data tables
const tag = el.tagName.toLowerCase(); // etc. scroll on purpose) — that's intended, not a clip.
let p: HTMLElement | null = el.parentElement;
let inScroll = false;
while (p) {
const ox = getComputedStyle(p).overflowX;
if (ox === 'auto' || ox === 'scroll') {
inScroll = true;
break;
}
p = p.parentElement;
}
if (inScroll) continue;
const cls = ((el as HTMLElement).className || '').toString().slice(0, 40); const cls = ((el as HTMLElement).className || '').toString().slice(0, 40);
const text = (el.textContent || '').trim().slice(0, 30); const text = (el.textContent || '').trim().slice(0, 30);
offscreen.push({ tag, cls, right: Math.round(r.right), text }); offscreen.push({ tag, cls, right: Math.round(r.right), text });