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
+622 -38

No files matched your search

+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();
});
});