feat(ui): add the ToBePlayed logo and name
Build, Test & Push Frontend / quality-check (push) Successful in 14s
Build, Test & Push Frontend / docker (push) Successful in 22s

The logo is a traced SVG whose colors follow the accent color;
violet shows it as designed. Favicon, PWA and Apple icons use
it with fixed colors. The header and landing page say
ToBePlayed instead of Game Log. Below 380 px the header shows
only the logo, and the home link keeps its name for screen
readers.
This commit is contained in:
lobo committed 2026-09-26 07:05:57 +02:00
1 parent 25d2f0d0bd
commit c52c5e02d2
13 files changed
+195 -16

No files matched your search

+6 -6
View File
@@ -1,6 +1,6 @@
import {Link, NavLink, useLocation} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Dices, Gamepad2, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
import {Dices, Languages, LayoutDashboard, Library, LogOut, Moon, Settings, Sun} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {
@@ -12,6 +12,7 @@ import {
} from '@/components/ui/dropdown-menu';
import {usePreferences} from '@/context/PreferencesContext.jsx';
import {cn} from '@/lib/utils';
import Logo from './Logo.jsx';
import PageContainer from './PageContainer.jsx';
import SteamAvatar from './SteamAvatar.jsx';
import useSteamProfile from '../hooks/useSteamProfile.js';
@@ -43,11 +44,10 @@ export default function AppHeader({isAuthenticated, onLogout}) {
return (
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<PageContainer className="flex h-16 items-center gap-2 px-4 md:px-8">
<Link to={isAuthenticated ? '/dashboard' : '/'} className="mr-2 flex items-center gap-2 font-bold tracking-tight whitespace-nowrap">
<span className="flex size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Gamepad2 className="size-5"/>
</span>
<span className="text-lg">Game Log</span>
<Link to={isAuthenticated ? '/dashboard' : '/'} aria-label="ToBePlayed" className="mr-2 flex items-center gap-2 font-bold tracking-tight whitespace-nowrap">
<Logo className="h-8 w-auto shrink-0"/>
{/* On a narrow phone the menu and buttons need the room; the logo alone stays. */}
<span className="hidden text-lg min-[380px]:inline">ToBePlayed</span>
</Link>
{isAuthenticated && (
+24
View File
@@ -0,0 +1,24 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import AppHeader from './AppHeader.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(cleanup);
describe('AppHeader', () => {
// jsdom applies no media queries, so the test hides the name the way the 380px breakpoint does.
it('keeps a name on the home link when a narrow phone hides the app name', () => {
render(
<MemoryRouter>
<PreferencesProvider><AppHeader isAuthenticated={false} onLogout={() => {}}/></PreferencesProvider>
</MemoryRouter>,
);
screen.getByText('ToBePlayed').style.display = 'none';
expect(screen.getByRole('link', {name: 'ToBePlayed'})).toBeTruthy();
});
});
+64
View File
@@ -0,0 +1,64 @@
import {useId} from 'react';
import {BODY, CHECKLIST, DPAD, OUTLINE, PROGRESS_BAR} from './logoPaths.js';
// Each color is [themed CSS, fixed hex]. The hex is the logo as designed; it also stands in where a browser
// cannot read color-mix, which would otherwise make that stop black.
const violet = ['var(--logo-violet, #7467f9)', '#7467f9'];
const blue = ['var(--logo-blue, #0192ef)', '#0192ef'];
const teal = ['var(--logo-teal, #17c7a4)', '#17c7a4'];
const mix = ([a], [b], percentOfB, hex) => [`color-mix(in oklch, ${a}, ${b} ${percentOfB}%)`, hex];
function Gradient({id, from, to, stops}) {
return (
<linearGradient id={id} gradientUnits="userSpaceOnUse" x1={from[0]} y1={from[1]} x2={to[0]} y2={to[1]}>
{stops.map(([offset, [color, hex]]) => <stop key={offset} offset={offset} stopColor={hex} style={{stopColor: color}}/>)}
</linearGradient>
);
}
/**
* The ToBePlayed logo. Its three colors come from --logo-violet, --logo-blue and --logo-teal, which style.css sets
* per accent color.
*/
export default function Logo({className, title}) {
// Every copy on the page needs its own ids, or a hidden copy can take the gradients of a visible one with it.
const id = useId().replace(/[^a-zA-Z0-9_-]/g, '');
const ref = (name) => `${name}-${id}`;
const url = (name) => `url(#${ref(name)})`;
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="255 225 690 445" className={className}
role={title ? 'img' : undefined} aria-label={title} aria-hidden={title ? undefined : true}>
<defs>
<clipPath id={ref('outline')}><path d={OUTLINE}/></clipPath>
<Gradient id={ref('ring')} from={[265, 660]} to={[935, 235]}
stops={[[0, violet], [0.15, violet], [0.42, blue], [0.7, teal], [1, teal]]}/>
<Gradient id={ref('list')} from={[674, 326]} to={[525, 489]}
stops={[[0, teal], [0.45, mix(blue, teal, 45, '#1dacad')], [0.7, blue], [1, mix(violet, blue, 50, '#4f6ad1')]]}/>
<Gradient id={ref('dpad')} from={[478, 336]} to={[363, 451]}
stops={[[0, blue], [1, mix(blue, violet, 20, '#2a83e4')]]}/>
<Gradient id={ref('buttons')} from={[843, 326]} to={[708, 463]}
stops={[[0, teal], [1, mix(teal, blue, 25, '#15b4ae')]]}/>
<Gradient id={ref('bar')} from={[476, 610]} to={[723, 610]}
stops={[[0, violet], [0.45, blue], [0.75, mix(blue, teal, 45, '#1dacad')], [1, teal]]}/>
</defs>
<path fill={url('ring')} d={OUTLINE}/>
<path fill="#2d3641" d={BODY}/>
<g fill="#192330" clipPath={url('outline')}>
<rect x="502" y="289" width="195" height="233"/>
<path d="M593.6 250H616.6L588 295H563Z"/>
<path d="M595.3 516H620.3L591.4 560H567.4Z"/>
</g>
<path fill={url('dpad')} d={DPAD}/>
<g fill={url('buttons')}>
<circle cx="775.5" cy="347.5" r="22"/>
<circle cx="729.5" cy="394" r="22"/>
<circle cx="821.5" cy="393.5" r="22"/>
<circle cx="775.5" cy="441" r="22"/>
</g>
<path fill={url('list')} fillRule="evenodd" d={CHECKLIST}/>
<path fill={url('bar')} fillRule="evenodd" d={PROGRESS_BAR}/>
</svg>
);
}
+39
View File
@@ -0,0 +1,39 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it} from 'vitest';
import {cleanup, render} from '@testing-library/react';
import Logo from './Logo.jsx';
afterEach(cleanup);
describe('Logo', () => {
it('gives each copy its own gradients, and each copy points only at its own', () => {
const {container} = render(<><Logo/><Logo/></>);
const [first, second] = container.querySelectorAll('svg');
const ids = (svg) => [...svg.querySelectorAll('[id]')].map((element) => element.id);
const references = (svg) => [...svg.querySelectorAll('*')]
.flatMap((element) => [element.getAttribute('fill'), element.getAttribute('clip-path')])
.filter((value) => value?.startsWith('url(#'))
.map((value) => value.slice(5, -1));
expect(ids(first).filter((id) => ids(second).includes(id))).toEqual([]);
for (const svg of [first, second]) {
expect(references(svg).length).toBeGreaterThan(5);
expect(references(svg).every((reference) => ids(svg).includes(reference))).toBe(true);
}
});
it('is hidden from screen readers unless it has a title', () => {
const {container, getByRole} = render(<><Logo/><Logo title="ToBePlayed"/></>);
expect(container.querySelector('svg').getAttribute('aria-hidden')).toBe('true');
expect(getByRole('img', {name: 'ToBePlayed'})).toBeTruthy();
});
it('gives every gradient stop a fixed color for browsers without color-mix', () => {
const {container} = render(<Logo/>);
const stops = [...container.querySelectorAll('stop')];
expect(stops.length).toBeGreaterThan(10);
expect(stops.every((stop) => /^#[0-9a-f]{6}$/.test(stop.getAttribute('stop-color')))).toBe(true);
});
});
+6
View File
@@ -0,0 +1,6 @@
// Traced from the ToBePlayed logo (a JPEG); used by Logo.jsx and, with fixed colors, by public/favicon.svg.
export const OUTLINE = 'M 429 235.6 C 392.6 239.4, 357.1 259.4, 337.1 287.5 C 308.1 328.3, 278.1 428.0, 267.0 520.5 C 260.7 572.6, 267.7 612.7, 287.0 635.5 C 311.9 665.0, 350.6 669.1, 386 646.1 C 393.4 641.2, 416.6 618.4, 430.8 602 C 465.6 561.5, 470.2 557.3, 483.9 553.8 C 493.4 551.3, 706.0 551.0, 715.4 553.5 C 729.4 557.1, 735.2 562.2, 765.0 597 C 805.6 644.4, 826.8 659.2, 856.5 660.6 C 896.8 662.7, 926.3 633.4, 933.3 584.3 C 943.2 515.9, 904.8 351.0, 865.1 291 C 843.3 258.0, 805.4 237.4, 762.4 235.2 C 744.0 234.3, 734.0 237.0, 714 248.5 C 690.1 262.0, 691.4 261.9, 600.3 261.9 C 508.8 262.0, 511.5 262.3, 485.1 247.6 C 464.7 236.2, 451.2 233.3, 429 235.6';
export const BODY = 'M 585.8 263.2 C 584.9 263.9, 580.7 269.6, 576.5 276 C 572.3 282.3, 567.8 288.0, 566.6 288.7 C 562.6 291.0, 519.9 290.4, 509.2 287.9 C 501.0 286.0, 498.2 285.1, 485.2 280.1 C 482.2 279.0, 475.7 275.6, 470.5 272.5 C 457.3 264.6, 454.9 264, 440.1 264 C 396.7 264, 358.2 292.8, 341.6 337.7 C 340.2 341.2, 338.8 344.9, 333.8 357.2 C 324.3 380.8, 312.9 420.7, 306.4 452.4 C 305.0 459.5, 303.6 465.7, 303.4 466.0 C 303.2 466.4, 302.5 469.8, 301.9 473.6 C 301.4 477.4, 300.5 482.9, 300 486 C 296.7 505.4, 295.9 510.6, 293.7 527 C 292.3 538.2, 292.2 570.5, 293.7 580 C 296.2 596.5, 302.2 611.4, 310.0 620.2 C 322.7 634.7, 347.7 636.7, 367.5 624.8 C 373.4 621.3, 398.0 597.7, 405.9 588 C 408.4 584.9, 411.6 581.3, 413 580 C 414.3 578.6, 417.5 575.0, 420 572 C 422.4 568.9, 425.6 565.3, 427.0 564 C 428.4 562.6, 432.0 558.5, 435.0 555.0 C 449.9 537.3, 457.6 532.3, 478.9 526.3 C 487.4 524.0, 487.9 524, 536.1 524 C 562.9 524, 585.5 524.2, 586.4 524.6 C 589.3 525.7, 588.0 529.8, 581.9 538.1 C 573.3 550.1, 573.6 551.4, 585.7 551.7 C 596.4 552.1, 598.0 551.4, 601.8 545.6 C 609.4 533.8, 616.2 525.1, 618.2 524.6 C 619.4 524.2, 641.6 524.0, 667.5 524.0 C 718.4 523.9, 717.4 523.9, 732.8 529.8 C 744.3 534.2, 755.3 542.9, 766.0 556 C 768.4 559.0, 772.0 563.0, 774 565 C 775.9 566.9, 779.5 570.9, 781.9 574 C 784.4 577.0, 787.1 580.1, 788 581 C 788.8 581.8, 791.5 584.9, 794.0 587.9 C 800.4 595.8, 822.3 617.4, 827.4 620.9 C 839.6 629.3, 847.4 632, 860.0 632 C 871.1 632, 871.9 631.8, 878.0 628.7 C 890.8 622.4, 898.9 610.6, 903.3 591.9 C 907.6 573.9, 908.2 568.5, 907.6 551 C 907.0 533.0, 905.7 520.1, 902.0 498 C 897.3 470.2, 897.4 470.7, 894.0 454.5 C 885.8 415.6, 871.5 367.1, 862.0 345.9 C 850.1 319.4, 847.3 313.9, 842.8 307.4 C 823.2 279.0, 794.4 263.9, 759.5 264.0 C 744.5 264.0, 744.4 264.0, 738.3 267.2 C 696.2 289.5, 694.3 290, 638.0 290 C 589.6 290, 592.2 290.9, 602 276.0 C 608.2 266.5, 608.7 265.1, 606.8 263.2 C 605.1 261.5, 587.9 261.5, 585.8 263.2';
export const DPAD = 'M 407.1 337.3 C 402.7 339.8, 402.0 342.5, 402.0 357.5 C 401.9 375.8, 402.7 375, 385.1 375 C 365.0 375, 362.9 376.7, 363.0 394.2 C 363.0 412.1, 364.9 413.9, 384.7 413.9 C 402.4 414.0, 402 413.5, 402 430.9 C 402 450.7, 403.1 452, 421 452 C 438.9 452, 440.4 450.2, 440.4 429.8 C 440.4 420.7, 440.8 416.7, 441.7 415.8 C 442.6 414.8, 447.6 414.3, 458.1 414 L 473.1 413.5 476.1 410.1 L 479.1 406.7 478.8 393.9 C 478.3 375.9, 478.0 375.6, 459.7 375.2 C 439.3 374.8, 440 375.4, 440 356.4 C 440 342.6, 439.0 339.2, 434.5 337.1 C 430.8 335.5, 410.2 335.6, 407.1 337.3';
export const CHECKLIST = 'M 563.5 333.5 C 559.7 337.6, 555.9 342.0, 555.1 343.2 C 550.8 349.4, 549.2 349.9, 545 346 C 537.8 339.3, 532.7 344.9, 539.2 352.2 C 541.0 354.2, 543.4 357.0, 544.6 358.4 C 547.8 362.1, 551.3 361.7, 554.7 357.4 C 556.2 355.4, 559.6 351.5, 562.3 348.6 C 565.0 345.8, 568.3 341.9, 569.8 340.0 C 571.3 338.0, 573.5 335.4, 574.7 334.2 C 577.2 331.6, 577.6 329.0, 575.8 327.2 C 573.2 324.6, 570.4 326.0, 563.5 333.5 M 525.4 329.2 C 525.1 329.9, 525.0 340.2, 525.2 352.0 L 525.5 373.5 547.2 373.7 L 569.0 374.0 570.0 371.2 C 571.2 368.3, 570.3 350.3, 569.0 349.5 C 566.9 348.2, 564.1 353.7, 563.8 359.5 L 563.5 365.5 556.3 366 C 552.4 366.2, 545.4 366.2, 540.8 366 L 532.5 365.5 532.2 351.1 C 531.8 333.1, 531.1 334, 545.4 334 C 555.2 334, 556.9 333.7, 558.4 332.0 C 559.4 330.9, 559.9 329.6, 559.5 329.0 C 558.5 327.4, 526.0 327.6, 525.4 329.2 M 591.6 335.8 C 589.4 338.2, 589.5 340.1, 592.0 342.8 C 594.0 344.9, 594.2 345, 631.2 344.9 C 651.7 344.9, 669.5 344.6, 670.8 344.3 C 673.7 343.5, 675.2 339.8, 674 336.5 L 673.0 334 633.1 334 C 595.8 334, 593.2 334.1, 591.6 335.8 M 591.6 357.8 C 589.4 360.2, 589.5 362.2, 591.8 364.3 C 593.4 365.8, 596.4 366.0, 617.5 365.7 L 641.5 365.5 641.8 362.6 C 642.5 356.3, 641.2 356, 616 356 C 595.2 356, 593.1 356.1, 591.6 357.8 M 567.4 387.2 C 547.5 409.5, 548.5 408.6, 545.4 404.5 C 543.0 401.3, 541.5 400.8, 538.4 402.0 C 534.2 403.6, 535.9 407.7, 544.3 416.7 C 548.5 421.2, 550.1 420.8, 555.6 414.2 C 558.3 411.1, 561.4 407.5, 562.5 406.3 C 563.7 405.2, 566.4 402.0, 568.5 399.3 C 570.7 396.7, 573.5 393.4, 574.7 392.2 C 577.2 389.6, 577.6 387.0, 575.8 385.2 C 573.6 383.0, 570.4 383.9, 567.4 387.2 M 525.4 387.2 C 525.1 387.9, 525.0 398.2, 525.2 410.0 L 525.5 431.5 546.8 431.7 C 561.7 431.9, 568.6 431.6, 569.6 430.8 C 571.6 429.2, 571.6 407, 569.5 407 C 566.8 407, 564.1 412.3, 563.8 418.5 L 563.5 424.5 548 424.5 L 532.5 424.5 532.5 409 L 532.5 393.5 544.1 393.2 C 554.8 392.9, 555.8 392.7, 557.9 390.5 C 559.1 389.2, 559.9 387.6, 559.5 387.0 C 558.5 385.4, 526.0 385.6, 525.4 387.2 M 591.6 393.8 C 589.3 396.3, 589.5 398.2, 592.3 400.8 C 594.6 402.9, 595.0 403, 631.5 402.9 C 651.8 402.9, 669.5 402.6, 670.8 402.3 C 674.1 401.4, 675.4 397.3, 673.5 394.3 L 671.9 392 632.6 392 C 595.8 392, 593.2 392.1, 591.6 393.8 M 592 416 C 589.5 418.4, 589.4 421.1, 591.7 423.3 C 594.1 425.4, 638.0 426.0, 640.5 423.9 C 642.7 422.1, 642.3 417.3, 639.7 415.5 C 635.9 412.8, 594.7 413.2, 592 416 M 525.6 444.6 C 525.3 445.0, 525 454.9, 525 466.6 L 525 488.0 527.5 489.0 C 531.4 490.4, 568.3 490.2, 569.8 488.8 C 571.4 487.1, 571.4 446.8, 569.8 445.2 C 568.5 443.9, 526.9 443.4, 525.6 444.6 M 592 452 C 589.4 454.5, 589.4 457.2, 592.1 459.5 C 595.5 462.7, 668.3 463.0, 672.3 459.9 C 675.6 457.3, 675.7 455.0, 672.6 452.1 C 669.2 448.8, 595.2 448.7, 592 452 M 532.4 452.2 C 532.1 453, 532.0 459.8, 532.2 467.5 L 532.5 481.5 548 481.5 L 563.5 481.5 563.5 466.5 L 563.5 451.5 548.2 451.2 C 536.2 451.0, 532.8 451.2, 532.4 452.2 M 596.8 472.9 C 590.0 474.2, 588.0 478.0, 592 482 C 595.0 485.0, 637.6 485.0, 640.4 482.0 C 642.8 479.3, 642.1 474.8, 639 473.2 C 636.2 471.9, 603.5 471.6, 596.8 472.9';
export const PROGRESS_BAR = 'M 532.4 584 C 478.7 584.5, 481.1 584.1, 477.2 595 C 475.5 599.7, 475.6 618.4, 477.4 623.5 C 480.2 631.7, 483.8 633.9, 494.3 634.1 C 499.3 634.2, 504.8 634.4, 506.5 634.5 C 508.1 634.7, 554.3 635.2, 609.0 635.6 L 708.6 636.3 713.9 633.9 C 722.2 630.1, 724.0 625.7, 723.9 608.7 C 723.9 594.3, 723.0 591.4, 717.1 586.2 L 714.5 584.0 657.5 583.8 C 626.1 583.8, 595.5 583.6, 589.5 583.6 C 583.4 583.5, 557.7 583.7, 532.4 584 M 491.5 591.1 C 484.5 593.6, 484 595.0, 484 609.0 L 484 621.8 487.0 624.8 L 490.0 627.8 598.6 627.8 C 716.1 627.8, 710.7 628.1, 714.3 622.5 C 716.3 619.5, 716.7 600.6, 714.8 596.6 C 712.7 592.0, 714.4 592.1, 604.5 591.6 C 546.7 591.3, 498.3 590.9, 497 590.6 C 495.6 590.3, 493.1 590.5, 491.5 591.1 M 491.6 597.7 C 490.3 599.2, 490 601.6, 490 609.1 C 490 621.9, 490.1 622.1, 504.6 621.7 L 515.5 621.5 515.5 609 L 515.5 596.5 504.4 596.2 C 494.6 595.9, 493.1 596.1, 491.6 597.7 M 524.4 597.2 C 524.1 598.0, 524.0 603.7, 524.2 610.0 L 524.5 621.5 537 621.5 L 549.5 621.5 549.5 609 L 549.5 596.5 537.2 596.2 C 527.6 596.0, 524.8 596.2, 524.4 597.2 M 557.9 598.5 C 557.4 599.9, 557 604.6, 557 609 C 557 613.3, 557.4 618.0, 557.9 619.4 L 558.9 622.0 571.2 621.7 L 583.5 621.5 583.5 609 L 583.5 596.5 571.2 596.2 L 558.9 595.9 557.9 598.5 M 592.1 597.3 C 591.3 598.3, 591.0 602.5, 591.2 610.1 L 591.5 621.5 621.5 621.5 L 651.5 621.5 651.5 609 L 651.5 596.5 622.3 596.2 C 598.3 596.0, 593.0 596.2, 592.1 597.3';
+2 -1
View File
@@ -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 Logo from '../components/Logo.jsx';
import StatCard from '../components/StatCard.jsx';
import {hasSession} from '../utils/auth.js';
@@ -59,7 +60,7 @@ export default function LandingPage() {
<div className="pointer-events-none absolute inset-0 bg-gradient-to-b from-primary/15 via-primary/5 to-transparent"/>
<div className="relative mx-auto max-w-3xl px-4 py-20 text-center md:py-28">
<Badge variant="outline" className="mb-6 gap-1.5 bg-background/60">
<Gamepad2 className="size-3 text-primary"/> Game Log
<span className="flex"><Logo className="h-4 w-auto"/></span> ToBePlayed
</Badge>
<h1 className="text-4xl font-bold tracking-tight md:text-6xl">{t('MAIN.WELCOME')}</h1>
<p className="mx-auto mt-6 max-w-2xl text-lg text-muted-foreground">{t('MAIN.DESCRIPTION')}</p>
+32
View File
@@ -182,6 +182,38 @@
--color-ring: var(--ring);
}
/* The three logo colors per accent (components/Logo.jsx). Violet is the logo as designed; the others use
neighbouring hues, because a mix of a warm accent with the logo's teal turns olive. */
[data-accent="blue"] {
--logo-violet: #3b82f6;
--logo-blue: #06b6d4;
--logo-teal: #14b8a6;
}
[data-accent="green"] {
--logo-violet: #22c55e;
--logo-blue: #14b8a6;
--logo-teal: #0ea5e9;
}
[data-accent="orange"] {
--logo-violet: #f97316;
--logo-blue: #f59e0b;
--logo-teal: #eab308;
}
[data-accent="pink"] {
--logo-violet: #ec4899;
--logo-blue: #a855f7;
--logo-teal: #6366f1;
}
[data-accent="red"] {
--logo-violet: #ef4444;
--logo-blue: #f97316;
--logo-teal: #f59e0b;
}
@layer base {
* {
@apply border-border outline-ring/50;