@@ -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 : 90 px ; }
. tabs { display : inline-flex ; gap : 4 px ; padding : 4 px ; border-radius : 12 px ; background : var ( -- bg -2 ); margin-bottom : 18 px ; }
. tabs button { border : 0 ; background : transparent ; padding : 8 px 16 px ; border-radius : 9 px ; color : var ( -- muted ); gap : 8 px ; }
. 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 : 16 px ; margin-bottom : 16 px ; align-items : start ; }
. quota { display : flex ; flex-direction : column ; gap : 14 px ; }
. q-head { display : flex ; gap : 12 px ; align-items : center ; }
. q-head h2 { margin : 0 ; font-size : 1.05 rem ; }
. q-head p { margin : 2 px 0 0 ; }
. ic { width : 38 px ; height : 38 px ; border-radius : 11 px ; display : grid ; place-items : center ; background : var ( -- accent - soft ); color : var ( -- accent -2 ); }
. big { display : flex ; align-items : center ; justify-content : center ; gap : 18 px ; padding : 6 px 0 ; }
. step { width : 40 px ; height : 40 px ; padding : 0 ; border-radius : 50 % ; }
. num { display : flex ; flex-direction : column ; align-items : center ; min-width : 120 px ; }
. num input { width : 100 px ; text-align : center ; font-size : 2.6 rem ; 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 : 8 px ; font-size : 0.85 rem ; color : var ( -- text -2 ); padding-top : 10 px ; border-top : 1 px solid var ( -- line ); }
. pin { display : inline-flex ; align-items : center ; gap : 5 px ; color : var ( -- muted ); }
. warn { display : flex ; gap : 7 px ; align-items : center ; color : #fcd34d ; margin : 0 ; }
. choice { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 260 px , 1 fr )); gap : 10 px ; margin : 12 px 0 ; }
. opt { justify-content : flex-start ; gap : 12 px ; text-align : left ; white-space : normal ; padding : 12 px 14 px ; background : var ( -- bg -2 ); }
. opt span { display : flex ; flex-direction : column ; gap : 2 px ; }
. 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 : 10 px ; margin : 12 px 0 ; }
. fields2 { display : grid ; grid-template-columns : repeat ( 2 , 1 fr ); gap : 10 px ; margin : 10 px 0 ; }
. rates { display : grid ; grid-template-columns : repeat ( 2 , 1 fr ); gap : 10 px ; margin-top : 12 px ; }
. field { display : flex ; flex-direction : column ; gap : 6 px ; font-size : 0.85 rem ; color : var ( -- text -2 ); font-weight : 500 ; }
. lab { display : flex ; justify-content : space-between ; gap : 8 px ; }
. unit { font-weight : 400 ; color : var ( -- muted ); font-size : 0.75 rem ; }
. mult { margin-top : 18 px ; }
. chart { width : 100 % ; height : 110 px ; margin-top : 6 px ; }
. 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 : 12 px 0 0 ; padding : 0 ; display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 130 px , 1 fr )); gap : 8 px ; }
. samples li { display : flex ; flex-direction : column ; padding : 9 px 12 px ; border-radius : 10 px ; background : var ( -- bg -2 ); font-size : 0.85 rem ; }
. search { display : flex ; align-items : center ; gap : 10 px ; padding : 0 12 px ; border-radius : 12 px ; background : var ( -- bg -2 ); margin-bottom : 14 px ; }
. search input { border : 0 ; background : transparent ; }
. search span { white-space : nowrap ; }
. plist { display : flex ; flex-direction : column ; gap : 4 px ; }
. prow { display : grid ; grid-template-columns : 34 px minmax ( 120 px , 1.3 fr ) minmax ( 80 px , 2 fr ) 56 px 100 px ; align-items : center ; gap : 14 px ; padding : 9 px 12 px ; border-radius : 12 px ; 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.75 rem ; }
. pbar { height : 6 px ; border-radius : 99 px ; background : var ( -- bg -2 ); overflow : hidden ; }
. pbar . wide { margin-top : 6 px ; width : 260 px ; 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.85 rem ; }
. skeleton { height : 220 px ; border-radius : 12 px ; background : var ( -- bg -2 ); }
. who { display : flex ; gap : 14 px ; align-items : center ; }
. who div { display : flex ; flex-direction : column ; }
. modes { display : flex ; flex-wrap : wrap ; gap : 6 px ; }
. mode { padding : 7 px 12 px ; border-radius : 99 px ; font-size : 0.83 rem ; background : var ( -- bg -2 ); gap : 6 px ; }
. 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 : 14 px ; padding : 14 px ; border-radius : 12 px ; 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 : 22 px ; transform : translateX ( -50 % ); z-index : 40 ; display : flex ; align-items : center ; gap : 12 px ; padding : 10 px 12 px 10 px 18 px ; border-radius : 14 px ; background : var ( -- surface ); border : 1 px 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 : 1 fr ; } . prow { grid-template-columns : 34 px 1 fr 56 px ; } . pbar , . pxp { display : none ; } . savebar { left : 12 px ; right : 12 px ; transform : none ; flex-wrap : wrap ; } }
</ style >