@@ -0,0 +1,409 @@
< script lang = "ts" >
import { onMount } from 'svelte';
import { Target , Sparkles , Users , Save , RotateCcw , Search , Shuffle , Shield , Pin , TrendingUp , Plus , Minus , Equal , ArrowUpToLine , Eraser , Info } from '@lucide/svelte';
import Avatar from '../components/Avatar.svelte';
import Modal from '../components/Modal.svelte';
import { get , post , put } from '../lib/api';
import { toast , toastError } from '../lib/toast.svelte';
import type { PlayerProgress , ProgressionSettings , ProgressionView } from '../lib/types';
const DEFAULTS: ProgressionSettings = {
daily_quest_limit: 5,
weekly_quest_limit: 3,
quest_rotation: 'per_player',
level_base: 100,
level_exponent: 1.5,
max_level: 0,
global_xp_multiplier: 1,
server_xp_multiplier: 1,
xp_rates: { playtime_per_hour : 600 , player_kill : 50 , mob_kill : 15 , block_broken : 0.2 , block_placed : 0.2 , message : 2 } ,
};
let tab = $state< 'quests' | 'xp' | 'players'>('quests');
let saved = $state< ProgressionSettings | null > (null);
let s = $state< ProgressionSettings > (structuredClone(DEFAULTS));
let pool = $state({ daily : { enabled : 0 , pinned : 0 }, weekly : { enabled : 0 , pinned : 0 } } );
let saving = $state(false);
const dirty = $derived(saved != null & & JSON.stringify(saved) !== JSON.stringify(s));
const curveChanged = $derived(saved != null & & (saved.level_base !== s.level_base || saved.level_exponent !== s.level_exponent || saved.max_level !== s.max_level));
async function load() {
try {
const v = await get< ProgressionView > ('/api/admin/progression');
saved = structuredClone(v.settings);
s = structuredClone(v.settings);
pool = v.quest_pool;
} catch (e) {
toastError(e);
}
}
async function save() {
saving = true;
try {
const v = await put< ProgressionView & { recalculated : number } > ('/api/admin/progression', s);
saved = structuredClone(v.settings);
s = structuredClone(v.settings);
pool = v.quest_pool;
toast(v.recalculated ? `Saved — ${ v . recalculated } player levels recalculated` : 'Progression settings saved');
} catch (e) {
toastError(e);
} finally {
saving = false;
}
}
const revert = () => saved & & (s = structuredClone(saved));
// ---- curve helpers (mirror the server's maths for live previews) ----
const xpFor = (lvl: number, base = s.level_base, exp = s.level_exponent) => (lvl < = 1 ? 0 : Math.round(base * Math.pow(lvl, exp)));
function levelFrom(xp: number): number {
const cap = s.max_level > 0 ? s.max_level : 10000;
let l = 1;
while (l < cap & & xp > = xpFor(l + 1)) l++;
return l;
}
const sampleLevels = $derived([5, 10, 25, 50, 100].filter((l) => !s.max_level || l < = s.max_level));
const chart = $derived.by(() => {
const top = Math.min(s.max_level > 0 ? s.max_level : 60, 60);
const pts = Array.from({ length : top - 1 } , (_, i) => ({ l : i + 2 , xp : xpFor ( i + 2 ) } ));
const maxXp = Math.max(1, ...pts.map((p) => p.xp));
const path = pts.map((p, i) => `${ i ? 'L' : 'M' } ${((( p . l - 2 ) / Math . max ( 1 , top - 2 )) * 280 + 10 ). toFixed ( 1 )} ,${( 100 - ( p . xp / maxXp ) * 88 ). toFixed ( 1 )} `).join(' ');
return { path , top , maxXp } ;
});
const n = (v: number) => Math.round(v).toLocaleString();
const per = (r: number) => (r > 0 && r < 1 ? ` 1 XP per $ { Math . round ( 1 / r )} ` : `$ { r } XP each `);
const quests = $derived([
{ key : 'daily_quest_limit' as const , label : 'Daily quests' , hint : 'Reset every day at 00:00 UTC' , pool : pool.daily , icon : Target } ,
{ key : 'weekly_quest_limit' as const , label : 'Weekly quests' , hint : 'Reset every Monday at 00:00 UTC' , pool : pool.weekly , icon : TrendingUp } ,
]);
// ---- players ----
let q = $state('');
let players = $state< PlayerProgress [ ] > ([]);
let total = $state(0);
let loadingPlayers = $state(false);
let timer: ReturnType< typeof setTimeout > ;
async function loadPlayers() {
loadingPlayers = true;
try {
const r = await get< { players : PlayerProgress []; total : number } >(`/api/admin/progression/players?q=${ encodeURIComponent ( q )} &limit=40`);
players = r.players;
total = r.total;
} catch (e) {
toastError(e);
} finally {
loadingPlayers = false;
}
}
$effect(() => {
void q;
clearTimeout(timer);
timer = setTimeout(() => tab === 'players' & & loadPlayers(), 250);
return () => clearTimeout(timer);
});
$effect(() => { if ( tab === 'players' && ! players . length ) loadPlayers (); } );
type Detail = {
uuid: string; name: string; title: string | null; max_level: number;
global: { xp : number ; level : number ; level_xp : number ; level_span : number ; progress_pct : number } ;
servers: { server_id : number ; server_name : string ; xp : number ; level : number ; progress_pct : number ; rank_name : string | null } [];
};
let detail = $state< Detail | null > (null);
let open = $state(false);
let scope = $state('global');
let mode = $state< 'add' | 'remove' | 'set_xp' | 'set_level' | 'reset'>('add');
let amount = $state(500);
let reason = $state('');
let applying = $state(false);
async function inspect(p: PlayerProgress) {
try {
detail = await get< Detail > (`/api/admin/progression/players/${ p . uuid } `);
scope = 'global';
mode = 'add';
amount = 500;
reason = '';
open = true;
} catch (e) {
toastError(e);
}
}
const currentXp = $derived(!detail ? 0 : scope === 'global' ? detail.global.xp : detail.servers.find((x) => String(x.server_id) === scope)?.xp ?? 0);
const nextXp = $derived(
mode === 'add' ? currentXp + amount
: mode === 'remove' ? Math.max(0, currentXp - amount)
: mode === 'set_xp' ? amount
: mode === 'set_level' ? xpFor(amount)
: 0
);
async function apply() {
if (!detail) return;
applying = true;
try {
const body: Record< string , unknown > = { scope : scope === 'global' ? 'global' : 'server' , mode , amount : mode === 'reset' ? 0 : Number ( amount ), reason } ;
if (scope !== 'global') body.server_id = Number(scope);
detail = await post< Detail > (`/api/admin/progression/players/${ detail . uuid } /adjust`, body);
toast('XP updated');
loadPlayers();
} catch (e) {
toastError(e);
} finally {
applying = false;
}
}
const modes = [
{ id : 'add' , label : 'Give XP' , icon : Plus } ,
{ id : 'remove' , label : 'Take XP' , icon : Minus } ,
{ id : 'set_xp' , label : 'Set XP' , icon : Equal } ,
{ id : 'set_level' , label : 'Set level' , icon : ArrowUpToLine } ,
{ id : 'reset' , label : 'Reset' , icon : Eraser } ,
] as const;
onMount(load);
< / script >
< div class = "page" >
< div class = "page-head" >
< div >
< h1 > Progression< / h1 >
< p > Decide how many quests players get, how fast they earn XP, how steep levels are — and fix any player's XP by hand.< / p >
< / div >
< / div >
< div class = "tabs" role = "tablist" >
< button role = "tab" aria-selected = { tab === 'quests' } class:on= { tab === 'quests' } onclick = {() => ( tab = 'quests' )} > <Target size = { 15 } / > Quest limits</ button >
< button role = "tab" aria-selected = { tab === 'xp' } class:on= { tab === 'xp' } onclick = {() => ( tab = 'xp' )} > <Sparkles size = { 15 } / > XP & levels</ button >
< button role = "tab" aria-selected = { tab === 'players' } class:on= { tab === 'players' } onclick = {() => ( tab = 'players' )} > <Users size = { 15 } / > Players</ button >
< / div >
{ #if tab === 'quests' }
< div class = "cols" >
{ #each quests as c ( c . key )}
< section class = "card quota" >
< div class = "q-head" >
< span class = "ic" >< c.icon size = { 18 } / ></ span >
< div >< h2 > { c . label } </ h2 >< p class = "muted small" > { c . hint } </ p ></ div >
< / div >
< div class = "big" >
< button class = "step" aria-label = "Fewer" onclick = {() => ( s [ c . key ] = Math . max ( 0 , s [ c . key ] - 1 ))} > <Minus size = { 16 } / ></ button >
< div class = "num" >
< input type = "number" min = "0" max = "100" bind:value = { s [ c . key ]} aria-label= { c . label + ' per player' } />
< span class = "muted small" > { s [ c . key ] === 0 ? 'every quest' : s [ c . key ] === 1 ? 'quest per player' : 'quests per player' } </ span >
< / div >
< button class = "step" aria-label = "More" onclick = {() => ( s [ c . key ] = Math . min ( 100 , s [ c . key ] + 1 ))} > <Plus size = { 16 } / ></ button >
< / div >
< input class = "range" type = "range" min = "0" max = { Math . max ( 20 , Math . min ( 100 , c . pool . enabled ))} bind:value= { s [ c . key ]} aria-label = "Limit slider" />
< div class = "pool" >
< span >< strong > { c . pool . enabled } </ strong > enabled in the pool</ span >
< span class = "pin" >< Pin size = { 12 } / > { c . pool . pinned } pinned (always included)</ span >
< / div >
{ #if s [ c . key ] > 0 && s [ c . key ] < c . pool . pinned }
< p class = "warn small" >< Info size = { 13 } / > Only { s [ c . key ]} of the { c . pool . pinned } pinned quests fit — unpin some or raise the limit.</ p >
{ /if }
< / section >
{ /each }
< / div >
< section class = "card" >
< div class = "section-title" >< h2 >< Shuffle size = { 16 } / > Rotation</ h2 ></ div >
< div class = "choice" >
< button class = "opt" class:on = { s . quest_rotation === 'per_player' } onclick= {() => ( s . quest_rotation = 'per_player' )} >
< Users size = { 17 } / >< span >< strong > Per player</ strong >< small > Everyone draws their own random set each day/week.</ small ></ span >
< / button >
< button class = "opt" class:on = { s . quest_rotation === 'shared' } onclick= {() => ( s . quest_rotation = 'shared' )} >
< Shield size = { 17 } / >< span >< strong > Shared</ strong >< small > Everyone gets the same set — good for community goals.</ small ></ span >
< / button >
< / div >
< p class = "muted small note" >
A player's set is fixed the first time they open their quests in a period, so edits here never swap out quests they've started.
Raising a limit adds quests, lowering it trims the list. Pin quests on the < a href = "#/quests" > Quests< / a > page to always include them.
< / p >
< / section >
{ :else if tab === 'xp' }
< div class = "cols" >
< section class = "card" >
< div class = "section-title" >< h2 >< TrendingUp size = { 16 } / > Level curve</ h2 ></ div >
< p class = "muted small" > XP needed for level < em > L< / em > is < code > round(base × L< sup > exponent< / sup > )< / code > .< / p >
< div class = "fields3" >
< label class = "field" > Base< input type = "number" min = "10" max = "100000" step = "10" bind:value = { s . level_base } / ></ label >
< label class = "field" > Exponent< input type = "number" min = "1" max = "3" step = "0.05" bind:value = { s . level_exponent } / ></ label >
< label class = "field" > Max level< input type = "number" min = "0" max = "10000" bind:value = { s . max_level } / >< span class = "tiny muted" > 0 = no cap</ span ></ label >
< / div >
< svg class = "chart" viewBox = "0 0 300 110" role = "img" aria-label = "XP required per level" >
< path d = "M10,100 H290" class = "axis" / >
< path d = { chart . path } class="line" />
< / svg >
< div class = "chart-legend tiny muted" >< span > Level 2</ span >< span > Level { chart . top } · { n ( chart . maxXp )} XP</ span ></ div >
< ul class = "samples" >
{ #each sampleLevels as l } < li >< span class = "muted" > Level { l } </ span >< strong > { n ( xpFor ( l ))} XP</ strong ></ li > { /each }
< / ul >
{ #if curveChanged } < p class = "warn small" >< Info size = { 13 } / > Saving recalculates every player's stored level from their XP. Nobody loses XP.</ p > { /if }
< / section >
< section class = "card" >
< div class = "section-title" >< h2 >< Sparkles size = { 16 } / > XP earned from play</ h2 ></ div >
< div class = "rates" >
< label class = "field" >< span class = "lab" > Online time < span class = "unit" > XP / hour</ span ></ span >< input type = "number" min = "0" step = "10" bind:value = { s . xp_rates . playtime_per_hour } / ></ label >
< label class = "field" >< span class = "lab" > Player kill < span class = "unit" > XP</ span ></ span >< input type = "number" min = "0" step = "1" bind:value = { s . xp_rates . player_kill } / ></ label >
< label class = "field" >< span class = "lab" > Mob kill < span class = "unit" > XP</ span ></ span >< input type = "number" min = "0" step = "1" bind:value = { s . xp_rates . mob_kill } / ></ label >
< label class = "field" >< span class = "lab" > Block broken < span class = "unit" > { per ( s . xp_rates . block_broken )} </ span ></ span >< input type = "number" min = "0" step = "0.05" bind:value = { s . xp_rates . block_broken } / ></ label >
< label class = "field" >< span class = "lab" > Block placed < span class = "unit" > { per ( s . xp_rates . block_placed )} </ span ></ span >< input type = "number" min = "0" step = "0.05" bind:value = { s . xp_rates . block_placed } / ></ label >
< label class = "field" >< span class = "lab" > Chat message < span class = "unit" > XP</ span ></ span >< input type = "number" min = "0" step = "1" bind:value = { s . xp_rates . message } / ></ label >
< / div >
< div class = "section-title mult" > < h2 > Multipliers< / h2 > < / div >
< div class = "fields2" >
< label class = "field" > Global XP × < input type = "number" min = "0" max = "100" step = "0.1" bind:value = { s . global_xp_multiplier } / ></ label >
< label class = "field" > Server XP × < input type = "number" min = "0" max = "100" step = "0.1" bind:value = { s . server_xp_multiplier } / ></ label >
< / div >
< p class = "muted small" > These stack with the multipliers in each server's plugin/mod config (server levels default to 1.5× there). Quest and achievement XP is set per quest/achievement.< / p >
< / section >
< / div >
{ : else }
< section class = "card" >
< div class = "search" >< Search size = { 16 } / >< input type = "search" placeholder = "Search players by name…" bind:value = { q } / >< span class = "muted small" > { total . toLocaleString ()} players</ span ></ div >
{ #if loadingPlayers && ! players . length }
< div class = "skeleton" > < / div >
{ :else if ! players . length }
< p class = "muted" > No players match.< / p >
{ : else }
< div class = "plist" >
{ #each players as p ( p . uuid )}
< button class = "prow" onclick = {() => inspect ( p )} >
< Avatar name = { p . name } uuid= { p . uuid } size = { 34 } / >
< span class = "pname" >< strong > { p . name } </ strong > { #if p . title } < small class = "muted" > { p . title } </ small > { /if } </ span >
< span class = "pbar" >< i style:width = " { p . progress_pct } %" ></ i ></ span >
< span class = "plvl" > Lv { p . global_level } </ span >
< span class = "pxp muted" > { n ( p . global_xp )} XP</ span >
< / button >
{ /each }
< / div >
{ /if }
< / section >
{ /if }
< / div >
{ #if dirty }
< div class = "savebar" role = "status" >
< span > You have unsaved progression changes.< / span >
< button class = "ghost" onclick = { revert } > <RotateCcw size = { 15 } / > Discard</ button >
< button class = "primary" disabled = { saving } onclick= { save } >< Save size = { 15 } / > { saving ? 'Saving…' : 'Save changes' } </ button >
< / div >
{ /if }
< Modal bind:open title = { detail ? `Adjust $ { detail . name } ` : 'Adjust XP' } width= { 560 } >
{ #if detail }
< div class = "who" >
< Avatar name = { detail . name } uuid= { detail . uuid } size = { 44 } / >
< div >
< strong > { detail . name } </ strong >
< span class = "muted small" > Global level { detail . global . level } · { n ( detail . global . xp )} XP</ span >
< div class = "pbar wide" >< i style:width = " { detail . global . progress_pct } %" ></ i ></ div >
< / div >
< / div >
< label class = "field" > Level track
< select bind:value = { scope } >
< option value = "global" > Global level (all servers)< / option >
{ #each detail . servers as sv } < option value = { String ( sv . server_id )} > { sv . server_name } — level { sv . level } · { n ( sv . xp )} XP </ option > { /each }
< / select >
< / label >
< div class = "modes" role = "tablist" >
{ #each modes as m }
< button role = "tab" aria-selected = { mode === m . id } class="mode" class:on = { mode === m . id } onclick= {() => ( mode = m . id )} >< m.icon size = { 14 } / > { m . label } </ button >
{ /each }
< / div >
{ #if mode !== 'reset' }
< label class = "field" > { mode === 'set_level' ? 'Level' : 'XP amount' }
< input type = "number" min = { mode === 'set_level' ? 1 : 0 } bind:value= { amount } />
< / label >
{ /if }
< label class = "field" > Reason < span class = "tiny muted" > (kept in the activity log)</ span >< input maxlength = "120" bind:value = { reason } placeholder="e.g. event prize , rollback after bug " /></ label >
< div class = "preview" >
< span > { n ( currentXp )} XP < small class = "muted" > (level { levelFrom ( currentXp )} )</ small ></ span >
< span class = "arrow" > →< / span >
< strong > { n ( nextXp )} XP < small > (level { levelFrom ( nextXp )} )</ small ></ strong >
< / div >
{ /if }
{ # snippet footer ()}
< button class = "ghost" onclick = {() => ( open = false )} > Close</button >
< button class = { mode === 'reset' || mode === 'remove' ? 'danger' : 'primary' } disabled= { applying || ( mode !== 'reset' && ! ( amount >= ( mode === 'set_level' ? 1 : 0 )))} onclick = { apply } >
{ applying ? 'Applying…' : 'Apply' }
< / button >
{ /snippet }
< / Modal >
< style >
.page { padding - bottom : 90px ; }
.tabs { display : inline - flex ; gap : 4px ; padding : 4px ; border - radius : 12px ; background : var ( -- bg - 2 ); margin - bottom : 18px ; }
.tabs button { border : 0 ; background : transparent ; padding : 8px 16 px ; border - radius : 9px ; color : var ( -- muted ); gap : 8px ; }
.tabs button.on { background : var ( -- accent - soft ); color : var ( -- text ); }
.cols { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 340 px , 1 fr )); gap : 16px ; margin - bottom : 16px ; align - items : start ; }
.quota { display : flex ; flex - direction : column ; gap : 14px ; }
.q-head { display : flex ; gap : 12px ; align - items : center ; }
.q-head h2 { margin : 0 ; font - size : 1.05rem ; }
.q-head p { margin : 2px 0 0 ; }
.ic { width : 38px ; height : 38px ; border - radius : 11px ; display : grid ; place - items : center ; background : var ( -- accent - soft ); color : var ( -- accent - 2 ); }
.big { display : flex ; align - items : center ; justify - content : center ; gap : 18px ; padding : 6px 0 ; }
.step { width : 40px ; height : 40px ; padding : 0 ; border - radius : 50 % ; }
.num { display : flex ; flex - direction : column ; align - items : center ; min - width : 120px ; }
.num input { width : 100px ; text - align : center ; font - size : 2.6rem ; font - weight : 700 ; letter - spacing : - 0.03 em ; background : transparent ; border : 0 ; padding : 0 ; font - variant - numeric : tabular - nums ; - moz - appearance : textfield ; appearance : textfield ; }
.num input::-webkit-outer-spin-button, .num input::-webkit-inner-spin-button { - webkit - appearance : none ; }
.range { width : 100 % ; accent - color : var ( -- accent ); }
.pool { display : flex ; justify - content : space - between ; flex - wrap : wrap ; gap : 8px ; font - size : 0.85rem ; color : var ( -- text - 2 ); padding - top : 10px ; border - top : 1px solid var ( -- line ); }
.pin { display : inline - flex ; align - items : center ; gap : 5px ; color : var ( -- muted ); }
.warn { display : flex ; gap : 7px ; align - items : center ; color : # fcd34d ; margin : 0 ; }
.choice { display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 260 px , 1 fr )); gap : 10px ; margin : 12px 0 ; }
.opt { justify - content : flex - start ; gap : 12px ; text - align : left ; white - space : normal ; padding : 12px 14 px ; background : var ( -- bg - 2 ); }
.opt span { display : flex ; flex - direction : column ; gap : 2px ; }
.opt small { color : var ( -- muted ); font - weight : 400 ; }
.opt.on { border - color : color - mix ( in srgb , var ( -- accent ) 60 % , transparent ); background : var ( -- accent - soft ); }
.note { line - height : 1.55 ; margin : 0 ; }
.fields3 { display : grid ; grid - template - columns : repeat ( 3 , 1 fr ); gap : 10px ; margin : 12px 0 ; }
.fields2 { display : grid ; grid - template - columns : repeat ( 2 , 1 fr ); gap : 10px ; margin : 10px 0 ; }
.rates { display : grid ; grid - template - columns : repeat ( 2 , 1 fr ); gap : 10px ; margin - top : 12px ; }
.field { display : flex ; flex - direction : column ; gap : 6px ; font - size : 0.85rem ; color : var ( -- text - 2 ); font - weight : 500 ; }
.lab { display : flex ; justify - content : space - between ; gap : 8px ; }
.unit { font - weight : 400 ; color : var ( -- muted ); font - size : 0.75rem ; }
.mult { margin - top : 18px ; }
.chart { width : 100 % ; height : 110px ; margin - top : 6px ; }
.axis { stroke : var ( -- line ); stroke - width : 1 ; fill : none ; }
.line { stroke : var ( -- accent - 2 ); stroke - width : 2.2 ; fill : none ; stroke - linejoin : round ; stroke - linecap : round ; }
.chart-legend { display : flex ; justify - content : space - between ; }
.samples { list - style : none ; margin : 12px 0 0 ; padding : 0 ; display : grid ; grid - template - columns : repeat ( auto - fit , minmax ( 130 px , 1 fr )); gap : 8px ; }
.samples li { display : flex ; flex - direction : column ; padding : 9px 12 px ; border - radius : 10px ; background : var ( -- bg - 2 ); font - size : 0.85rem ; }
.search { display : flex ; align - items : center ; gap : 10px ; padding : 0 12 px ; border - radius : 12px ; background : var ( -- bg - 2 ); margin - bottom : 14px ; }
.search input { border : 0 ; background : transparent ; }
.search span { white - space : nowrap ; }
.plist { display : flex ; flex - direction : column ; gap : 4px ; }
.prow { display : grid ; grid - template - columns : 34px minmax ( 120 px , 1.3f r ) minmax ( 80 px , 2 fr ) 56 px 100 px ; align - items : center ; gap : 14px ; padding : 9px 12 px ; border - radius : 12px ; background : transparent ; border - color : transparent ; text - align : left ; }
.prow:hover { background : var ( -- bg - 2 ); }
.pname { display : flex ; flex - direction : column ; min - width : 0 ; }
.pname small { font - size : 0.75rem ; }
.pbar { height : 6px ; border - radius : 99px ; background : var ( -- bg - 2 ); overflow : hidden ; }
.pbar.wide { margin - top : 6px ; width : 260px ; max - width : 100 % ; }
.pbar i { display : block ; height : 100 % ; border - radius : inherit ; background : linear - gradient ( 90 deg , var ( -- accent ), var ( -- accent - 2 )); }
.plvl { font - weight : 700 ; color : # fcd34d ; }
.pxp { text - align : right ; font - variant - numeric : tabular - nums ; font - size : 0.85rem ; }
.skeleton { height : 220px ; border - radius : 12px ; background : var ( -- bg - 2 ); }
.who { display : flex ; gap : 14px ; align - items : center ; }
.who div { display : flex ; flex - direction : column ; }
.modes { display : flex ; flex - wrap : wrap ; gap : 6px ; }
.mode { padding : 7px 12 px ; border - radius : 99px ; font - size : 0.83rem ; background : var ( -- bg - 2 ); gap : 6px ; }
.mode.on { border - color : color - mix ( in srgb , var ( -- accent ) 60 % , transparent ); background : var ( -- accent - soft ); }
.preview { display : flex ; align - items : center ; justify - content : center ; gap : 14px ; padding : 14px ; border - radius : 12px ; background : var ( -- bg - 2 ); font - variant - numeric : tabular - nums ; }
.preview strong { color : var ( -- accent - 2 ); }
.arrow { color : var ( -- muted ); }
.savebar { position : fixed ; left : 50 % ; bottom : 22px ; transform : translateX ( - 50 % ); z - index : 40 ; display : flex ; align - items : center ; gap : 12px ; padding : 10px 12 px 10 px 18 px ; border - radius : 14px ; background : var ( -- surface ); border : 1px solid color - mix ( in srgb , var ( -- accent ) 45 % , transparent ); box - shadow : 0 14 px 40 px - 12 px rgba ( 0 , 0 , 0 , 0.6 ); animation : rise 0.2 s ease ; }
@keyframes rise { from { opacity : 0 ; transform : translate ( - 50 % , 10 px ); } }
@media (max-width: 720px) { . fields3 , . rates { grid - template - columns : 1fr ; } . prow { grid - template - columns : 34px 1 fr 56 px ; } . pbar , . pxp { display : none ; } . savebar { left : 12px ; right : 12px ; transform : none ; flex - wrap : wrap ; } }
< / style >