fix(ui): mobile cutoff polish — onboarding banner + yacht owner truncate (R1/R2)
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:
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
Reference in New Issue
Block a user