Public Access
feat(points): unlock items, progress bars and skins
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:
1 parent
8598024bce
commit
b6f3d123fb
22 files changed
+622
-38
No files matched your search
+1
-1
@@ -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)}/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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={{
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
<span className={cn('font-semibold tabular-nums', entry.points < 0 && 'text-muted-foreground')}>
|
||||
{entry.points > 0 ? `+${entry.points}` : entry.points}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'))}>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user