fix(layout): fit names on phones without sideways scroll
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 24s

Display and game names shrink to 0.875rem, then end with an ellipsis
(FitText + fit-text utility, CSS container units, full text in title).
Rows and buttons wrap at large fonts, header points chip can shrink.
Guard: scripts/check-overflow.js.
This commit is contained in:
lobo committed 2026-10-03 12:18:31 +02:00
1 parent 04387fcd0c
commit d7810fbf94
37 files changed
+263 -79

No files matched your search

+1 -1
View File
@@ -40,7 +40,7 @@ export default function AccountInfoCard() {
</div>
))}
</dl>
<Button asChild variant="link" className="mt-3 h-auto p-0">
<Button asChild variant="link" className="mt-3 h-auto max-w-full whitespace-normal p-0 text-left">
<Link to="/welcome"><Rocket/>{t('SETTINGS.OPEN_WELCOME')}</Link>
</Button>
</CardContent>
+4 -3
View File
@@ -4,6 +4,7 @@ import {Trophy} from 'lucide-react';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
/**
* @param summary the answer of GET /api/library/achievements/summary
@@ -55,10 +56,10 @@ export default function AchievementsCard({summary}) {
className="group flex items-center gap-3 rounded-lg p-1 transition-colors hover:bg-muted/50"
>
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<div className="min-w-0 flex-1 space-y-1">
<div className="@container min-w-0 flex-1 space-y-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-sm font-medium group-hover:text-primary transition-colors">
{game.name ?? `App ${game.appid}`}
<span className="min-w-0 text-sm font-medium group-hover:text-primary transition-colors">
<FitText text={game.name ?? `App ${game.appid}`}/>
</span>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">
{game.unlocked} / {game.total}
+3 -2
View File
@@ -4,6 +4,7 @@ import {Activity} from 'lucide-react';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {formatPlaytime} from '../utils/games.js';
import {formatDay} from '../utils/format.js';
@@ -64,8 +65,8 @@ export default function ActivityCard({activity}) {
<li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group flex items-center gap-3">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
<span className="@container min-w-0 flex-1 text-sm font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</span>
<span className="text-sm text-muted-foreground tabular-nums">{formatPlaytime(g.playtime2Weeks)}</span>
</Link>
+4 -1
View File
@@ -10,6 +10,7 @@ import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import GameSearch from './GameSearch.jsx';
import useStartImport from '../hooks/useStartImport.js';
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
@@ -122,7 +123,9 @@ function AddForm({hit, favorite, onBack, navigate}) {
<form onSubmit={submit} className="space-y-4">
<div className="flex items-center gap-3">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-24 shrink-0"/>
<div className="min-w-0 font-medium">{hit.name}{hit.year ? ` (${hit.year})` : ''}</div>
<div className="@container min-w-0 flex-1 font-medium">
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
+3 -3
View File
@@ -123,14 +123,14 @@ export default function AppHeader({isAuthenticated, onLogout}) {
</nav>
)}
<div className="ml-auto flex items-center gap-1">
<div className="ml-auto flex min-w-0 items-center gap-1">
{/* Below md the badge hangs on the header's bottom edge: in the row it would push the page sideways. */}
{isAuthenticated && viewingAs && (
<Badge variant="secondary" className="max-md:absolute max-md:top-full max-md:left-1/2 max-md:-translate-x-1/2 max-md:-translate-y-1/2 max-md:pointer-events-none max-md:border-border md:mr-1">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
)}
{isAuthenticated && wallet && (
<Button asChild variant="ghost" size="sm" title={t('POINTS.TITLE')}>
<Link to="/points"><Coins className="text-primary"/><span className="tabular-nums">{wallet.balance}</span></Link>
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="min-w-0 shrink">
<Link to="/points"><Coins className="text-primary"/><span className="min-w-0 truncate tabular-nums">{wallet.balance}</span></Link>
</Button>
)}
{isAuthenticated ? (
+1 -1
View File
@@ -31,7 +31,7 @@ describe('AppHeader points chip', () => {
fireEvent.click(screen.getByText('load'));
const chip = await screen.findByTitle('POINTS.TITLE');
const chip = await screen.findByTitle('POINTS.TITLE: 135');
expect(chip.textContent).toBe('135');
expect(chip.getAttribute('href')).toBe('/points');
});
+4 -3
View File
@@ -6,6 +6,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {formatPlaytime} from '../utils/games.js';
/**
@@ -15,7 +16,7 @@ export default function CurrentlyPlayingCard({games, syncing, wide}) {
const {t} = useTranslation();
return (
<Card className="gap-0 py-0">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex flex-wrap items-center justify-between gap-x-3 px-5 py-4 border-b">
<h2 className="flex items-center gap-2 font-semibold">
<Gamepad2 className="size-4 text-primary"/>
{t('DASHBOARD.CURRENTLY_PLAYING')}
@@ -35,8 +36,8 @@ export default function CurrentlyPlayingCard({games, syncing, wide}) {
<li key={g.appid}>
<Link to={`/library/${g.appid}`} className="group block space-y-1.5">
<GameCover appid={g.appid} imageUrl={g.imageUrl} className="w-full transition-opacity group-hover:opacity-80"/>
<div className="truncate text-sm font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
<div className="@container text-sm font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</div>
<div className="text-xs text-muted-foreground tabular-nums">
{formatPlaytime(g.playtimeMinutes)}
+7 -6
View File
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import HeroBanner from './HeroBanner.jsx';
import FitText from './FitText.jsx';
import PointsBadge from './PointsBadge.jsx';
import SteamAvatar from './SteamAvatar.jsx';
import SyncStatusBadge from './SyncStatusBadge.jsx';
@@ -24,7 +25,7 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
lookClass(equipped, 'BANNER_FRAME'))}>
{/* A picked game, else the game played most in the last two weeks. */}
<HeroBanner appid={bannerAppid} effect={bannerEffect(equipped)}/>
<div className="relative flex min-w-0 items-center gap-4">
<div className="relative flex min-w-0 items-center gap-4 md:flex-1">
{/* The slot is there before the avatar loads, so the title does not jump. */}
{hasSteamId && (
<span className={cn('flex size-16 shrink-0 items-center justify-center rounded-full bg-muted',
@@ -32,16 +33,16 @@ export default function DashboardWelcomeCard({bannerAppid, avatarUrl, sync, forc
<SteamAvatar url={avatarUrl} className="size-16" iconClassName="size-8 text-muted-foreground"/>
</span>
)}
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-muted-foreground">{t('DASHBOARD.WELCOME_BACK')}</p>
<h1 className="mt-1 text-3xl md:text-4xl font-bold tracking-tight truncate">
{t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 👋
<h1 className="@container mt-1 text-3xl md:text-4xl font-bold tracking-tight">
<FitText text={[t('DASHBOARD.HELLO'), userData?.displayName ?? userData?.email?.split('@')[0], '👋'].filter(Boolean).join(' ')}/>
</h1>
<PointsBadge equipped={equipped} className="mt-2"/>
{hasSteamId ? (
<Badge variant="outline" className="mt-3 gap-1.5 font-mono text-muted-foreground">
<Badge variant="outline" className="mt-3 max-w-full gap-1.5 font-mono text-muted-foreground">
<Hash className="size-3"/>
{userData.steamId}
<span className="min-w-0 truncate">{userData.steamId}</span>
</Badge>
) : (
<Button asChild variant="link" className="mt-2 h-auto p-0">
@@ -52,3 +52,14 @@ describe('DashboardWelcomeCard looks', () => {
.toContain('ring-primary/60');
});
});
describe('DashboardWelcomeCard greeting', () => {
it('greetsWithoutUndefinedBeforeTheUserIsLoaded', () => {
fetchRoutes({'GET /api/library/hero-art': json(200, {})});
renderWithProviders(<DashboardWelcomeCard sync={{}} forceSync={{}} equipped={{}}/>);
const heading = screen.getByRole('heading', {level: 1});
expect(heading.textContent).not.toContain('undefined');
expect(heading.querySelector('[title*="undefined"]')).toBeNull();
});
});
+4 -3
View File
@@ -7,6 +7,7 @@ import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameStatusBadge from './GameStatusBadge.jsx';
import FitText from './FitText.jsx';
import {unlinkGame} from '../utils/links.js';
import {platformLabel} from '../utils/platforms.js';
@@ -44,9 +45,9 @@ export default function EditionsCard({game, auditor, onRequest, onChanged}) {
<p className="font-medium">{t('LINKS.ALSO_IN_LIBRARY')}</p>
<ul className="space-y-1">
{game.editions.map((edition) => (
<li key={edition.appid} className="flex flex-wrap items-center gap-2">
<Link to={`/library/${edition.appid}`} className="hover:text-primary hover:underline">
{edition.name}
<li key={edition.appid} className="@container flex flex-wrap items-center gap-2">
<Link to={`/library/${edition.appid}`} className="min-w-0 max-w-full hover:text-primary hover:underline">
<FitText text={edition.name}/>
</Link>
{platformLabel(edition) && (
<span className="text-muted-foreground">({platformLabel(edition)})</span>
+1 -1
View File
@@ -60,7 +60,7 @@ export default function FinishedHint({game, onChanged, onDismissed}) {
<Flag className="size-5 shrink-0 text-emerald-500"/>
<p className="text-sm">{text}</p>
</div>
<div className="flex gap-2 self-end sm:self-auto">
<div className="flex flex-wrap justify-end gap-2">
<Button size="sm" variant="outline" onClick={notYet} disabled={busy}>{t('GAME_DETAIL.NOT_YET')}</Button>
<Button size="sm" onClick={setFinished} disabled={busy}>
{busy && <Loader2 className="animate-spin"/>}
+18
View File
@@ -0,0 +1,18 @@
import {cn} from '@/lib/utils';
const WRAP_ON_DESKTOP = 'md:whitespace-normal md:overflow-visible md:[font-size:1em] md:break-words';
/**
* A name that shrinks to 0.875rem with its length, then ends with "...". The parent sets the font size, so keep
* text-* classes off this element; the nearest `@container` ancestor sets the width. Put @container only on an element
* whose width does not come from its content, or it collapses to 0.
* @param wrapOnDesktop from md up: full size and wrapping, no shrink or "..."
*/
export default function FitText({text, wrapOnDesktop, className}) {
return (
<span className={cn('fit-text', wrapOnDesktop && WRAP_ON_DESKTOP, className)}
style={{'--fit-chars': Math.max(text?.length ?? 0, 1)}} title={text || undefined}>
{text}
</span>
);
}
+25
View File
@@ -0,0 +1,25 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import FitText from './FitText.jsx';
afterEach(cleanup);
describe('FitText', () => {
it('shows the text, the full text as title, and its length for the CSS', () => {
render(<FitText text="Hollow Knight"/>);
const element = screen.getByText('Hollow Knight');
expect(element.title).toBe('Hollow Knight');
expect(element.style.getPropertyValue('--fit-chars')).toBe('13');
expect(element.className).toContain('fit-text');
});
it('renders an empty span without title for missing text', () => {
const {container} = render(<FitText/>);
const element = container.querySelector('span');
expect(element.hasAttribute('title')).toBe(false);
expect(element.style.getPropertyValue('--fit-chars')).toBe('1');
});
});
+3 -2
View File
@@ -8,6 +8,7 @@ import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import GameStatusBadge from './GameStatusBadge.jsx';
import ProfileArtMenu from './ProfileArtMenu.jsx';
import TimeToBeat from './TimeToBeat.jsx';
@@ -68,8 +69,8 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
className="w-full rounded-none md:w-96 md:shrink-0"/>
<div className="relative flex flex-1 flex-col justify-between gap-4 p-6">
<div>
<h1 className="text-2xl md:text-3xl font-bold tracking-tight">
{game.name ?? `App ${game.appid}`}
<h1 className="@container text-2xl md:text-3xl font-bold tracking-tight">
<FitText wrapOnDesktop text={game.name ?? `App ${game.appid}`}/>
</h1>
<div className="mt-3 flex flex-wrap gap-2">
<Badge variant="secondary" className="tabular-nums">
+4 -3
View File
@@ -5,6 +5,7 @@ import {Loader2, Search} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {searchGames} from '../utils/manualGames.js';
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
@@ -59,9 +60,9 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
<button type="button" disabled={blocked} onClick={() => onPick(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{hit.name}{hit.year ? ` (${hit.year})` : ''}
<span className="@container min-w-0 flex-1">
<span className="block font-medium">
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/>
</span>
<span className="block truncate text-xs text-muted-foreground">
{hit.platforms.join(' · ')}
+4 -4
View File
@@ -12,19 +12,19 @@ const TABS = [
export default function LibraryTabs() {
const {t} = useTranslation();
return (
<nav aria-label={t('LIBRARY.TITLE')} className="inline-flex rounded-lg bg-muted p-1 text-sm">
<nav aria-label={t('LIBRARY.TITLE')} className="inline-flex max-w-full rounded-lg bg-muted p-1 text-sm">
{TABS.map(({to, labelKey, icon: Icon}) => (
<NavLink
key={to}
to={to}
end
className={({isActive}) => cn(
'flex items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors',
'flex min-w-0 items-center gap-1.5 rounded-md px-3 py-1.5 font-medium transition-colors',
isActive ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
)}
>
<Icon className="size-4"/>
{t(labelKey)}
<Icon className="size-4 shrink-0"/>
<span className="truncate">{t(labelKey)}</span>
</NavLink>
))}
</nav>
+2 -1
View File
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {linkGames, requestLink, searchOwnGames} from '../utils/links.js';
import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
import {platformLabel} from '../utils/platforms.js';
@@ -112,7 +113,7 @@ export default function LinkGameDialog({game, open, onOpenChange, auditor, onDon
<button type="button" onClick={() => setPicked(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{hit.name}</span>
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={hit.name}/></span>
{platformLabel(hit) && (
<span className="text-xs text-muted-foreground">{platformLabel(hit)}</span>
)}
+5 -4
View File
@@ -5,6 +5,7 @@ import {ArrowRight, Trophy} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import {formatPlaytime} from '../utils/games.js';
import {coverEffect} from '../utils/rewards.js';
@@ -13,7 +14,7 @@ export default function MostPlayedCard({games, syncing, equipped}) {
const maxMinutes = games[0]?.playtimeMinutes || 1;
return (
<Card className="gap-0 py-0">
<div className="flex items-center justify-between px-5 py-4 border-b">
<div className="flex flex-wrap items-center justify-between gap-x-3 px-5 py-4 border-b">
<h2 className="flex items-center gap-2 font-semibold">
<Trophy className="size-4 text-amber-500"/>
{t('DASHBOARD.MOST_PLAYED')}
@@ -40,9 +41,9 @@ export default function MostPlayedCard({games, syncing, equipped}) {
</span>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<div className="min-w-0 flex-1">
<div className="truncate font-medium group-hover:text-primary transition-colors">
{g.name ?? `App ${g.appid}`}
<div className="@container min-w-0 flex-1">
<div className="font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</div>
<div className="mt-2 h-1.5 rounded-full bg-muted">
<div
+4 -2
View File
@@ -73,12 +73,14 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
<p className="text-sm text-muted-foreground">{t('SETTINGS.STEAM_LINK.VERIFY_HINT')}</p>
)}
<div className="flex flex-wrap gap-2">
<Button type="button" variant={steamId && verified ? 'outline' : 'default'} onClick={link} disabled={busy}>
<Button type="button" variant={steamId && verified ? 'outline' : 'default'} onClick={link} disabled={busy}
className="h-auto max-w-full whitespace-normal py-2 text-left">
{busy ? <Loader2 className="animate-spin"/> : <Link2/>}
{t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')}
</Button>
{steamId && (
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
<Button type="button" variant="outline" onClick={unlink} disabled={busy}
className="h-auto max-w-full whitespace-normal py-2 text-left">
<Unlink/>{t('SETTINGS.STEAM_LINK.UNLINK')}
</Button>
)}
+1 -1
View File
@@ -43,7 +43,7 @@ export default function ImportInput({storeRows, onCreated}) {
return (
<Card className="gap-4 p-5">
<p className="text-sm text-muted-foreground">{t('IMPORT.HINT')}</p>
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex gap-2">
<div role="group" aria-label={t('IMPORT.SOURCE')} className="flex flex-wrap gap-2">
{['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => (
<Button key={value} size="sm" variant={source === value ? 'default' : 'outline'} aria-pressed={source === value}
onClick={() => setSource(value)}>{t(`IMPORT.SOURCE_${value}`)}</Button>
+2 -1
View File
@@ -1,5 +1,6 @@
import {Link} from 'react-router-dom';
import GameCover from '../GameCover.jsx';
import FitText from '../FitText.jsx';
/**
* A game with its cover and a value on the right; links to the game page only in the user's own library.
@@ -8,7 +9,7 @@ export default function GameRow({game, value, linked = true}) {
const content = (
<>
<GameCover appid={game.appid} imageUrl={game.imageUrl} className="w-16 shrink-0"/>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{game.name ?? `App ${game.appid}`}</span>
<span className="@container min-w-0 flex-1 text-sm font-medium"><FitText text={game.name ?? `App ${game.appid}`}/></span>
<span className="text-sm text-muted-foreground tabular-nums">{value}</span>
</>
);