feat(points): unlock items, progress bars and skins
Build, Test & Push Frontend / quality-check (push) Successful in 28s
Build, Test & Push Frontend / docker (push) Successful in 25s

Locked items show a progress bar until the goal is reached. Four
CSS-only skins (Pastel, Retro CRT, Cyberpunk, Steam) in skins.css,
light and dark. A skin overrides accent and corners; badges, borders,
frames, effects and game art stay on top. New Platinum shine and
Equalizer effects.
This commit is contained in:
lobo committed 2026-10-02 15:40:46 +02:00
1 parent 8598024bce
commit b6f3d123fb
22 files changed
+619 -35

No files matched your search

+4 -1
View File
@@ -34,7 +34,10 @@ To Be Played is split into three repositories:
- **Pile Points** (`/points`): points for playing games off your pile (start, finish by time to beat, 100 %, month and
streak bonuses; Core checks proof of play), spent in a small shop on an avatar border, a badge, a premium accent, a
banner frame, an effect for 100 % games and an animated page or banner background (CSS and SVG, in `effects.css`),
shown on the dashboard and the public profile. The balance is in the header. Every effect stands still with the
shown on the dashboard and the public profile. Twelve more items are unlock-only: they show locked with a progress bar
until a goal is reached (games finished, 100 % games, weeks in a row, months with a smaller pile, months with more
play time). Skins (`skins.css`, Pastel, Retro CRT, Cyberpunk bought, Steam unlocked) are full themes made of CSS
variables; a skin wins over accent and corners, while badges, borders, frames and effects stay on top. The balance is in the header. Every effect stands still with the
system setting "reduce motion" or the "Reduce animations" switch in the settings.
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users.
+32 -5
View File
@@ -168,7 +168,8 @@
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".",
"FAVORITE_PLATFORM": "Lieblingsplattform",
"FAVORITE_PLATFORM_HINT": "Wird zuerst gewählt, wenn du ein Spiel für mehrere Plattformen hinzufügst.",
"FAVORITE_PLATFORM_NONE": "Keine"
"FAVORITE_PLATFORM_NONE": "Keine",
"SKIN_OVERRIDES": "Dein Skin legt Farben und Ecken fest. Nimm ihn auf der Punkte-Seite ab."
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email ist erforderlich.",
@@ -1006,7 +1007,8 @@
"COMPLETED": "+40 Punkte, wenn du in einem abgeschlossenen Spiel 100 % der Erfolge hast.",
"FINISHED_MANUAL": "15 Punkte für ein manuelles Spiel, das du durchspielst, höchstens 3 pro Woche.",
"MONTH": "40 Punkte, wenn dein Stapel am Monatsende kleiner ist als am Monatsanfang und du in dem Monat ein Spiel durchgespielt hast.",
"STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel."
"STREAK": "20 Punkte für jede Woche mit einem beendeten Spiel nach einer Woche mit einem beendeten Spiel.",
"UNLOCK": "Gegenstände mit Schloss kann man nicht kaufen. Sie werden einmal und für immer freigeschaltet, wenn du ihr Ziel erreichst."
},
"SLOTS": {
"AVATAR_BORDER": "Avatarrahmen",
@@ -1015,7 +1017,8 @@
"BANNER_FRAME": "Bannerrahmen",
"COMPLETED_EFFECT": "Effekt für 100 %",
"PAGE_BACKGROUND": "Seitenhintergrund",
"BANNER_BACKGROUND": "Bannerhintergrund"
"BANNER_BACKGROUND": "Bannerhintergrund",
"SKIN": "Skin"
},
"ITEMS": {
"BORDER_GOLD": "Goldener Avatarrahmen",
@@ -1045,7 +1048,22 @@
"BG_WAVES": "Animierter Hintergrund: Wellen",
"BN_WAVES": "Animiertes Banner: Wellen",
"BG_BOKEH": "Animierter Hintergrund: Bokeh-Lichter",
"BN_BOKEH": "Animiertes Banner: Bokeh-Lichter"
"BN_BOKEH": "Animiertes Banner: Bokeh-Lichter",
"BADGE_FINISH_10": "Beender-Abzeichen",
"BORDER_FINISH_25": "Silberner Avatar-Rand",
"FRAME_FINISH_50": "Smaragd-Bannerrahmen",
"BADGE_PERFECT_5": "Perfektionisten-Abzeichen",
"FX_PLATINUM": "Platin-Glanz",
"BADGE_STREAK_4": "Serien-Abzeichen",
"BORDER_STREAK_12": "Flammen-Avatar-Rand",
"FRAME_PILE_DOWN_3": "Türkiser Bannerrahmen",
"BADGE_PLAY_MORE_1": "Mehr-spielen-Abzeichen",
"BORDER_PLAY_MORE_3": "Himmelblauer Avatar-Rand",
"BN_EQUALIZER": "Animiertes Banner: Equalizer",
"SKIN_PASTEL": "Skin: Pastell",
"SKIN_RETRO_CRT": "Skin: Retro-Röhre",
"SKIN_CYBERPUNK": "Skin: Cyberpunk",
"SKIN_STEAM": "Skin: Steam"
},
"KINDS": {
"STARTED": "Gestartet",
@@ -1055,7 +1073,16 @@
"MONTH": "Kleinerer Stapel im",
"STREAK": "Serien-Woche",
"STARTER": "Startpunkte",
"BUY": "Gekauft"
"BUY": "Gekauft",
"UNLOCK": "Freigeschaltet"
},
"LOCKED": "Gesperrt, Fortschritt",
"PROGRESS": {
"GAMES_FINISHED": "{{count}} / {{target}} Spiele beendet",
"GAMES_COMPLETED": "{{count}} / {{target}} Spiele zu 100 %",
"FINISH_WEEKS": "{{count}} / {{target}} Wochen in Folge mit einem beendeten Spiel",
"PILE_DOWN_MONTHS": "{{count}} / {{target}} Monate in Folge mit kleinerem Stapel",
"PLAY_MORE_MONTHS": "{{count}} / {{target}} Monate in Folge mit mehr Spielzeit als im Monat davor"
}
},
"UNSAVED_CHANGES": {
+32 -5
View File
@@ -168,7 +168,8 @@
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
"FAVORITE_PLATFORM": "Favorite platform",
"FAVORITE_PLATFORM_HINT": "Selected first when you add a game on more than one platform.",
"FAVORITE_PLATFORM_NONE": "None"
"FAVORITE_PLATFORM_NONE": "None",
"SKIN_OVERRIDES": "Your skin sets colors and corners. Take it off on the Points page."
},
"VALIDATION": {
"EMAIL_REQUIRED": "Email is required.",
@@ -1006,7 +1007,8 @@
"COMPLETED": "+40 points when you reach 100 % achievements in a completed game.",
"FINISHED_MANUAL": "15 points for a manual game you finish, at most 3 per week.",
"MONTH": "40 points when your pile is smaller at the end of a month than at its start and you finished a game that month.",
"STREAK": "20 points for every week with a finished game after a week with a finished game."
"STREAK": "20 points for every week with a finished game after a week with a finished game.",
"UNLOCK": "Items with a lock cannot be bought. They unlock once, for good, when you reach their goal."
},
"SLOTS": {
"AVATAR_BORDER": "Avatar border",
@@ -1015,7 +1017,8 @@
"BANNER_FRAME": "Banner frame",
"COMPLETED_EFFECT": "Completed effect",
"PAGE_BACKGROUND": "Page background",
"BANNER_BACKGROUND": "Banner background"
"BANNER_BACKGROUND": "Banner background",
"SKIN": "Skin"
},
"ITEMS": {
"BORDER_GOLD": "Golden avatar border",
@@ -1045,7 +1048,22 @@
"BG_WAVES": "Animated background: waves",
"BN_WAVES": "Animated banner: waves",
"BG_BOKEH": "Animated background: bokeh",
"BN_BOKEH": "Animated banner: bokeh"
"BN_BOKEH": "Animated banner: bokeh",
"BADGE_FINISH_10": "Finisher badge",
"BORDER_FINISH_25": "Silver avatar border",
"FRAME_FINISH_50": "Emerald banner frame",
"BADGE_PERFECT_5": "Perfectionist badge",
"FX_PLATINUM": "Platinum shine",
"BADGE_STREAK_4": "Streak badge",
"BORDER_STREAK_12": "Flame avatar border",
"FRAME_PILE_DOWN_3": "Teal banner frame",
"BADGE_PLAY_MORE_1": "Play more badge",
"BORDER_PLAY_MORE_3": "Sky avatar border",
"BN_EQUALIZER": "Animated banner: equalizer",
"SKIN_PASTEL": "Skin: Pastel",
"SKIN_RETRO_CRT": "Skin: Retro CRT",
"SKIN_CYBERPUNK": "Skin: Cyberpunk",
"SKIN_STEAM": "Skin: Steam"
},
"KINDS": {
"STARTED": "Started",
@@ -1055,7 +1073,16 @@
"MONTH": "Smaller pile in",
"STREAK": "Streak week",
"STARTER": "Starter points",
"BUY": "Bought"
"BUY": "Bought",
"UNLOCK": "Unlocked"
},
"LOCKED": "Locked, progress",
"PROGRESS": {
"GAMES_FINISHED": "{{count}} / {{target}} games finished",
"GAMES_COMPLETED": "{{count}} / {{target}} games at 100 %",
"FINISH_WEEKS": "{{count}} / {{target}} weeks in a row with a finish",
"PILE_DOWN_MONTHS": "{{count}} / {{target}} months in a row with a smaller pile",
"PLAY_MORE_MONTHS": "{{count}} / {{target}} months in a row with more play time than the month before"
}
},
"UNSAVED_CHANGES": {
+1 -1
View File
@@ -64,7 +64,7 @@ function App() {
return (
<TooltipProvider>
{/* isolate: the background layer sits behind the content but in front of this div's own background. */}
<div className="isolate min-h-screen flex flex-col bg-background text-foreground font-sans">
<div className="isolate min-h-screen flex flex-col bg-background skin-bg text-foreground font-sans">
{/* A public profile shows the owner's background instead. */}
{isAuthenticated && !location.pathname.startsWith('/u/') && (
<PageBackground appid={appearance.backgroundAppid} effect={backgroundEffect(equipped)}/>
+3 -1
View File
@@ -6,13 +6,14 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {AccentPicker, CornersPicker} from './StylePickers.jsx';
import {equippedSkin} from '../utils/rewards.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import {coverUrl} from '../utils/games.js';
export default function AppearanceCard() {
const {t} = useTranslation();
const {appearance} = usePreferences();
const {appearance, equipped} = usePreferences();
const saveAppearance = useSaveAppearance();
return (
@@ -23,6 +24,7 @@ export default function AppearanceCard() {
<CardContent className="space-y-6">
<AccentPicker id="accent" value={appearance.accent} onChange={(accent) => saveAppearance({accent})}/>
<CornersPicker id="corners" value={appearance.corners} onChange={(corners) => saveAppearance({corners})}/>
{equippedSkin(equipped) && <p className="text-xs text-muted-foreground">{t('SETTINGS.SKIN_OVERRIDES')}</p>}
<div className="flex items-center justify-between gap-4">
<div className="space-y-1">
<Label htmlFor="wideLayout">{t('SETTINGS.WIDE_LAYOUT')}</Label>
+31
View File
@@ -0,0 +1,31 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen} from '@testing-library/react';
import AppearanceCard from './AppearanceCard.jsx';
const preferences = vi.hoisted(() => ({value: null}));
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('../context/PreferencesContext.jsx', () => ({usePreferences: () => preferences.value}));
vi.mock('../hooks/useSaveAppearance.js', () => ({default: () => vi.fn()}));
const show = (equipped) => {
preferences.value = {
appearance: {accent: 'violet', corners: 'default', wideLayout: false, reduceMotion: false, showBanner: true},
equipped,
};
render(<AppearanceCard/>);
};
afterEach(cleanup);
describe('AppearanceCard', () => {
it('tells that the skin sets colors and corners only while a skin is worn', () => {
show({SKIN: 'SKIN_STEAM'});
expect(screen.getByText('SETTINGS.SKIN_OVERRIDES')).toBeTruthy();
});
it('shows no skin hint without a skin', () => {
show({});
expect(screen.queryByText('SETTINGS.SKIN_OVERRIDES')).toBeNull();
});
});
+3 -1
View File
@@ -1,6 +1,6 @@
import {cn} from '@/lib/utils';
export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh'];
export const EFFECTS = ['triangles', 'starfield', 'pixels', 'aurora', 'mesh', 'grid', 'waves', 'bokeh', 'equalizer'];
const range = (count) => Array.from({length: count}, (_, i) => i);
@@ -73,6 +73,8 @@ function shapes(effect) {
<path d={WAVE} fill="currentColor"/>
</svg>
));
case 'equalizer':
return range(16).map((i) => <span key={i}/>);
case 'bokeh':
return range(10).map((i) => (
<span key={i} style={{
+1 -1
View File
@@ -21,7 +21,7 @@ import {coverEffect, detailEffect} from '../utils/rewards.js';
const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
// The spans of each completed effect decoration; effects.css places them.
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6};
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6, 'fx-detail-platinum': 6};
export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
const {t} = useTranslation();
+1 -1
View File
@@ -8,7 +8,7 @@ function Card({
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-(--card-shadow)",
className
)}
{...props}
+2 -2
View File
@@ -12,7 +12,7 @@ import {
import {hasSession} from '../utils/auth.js';
import useSession from '../hooks/useSession.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
import {cacheEquipped, equippedAccent, loadWallet, readCachedEquipped} from '../utils/rewards.js';
import {cacheEquipped, equippedAccent, equippedSkin, loadWallet, readCachedEquipped} from '../utils/rewards.js';
import {storage} from '../utils/storage.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
@@ -57,7 +57,7 @@ export function PreferencesProvider({children}) {
// Layout effect: the cached look is set before the first paint.
useLayoutEffect(() => {
applyAppearance(appearance, equippedAccent(equipped));
applyAppearance(appearance, {accent: equippedAccent(equipped), skin: equippedSkin(equipped)});
cacheAppearance(appearance);
cacheEquipped(equipped);
}, [appearance, equipped]);
+11
View File
@@ -137,4 +137,15 @@ describe('PreferencesProvider worn looks', () => {
expect(preferences.equipped).toEqual({});
});
it('wears the skin on the root and takes it off on reset', () => {
localStorage.setItem('tbp_session', '1');
localStorage.setItem('tbp_equipped', JSON.stringify({SKIN: 'SKIN_RETRO_CRT'}));
render(<PreferencesProvider><Spy/></PreferencesProvider>);
expect(document.documentElement.dataset.skin).toBe('retro-crt');
act(() => preferences.resetAppearance());
expect(document.documentElement.dataset.skin).toBeUndefined();
});
});
+60
View File
@@ -16,6 +16,7 @@
/* ---- COMPLETED_EFFECT, cover of a tile (GameCover has relative and overflow-hidden) ---- */
.fx-cover-gold-shine::after,
.fx-cover-platinum::after,
.fx-cover-neon-pulse::after,
.fx-cover-holo::after,
.fx-cover-ember::before,
@@ -33,6 +34,14 @@
animation: fx-sweep 4s ease-in-out infinite;
}
/* The silver border is an inside outline in the look; a cool light sweeps over the cover. */
.fx-cover-platinum::after {
inset: 0;
background: linear-gradient(115deg, transparent 38%, rgb(226 232 240 / 0.4) 48%, rgb(186 230 253 / 0.3) 54%, transparent 62%);
transform: translateX(-100%);
animation: fx-sweep 5s ease-in-out infinite;
}
/* The ring is an inside outline in the look; the glow is inside, because the cover clips outside shadows. */
.fx-cover-neon-pulse::after {
inset: 0;
@@ -98,6 +107,26 @@
.fx-detail-gold-shine > :nth-child(7) { --x: 70%; --y: 44%; --d: -2.4s; }
.fx-detail-gold-shine > :nth-child(8) { --x: 40%; --y: 82%; --d: -0.2s; }
.fx-detail-platinum > span {
position: absolute;
left: var(--x);
top: var(--y);
width: 12px;
height: 12px;
background: #6b7a90;
clip-path: polygon(50% 0, 62% 38%, 100% 50%, 62% 62%, 50% 100%, 38% 62%, 0 50%, 38% 38%);
transform: scale(0.6);
animation: fx-twinkle 3s ease-in-out infinite;
animation-delay: var(--d);
}
.dark .fx-detail-platinum > span { background: #e2e8f0; }
.fx-detail-platinum > :nth-child(1) { --x: 48%; --y: 16%; --d: 0s; }
.fx-detail-platinum > :nth-child(2) { --x: 66%; --y: 64%; --d: -0.5s; }
.fx-detail-platinum > :nth-child(3) { --x: 80%; --y: 22%; --d: -1s; }
.fx-detail-platinum > :nth-child(4) { --x: 90%; --y: 58%; --d: -1.5s; }
.fx-detail-platinum > :nth-child(5) { --x: 56%; --y: 44%; --d: -2s; }
.fx-detail-platinum > :nth-child(6) { --x: 84%; --y: 84%; --d: -2.5s; }
.fx-detail-neon-pulse > span {
position: absolute;
inset: 0;
@@ -333,6 +362,37 @@
background: radial-gradient(closest-side, color-mix(in oklab, var(--logo-teal) 22%, transparent), transparent);
}
/* equalizer: 16 bars at the bottom, scaleY only. The heights are fixed, so frame 0 is a static skyline. */
@keyframes fx-bar { from { transform: scaleY(var(--lo)); } to { transform: scaleY(var(--hi)); } }
.fx-bg-equalizer > * {
bottom: 0;
left: calc(var(--i) * 6.25% + 1%);
width: 4.25%;
height: 55%;
transform-origin: bottom;
transform: scaleY(var(--hi));
border-radius: 2px 2px 0 0;
background: linear-gradient(to top, color-mix(in oklab, var(--primary) 35%, transparent), color-mix(in oklab, var(--logo-teal) 12%, transparent));
animation: fx-bar var(--dur) ease-in-out infinite alternate;
animation-delay: var(--d);
}
.fx-bg-equalizer > :nth-child(1) { --i: 0; --hi: 0.55; --lo: 0.2; --dur: 1.1s; --d: 0s; }
.fx-bg-equalizer > :nth-child(2) { --i: 1; --hi: 0.8; --lo: 0.3; --dur: 1.4s; --d: -0.3s; }
.fx-bg-equalizer > :nth-child(3) { --i: 2; --hi: 0.4; --lo: 0.15; --dur: 0.9s; --d: -0.6s; }
.fx-bg-equalizer > :nth-child(4) { --i: 3; --hi: 0.95; --lo: 0.4; --dur: 1.7s; --d: -0.2s; }
.fx-bg-equalizer > :nth-child(5) { --i: 4; --hi: 0.65; --lo: 0.25; --dur: 1.2s; --d: -0.9s; }
.fx-bg-equalizer > :nth-child(6) { --i: 5; --hi: 0.3; --lo: 0.1; --dur: 0.8s; --d: -0.4s; }
.fx-bg-equalizer > :nth-child(7) { --i: 6; --hi: 0.75; --lo: 0.35; --dur: 1.5s; --d: -1.1s; }
.fx-bg-equalizer > :nth-child(8) { --i: 7; --hi: 1; --lo: 0.45; --dur: 1.9s; --d: -0.7s; }
.fx-bg-equalizer > :nth-child(9) { --i: 8; --hi: 0.5; --lo: 0.2; --dur: 1s; --d: -1.3s; }
.fx-bg-equalizer > :nth-child(10) { --i: 9; --hi: 0.85; --lo: 0.3; --dur: 1.6s; --d: -0.1s; }
.fx-bg-equalizer > :nth-child(11) { --i: 10; --hi: 0.35; --lo: 0.12; --dur: 0.85s; --d: -0.8s; }
.fx-bg-equalizer > :nth-child(12) { --i: 11; --hi: 0.7; --lo: 0.28; --dur: 1.3s; --d: -1.5s; }
.fx-bg-equalizer > :nth-child(13) { --i: 12; --hi: 0.9; --lo: 0.4; --dur: 1.8s; --d: -0.5s; }
.fx-bg-equalizer > :nth-child(14) { --i: 13; --hi: 0.45; --lo: 0.18; --dur: 1.05s; --d: -1.2s; }
.fx-bg-equalizer > :nth-child(15) { --i: 14; --hi: 0.6; --lo: 0.22; --dur: 1.25s; --d: -0.35s; }
.fx-bg-equalizer > :nth-child(16) { --i: 15; --hi: 0.25; --lo: 0.1; --dur: 0.95s; --d: -1.0s; }
/* ---- Off screen covers hold their frame (components/GameCover.jsx) ---- */
.fx-paused,
+35 -3
View File
@@ -1,7 +1,7 @@
import {useRef} from 'react';
import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Coins} from 'lucide-react';
import {Coins, LockKeyhole} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
@@ -17,7 +17,7 @@ import {formatDate} from '../utils/format.js';
import {buyItem, equipItem, LOOKS, SLOTS} from '../utils/rewards.js';
const GAME_KINDS = ['STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED'];
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK'];
const HOW_TO = ['STARTED', 'FINISHED', 'COMPLETED', 'FINISHED_MANUAL', 'MONTH', 'STREAK', 'UNLOCK'];
export default function PointsPage() {
const {t, i18n} = useTranslation();
@@ -58,7 +58,7 @@ export default function PointsPage() {
</Link>
);
}
if (entry.kind === 'BUY') return t(`POINTS.ITEMS.${entry.ref}`);
if (['BUY', 'UNLOCK'].includes(entry.kind)) return t(`POINTS.ITEMS.${entry.ref}`);
if (['MONTH', 'STREAK'].includes(entry.kind)) return entry.ref;
return null;
};
@@ -89,6 +89,7 @@ export default function PointsPage() {
{wallet.catalog.filter((item) => item.slot === slot).map((item) => {
const owned = wallet.owned.includes(item.code);
const worn = wallet.equipped[slot] === item.code;
const locked = !owned && item.track;
return (
<li key={item.code} className="flex items-center gap-3 py-3">
<Preview code={item.code}/>
@@ -99,6 +100,8 @@ export default function PointsPage() {
onClick={() => act(() => equipItem(slot, worn ? null : item.code))}>
{t(worn ? 'POINTS.UNEQUIP' : 'POINTS.EQUIP')}
</Button>
) : locked ? (
<LockedProgress item={item} progress={wallet.progress?.[item.track] ?? 0}/>
) : (
<>
<span className="text-sm tabular-nums text-muted-foreground">{item.price}</span>
@@ -133,9 +136,11 @@ export default function PointsPage() {
{t(`POINTS.KINDS.${entry.kind}`)}
{label && <> {label}</>}
</span>
{entry.points !== 0 && (
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
{entry.points > 0 ? `+${entry.points}` : entry.points}
</span>
)}
</li>
);
})}
@@ -157,6 +162,14 @@ function Preview({code}) {
if (look.Icon) {
return <span className={cn('flex size-8 items-center justify-center rounded-full', look.className)}><look.Icon className="size-4"/></span>;
}
if (look.skin) {
return (
<span data-skin={look.skin}
className="skin-bg bg-scroll flex size-8 shrink-0 items-center justify-center rounded-[var(--radius)] border bg-background">
<span className="size-3 rounded-full bg-primary"/>
</span>
);
}
if (look.accent) {
return <span data-accent={look.accent} className="size-8 rounded-full bg-primary"/>;
}
@@ -172,3 +185,22 @@ function Preview({code}) {
}
return <span className={cn('size-8 shrink-0 bg-muted', look.className, code.startsWith('BORDER') ? 'rounded-full' : 'rounded-md')}/>;
}
// An unlock-only item that is not earned yet: the lock, the progress as text and as a bar. Never a price or a Buy button.
function LockedProgress({item, progress}) {
const {t} = useTranslation();
const count = Math.min(progress, item.target);
const text = t(`POINTS.PROGRESS.${item.track}`, {count, target: item.target});
return (
<div className="flex w-40 shrink-0 flex-col gap-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<LockKeyhole className="size-3 shrink-0"/>
<span title={text}>{text}</span>
</span>
<div role="progressbar" aria-label={t('POINTS.LOCKED')} aria-valuemin={0} aria-valuenow={count}
aria-valuemax={item.target} className="h-1.5 overflow-hidden rounded-full bg-muted">
<div className="h-full bg-primary" style={{width: `${(count / item.target) * 100}%`}}/>
</div>
</div>
);
}
+47
View File
@@ -19,12 +19,17 @@ const wallet = (extra = {}) => ({
{code: 'FX_GOLD_SHINE', slot: 'COMPLETED_EFFECT', price: 300},
{code: 'BG_GRID', slot: 'PAGE_BACKGROUND', price: 450},
{code: 'BN_GRID', slot: 'BANNER_BACKGROUND', price: 300},
{code: 'BADGE_FINISH_10', slot: 'BADGE', price: 0, track: 'GAMES_FINISHED', target: 10},
{code: 'BADGE_STREAK_4', slot: 'BADGE', price: 0, track: 'FINISH_WEEKS', target: 4},
{code: 'SKIN_PASTEL', slot: 'SKIN', price: 600},
],
progress: {GAMES_FINISHED: 7, FINISH_WEEKS: 9},
owned: ['BADGE_PILE_SLAYER'],
equipped: {},
history: [
{kind: 'FINISHED', ref: '620', gameName: 'Portal 2', points: 50, eventAt: '2026-09-30T10:00:00Z'},
{kind: 'MONTH', ref: '2026-09', gameName: null, points: 40, eventAt: '2026-09-30T10:00:00Z'},
{kind: 'UNLOCK', ref: 'BADGE_PERFECT_5', gameName: null, points: 0, eventAt: '2026-09-29T10:00:00Z'},
],
...extra,
});
@@ -60,6 +65,48 @@ describe('PointsPage', () => {
expect(card('BANNER_BACKGROUND').querySelector('[data-effect="grid"]')).not.toBeNull();
});
it('shows a locked unlock item with its progress, a progress bar and no price or Buy button', async () => {
await renderPage();
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
expect(within(item).getByText('POINTS.PROGRESS.GAMES_FINISHED:7')).toBeTruthy();
const bar = within(item).getByRole('progressbar');
expect(bar.getAttribute('aria-valuenow')).toBe('7');
expect(bar.getAttribute('aria-valuemax')).toBe('10');
expect(within(item).queryByRole('button')).toBeNull();
});
it('caps the progress at the target', async () => {
await renderPage();
const item = screen.getByText('POINTS.ITEMS.BADGE_STREAK_4').closest('li');
expect(within(item).getByText('POINTS.PROGRESS.FINISH_WEEKS:4')).toBeTruthy();
expect(within(item).getByRole('progressbar').getAttribute('aria-valuenow')).toBe('4');
});
it('lets the user equip an unlock item once it is owned', async () => {
await renderPage({'GET /api/rewards/wallet': json(200, wallet({owned: ['BADGE_FINISH_10']}))});
const item = screen.getByText('POINTS.ITEMS.BADGE_FINISH_10').closest('li');
expect(within(item).getByRole('button', {name: 'POINTS.EQUIP'})).toBeTruthy();
expect(within(item).queryByRole('progressbar')).toBeNull();
});
it('previews a skin with its data-skin', async () => {
await renderPage();
const card = screen.getByText('POINTS.SLOTS.SKIN').closest('[data-slot="card"]');
expect(card.querySelector('[data-skin="pastel"]')).not.toBeNull();
});
it('shows an UNLOCK row with the item name and no number', async () => {
await renderPage();
const row = screen.getByText(/POINTS.KINDS.UNLOCK/).closest('li');
expect(row.textContent).toContain('POINTS.ITEMS.BADGE_PERFECT_5');
expect(row.textContent).not.toMatch(/\+?0$/);
});
it('holds the frame of previews that are off screen', async () => {
const callbacks = [];
vi.stubGlobal('IntersectionObserver', class {
+5 -2
View File
@@ -10,7 +10,7 @@ import {cn} from '@/lib/utils';
import GameCover from '../components/GameCover.jsx';
import GameStatusBadge, {STATUS_META} from '../components/GameStatusBadge.jsx';
import {formatDay} from '../utils/format.js';
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, lookClass} from '../utils/rewards.js';
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
import PointsBadge from '../components/PointsBadge.jsx';
import {GAME_STATUSES} from '../utils/limits.js';
import {formatPlaytime} from '../utils/games.js';
@@ -152,7 +152,10 @@ export default function PublicProfilePage() {
// The owner's accent and corners apply to this page only; light or dark mode stays the viewer's.
return (
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}>
<div data-accent={equippedAccent(equipped) ?? style.accent} data-corners={style.corners}
data-skin={equippedSkin(equipped) ?? 'none'}>
{/* The owner's base layer, below PageBackground (-z-10); the page's own bg-background is the viewer's. */}
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-20 bg-background skin-bg"/>
<PageBackground appid={style.backgroundAppid} effect={backgroundEffect(equipped)}/>
<PageContainer className="p-4 md:p-8 space-y-6">
<Card className={cn('relative overflow-hidden p-6 md:p-8', lookClass(equipped, 'BANNER_FRAME'))}>
+17
View File
@@ -55,6 +55,23 @@ describe('PublicProfilePage', () => {
expect(screen.getByText('Portal 2').closest('li').querySelector('.fx-cover-neon-pulse')).not.toBeNull();
});
it('wears the skin of the owner on the wrapper with its own base layer, or resets to the plain theme', async () => {
mockBackends({style: {accent: 'red'}, equipped: {SKIN: 'SKIN_CYBERPUNK'}});
const {unmount} = renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
const wrapper = screen.getByText('Lobo').closest('[data-skin]');
expect(wrapper.dataset.skin).toBe('cyberpunk');
expect(wrapper.querySelector('.skin-bg')).not.toBeNull();
unmount();
cleanup();
mockBackends({style: {accent: 'red'}, equipped: {}});
renderPage();
expect(await screen.findByText('Portal 2')).toBeTruthy();
expect(screen.getByText('Lobo').closest('[data-skin]').dataset.skin).toBe('none');
});
it('still shows the profile when the looks call fails', async () => {
mockBackends({style: {accent: 'red'}, rewardsStatus: 500});
renderPage();
+268
View File
@@ -0,0 +1,268 @@
/*
* Skins of the points shop (utils/rewards.js LOOKS, data-skin on <html>, or on the wrapper of a public profile).
* A skin is a full theme made of CSS variables only: every shadcn token, --radius, --logo-*, and four more:
* --card-shadow (components/ui/card.jsx), --font-heading and --heading-case (style.css), --skin-bg (.skin-bg).
* Skins are static: no @keyframes, no animation. System font stacks only.
*
* Precedence while a skin is worn (top wins):
* badge, avatar border, banner frame, completed effect stay, and take the skin colors (--primary, --logo-*)
* page background effect, banner effect, item 10 art stay, and paint over the skin background
* skin colors, card style, radius, heading font, base pattern
* premium accent, free accent, corners overridden by the skin
* light/dark mode, "Reduce animations" always the viewer's; every skin has both modes
*
* Specificity is the whole precedence, because this file is imported before the accent blocks of style.css:
* light [data-skin][data-skin="x"] (0,2,0) beats [data-accent], [data-corners]
* dark .dark[data-skin][data-skin="x"], .dark [data-skin][data-skin="x"] (0,3,0) beats .dark[data-accent] (0,2,0)
* The light block also matches in dark, so a dark block holds only the tokens that change with the mode. A rule that
* uses another token (--card-shadow, --skin-bg with var(--primary)) sits in the block of that token, because custom
* properties resolve where they are declared. The "none" reset is a plain [data-skin="none"] (0,1,0): the accent and corners of the same wrapper, later
* in the source, still win over it. Do not add a rule with a higher specificity than these for accent or corners.
*/
/* A public profile of an owner without a skin: the shadcn tokens come from the plain theme blocks of style.css. */
[data-skin="none"] {
--font-heading: initial;
--heading-case: initial;
--logo-violet: initial;
--logo-blue: initial;
--logo-teal: initial;
}
.skin-bg {
background-image: var(--skin-bg);
background-attachment: fixed;
}
.skin-bg.bg-scroll {
background-attachment: scroll;
}
/* ---- Pastel: blush and plum, soft and round ---- */
[data-skin][data-skin="pastel"] {
--background: oklch(0.97 0.02 350);
--foreground: oklch(0.3 0.05 340);
--card: oklch(1 0.005 350);
--card-foreground: oklch(0.3 0.05 340);
--popover: oklch(1 0.005 350);
--popover-foreground: oklch(0.3 0.05 340);
--primary: oklch(0.55 0.17 5);
--primary-foreground: oklch(0.99 0.01 350);
--secondary: oklch(0.94 0.03 350);
--secondary-foreground: oklch(0.3 0.05 340);
--muted: oklch(0.94 0.03 350);
--muted-foreground: oklch(0.46 0.05 340);
--accent: oklch(0.93 0.06 165);
--accent-foreground: oklch(0.32 0.07 165);
--border: oklch(0.88 0.04 350);
--input: oklch(0.88 0.04 350);
--ring: oklch(0.65 0.15 5);
--radius: 1.25rem;
--logo-violet: #f472b6;
--logo-blue: #c084fc;
--logo-teal: #5eead4;
--card-shadow: 0 8px 28px -8px rgb(190 90 130 / 0.28);
--font-heading: ui-rounded, "SF Pro Rounded", "Inter Variable", sans-serif;
--heading-case: none;
--skin-bg:
radial-gradient(40rem 30rem at 0% 0%, rgb(249 168 212 / 0.35), transparent 70%),
radial-gradient(36rem 28rem at 100% 100%, rgb(134 239 172 / 0.3), transparent 70%);
}
.dark[data-skin][data-skin="pastel"],
.dark [data-skin][data-skin="pastel"] {
--background: oklch(0.2 0.04 330);
--foreground: oklch(0.95 0.02 350);
--card: oklch(0.25 0.05 330);
--card-foreground: oklch(0.95 0.02 350);
--popover: oklch(0.25 0.05 330);
--popover-foreground: oklch(0.95 0.02 350);
--primary: oklch(0.75 0.13 5);
--primary-foreground: oklch(0.2 0.04 330);
--secondary: oklch(0.31 0.05 330);
--secondary-foreground: oklch(0.95 0.02 350);
--muted: oklch(0.31 0.05 330);
--muted-foreground: oklch(0.76 0.04 340);
--accent: oklch(0.35 0.07 165);
--accent-foreground: oklch(0.93 0.05 165);
--border: oklch(0.4 0.05 335);
--input: oklch(0.4 0.05 335);
--ring: oklch(0.75 0.13 5);
--card-shadow: 0 8px 28px -8px rgb(0 0 0 / 0.5);
--skin-bg:
radial-gradient(40rem 30rem at 0% 0%, rgb(244 114 182 / 0.18), transparent 70%),
radial-gradient(36rem 28rem at 100% 100%, rgb(94 234 212 / 0.14), transparent 70%);
}
/* ---- Retro CRT: amber ink on paper, phosphor green on black, scanlines ---- */
[data-skin][data-skin="retro-crt"] {
--background: oklch(0.95 0.03 85);
--foreground: oklch(0.32 0.07 70);
--card: oklch(0.975 0.02 85);
--card-foreground: oklch(0.32 0.07 70);
--popover: oklch(0.975 0.02 85);
--popover-foreground: oklch(0.32 0.07 70);
--primary: oklch(0.5 0.12 65);
--primary-foreground: oklch(0.97 0.02 85);
--secondary: oklch(0.91 0.04 85);
--secondary-foreground: oklch(0.32 0.07 70);
--muted: oklch(0.91 0.04 85);
--muted-foreground: oklch(0.46 0.07 70);
--accent: oklch(0.88 0.08 80);
--accent-foreground: oklch(0.3 0.07 70);
--border: oklch(0.78 0.07 75);
--input: oklch(0.78 0.07 75);
--ring: oklch(0.6 0.14 70);
--radius: 0.25rem;
--logo-violet: #d97706;
--logo-blue: #b45309;
--logo-teal: #ca8a04;
--card-shadow: inset 0 0 18px rgb(180 110 20 / 0.18);
--font-heading: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
--heading-case: uppercase;
--skin-bg:
repeating-linear-gradient(0deg, rgb(120 70 10 / 0.07) 0 1px, transparent 1px 3px),
radial-gradient(ellipse at center, transparent 55%, rgb(120 70 10 / 0.18));
}
.dark[data-skin][data-skin="retro-crt"],
.dark [data-skin][data-skin="retro-crt"] {
--background: oklch(0.15 0.02 150);
--foreground: oklch(0.85 0.19 145);
--card: oklch(0.19 0.03 150);
--card-foreground: oklch(0.85 0.19 145);
--popover: oklch(0.19 0.03 150);
--popover-foreground: oklch(0.85 0.19 145);
--primary: oklch(0.82 0.2 145);
--primary-foreground: oklch(0.15 0.03 150);
--secondary: oklch(0.25 0.05 150);
--secondary-foreground: oklch(0.85 0.19 145);
--muted: oklch(0.25 0.05 150);
--muted-foreground: oklch(0.7 0.14 145);
--accent: oklch(0.3 0.08 150);
--accent-foreground: oklch(0.9 0.15 145);
--border: oklch(0.45 0.12 145);
--input: oklch(0.45 0.12 145);
--ring: oklch(0.82 0.2 145);
--logo-violet: #4ade80;
--logo-blue: #86efac;
--logo-teal: #22c55e;
--card-shadow: inset 0 0 18px rgb(74 222 128 / 0.14);
--skin-bg:
repeating-linear-gradient(0deg, rgb(0 0 0 / 0.28) 0 1px, transparent 1px 3px),
radial-gradient(ellipse at center, transparent 50%, rgb(0 0 0 / 0.5));
}
/* ---- Cyberpunk: neon yellow and cyan on violet black, a faint grid ---- */
[data-skin][data-skin="cyberpunk"] {
--background: oklch(0.97 0.05 100);
--foreground: oklch(0.22 0.06 300);
--card: oklch(0.99 0.03 100);
--card-foreground: oklch(0.22 0.06 300);
--popover: oklch(0.99 0.03 100);
--popover-foreground: oklch(0.22 0.06 300);
--primary: oklch(0.5 0.24 350);
--primary-foreground: oklch(0.99 0.01 100);
--secondary: oklch(0.93 0.06 100);
--secondary-foreground: oklch(0.22 0.06 300);
--muted: oklch(0.93 0.06 100);
--muted-foreground: oklch(0.42 0.07 300);
--accent: oklch(0.85 0.1 200);
--accent-foreground: oklch(0.25 0.07 220);
--border: oklch(0.6 0.12 200);
--input: oklch(0.6 0.12 200);
--ring: oklch(0.6 0.2 350);
--radius: 0.125rem;
--logo-violet: #db2777;
--logo-blue: #06b6d4;
--logo-teal: #eab308;
--card-shadow: 0 0 0 1px oklch(0.6 0.12 200), 0 0 14px -2px oklch(0.7 0.12 200 / 0.5);
--font-heading: ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
--heading-case: uppercase;
--skin-bg:
repeating-linear-gradient(0deg, rgb(6 182 212 / 0.12) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgb(6 182 212 / 0.12) 0 1px, transparent 1px 40px);
}
.dark[data-skin][data-skin="cyberpunk"],
.dark [data-skin][data-skin="cyberpunk"] {
--background: oklch(0.15 0.04 300);
--foreground: oklch(0.95 0.03 100);
--card: oklch(0.2 0.05 300);
--card-foreground: oklch(0.95 0.03 100);
--popover: oklch(0.2 0.05 300);
--popover-foreground: oklch(0.95 0.03 100);
--primary: oklch(0.92 0.18 105);
--primary-foreground: oklch(0.18 0.04 300);
--secondary: oklch(0.27 0.06 300);
--secondary-foreground: oklch(0.95 0.03 100);
--muted: oklch(0.27 0.06 300);
--muted-foreground: oklch(0.78 0.08 200);
--accent: oklch(0.8 0.13 200);
--accent-foreground: oklch(0.18 0.04 220);
--border: oklch(0.6 0.12 200);
--input: oklch(0.6 0.12 200);
--ring: oklch(0.8 0.13 200);
--logo-violet: #facc15;
--logo-blue: #22d3ee;
--logo-teal: #e879f9;
--card-shadow: 0 0 0 1px oklch(0.6 0.12 200), 0 0 18px -2px oklch(0.8 0.13 200 / 0.45);
--skin-bg:
repeating-linear-gradient(0deg, rgb(34 211 238 / 0.07) 0 1px, transparent 1px 40px),
repeating-linear-gradient(90deg, rgb(34 211 238 / 0.07) 0 1px, transparent 1px 40px);
}
/* ---- Steam: the dark blue of a game store (colors only, no logo or art) ---- */
[data-skin][data-skin="steam"] {
--background: #e5edf3;
--foreground: #1b2838;
--card: #ffffff;
--card-foreground: #1b2838;
--popover: #ffffff;
--popover-foreground: #1b2838;
--primary: #1a6fa8;
--primary-foreground: #ffffff;
--secondary: #d3e0ea;
--secondary-foreground: #1b2838;
--muted: #d3e0ea;
--muted-foreground: #4a5e70;
--accent: #cfe3f2;
--accent-foreground: #124e78;
--border: #b5c7d6;
--input: #b5c7d6;
--ring: #66c0f4;
--radius: 0.125rem;
--logo-violet: #1a6fa8;
--logo-blue: #66c0f4;
--logo-teal: #2a475e;
--card-shadow: 0 2px 6px rgb(27 40 56 / 0.25);
--font-heading: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--heading-case: uppercase;
--skin-bg: radial-gradient(60rem 28rem at 50% 0%, rgb(42 71 94 / 0.22), transparent 70%);
}
.dark[data-skin][data-skin="steam"],
.dark [data-skin][data-skin="steam"] {
--background: #1b2838;
--foreground: #c7d5e0;
--card: #16202d;
--card-foreground: #c7d5e0;
--popover: #16202d;
--popover-foreground: #c7d5e0;
--primary: #66c0f4;
--primary-foreground: #0e1822;
--secondary: #2a475e;
--secondary-foreground: #c7d5e0;
--muted: #2a475e;
--muted-foreground: #a9bccb;
--accent: #2a475e;
--accent-foreground: #8fd0f7;
--border: #3d5a73;
--input: #3d5a73;
--ring: #66c0f4;
--logo-violet: #66c0f4;
--logo-blue: #c7d5e0;
--logo-teal: #4b8fb8;
--card-shadow: 0 4px 12px rgb(0 0 0 / 0.5);
--skin-bg: radial-gradient(60rem 28rem at 50% 0%, #2a475e, transparent 70%);
}
+13 -2
View File
@@ -1,12 +1,14 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "./effects.css";
@import "./skins.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
/* shadcn/ui theme tokens: zinc surfaces, violet accent. */
:root {
:root,
[data-skin="none"] {
--radius: 0.75rem;
--background: oklch(0.985 0 0);
--foreground: oklch(0.141 0.005 285.823);
@@ -26,9 +28,12 @@
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.606 0.25 292.717);
--card-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--skin-bg: none;
}
.dark {
.dark,
.dark [data-skin="none"] {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.21 0.006 285.885);
@@ -277,6 +282,12 @@
option {
@apply bg-popover text-popover-foreground;
}
/* Skins (skins.css) set both; without a skin they change nothing. */
h1, h2, h3 {
font-family: var(--font-heading, inherit);
text-transform: var(--heading-case, none);
}
}
/* Radix menus and selects lock the page scroll and add a right margin as wide as the scrollbar, measured as
+6 -3
View File
@@ -39,9 +39,12 @@ export const readCachedAppearance = () => {
export const cacheAppearance = (appearance) => storage.set(STORAGE_KEY, JSON.stringify(appearance));
// accentOverride: a premium accent worn from the shop (Core owns it), which wins over the saved one.
export const applyAppearance = (appearance, accentOverride = null, root = document.documentElement) => {
root.dataset.accent = accentOverride ?? appearance.accent;
// accent: a premium accent worn from the shop (Core owns it), which wins over the saved one. skin: the skin worn;
// skins.css lets it win over accent and corners.
export const applyAppearance = (appearance, {accent = null, skin = null} = {}, root = document.documentElement) => {
root.dataset.accent = accent ?? appearance.accent;
if (skin) root.dataset.skin = skin;
else delete root.dataset.skin;
root.dataset.corners = appearance.corners;
root.toggleAttribute('data-reduce-motion', appearance.reduceMotion === true);
};
+15 -4
View File
@@ -45,7 +45,7 @@ describe('applyAppearance', () => {
it('sets the presets as data attributes for the CSS', () => {
const root = document.createElement('html');
applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, null, root);
applyAppearance({accent: 'orange', corners: 'round', wideLayout: true, backgroundAppid: 620}, {}, root);
expect(root.dataset.accent).toBe('orange');
expect(root.dataset.corners).toBe('round');
@@ -54,10 +54,21 @@ describe('applyAppearance', () => {
it('lets a worn premium accent win over the saved one', () => {
const root = document.createElement('html');
applyAppearance({accent: 'orange', corners: 'round'}, 'gold', root);
applyAppearance({accent: 'orange', corners: 'round'}, {accent: 'gold'}, root);
expect(root.dataset.accent).toBe('gold');
});
it('sets the worn skin and removes it again', () => {
const root = document.createElement('html');
applyAppearance({accent: 'orange', corners: 'round'}, {accent: 'gold', skin: 'cyberpunk'}, root);
expect(root.dataset.skin).toBe('cyberpunk');
expect(root.dataset.accent).toBe('gold');
applyAppearance({accent: 'orange', corners: 'round'}, {}, root);
expect(root.dataset.skin).toBeUndefined();
});
});
describe('reduce motion', () => {
@@ -71,10 +82,10 @@ describe('reduce motion', () => {
it('is set on the root by applyAppearance and removed again', () => {
const root = document.createElement('html');
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: true}, null, root);
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: true}, {}, root);
expect(root.hasAttribute('data-reduce-motion')).toBe(true);
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: false}, null, root);
applyAppearance({accent: 'blue', corners: 'default', reduceMotion: false}, {}, root);
expect(root.hasAttribute('data-reduce-motion')).toBe(false);
});
+22 -2
View File
@@ -1,11 +1,11 @@
import {Swords, Trophy} from 'lucide-react';
import {Flame, Flag, Gem, Swords, TrendingUp, Trophy} from 'lucide-react';
import {apiFetch, request} from './api.js';
import {storage} from './storage.js';
const REWARDS_URL = `${import.meta.env.VITE_CORE_API_URL}/api/rewards`;
const JSON_HEADERS = {'Content-Type': 'application/json'};
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME', 'COMPLETED_EFFECT', 'PAGE_BACKGROUND', 'BANNER_BACKGROUND'];
export const SLOTS = ['AVATAR_BORDER', 'BADGE', 'ACCENT', 'BANNER_FRAME', 'COMPLETED_EFFECT', 'PAGE_BACKGROUND', 'BANNER_BACKGROUND', 'SKIN'];
// The shop catalog of Core (ShopItem) as looks: only these classes are ever applied, never a string from the server.
// An unknown code is skipped.
@@ -40,11 +40,31 @@ export const LOOKS = {
BN_GRID: {effect: 'grid'},
BN_WAVES: {effect: 'waves'},
BN_BOKEH: {effect: 'bokeh'},
// Unlock-only items (Core: earned by progress, never sold).
BADGE_FINISH_10: {Icon: Flag, className: 'bg-emerald-500/15 text-emerald-500'},
BORDER_FINISH_25: {className: 'ring-4 ring-slate-300 ring-offset-2 ring-offset-background'},
FRAME_FINISH_50: {className: 'ring-2 ring-emerald-400 ring-offset-2 ring-offset-background shadow-[0_0_14px] shadow-emerald-400/50'},
BADGE_PERFECT_5: {Icon: Gem, className: 'bg-cyan-500/15 text-cyan-500'},
FX_PLATINUM: {cover: 'fx fx-cover-platinum outline outline-2 -outline-offset-2 outline-slate-200', detail: 'fx-detail-platinum'},
BADGE_STREAK_4: {Icon: Flame, className: 'bg-orange-500/15 text-orange-500'},
BORDER_STREAK_12: {className: 'ring-4 ring-orange-500 shadow-[0_0_14px] shadow-orange-500/60'},
FRAME_PILE_DOWN_3: {className: 'ring-2 ring-teal-400 ring-offset-2 ring-offset-background'},
BADGE_PLAY_MORE_1: {Icon: TrendingUp, className: 'bg-sky-500/15 text-sky-500'},
BORDER_PLAY_MORE_3: {className: 'ring-4 ring-sky-400 ring-offset-2 ring-offset-background'},
BN_EQUALIZER: {effect: 'equalizer'},
// Skins (skins.css): full themes, set as data-skin.
SKIN_PASTEL: {skin: 'pastel'},
SKIN_RETRO_CRT: {skin: 'retro-crt'},
SKIN_CYBERPUNK: {skin: 'cyberpunk'},
SKIN_STEAM: {skin: 'steam'},
};
/** The premium accent worn, or null (then the appearance accent applies). */
export const equippedAccent = (equipped) => LOOKS[equipped?.ACCENT]?.accent ?? null;
/** The skin worn (a data-skin value of skins.css), or null. */
export const equippedSkin = (equipped) => LOOKS[equipped?.SKIN]?.skin ?? null;
/** The classes of the item worn in a slot, or undefined. */
export const lookClass = (equipped, slot) => LOOKS[equipped?.[slot]]?.className;
+10 -1
View File
@@ -1,5 +1,5 @@
import {describe, expect, it} from 'vitest';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, LOOKS} from './rewards.js';
import {backgroundEffect, bannerEffect, coverEffect, detailEffect, equippedSkin, LOOKS} from './rewards.js';
describe('effects worn', () => {
const equipped = {COMPLETED_EFFECT: 'FX_GOLD_SHINE', PAGE_BACKGROUND: 'BG_STARFIELD', BANNER_BACKGROUND: 'BN_WAVES'};
@@ -24,3 +24,12 @@ describe('effects worn', () => {
expect(LOOKS.BG_GRID.effect).toBe(LOOKS.BN_GRID.effect);
});
});
describe('equippedSkin', () => {
it('gives the data-skin value of a known skin and null otherwise', () => {
expect(equippedSkin({SKIN: 'SKIN_RETRO_CRT'})).toBe('retro-crt');
expect(equippedSkin({SKIN: 'SKIN_FUTURE'})).toBeNull();
expect(equippedSkin({})).toBeNull();
expect(equippedSkin(undefined)).toBeNull();
});
});