diff --git a/README.md b/README.md index ab75f8d..b06e557 100644 --- a/README.md +++ b/README.md @@ -281,6 +281,18 @@ npm test npm run build ``` +### Overflow check (phones) + +`scripts/check-overflow.js` visits every page at 360, 390, 412 and 414 px and reports any page that scrolls sideways +(`scrollWidth != clientWidth`) with its widest elements. It is not part of the gate and needs no dependency: + +1. Start the local stack (see "Run locally") and log in as an admin in the Playwright MCP browser. +2. Set `GAME_ID` (an appid in the library) and `PROFILE_NAME` (the display name of that user, the profile must be public) at the top of the script. +3. Run the file with the Playwright MCP tool `browser_run_code_unsafe` (`filename` = the script). An empty `hits` list is green. +4. Other runs: edit `FONT_PERCENT = 130` (large system font), `DARK`, `SKIN`. For the logged-out pages set `LOGGED_OUT = true` and run in a browser without a session (a logged-in browser redirects them). + +Use long display names (up to 24 characters in Auth) and long game names in the local data to stress the layout. + ## Roadmap See `ROADMAP.md` in the ToBePlayed-Deployment repo. diff --git a/scripts/check-overflow.js b/scripts/check-overflow.js new file mode 100644 index 0000000..9908a89 --- /dev/null +++ b/scripts/check-overflow.js @@ -0,0 +1,65 @@ +/* + * Sideways scroll check for phones. Not part of the gate, run by hand on the local stack (see README, "Overflow check"). + * A Playwright function, run with the Playwright MCP tool browser_run_code_unsafe (filename = this file) or + * `playwright-cli run-code`. Log in as an admin in that browser first. A logged-in browser redirects the logged-out + * pages, so check those in a second run with LOGGED_OUT = true in a clean browser (no session). + * A hit is: scrollWidth != clientWidth, an element sticking out on the left (never raises scrollWidth), or a + * redirect away from the page. + * Content clipped inside an overflow-hidden ancestor is not checked (same rule as scrollWidth). + */ +async (page) => { + const BASE = 'http://localhost:5173'; + const WIDTHS = [360, 390, 412, 414]; + const FONT_PERCENT = 100; // set 130 for the large system font run + const DARK = false; // set true for dark mode + const SKIN = ''; // e.g. 'pastel' to check a skin + const LOGGED_OUT = false; // true: only the logged-out pages, run in a browser without a session + const GAME_ID = 620; // any appid in the library + const PROFILE_NAME = 'Dev'; // display name of the logged-in user, the profile must be public + const PAGES = LOGGED_OUT ? ['/', '/login', '/register', '/reset-password', '/impressum', `/u/${encodeURIComponent(PROFILE_NAME)}`] : [ + '/dashboard', '/library', `/library/${GAME_ID}`, '/wishlist', '/play-next', '/stats', '/import', '/points', + '/settings', '/whats-new', '/welcome', '/audit/games', '/audit/links', '/audit/log', '/admin/users', + '/admin/hints', '/admin/news', '/admin/broadcasts', `/u/${encodeURIComponent(PROFILE_NAME)}`, + ]; + + const hits = []; + let checked = 0; + for (const width of WIDTHS) { + await page.setViewportSize({width, height: 800}); + for (const path of PAGES) { + await page.goto(BASE + path); // no networkidle: some pages poll + await page.waitForTimeout(1500); + if (FONT_PERCENT !== 100) await page.addStyleTag({content: `html{font-size:${FONT_PERCENT}%}`}); + await page.evaluate(([dark, skin]) => { + document.documentElement.classList.toggle('dark', dark); + if (skin) document.documentElement.dataset.skin = skin; + }, [DARK, SKIN]); + await page.waitForTimeout(300); + const result = await page.evaluate(() => { + const root = document.documentElement; + const describe = (element) => `${element.tagName.toLowerCase()}.${String(element.className).slice(0, 60)}`; + const all = [...document.querySelectorAll('body *')].map((element) => [element, element.getBoundingClientRect()]); + const right = all.filter(([, box]) => box.right > root.clientWidth).sort((a, b) => b[1].right - a[1].right); + // Clipped by an overflow:hidden/auto ancestor (an effect preview, a scroller): not visible, not a hit. + const clipped = (element) => { + for (let parent = element.parentElement; parent && parent !== document.body; parent = parent.parentElement) { + if (getComputedStyle(parent).overflowX !== 'visible') return true; + } + return false; + }; + const left = all.filter(([element, box]) => box.width > 0 && box.left < 0 && !clipped(element)); + if (root.scrollWidth === root.clientWidth && left.length === 0) return null; + return { + scrollWidth: root.scrollWidth, clientWidth: root.clientWidth, + widest: right.slice(0, 5).map(([element]) => describe(element)), + offLeft: left.slice(0, 5).map(([element]) => describe(element)), + }; + }); + checked++; + const landed = await page.evaluate(() => location.pathname); + if (decodeURIComponent(landed) !== decodeURIComponent(path)) hits.push({path, width, redirectedTo: landed}); + if (result) hits.push({path, width, ...result}); + } + } + return {checked, hits}; +} diff --git a/src/components/AccountInfoCard.jsx b/src/components/AccountInfoCard.jsx index 5ffe207..d3d7f82 100644 --- a/src/components/AccountInfoCard.jsx +++ b/src/components/AccountInfoCard.jsx @@ -40,7 +40,7 @@ export default function AccountInfoCard() { ))} - diff --git a/src/components/AchievementsCard.jsx b/src/components/AchievementsCard.jsx index a5233db..b7a66c2 100644 --- a/src/components/AchievementsCard.jsx +++ b/src/components/AchievementsCard.jsx @@ -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" > -
+
- - {game.name ?? `App ${game.appid}`} + + {game.unlocked} / {game.total} diff --git a/src/components/ActivityCard.jsx b/src/components/ActivityCard.jsx index 86cf669..3e1d444 100644 --- a/src/components/ActivityCard.jsx +++ b/src/components/ActivityCard.jsx @@ -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}) {
  • - - {g.name ?? `App ${g.appid}`} + + {formatPlaytime(g.playtime2Weeks)} diff --git a/src/components/AddGamePanel.jsx b/src/components/AddGamePanel.jsx index 72c03cb..027d61b 100644 --- a/src/components/AddGamePanel.jsx +++ b/src/components/AddGamePanel.jsx @@ -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}) {
    -
    {hit.name}{hit.year ? ` (${hit.year})` : ''}
    +
    + +
    diff --git a/src/components/AppHeader.jsx b/src/components/AppHeader.jsx index 9bd43a1..dba7030 100644 --- a/src/components/AppHeader.jsx +++ b/src/components/AppHeader.jsx @@ -123,14 +123,14 @@ export default function AppHeader({isAuthenticated, onLogout}) { )} -
    +
    {/* Below md the badge hangs on the header's bottom edge: in the row it would push the page sideways. */} {isAuthenticated && viewingAs && ( {t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})} )} {isAuthenticated && wallet && ( - )} {isAuthenticated ? ( diff --git a/src/components/AppHeader.points.test.jsx b/src/components/AppHeader.points.test.jsx index 28d8f71..7332875 100644 --- a/src/components/AppHeader.points.test.jsx +++ b/src/components/AppHeader.points.test.jsx @@ -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'); }); diff --git a/src/components/CurrentlyPlayingCard.jsx b/src/components/CurrentlyPlayingCard.jsx index f294b58..7716535 100644 --- a/src/components/CurrentlyPlayingCard.jsx +++ b/src/components/CurrentlyPlayingCard.jsx @@ -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 ( -
    +

    {t('DASHBOARD.CURRENTLY_PLAYING')} @@ -35,8 +36,8 @@ export default function CurrentlyPlayingCard({games, syncing, wide}) {
  • -
    - {g.name ?? `App ${g.appid}`} +
    +
    {formatPlaytime(g.playtimeMinutes)} diff --git a/src/components/DashboardWelcomeCard.jsx b/src/components/DashboardWelcomeCard.jsx index 817a567..d69c908 100644 --- a/src/components/DashboardWelcomeCard.jsx +++ b/src/components/DashboardWelcomeCard.jsx @@ -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. */} -
    +
    {/* The slot is there before the avatar loads, so the title does not jump. */} {hasSteamId && ( )} -
    +

    {t('DASHBOARD.WELCOME_BACK')}

    -

    - {t('DASHBOARD.HELLO')} {userData?.displayName ?? userData?.email?.split('@')[0]} 馃憢 +

    +

    {hasSteamId ? ( - + - {userData.steamId} + {userData.steamId} ) : (
    -
    +
    {steamId && ( - )} diff --git a/src/components/import/ImportInput.jsx b/src/components/import/ImportInput.jsx index ec53aaa..ab31b02 100644 --- a/src/components/import/ImportInput.jsx +++ b/src/components/import/ImportInput.jsx @@ -43,7 +43,7 @@ export default function ImportInput({storeRows, onCreated}) { return (

    {t('IMPORT.HINT')}

    -
    +
    {['TEXT', 'CSV', 'BOOKMARKLET'].map((value) => ( diff --git a/src/components/stats/GameRow.jsx b/src/components/stats/GameRow.jsx index 0be9bbf..f86c60b 100644 --- a/src/components/stats/GameRow.jsx +++ b/src/components/stats/GameRow.jsx @@ -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 = ( <> - {game.name ?? `App ${game.appid}`} + {value} ); diff --git a/src/pages/AdminBroadcastPage.jsx b/src/pages/AdminBroadcastPage.jsx index 1742cdb..50e4283 100644 --- a/src/pages/AdminBroadcastPage.jsx +++ b/src/pages/AdminBroadcastPage.jsx @@ -18,6 +18,7 @@ import {Input} from '@/components/ui/input'; import {Label} from '@/components/ui/label'; import {Textarea} from '@/components/ui/textarea'; import PageContainer from '../components/PageContainer.jsx'; +import FitText from '../components/FitText.jsx'; import {fetchUsers} from '../utils/admin.js'; import {fetchBroadcasts, fetchRecipientCount, sendBroadcast, sendTestBroadcast} from '../utils/broadcasts.js'; import {formatDateTime} from '../utils/format.js'; @@ -218,12 +219,12 @@ export default function AdminBroadcastPage() { {found.length > 0 && (
      {found.map((user) => ( -
    • -
    • ))} @@ -235,9 +236,9 @@ export default function AdminBroadcastPage() { {picked.length > 0 && (
        {picked.map((user) => ( -
      • - {user.email} - diff --git a/src/pages/AdminHintsPage.jsx b/src/pages/AdminHintsPage.jsx index 5d95d10..dd83a50 100644 --- a/src/pages/AdminHintsPage.jsx +++ b/src/pages/AdminHintsPage.jsx @@ -182,11 +182,11 @@ export default function AdminHintsPage() {

        {t('ADMIN_HINTS.TARGET_HELP')}

        -
        +
        -
        +
        diff --git a/src/pages/AdminUsersPage.jsx b/src/pages/AdminUsersPage.jsx index 39b2c89..4c6c454 100644 --- a/src/pages/AdminUsersPage.jsx +++ b/src/pages/AdminUsersPage.jsx @@ -151,6 +151,11 @@ export default function AdminUsersPage() { const confirmDisabled = busy || (pending?.kind === 'delete' && typedEmail.trim().toLowerCase() !== pending.user.email.toLowerCase()); + const metaLine = (user) => [user.displayName, + t('ADMIN.REGISTERED', {date: formatDateOrNull(user.registrationDate, i18n.language)}), + t('ADMIN.LAST_LOGIN', {date: formatDateOrNull(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}), + ].filter(Boolean).join(' 路 '); + return ( @@ -184,11 +189,8 @@ export default function AdminUsersPage() {
      • {user.email}

        -

        - {[user.displayName, - t('ADMIN.REGISTERED', {date: formatDateOrNull(user.registrationDate, i18n.language)}), - t('ADMIN.LAST_LOGIN', {date: formatDateOrNull(user.lastLoginDate, i18n.language) ?? t('ADMIN.NEVER')}), - ].filter(Boolean).join(' 路 ')} +

        + {metaLine(user)}

        diff --git a/src/pages/AuditLogPage.jsx b/src/pages/AuditLogPage.jsx index dfca2bc..0729c4b 100644 --- a/src/pages/AuditLogPage.jsx +++ b/src/pages/AuditLogPage.jsx @@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import PageContainer from '../components/PageContainer.jsx'; +import FitText from '../components/FitText.jsx'; import {toast} from 'sonner'; import {fetchAuditLog, undoMerge} from '../utils/links.js'; import {formatDateTime} from '../utils/format.js'; @@ -18,7 +19,11 @@ const ALL = 'ALL'; function GameRef({appid, name, fallback}) { if (appid == null) return null; - return {name ?? fallback}; + return ( + + + + ); } /** @@ -117,10 +122,10 @@ export default function AuditLogPage() { {status === 'ready' && result.content.length > 0 && (
          {result.content.map((entry) => ( -
        • +
        • {formatDateTime(entry.createdAt, i18n.language)} - {entry.actorName ?? t('AUDIT.USER_ID', {id: entry.actorUserId})} + {t(`AUDIT.ACTION.${entry.action}`)} diff --git a/src/pages/AuditLogPage.test.jsx b/src/pages/AuditLogPage.test.jsx index a81d7a5..3af0ca9 100644 --- a/src/pages/AuditLogPage.test.jsx +++ b/src/pages/AuditLogPage.test.jsx @@ -51,6 +51,14 @@ describe('AuditLogPage', () => { expect(screen.getAllByText('Old key').length).toBeGreaterThan(0); }); + it('showsNoNullTextForAKeyWithoutNameAndDetail', async () => { + renderPage(answer([entry({action: 'GAME_LINK_DISMISS', gameName: null, detail: null, targetGameName: 'Hades'})])); + + await screen.findByText('lobo'); + expect(screen.queryByText('null')).toBeNull(); + expect(screen.queryByText('undefined')).toBeNull(); + }); + it('pagesThroughTheLog', async () => { renderPage(() => answer([entry({})], 2)); await screen.findByText('lobo'); diff --git a/src/pages/GameCatalogPage.jsx b/src/pages/GameCatalogPage.jsx index da2ceea..c43c4ab 100644 --- a/src/pages/GameCatalogPage.jsx +++ b/src/pages/GameCatalogPage.jsx @@ -9,6 +9,7 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Input} from '@/components/ui/input'; import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx'; import GameCover from '../components/GameCover.jsx'; +import FitText from '../components/FitText.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {fetchCatalogGame, searchCatalog} from '../utils/corrections.js'; import {MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js'; @@ -137,8 +138,8 @@ export default function GameCatalogPage() { {result.content.map((game) => (

        • -
          -

          {game.name}

          +
          +

          {game.platforms.join(', ')} 路 {t('CATALOG.KEY', {appid: game.appid})}

          diff --git a/src/pages/GameLibrary.jsx b/src/pages/GameLibrary.jsx index 1cbc305..4444863 100644 --- a/src/pages/GameLibrary.jsx +++ b/src/pages/GameLibrary.jsx @@ -17,6 +17,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/c import {Skeleton} from '@/components/ui/skeleton'; import AddGamePanel from '../components/AddGamePanel.jsx'; import GameCover from '../components/GameCover.jsx'; +import FitText from '../components/FitText.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx'; import GameStatusBadge from '../components/GameStatusBadge.jsx'; import PageContainer from '../components/PageContainer.jsx'; @@ -302,8 +303,8 @@ export default function GameLibrary() { > - - {g.name ?? `App ${g.appid}`} + + {platformLabel(g) && ( diff --git a/src/pages/LandingPage.jsx b/src/pages/LandingPage.jsx index c086ace..67accaf 100644 --- a/src/pages/LandingPage.jsx +++ b/src/pages/LandingPage.jsx @@ -9,6 +9,7 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; +import FitText from '../components/FitText.jsx'; import Logo from '../components/Logo.jsx'; import StatCard from '../components/StatCard.jsx'; import {YearBars} from '../components/stats/FinishedStats.jsx'; @@ -30,7 +31,7 @@ function RankedGames({title, games, formatValue}) {
        • {idx + 1} - {g.name ?? `App ${g.appid}`} + {formatValue(g.value ?? 0)}
        • ))} diff --git a/src/pages/LinkQueuePage.jsx b/src/pages/LinkQueuePage.jsx index 4f2f1d9..59abe9c 100644 --- a/src/pages/LinkQueuePage.jsx +++ b/src/pages/LinkQueuePage.jsx @@ -8,6 +8,7 @@ import {Badge} from '@/components/ui/badge'; import {Button} from '@/components/ui/button'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import GameCover from '../components/GameCover.jsx'; +import FitText from '../components/FitText.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {dismissPair, fetchLinkQueue, linkGames} from '../utils/links.js'; @@ -17,7 +18,7 @@ function PairGame({game}) { return ( - {game.name ?? `App ${game.appid}`} + ); } diff --git a/src/pages/PlayNextPage.jsx b/src/pages/PlayNextPage.jsx index 417a950..381c3a3 100644 --- a/src/pages/PlayNextPage.jsx +++ b/src/pages/PlayNextPage.jsx @@ -9,6 +9,7 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; +import FitText from '../components/FitText.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {saveGameStatus} from '../utils/backlog.js'; import {formatPlaytime} from '../utils/games.js'; @@ -210,8 +211,8 @@ function PickCard({game}) {
          - - {game.name ?? `App ${game.appid}`} + +
          {game.timeToBeatMinutes > 0 && ( diff --git a/src/pages/PointsPage.jsx b/src/pages/PointsPage.jsx index 180e46a..703ed88 100644 --- a/src/pages/PointsPage.jsx +++ b/src/pages/PointsPage.jsx @@ -10,6 +10,7 @@ import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; import {cn} from '@/lib/utils'; import EffectBackground from '../components/EffectBackground.jsx'; +import FitText from '../components/FitText.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {usePreferences} from '../context/PreferencesContext.jsx'; import useInView from '../hooks/useInView.js'; @@ -53,8 +54,8 @@ export default function PointsPage() { const entryLabel = (entry) => { if (GAME_KINDS.includes(entry.kind)) { return ( - - {entry.gameName ?? entry.ref} + + ); } @@ -128,13 +129,13 @@ export default function PointsPage() { {wallet.history.map((entry) => { const label = entryLabel(entry); return ( -
        • +
        • {formatDate(entry.eventAt, i18n.language)} - - {t(`POINTS.KINDS.${entry.kind}`)} - {label && <> {label}} + + {t(`POINTS.KINDS.${entry.kind}`)} + {label && {label}} {entry.points !== 0 && ( diff --git a/src/pages/PublicProfilePage.jsx b/src/pages/PublicProfilePage.jsx index fd8a5c5..5f3b647 100644 --- a/src/pages/PublicProfilePage.jsx +++ b/src/pages/PublicProfilePage.jsx @@ -8,6 +8,7 @@ import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; import {cn} from '@/lib/utils'; import GameCover from '../components/GameCover.jsx'; +import FitText from '../components/FitText.jsx'; import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx'; import {formatDay} from '../utils/format.js'; import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js'; @@ -162,7 +163,9 @@ export default function PublicProfilePage() {

          {t('PROFILE.TITLE')}

          -

          {profile.displayName}

          +

          + +

          {memberSince && (

          {t('PROFILE.MEMBER_SINCE', {date: memberSince})}

          @@ -241,7 +244,7 @@ function Playing({games, compact}) { {games.map((g) => (
        • -
          {g.name ?? `App ${g.appid}`}
          +
          {formatPlaytime(g.playtimeMinutes)}
        • ))} @@ -262,7 +265,7 @@ function Finished({games, equipped}) {
        • - {g.name ?? `App ${g.appid}`} + {g.rating && ( {Array.from({length: g.rating}, (_, i) => ( diff --git a/src/pages/WishlistPage.jsx b/src/pages/WishlistPage.jsx index bf69ed3..3dd6231 100644 --- a/src/pages/WishlistPage.jsx +++ b/src/pages/WishlistPage.jsx @@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button'; import {Card} from '@/components/ui/card'; import {Skeleton} from '@/components/ui/skeleton'; import GameCover from '../components/GameCover.jsx'; +import FitText from '../components/FitText.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx'; import PageContainer from '../components/PageContainer.jsx'; import {apiFetch} from '../utils/api.js'; @@ -101,9 +102,9 @@ export default function WishlistPage() { className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50" > -
          -
          - {g.name ?? `App ${g.appid}`} +
          +
          +
          {g.addedAt && (
          diff --git a/src/style.css b/src/style.css index 6c8b512..52a0555 100644 --- a/src/style.css +++ b/src/style.css @@ -162,6 +162,16 @@ --radius: 1.25rem; } +/* See components/FitText.jsx. 0.55 = rough glyph width of Inter in em. No @container ancestor: the viewport counts. */ +@utility fit-text { + display: block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: min(1em, max(0.875rem, 100cqi / (var(--fit-chars, 1) * 0.55))); +} + @theme inline { --font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif; --radius-sm: calc(var(--radius) - 4px); @@ -271,6 +281,8 @@ body { @apply bg-background text-foreground antialiased; + /* One long word breaks instead of widening the page. */ + overflow-wrap: break-word; } /* The open list of a native select is drawn by the browser: without these it stays light in dark mode, with the