d#T`!dWn~IUzb>j% za###N1GsKZkkFtq6mYO5=5ZfY42onr5y`g&Jc~A(dYYj`v{w>)TO=_!@M+fM3O8 zNXH!L%&Y!fLuQpwQOOoNp_!-85yyUG3^KGwqAFRHrX0u_nL|cy!@Lxkv zXVU2J{3_8a1PjYcVQ;I723}Qrp|$7lM>y^(q{buBM(Z5B9o?xCoI=ZB2-K|vrR>p_ z4D-~!c;6>}$7GuOZ`TkrCck&El!TrRCsn2yI0L9}YE^ GJPXdW`lXTSdhkUhfpt(B(WZeuXchMVm}Xpg_xsxc}|1iwTd+C>w-dU zu~6uVgLO{J)P(jt4`q=S*&0OaNw8goT7v%7JNQoOQ-OQ6`^Qe}2B*!F)_uLm+pd?j z-=xywPIR_RdD!O=Jw+ZQ7~O-1vySy{dv|-)ZWLBK3$P5iQo>l^_?NbuE}{zZ=UxA@ zHV|w9e+*B&D))vq5X1wz3d~OqylvCePqGWqXyo=(s1Lne-H!2Kk$y*%EvS-p<7rHf zr_W8yR;;98S*DVYL!`isAUVN{CunTiDh;tzVuR3k*Fg;F9`sTn)(5O4Pa-so&v|8) ziJ7g-XwIYM(TG!wqXf0NA$kMIX k z;^>h+K2|F4#{`Z8I` 4Dej&wOhdZ$i*E_-TBjOBP9hW-eYLF}^}w=|(hj-zfr&4Fsb zBQOK{gWbwKuN^a`Yc${~nU7`FlyN<=PfiO+qoK91nDZ$+N2471d=C3+NrbfI&Lay3 z5uY;-wH7Eps6yEMgiZ*Z5mwXH{jlmgQ3`5A+CB>9$2kYAi#|S!eWWGyp4U6~2Tq1- z?r6 fDyO6qMwChh=@3-pYrdj*>*jJC zOoVpX_)3*d1Pd%Z+Jn0j8H3g6l)N-(CGve50cl5ZHCv?;ga{pRKa1QfcV3sbzvKf8 zNGKVJ5mCfghS30WHr}Zx!?P{F1$9mRpXOyktQUZ$f!n{Pig^alcA!}8=_NKk>rWIO z9ZqdOMaBWFShP24Fc{uNi6yGT5DCY4yV^iSgUv6)#UOJxIFL+ZR|2|nGe)V8PKlDR z6Fe;mLvTI}ChDa8Olm3|_EgwA8wEOP+ER9rkYrPI&kzLI=H9@da4=D2H}}acILa!` z?~`H$&7OcCl|OD-H;-leAJgeHXG1%Fnq-YKq**+tKqh1o27F#zoZwHh?no&-9uK`> z|5VE+5cC0xY=@kmq}aAX<^bc6k48TNGw=-454&U2K?EhyZP4RE8YLZ&x}9iUkY(iA zs!ri@Q-Zp8Y>FvmVfcs8$Xy^bWwx3{=dR6tqbqMd@UjylKso*Lh10pEm+fYs)#l6S zd41pE_CC~PBe0B$>#o3~FYi%BCB%`w#EC4&!Ts3^Qc6p{6O6gY^0{ehJfY&@PG_{V zx%v`3N+len97H1H4Mc!*vnVkr-UK=Iss8YHirru2HtnNWf}2atYlT&I02Xr5tIG@u z!eB(Et}!H-{9L=85cUBJdsqUbKt;+54r}hdP>f&6I&^^EJtI`NOb`Z>s8mS`iAuE6 z+tmy+`si5(IT?i(@R|_Fe;5y2@BWD?S+KK>4393ScibeoRug1$T>cVOA-GjR6VwI+ zNC12!JQqiRMoFn@o>yu3p7nmt?}}zJJ9?9HG=qL*?QjhHd5FXc(TcS6X02_)o}&>T z-=Oiuex9BQ8$S1TTtM97P80fq!tejB(+gcBJh> g5rz->)ld!eQp~*h4r}I`aauy+4CC1m@*0wP95J1sSO(_gOOdAC^_`>$_|| zGxnK*$TULqPVb24+X{a4XGdF_Dy%&4@5_RN+&;Eh$=wnPgUPayT;jb(y7xNX^&sHJ z`b#oKH)U1G3-TV0QV~1t?jG|z3)(`7rhJFK!mqEMG72m(1iTJdmk?@k)gO}#%a?^8 zXQJ> (p7I0s-(OZl5mQLOVb&^D;Azu#E^=O`M>64d bDvT+U`( zUPoT;Kj4`;OsX`zE&_ZFr~K1=alN9-PK{UOr+09k{Ej>8BB;!`&Q4ChZ|2BYwwGG1 zr@Ji=tGJyV4wHVLs%$DZuj5m3{#W06%0`2OFf=a8Pt7P|RMmdziX5Tz_pl0{(79OL zrl%CL!XD(wgLP!4xAR@C^7^bcoOrek2g|$+_9qInF}*NLggb UfgDnbXC-a&Ggpj1Xd-GDRmnv>N$ zTO;UAU=As}z@<%3@I?o=kXsc$>vTp+UqOfz+eESk)ueUVy>WUlS>Y#9bqlX68Y}wD zexsr3yl|YBT5DNHHO4hHi4#IOs+f5g8Ik%N8~l3^q$~j}EgpLo)xiLYRjIE?@O(@h zdY{ud+qW-D%{;$Q4^M3K2WmuF)<*VIz`h@neLU9t-l}B)-&K6vdCy7~Fe^G;f(yS9 zR7!2UtUT31@EEd9uxT!R1l~N=ttW$G{7rmhCi6VX=>ahBVi;9(|Tv7 zQfKkW-1SCsmEc ##zlgB T z(?_f4AhhA@GBbm(*z!WV_Z6_q#Fne>xu)Z>D|-g0(laPb;=k^{3|Iu8|M1H){_ K%op-xbm4=e+27f568B9TxK4qDUwd%GmXsA(!Cakru@`rU0(b+YwKF5ZwS_J&Byh zAK6P^+ic;EF$L0G&Erj_7Wx`*kqq{)LJZz<8n7qaC}6Y)Hfh~RdB+&i W9sm_ah7dfGMYWYv4V-W}Capco zi(J!q7Gv|7{&5KXG+=P5PRIFed_PkF%~!ePapd<;+Y=+};^x6TSypO5G_4fvjn(hP zXUV$)MfuB#hoLsu-eDUS_08nZ(kh|g(qAo-gw>x!FYE1kW4x77*jTh|VquU_K~=Uv z-4_p+Nz=^^N#2l}G7=&r;WQAqQ1h01I2U|u)5-N(g1ft+)WTuwLXs=dY=F?6@3*b$ zvX-jlUO7``_Fp{KyYD?YXm;w^#paK#e$MgS!auXC ltpu70v~19rEqPN^m6VTD%JOI4%1b-ta-JO1 zD)_IX`jNJZ5s3#r64?Ek$BVn3N0HGVpz$bC%DDYt%Qg27z5)4yHjLZP)z#gLH4VHU zdUn4)pS}Xl>)LmK$(jp?Lem^ZLG!p(BpP4a-GzPRU+E*c{Jwn2QUdtFtuf6}rNqhP zZgLWELvJ4*2Q_!&uZGfn24!5)(4^^*NVFiNXO2fGkBMw)?8&3i-Hi7UXUer8MNw^K z31R UT{vk;MC#@@O`pmTMT3wlLVL+a{Xds%NA(!C}i;xBm 3}xlvVQ_*q#-{$*%weO9fPheX`8Z%V`OA21kn z2+l4jP1XF8JIBP}O?&XegOie k+Nexerx4Q&%?W1Et!CFO3LO6jf1BK0UjblDvM$ z0z+k_*@CJ$L6XUFuK9`H@#!gaGxp(9d#zx1#&_oXnSf(kr_LN)j0cL0y{~ThvPOOC z4}C}3k5%kytSI=JBCgcR#`Xd7DypE%1Fu@jSG^tsLuy`L)vqbd`p-AesHZ=;dokbo zwakMqOo?>np4LmOr33X=l8<`j+_&!+D0S3C30i*w+CgwmpXx8Phsf&v>DaxLT+}M! z >I`Gf`Bd^`rszjseMip_p|fZ(q?YL13%1& X^WKYb03!C(2ue12I^3~EW_nV=AKn)T@g zgPW`nUOT+FU(T5unzhHZDDnv5Mt0SY{NM+S54DQBD1WY`T*!rzB5{8sdu$?Aah6hA z<)og)D7Esowf{|pc|=!U``5gh(Vu7 Gm&PBfku59 qhQaQ W9n!CsRuK-4%VdAw-3)?nSK$p{H8d7o`$2^8 z3$RGKV1_FZ<^p}_(42Oc1Tr!*%Z?rUxyYY)nf>orHd{=}HFKmH%{oAQy6b@B=AH*> zLwm=oIO+u(j8^XG4cI0d+Y_wL@<(2PIP`$B7l2Do2fr=wQRrly?n~!J@ HhFnpXTIi6@c_Gj~$jL*K=0zoIt zeGrWJ{a>lzP)cJS7-bCyxst)jAKXnc@L1)iFQ~O!E_+n4c8Nq6M+0OHyr?#yAZQiN zOiBKfj-kis#Kfhi%S~Iq=Y2^|zW1BqlTzR-h0TNX$WSa$;dv}xJRwguuiw3)N!Ai; zRkz4t&9dr21oET9EL!~=59h=T;% DS#$wGRI L7&P({$N=VEug zz(l$ahFh``7kPNNc(SIU{|eI5wd1^pV~R&9`>(a}v{wj}L5JoEj@Er>>`(SU-zhbn zx+2fGj`n}0dqF%LK6e|RR@nfJQQIXHblUBW0=KfDc%P y?+rcY7bb6qbQwOt~=v=1HVZ2V*q;;G$qv*Ex}TV#ntk zRA2Sb^j)Ds=$|3{WCnkS=As!`2#Tp_Ok%-LqQId^s0P5ij9e)r_dT_Wnbk<3m$Qob zNFdlr%fddfhZ9S&p|I+IZT}{g7_~_5-9+-y{cA~kr#J&W9^Jyr8bALUpKI}Tn;7^? zQvbPs)qSnO?)MCh%>Ru|h`w +M5^03b?$#mwnj=6L z1_^s_e_RkA|F%)>63C_CKAV1x@vi35WRZ?cAf%mswx__~nl14M!6tHOqgd4$EYSVZ zVx#APTf6%VRXSoMITzY!-~|^6nW9~Y4)lRX3Y$iQ6Oas};T$bA9=uoNH&|aH4Ju&O z#m5~5{lX_pNTXj!WgRJiQ0h1{3YHo{9-IfqsJN1_3SBCqd(H(9t_BBja-#_7LBZy# z2eTM~;c )AQFx1onRl1y%Whs(CLQ9Q8#o%BWu{IQGjK*w+IJRu61|TiL_k>XbWgP`ToSW z7M?ZBDjDqAf5(UOvSmg^$G|wliTGx-!?6A$AAecD (vUw%v5bTm7K!H0Ap zN$Lu)T+);GB|M}thLDyNi`yg+bK|XMd`0XP)}bXq;4b)Dx;a05P3c7aqk5grNeT1Q z;Xo!8 &CL l`lqR$Il6V7r2s3MW|MhE&Re%8#NB4`^E%nxZQIP}pB7LrUL#a7p zAChpFP4!4_5bzVOxJ)B5aC;rosyrJT5yqnD=qB^bKR?mVO)2N-WGf?4$%MqY8qqYl z5dM{hZcL)?qPeuKVyrf$Wsl%CP_QSZly^Y-WbWdB^+b-l;DMmr9t?m9S;$nPeQy^9 zo`jY9s~1j8$s +P-h-aNk$aHsW76zpVn0Jk1aA z3%ri1x~dqu>#tlr;6PzzX@D6tXd{1$e@87-4jLwz*~fa3oerF4H0_RCq8xHM7bY@d z9~At9-(Bqb%Tu42^UzOI<*%Ak!UB$+R8`5ZQ?-OG52yHySv>9bdu?yqgRfLM`ec }ZLXfG{D zM8rInF64+hp^&VgR%8!{0XudCk>RJ25^{T+_!LM5`~5vUz4Gf=_V<)l$AmI)ag@L9 z)U%>40s84itd*EjIJg|qv*fwB)RbSV_A+@@(#-qWKHDglo?szL4<6r_l-Io8v~Mfz z9$b25pMZ*Mf?Li9|H|a)RUjl(ml9%d+atZ7?AqZoHI36Tr3B#=H|=XUZ-C>btj#vG zM4qjx-8fUIKfU#~ax#lMQ$}asjSF)e41 *7!NRLok|VXzv38Dk_0|!h)PD zp&UbEV@Viz4tOUGoF$-sGjDQAHUOG9C%OxIzb-EyT`{@i7+57dW2gGCb<9YDPHHAW zh1P)@1vi2u=$EPGz0h2zC&XL|D|11S_;8(Ms^XU_wRpj#PdflHI-VIIG@fUxe&+$K zx92%{3OKxy@RS+Al$l-uE@ZanNWOk!=>7Pa%T^t1Nz z`uNmU3`d9zx?jT=bvUA)@nN4jC*STYuMN#gK=}D)cQ#_KgB`lK)k!pM)_0|~g)(hq zk}upCCpcZC4|J9;i1kQ{=5C%VGT!bV4du gb&YSx2T!IX;>ZnVT+_{({kGy!3S!TBBPNM<_rM$e}j<%s=aX7W1r#(DQ>>qqC$ zUz;~m;j%4qc!G=PiBf-T_BpeOd`^&M6=c>O`%BQ_8u3OF)0R*QsGgieXbi7)nLNEe z<;ONCrKsGH=!|ty&3i@O>)zS#Pg1VwmGVOOaO|V^=8Z)jOal9m+xaPxYo9A1hU!$3 zxTG(!O&oSM48b5F0XQCVS2w0I&X=F|DJdL^vj#Vs+;3BnW{b79 01}7$phj;PFzk7eohf?l>q|i-ni6F%KYcmJMruK37q%XqT zPqJR4q7xcqRT?JvjOjVv0J2UUV8#Y4sFns+^#UX}@LMYVo#sFQmxz 1D3D=~13WAwMp8tAT4=XlFFv5f;hu#G@GAXz^G0U1w}RHLf=m zX&4P6DJ7=8Y8V)$Qrd#e%r_}I{10vVh&l?Rx9!DcdVkNxOG>`m{2jOCVKlxdWc})J zLmw+?hoRxehSyF`oVFDxeWV&G85E;LP3zd!Y5#m=nO rY3{JNp>=_X+BvL0QQ8VQC|r*U<9!sGuQ>JP-ztTcKbqys zO1L)=1M{JfClO0fZM0Cc)34v`!jv3~#YgTsr%NwxdNLFlsjl5&^zAQv+oY9kW`L$I z3ZYn3oCPxU@S94D>W*xI6GMxh+gHvoYpke(2*v;aLA1U}%hS;8iw86`Dh2Gq@nVaU zc1DiUaD3ZI7li)Qq_l3-623Q*%-xI1)K4aGse3zj0b*(GaW4U^Z!?VPFHjB2{~~Ht z=kWi$JAQ;d{J0x0=Q4*2x=`oceoQ0~9URxucRO2IxlGE-qj6a@t>_VMEQ$gmXr$ zFb06%WxQsEPNKp+(+I@lDAK@7mMtd#byu1VwC0?JnjxAJM*>D|A;LkF+dsejovYOB zs`NF2A0iGAqp)b)%wnqC5ia+`9+C<;>=lLEr?@t&bz@&J`H8%9uX<5AwL+t5>dcv` z( #c|lc|fTK!cXQQR j_aUF`<9f>*S6p)0@4V(ac#(Q|rzh&W>5j ;lxwoE49?X{STginX-J1&3nZA8Z^!6r`j(DX?WMVk9xQzC zsDAW4o8!RNWl`EN{V&2rH`2YG))~%(Cx*`DtHIJIA23J5jG)~9RObf+3Rm=oQ<1)l z>O8b*dlLTxOU`;{k+QZ(kKMzuUgDDD6rA`GbjL3wKoss`fT`1lzibRT;ZH-HyAW#B zz6QWzq07@fF6~Cc^G=5Oz9zAbd`erD)#$|pzlWsE5i-rwlvulU)1QO}kvd6c `4ljqw*)|+blNbYNR0JXU^_a zh+>>TLl*&w>q`(Eu{#nB1Q2NS;>--Go!x-CQUl4Hie`L^h7iTSht8`JyUbMyal( FAjO}MvsOUa2APRFa;BNCZul3I%LW9vJ7$cF)IwJTb>aGI) zR@?VR>42kt$+2ORmEts)R9 UcOV%_0PK^nW?Q1UQ*lc?(#lqfCpU0U+kAK;`FXjH~ve&+DOgl(e{a3(6pT-U8v zW&gncL_ts%vVu8PV-sr9>3L>0U4SkbC#N+Zv;Bdpi*CXEeJzH4PfL@r+5ggbGIM@t zQ*?*T0!yISkRN(C(|)7*2p}VfLfkBbIZoKL9fd1lLQV>b@f9Jsk#fZR^TmONCf^mm zZ}H;z6^es`MkBRfON;g#q3SUmz%45%%P8L?7Qd{eOTdI5G4!Cp&|?q`Uf`T?O83Z5 zqnP %B8C5gTRdyhfT ?RLhK?79`k#VisaYu?O&iw5A%F;7vxNOo03hO32 zw1M{oS%GxoUG=?vO(GTr+kW6z25oNE>~8!yy4XTW%&nbR-j{ QQG-VBX;0x`8Jl-nMd#j {s86UJtgj}+W`G3$0Zd~WMAx$SvP#={X8XG@i9;!Q<74e-Ra4r+agdxvYHClt14 zxWKbA-6S0RJ2A$=7lLo1n@4MdzWWIH& u04RbLD~5@r&76EwejcHN(o zQliUL8WXtcfQruSl}m<&m?cE?Y?Ep%R{%TGrRaF@4+4R+Ov$R((D_WvqS6#t&C-YY zD%gh?9S0`qy%o$?d{3;Tz(;^`-SIkzXCjUq_np &lqs2WZ?YQ-%Q9nj ziSp7{`~BoO{ujbnrbhi+tp+k=fzowp_L__yQrcdJO26LmxWB=S-nT!j+SgZ?RxU^* zK|p+_%PvEM8Xu`KF#NuNU~sZzerX#PYZ|5(@*vZuL-RriVCs@h=L(ou+zy;8y?#pv zWSZ@_UweQ5O7d)u7`Dj1cdtqrozTJt{`J$0Z_U$N7%Q}L0jJ$MMEH9T(LM92&-ru! zkKM;ePyb!udCxSP&wKG@0_05nCoNW81t5H2%C{P~_IbYmak+8xf|{cyiV{{wveo4q zm2e2<2)&c3N$NOdrd6j?szE2At&D8kMHvu0GAw+o$@q~>hJ%5bF9V5Janh*hj0BnR zTn&( f!vtB6v`twTUXL3(RD^V>HBTyP?cmUCnXJl>ZRtE(@T;}<0t*R$IhocFjCM{vn)b*~(U?7y3ngcb zij|lW_39?k0L&*@+;jS91wYWwWLd0}Jq )8E>s zu`NGDEabYj*mq6J_!v8&f(H9QVDL%eED)xr_O)pV+qNV3lkjNo&yeoh8Go=gvnD%G z$t9g(HL8mtMh2JBhwelsC(pDbtW_U39sBu_*9^5Aje-&3e6@gP!oRs}zVEjp4_9u6 z!s4{>T6Mc1aFD3H97APIhd6>BNY< %=(1{t_x((TH%{S^%?uV z8es_%7NAytB=!8fhV`z$V^VfvuK2-p0LzWyF&ci*xS&b3%k|d4+JpllI}@b=k@3Rd zw_W*jTRS71Vtzlx7Opiv8Wsq*&((}NDWxLU>-=_ZRy-OrHXI*(6c(aX)HJXKL&*?F zleQiyx!Y#HsZBjjXKif_hrZsv71Qf8K@b>xE>}GO8k0uthM3p&Kf4!LojoK}(8k^& z4UB%5r`#hN(oti~Scw^)<^=VTN?Xb6)LE6|NIOw)@=6S1z7=LrynO+j K_!6sFIwY-A6%xt(&J!&E*NEOeVmVn*FBlLAR<;Uul>UoC?5(lr)s&*E+Y8 zRyo;Z9F4A=DsQ5z;hlEwIWX+SZvht=oaKm%WE z0M-j;q4`s$#F1F}m#8YLmM`Yau-RAVNnv0gisJ^dZAJytwh1FlW=4jO3rGjzV Do)f6$;m1mdqE{F!K8HFL`oagF--k?{QLAPcYZ )zI!&(L$tpU_lW1KBWShvtXOl725(fV_Hl!tMySYR6rGvRHE_&bD&H(7n=Hla zG{~q@O$mguj96%D%xkt(UkPXi^b$};_zM#GQSqf!R$)&JXVYB<FIH%u9>=|Nq7@d* zz|?9$b30QO4#Rbp5}yV&cCXR&{S>BkB85LN 5|6gVw2IXsiP)d@W9z@RJP z1sm+=areiDDR%#u=0Ec#Y^>1&aJ7Swu2 q)JtES=k$9V4A z!2V{3Wdtk2FisS8Nv4HN%CqtOVJD=Hzz=`@j8&6OtDs?QmdZE6+d*(f6#QtF<8ZC? zZXw33NB!C CCEVB^` *QO?ewu$exBS^~N7ctRWrpO^1 z*c<&M=nu}+Q!uqC3+jfiG|b!W?xpYEUvp*a9E61L!;&E${EG*%Xk@5v9lPzK3e!Dm zj0I3lQ`uk+|8{Wr9SCO(CpqQ~<1+u)(WfqIUZoQt`=R@WF2}Zb#Z(8GlEj?Of6uW+ zbKRJr0V3E>XXLNLv_9htRB=VsEe59K1mWzLjSO18VLe%~>Ksi@(4i&T!!3)rQ3J%W z3{Jk_n+g~)-HG1M$rf%_;m8sD@Vvct`(?ZM!ADI_&Fo5{u3bs%e-J7Rq^6uqW-9ck zqivtjs(oZNYKV6igqehLjgAu8wDnQ=8oUOJ;qj0Yg3^Ao@=PctDHl)|h(?x`*PWNf zAe?{uv-Y5T()EU|n#EyB>j4H6VR@GVCUr-<{%1n=hJLf^&0~l4X5{^JSIf~Z9ohT= z*aa;rGr`|y+ix&-PJvSUtK1i=8$q%GQc^CL^my$@hS6Zur3xqu0y&_z1XJ{Jdl9!j zRqjOW ~^du=M8-DR|V_dECsTn;@hvjPwY!m$!?*kPKKw;j2 z9|M=rt)l5*7xgqMZhU>MuTKQ^pL1H*QuXTY{@cyB)9&QU=loMc6yV+S`6iXHOl+ef zc}~1LC`ZX<=k(z|(LCW|R!4(!LvTo#vFKZHU? KlRI^PKZGvNfCC+#g zBQ0$~7YsL*cYvkZW0r!82N*W>N0casRo3hndt_o_cu~EvvE0bqpx&r3iCV(1L<|Hf z@)UiNfB-ykMKhxTWO-UizIr8?zXo|l58NcPEh7rD(jv}kP=gYInV~t<9$Yt$bRFX@ zcR3(Jdz%7oa5REFg6>0}%@x*FgJtth(y9mw@kkVPiBkge{O+SC^>&$HQ-r?>eZM#d zs$#p;UOgrm*a(wAt<`(@{B`QXD!G0AVWO01gTmrlM5js34|CFIEuJyFyrNs9>mkOi z)cY8jdCnHWs0S44N5`I@3^;VGfyGj@ILRfjCWn~O$p!Ez WP|P-O&b?hLibGZhblqhW9Y zH|UGM!v&>$9~^<#PyL-t$v)lGIWP(h8_@gPTxx&-IHDrew&njNHLISGL@MD3fg$HG zg^>QfV1UoT8L9SI=cwJfUyMp~(9&`XM7#I~(I(FlEq|OyM!pzn;1F>MiW8{45gEP% z;F}Q-s3=x8(Ly+)gOxRxTnPWw%v5y_v;fkhppBE^j)Vl;F2j|fr>v&u4ur~UgUmgc zDHmecE0uD;JR#%oM%0`XhDlj@|M_+XXVkK3=WV!GaGfQ7HmpJ881AVY$55`HGBl zP7x1gH(H_}>u{vR=6AuIyAS5vON(!kQwq*gGvwItqdl?giWaaeYD4)t+4%CEnANvg z630x7at+pC1SK0dA%g?CPypvBYdi+o|V^KI02w!;ZRI!FZAM99Uhk4K+Um0w-3f zIp}aM!#GNgphPi_w+akV6j t5NWkc-o@R?5VXvR z^#n>C&d< T zdPhPdAW~rgJg@*Us!G-DTKo8u#Cg#V#d*?Pu|ucOu-CfUff#_G8Q$|ZZA?+$+SIXsb z``56yIUR;nm4=tF&?jnM`P!xr TB!Q5epkQiKx+`W1 -t zvqBRnv1vKL!9@Rtb)M5~nP%Hg!Z-}ygYu{kajn%G%|GN44v0fl-U|6^?XM-@Q4G;4 zR;!g6MzYVlL#`;kMNY_^qZW)CZh-fwPE21(jCLr!o$Yn9e#N~~4epUba0G|Zw_yPT z^FzWgA+yKhF8{H_opPb_&I^@noa1h~C@#n}3JOIU!$}`_7sR)Yh-B&ZmI(YJC|38B z@0c3A_nTlDfq1oFs-c{}F8NZ}<@;d7?mnpP*J@YD7#nws8eYdBJAlN@y+qz=d{x@9 zOizZM8?#y0jySQFP??;Rbh^wfx?Igs4 CUj2o%l&z4& zq7wj);ftfvu)dc85mW&KOd#=^yCr_+dE&?|fzS_AEUrahX;MnF0tTCF_(0LKb0ANF zi5OTwU5nTCX6hsgAU*Ht_jeK?Ka|+E!b%mAMufFbgvMcx(6nALont!#eh^nQtYpxo zJN1sk{V} fs<#9Jlsyh>U2mKwxD;^%MUPn5| z`qGTs+EgYVzkHjt*R7KHEa-iTa||^Bf)Ws{H5&<9QT1VN?$0TTP236ULv4e4oZ=M|O};Uq2)3)s8|GWRPDPpbintEI%2C|bea0nzHZv3JSX)M;kuIyzOg zUu>BS^Icr|(JjsOd*s8He< IpQT!vz5{ClJw=DO7fh` z#FaNrd9SfeAXYO$+beD{d;?@G9iRY$UTv}?wnAg55z`>{GUKjlrA8i RH8t*$_h0yq1bn;2nJ{VS&A8+m5UE0C+r*w< zD0$z1RFbC76&pf6xP+>*NOkEKy;fnwFOn)f0I_cO4km$8IkzE{-7BlK&8m`gMllgI zT&;aF{!P$I01orKx;PAf6dhx@U*EP`wg%SqiF-KV5G?bT$~A>Qk#Q;0Osf?{HIs{s zHgpgiZfQj{?uW1aRQ%rE;)(}2;O`kaBxB6Qgs_OG3S6U2(!X?@WSsdGab^~oPCe=v zXs^+1$T*k;*{gn(*=7r);A9_zkeCZc+Ip>7{wB{$3I18)RXhZ&TB(xDwR7P6NCrTL zZ@xJRncSlZ+~c{HCt$ma-D<^nF;ApLCdEycGx9Ei5(!|?i830KGUYm@qrFBxdh?gk z-nd5G2~&m;9B2o`v1yoRA1vQ4dd3tP^W&!_|Ds#Ol~JG;g;V>Z5FDdy9O Dpu!W@7zTCx_t=%docfV<`=6Mrcbi$`cz0b7G0m z`ar#0TKFwVb7e)j$0+P=fWwBkDzB$WR<8JsG*m8^_~fBf$kDGlyx`! Xsq@V{#7%h`dgoX?aX?^n93g!SZ8BIu!FVOq}|B+Lz>nv{?v=8D5`F3`LCI zf~f6X{RdjiXa-! zk%bjr)Alh|k>4c=2#k!Cdr-XMCYk5tFOWC~mLWno2J;$CN$Eit#i^KY-S~uT`uKiH z8%l8CT^i=t2cU?%CtWI&e)z6rPQMU1G<_QMV;m2OkZ{x35srs^&NT@JSTDtnKu{H0 zt-LK*1X#vHC5JhWFJj~_(y1B((eTd _wRCZkuG=gIcJ(M1aXnQd9lfnluJaTkpT-ACp{U6wVQ8fbnAdJ1b6xOReEgNTt zPmZ@^WbJ?){&w4|SYH Q4C1!HJt)SEqHeX~tD2kl%6rRy zESWiZDxOJ2`3-700S-L@Cfo`vya_Y?_m@ig kppKO}_kC#+Q?}70JsgVI zLdo>v0Uh+`^)pcpR(=l6BOhy9u+9~%J<*Yx)8Ls-#%%ONEcj!M%>8^-%#X251jtU$ z4td|VT#9TU^cc7Yfvy07_L!YD4^jhU>rrRcMcpu`!|!XARV)7_J}gsY*Rc>mi~~UI zjR+9~{*6%6PO&Ez%hd0@E*X=snGrqVXcMJxjHXc|=8E-xqk;kk#8jB#Y(}CDQG^hD z1}I)rL9dp2S%>L~Spb4k9#IaSXPP48`4;tkIQ}qL(iic{yKQeN@RR6cVUZ3*>sAK$ z$=9-eA~|uexKZ~QMT)1{t`Gqcj=gEqld^N?^H`Ms1dQf`ph))Y7@Cb;h#08aE^*_| zm#JTWP*Ms{0ywaiGTVcn;}9Hd0u1(4xDaG3oi^-@i8WIoJ`8|%*=ZUwDA#IK6DPm} za B=_LXvIsJjQ!>t0fsMW0 zKO)!AfZTd|U)g)|&b$91Ij~rT2Hl(si2&g6V=LyQ6EBgeSNv8IGD}Q-V;Bdy5ouq% zR8c9MgmE@$ve|U|E?9iYGW$)t9IgFYKz7?q1-++vcNZqtULa>-End?(Hvk;Q++KVK zmGRA<^=gzOSS*g#TPEDAy&||*u1x=~WXI)$dk|E^QR3c8)qPFr9dHIVHSd%6-v6%T z X|4Cgc0|1p92p39%9^!*!!e{n*$qzC8mvUI3O)`W zd>rYgT`ebE@(Xb%Wkt=npAK-aDG@>&fe ~SC1eAWI&4DVtK_TODd1-%0SiZ5dJ z`4~xskYZxZBCZKi1PF5Nt)V73^NpPILFIgBXqzO)=oqB#Q5M=I7pGn;1@Q=`jC#!n zi}nU1ADUB3cfT&{Ha#xM&^y9msE>mM;V3x#RR|lI`DHobqT9ulkOpvoT=3pcOCkjY zu `juQEIg#G ze(51p(5p3bkzmuIVm5>okeID;mGpjP-KQ4iI|$IaV68mse^N>?YcUY@=J%EnMa1Sv zjm(ZeTS_o*LWPu!63Z1eLyA^h7Ie+VN3hQbYr?i1toFpf1t(w#E3~-tX3Mlo7E4@m zj JbT1ByQT{=`fFT@uov_^Z&7PaingN@#65nQWe5 zpe0}TueZ&->5HY2$}l}mAgaDwvN-yBprG&9>QpaS`Ue$s5BwrC-~n+(&Avk^vk(RZ zrx(7DHQrD7L# F4-#f`eTb0SnD{xsD|{h zUC0$kV4a#5F<4c|pj$y_1TRyO g~``SmOD>{Pz uPk>DN9W z*< ;xeY2BFUKcdDpX1&MHxW1B)pKQ>_FsO{7I@`h}bVwiX>#oQvY z9PES6c&y Kp}`ko+UMyYRPP+(k%^ 16?? XbV-OFNwPvHfxxgAKr$A}uvM_H8Vy4QD6Bx}Gkvy#q|6i50f@|<%J zv&)`}4`gfk8xrrrq6;dPtk>$qbWy{64le!UOc^6n=VOuRxD+!g_c)HNim7366G@+u zyECwQO+lNvcbfu8_2=~J&SYO3=5TNtN1t?55@(}u2}@$ce3ar =b3>J=2XURMQY_8G z7saA`(750=I%JAtx)j8LP*k;tvXrscp1>_Fds`|ROU0o>L{( i~pB1eMb8ASMD_87)sbtbkta}rVu9D+<1 z5KMpw47?eDijv}D%dB5_QU8D^KUgE%!&@*DC6-B9Q5zfTIVOGrwr}Q?S~AX&Cl-oW zyUJHceMd_)7%^ss-fQb|)e}E9kD7MY_pw%1hH14L`4OSlG7tAfJJ@MEf jgLuDHwn|P1>q1hEi4v5EKo3lm&Dr#y!ES1=-Ap9 z*rT4MHS1`J@bF->oP0;26u8Hjp7W6=768Kz-J_+wLO$MkujD6_dkl`pT=_dNmwf64 zcS&a6iP1? gCE@X%^#%e`)cm zja11=?m3bQ>y>4iKH>wzaRJT4vX_>>BMluMa1V0_rh&eiR;!?QpJYy)E8|Z560#fm z0c)UT9*O5MTtr~f(~^zw>ficqs$m)Kx{G`GfMFQ3Ry&r>zR?r#1>cuEm)JUNN41ff zBoBaqgS5hCWyef(OGzi!U`oI_3oPoyApar9;=J?raJy_nXnU$H6W$lo|I*v5=tQIy z_{5}B;2a!G?{sf(J@U0K%T;H!tls^g n_%#RqJ Kap2+M5kcU|BvRsb7n5%8!D8NH#b zW{uRgRAI{R-~@Ntz=b>w+hyXJH%WHk%&52g=ptyW j{yvl^T^e8a9V6Aa#0(E*>x?))83J43n>r!(gi&L0wPwFHRYbm z+QWIax9kWRfps<}^NBf))(^6yhmqN6kX}p)&5Xffm4>EUm@D78JGh%KfwFz{B2$TP zF`A{=F;0>lX{aOtGBo8=h3fSWi5Si(h=~<-8)RqAhvIT%E5h#eu^2Aeu&{J%)gGC4 z_OB#06Wg6(^3zcDLkz_k)wk#rDO_=qXN2BdJb#*Ll7)E1LU-}o)r;;^Q*i=X;3b&? zTW>q&<#t^?T>VVvAHuiPMQfrINzKwlOmnY!{ct%9 {n%8KG#;yikVPIB zH(4!98jDgtkaG6^tjRt6m}kDN{7p&Xmf>olh~A$%>QxK^Va#ty#dc?7CY=kPhb0b- z)If055|7voB=J^Fi%f;())ofsBQ09JT490TyCCplezh9*>(6$--4SVP#e^ryOh=_| zizsQ@wQjZmVwrpSqQOrfE)W8DsjZ6CN(yEzT4m|&T@aI;Pll z qmM-rHB=gJ=Ny?`54Bdqc#~m z |*|O5;A*>ui)z-C+6H7LR0`=rxut#%DWieUKgp5 zxvujh4wfQwYV9BwD%R}KFIbdJ+Pw|3wc-`5!v%lLAVloaa@Aa0FH>gzizFmrZn;{O zexwJ2SQ}+{?_;ZA58KEXL|oY;k^9u!mmWB39te=d3luf)In%eYHK)Br$K*qUbDer= zUrVg%Y0jlyk^dP>NXO2DCuchH4d3`Tb~#J`?V1p64i#c!(ip*?Xzn(J~z1b=IM} zal7oT--yK+sGJ|&htu0DIej)1Y>~{ONs?bY=P1KPZ#jKBuGzVQtM1VAS{s@_-?keQ zi9{AJII!6c0Eo6&ix~5k>vL{fxHptxyy1v<39Pu5N^#g$Q_JPxN>4t)OuZ1a218^x z>`S93X)UV^!Ndz1w=8G9-LJ}uf@V2$vFAC>p1oqfx2y(yjf!&!mZj3F_az|# q$T
afH|`at&q5#TQG| z;yF_v@gpO?Z)iG-BQRHQuQTc(&SB&F7)ahKW152sI!iioi2;_?O*^EnwH|wyVC#ZI z`=(EYqvfi#eveF={$oi=DFpYRxHdFx-=~6&(wjl}5yKT{&+ly6{FlAs-(WEpABHFn z0SHEBL-|CeEe!aw!;fis4zYE1YxM`#7b77}LP#bD;WwT$JEr>_&kt^*xYQBplm?>( z{(3B=8beY(4-_rQZqAdB;gKP@1?>ZH1cDwZt6xL^lp6U5_9FT@0C@Z_9CIdCD;fFt zvVqlS=%4O@rK$+a_~!&Yk7+kbQ29Qh&cU8 ?epDmY$JHkggE^ zfdETSgRHN5STfyXp??g__d(Ncz*j28lP;0;%n7FM0T6zc4&MS$&W#Wz$Ur&o{9LH^ zAH3`SS+fpnAvtU-l8azOvYqymFYosk8BfH;!^Imq)J#Og9LPb;k<3~Xi*Z_}UUCq& z4qxt=&ssW28vw#l(-DmW>n#J8v(BiNu}*l EcdPhltVxeZ_J0JiU9#u#6Oid{5qT@#z(4FpRecGXS&7hXn{PEnQ&xL|RLN ze{C7xPM^pja}7zTTtP8Su*CqPXvtFu1vA9NT-k(q4OL>8J6}lit@-97zlk1eiusw2 z9E6yV;|%Xs)K =7yV=tbq|hk5{U%d=C|FpTYIG_`U;*b@1)#w`EL7(bG*1qM&@( zTZix@^$)Cfg}JX~n6#D^hhWJV=Fj(5Ao@N$9hFir=2A&a!U`y#Dd)X47sGNR_W1wb z-gQ97Rh{Yk-n{8El3Zk4wu7;8m1W}=3>bn%bHf$v7_ziboF%Zy5|Xp9o5PSp0=vl) zAR!45ve^_CEWDuw2qBRj2!w^00C6{NvaDulq*0mnW@f+dK0R5sHAOQcSvI~t)_e2H zZSVi?U+#bZ>k*^%;PVeIp78;=hoxf8nxvCxNdY2DS#6Zneun< r6rL6`to89>jz(N!StRN=EPw3ifG|IS1lWU6SKu@JgB-v%5{} zRiL9@>}YsLI5o^w $05 )Q9<@%HHe(F?<~t4(c9!)Nczz| zbrpNQO9MbV)|Im&W_dk7dGu8W;zAKD62$pN?$Jf{_>1Kp@*}DxRF7Ouy98Std!ZB$ zFBU_FOyEe-W0|Kf8a;raI%aiv)KQ_pYfmm1|2FzdtyqJ2qA5~Jfbcym9FeL0toVb$ z$xyljpfAI+OPmNBIEX8Ocv>J>Q^Yb|;G6I;iPyNEuaAdjLXeCBr>$|rb>9%
PkeN}Qkc-B@oeJpGQNRG-hXU<#v`^A`XR|%IJ8{Q1CJr$2` z{fYfWlJ$dfri9zYWWD*@tW7H2!>nyufEc<+LY?sAWt&Grk%UHqZ^j%5hvIZ2F&Tw4Ipf3*4#Kh9;DI>wQ=%A1g}OcL zM@XN&qJRP62-=(y+*RP5Ts(ZL&@=}Cm#;x6r%DaTmoa?nUXNED8EkpskwsJAGPp-_ z-NS4|T7d9D)1u`s4d3595n@dMREe03OeEHl#E{N&dBA5UKy0E))ZvUeNd9FRt{!Yd zWJH$&uUtv+M%l^!np>(7AX2 ?2)%*iBv1c*z} zb;AG)k0e-XCN+rK%QYGVNWRB5mL0+bo3WEFOHG|vCpW;v{Xp_wnbbvh-bEh()c$tt zqk>VN)~sBBO6yewp^iq6SpsLrVgn%gABQq(*+73Nu7G>Ey<$S7_1BLqDtnJ1MN?U> zy0<4IfXEj*UEvRXHu9~8nSqGfj4y<5%-C4Ph{4NbidY0gAvj0_a2ue Y7ySS6VU* z3v_R<@_#4_5n)8CacSzD{5C2tbsFsHRWt^o8zw$a>mSE;7e6ta6=GxD9x*=F{=GYw zpTD~&m-gdc>~ 65T+(T zgsv6oM|t^p#(zi<4e=)Mk4!iStA{Pxz;8@Hv5X7K!-J$ M7iIA_roY!nUQJ&3_et6%Cmr>X rMBR-*{ulS6Qi%hI`W;y#fd`F87t|>*0bj`O#(T50|$8#_h%G?zmGgO^gAr zsFNhf=gi%1>!7GJey^O#=8Zh1BcR9}dQun4{P8^1l-W?k!VG$-K}0b5uVFwrQvco; zLj^q;jKcqFHq5(5K)BxLa_YW81mHgvbIatmhW~x!im4S;%zfqMJ$me>>WN+hgqf}O z84=<4T-fo|+G#C;qDGH4QXMLhNtuf&!l@F#WfwG&5F|lkq?fny@`s|53Z b S!45k;2edM$DuAF2~li)pwAAiJ^CE1h4 zb#jm>I9d0FU8<6SD%F!B0>)mY6)g3f+|kfNHdw}}xWh6*P_1#xaA9S6;0O9pgS zq%n+px(d;1N}%?oA6_x(N9Z7RV>z~`%`#;T5LuqjDAl#1==RubwOLdvT89RVbS86M zNDb8Ye11ezta8EyKQj(uh+$#Cl^i7BU^UyD?8vrr-lMv8Gkn1iFhX*%WKXBN4xmS? zprFqb4o4wEm1YmNbS0f^_f?D*4Vq1x6{z LO4rL@_eQvfr%SB2&R8L z$8J6Hy3t+)m_Yp9#t1|#Zugc$FOY*oFca@`4egvD (X^W~RreQtp z)R_O4>((qi(AznWo*I-BK$u~<&!_vueC@J-S^dJS^XEJ`bH;2@U0oZqK$I{y%>6_h zgh{pogv{b{hX@IbGvZ#g0E?!$NF{wU Tu$0x)1v&HpG2^E;9JF%j^J6QoN>nq zLj@T`ny;F$1}Ty(M#0WSSk*-4mE(k-p@SeYi@PLutfi>-$#V vtgsebQg zOUK{Jx+gmK_sF#!c>xF~U)HZ*FYUWmU3Jwhix)4btF5iZ(r-)515<1x=0_S5h@qR8 zWK0YaATEUqwMZxFMrCjuK!oTF#bDzwpYJD(u#Ob5SbkU8yqHx22f=Ve5$iNU=-!lw z`(e^OucI-zRNZ$yyL2_UM{g$tlYNT30EC&Z!Ma{Vh}UM#nzeu4ym@O^u3Qz{zkiQK zvB57Mgs~HTSv*Nc%E>Bv)OF-XpDMR%xVU7ZgP8AgnXX}J%S%nnY*QWyif|MY7pQmc zM9q+;0t$LT9>A8e+5`S3h!+bU6m=2=tg>kH)wf)6(KhNG=~fB$reS#l2nz*3bb{pC zYp>n7aN(jmmM&c;w(sAL-Frl0b7C8etY#RX-c<4UX9+X{#H)DaP3j}*k^}`XK@K7< zS^b9(S+!7}03t;+0L^9v_w463r0e7Uyw5KVSs*sT +ms=y)D^D20hu+BM>iPg|osBlzR?-S`)t xQKF)XKFu|0hGxH7*mIGG`Nd mq{S3T(QRH+z0C?Nuu1aTpl} z4;q4f)IXgr?gR$}^{B<|wayAuzw_jW6TZNX^NIWLw|9<+d@o(fAGLNXi3$>u%==sS ze{0p!tC#&5x3z7-Pjnqyuyc`BE{QTd3C*o|BtW?JbGjV4hJBb)BKAafz}y;znK<1W zxzx~nm*a#n>T@F-qmsFC5W2a$T;t>#5R%HLDRq7^J?eB@O9M6AF0Ft4QpG3q)i4l2 za_O3&&S#&{J)}<(KoX}uf}>u$d-o~?k^9qY54|~Q#fZywr_HIEkx#NMoB;76DteJp zEWL%vnNp3;)IscuL+pxeg&q=wn~JdtGTZVqdJptV9CEoYBe!b&!a312!4| e!qp z1HCJwI-W!iVY_C%1++7>t)X_gZRf&u>(+J9g*_WD-AtR^Cmh5qocahsGGhGr`nAhG zzjE4F=2lnv4{6qf1yLJwq7-R~@~B=h8~_R9UnWVKSW#>%LUr~B|B!W(pa47}X0fw# zX*aWqW0Oyd#U|%da#?egNq-HbXV7;szs0Ui5Bpn}+7Df^Zs}#lOnDYA?7eAMp8|vx zAxJn!a^6MfZ7W-T?uyX$%3*OpLF|Oo0lU&Qf@%( z2uFRmCql%67esCe?1&tM0D*Ngx4ce@w~awmf 1A_{ z3w>S}smY?$ZW;CRl;RyNSq4CI W hPoWDw^R`jf q@4XrOh0 zt$vXS4w1v!j%@m?Ujf1ja-mhFsMId{`r NZ65_+bF<(gQb?Kh@BEZV^TMVTt~w{QfhOA&XeYN#ai`Y!mw{dQp~LkIViwX z9mn!rxsvb{!w0_})<;HFm)APJy?*;rb=Nt!t-Mew U(Mg=>&YQ$px2i0m97lOe^p7neH480}2? zU}gXzl?t(|V=ETW=f`=HZRs&dBY+XKT0^K%Lv3KqvrTd6GENZ+ZUaCdX5}KTWm| #t>hBbNlEE-l*JXN-s~^EZ5DN?J z3$;zxqU8BfG3TBjP0bM*1cD0t$(&eydRl}nR&|I)Ri*_Ge)Ea76W4UPkM!{*U?zQc z!U4icGI~7Bz^YI9)Ok-WxW933>#ER{NXQj*XdqoZnd=tGngmy3g{c=QlXW2!v5M58 zVoP|lXo@sQxA{JW2Qnu>Y%<;tYncmGQ%Psg26T#^nfCxo9Egd%z;7F|xwLVy*3f5T z2Uk9M_2m1(W)W4&hn=6&@7>WK?HUjOVMEa=@p}ap??Nw`|LCIMv|SOI(_GfJ*{gXp z=p~G~B}J}dJB5BuMOvYlVkr|Xi9p&3Ln`X#C|(<`mR`QOknFMb>FGDivs;Y--^qo> zp58qE8o3CKZ;^ p56^(!xM# z#ee|_I|PK3ka`I~a`Ej8x19gtC36~PH~q{$+-}i=78RW2sQD5S9>e|=SPI1q>kI_5 zu0?oiYudhz!o3kl65HE91bfPVSkz-HKv24DSlk8b;CXI|iaHj4g#vlVE4P~T_H8*3 z;TuLQHg&MwYANeD_|$XPj-T_THRo>aTI|%z*txx}LvNoSPyjJ2PmrvadWlF}`18wd zYMj%2Rp_j6izn`;2NyxoMP)R1+98*ZwT+Xnb9Ovk;)LOFwM1<0cw3r#Ef|vfdg9Gt zg=@a-?XIe)t7ek>00_Vx3~~FpT=Pm&L?2w{*bv92OTyvR&WN=(Pt$8w|Mc2PH?t_N zAwSW|c(NuBNPw`ia#t(JqYRXN@goa2v|Z6QvuSGcE3QJ9W(mUyp&K_LagVi)m2IRF zo-WA|9m{nOApqOK;9jXC^ck^9ykb9jlY xyKKu_;pJQkqd^>EWoSWlnU$$4ue0^C!E_s6+aEOdiFDbvHd@s(I z{NeWB-=S^SzEgO($P uXtU YqpjQ_V(iKu 2F6C}|LxHmrv2b)
_} MlT zHtma&`y$Vi1|VkRyK|ETk1lxT;1>=|Yn;^hV02_OGR0n|i9_(SiZ<&uhW<#CsK_vF z3;YcuyWzT#e~1_hrZg;a8tOh%G)wYH58z}~CkN3qkEpBji;j*a0E8Bc{MR~J`N^*< zh+3{BRHs)PqJ*Poh7UgQ^`aN1J$%F2|0B5uILC=}8A)F6)OSugfUr4T+yo;M-BcF* z{F1vn&JRtuPq#gL)5=@)m?x&~Kd^_7#xzY!B(rSkSe-Xi8z%nJ`bU5YA&FEHZvKf) z618d*g-+~^j`_XZwt-{)j5@Zp)kvQ#6X c^h z^OSqeUvp=WYsMwl0O#oUy5*t3?1!>E6rO7qbW@=fWMlb8OZhj;cObvD!;Jw0P2x zZ*1GP{rdOc-)yO>+K0U~M(F^GB_5A6+)7umoI~* _w>Mb9&yemov&0>zsHt&gC#OpvjD+EO3 zULhRyvlWdJQ1l%R2gIz+uw;dVW^*C0z8|33X&8@UPzE)N-D7bR7}2ITMmR#>d1zf( zrR+4AZn+P+4?b3X)027sG$}JY720rA1AX-A^7+^ `RJ39&rewkkh2E3x6jN ze}?xA-xpk$_xT$aJFj;=&);}Q#pFd(uvYxn+qVE9L8!5|9Eh5dF# uJ6(+tk@s9OAmFK$225&FK+Zv;~2~!!LWS z;qN~7rRjgdefWnY)YYXGn9~>-K2)+!dH92|^VCWkH*S kW?{J^Cz*Me*+0b3pa`8{=+|JMMv>RF%F@nP4pwXLu$^xJWSwgu_Il;ct25 z1)LKI#_!JS@^`oE=JUA3XPFocaZmjRqAB!uI4>6E01)H_vvFNbjX3kHi^br<<0L@L zdYMO~-mGU&^*T~-SwGg7m6Y|B_vo}nP{0J|us9q})gEgP7A6AEjIy@f`lBzGeeb=O zf4pA; 3yZdkX~pL-L@I|}44>xsZ|0cYk;VaXkYNSm zL;_)YEiNlKRVcg6@KzjASq2R)5Qh%_SxlL_Qdk|(+B;3vy~>oJz`O*81K`49RjhW! zX|-XWx5iex-u_0RGy025#&3Le@#23n{Oh|!se$Vr(yR8Tc{pD~WSU|I)NQUeU@X@z z>&mWOyNWkFyW!mWK+PB1cWCD=Qf?d`!>UHQt>_AU?BErTC|)2U0;bnGh}1{OL9BLB z)wET7f76xXGZf{p9bC4GI(t-A9iqj*TdZ39D!?*Pu8=hJBZCzr(?QUlcpR*Oh)7tV zY-tYRa@s_&qp8)UMgNY4YEO(S^uKlY&FAknzuc{lzi)l~*4aA1bjpcQW*d7tp7I=| zhejs|4w_@`yYD^@5bA^p6B=-QiT{81=id#hf2whXC1Sq{k%8vf5>A)R;!=abkccSZ z1okchO@kefzkozRgc%PqTu1J83>A}#&J%}WT6S25BC9T(_E6Q-mpF?a!MJ|-@J=zf zc=GWN8#NKpbN;rAWq;^*6meI5H@5Wptl?hQ* ETq?=D&Jmr*mp}NP_)GOK z#c&6#Pn # XuTWOx+4o?MXVhXSO9DM|9!o)V&ADSvKCc z(ZWf{?$=-W)hh*uUMm`A9V9*%3&)ql8XU#Ko0wydd!6wp!b3v-$226wuLSl2(IL>s zIbQ&dgZ~5$oV`H2m|3$bAMATYJhSENVvK8wh+@YpG9>dq%f#bH{f9Z4zsUpm98x@? z>#=xaV*vhG5vb}x%k{Tx5<`Yh=oGWC#2;p5RYQQeSfHarSKljigbrKn*59_Yhu&N; z^KW&_m*0dXiANGesF5` oA0tML7URc{6NQC?A!h9t z!$y6jaqx%>{8lXx^mvp#fw24ec)YD@!l>5zJJwzF@v(Uj5SI>`9bdBsSO&v8_WtSL zMh~0@NQRVvVH-M&P)fH{D8*BYl})})ai8dUwEgb~-kuo_MoWWrHg7aww`yKxoaWKb z(c|$m;!&*#TS8m+>d(bm+v}}?mUfH3xk+hmZc$oWvC$`z*NfdtiV71&MS~KBg#|H> z#}jqCT^%-?qZuJg>%!sSUYMu1Oq@8rxwN#jt)!#`tEU{FtX#QhpUmLcKM + + + + + + diff --git a/frontend/public/assets/tezfin-logo.svg b/frontend/public/assets/tezfin-logo.svg new file mode 100644 index 00000000..ca2a242a --- /dev/null +++ b/frontend/public/assets/tezfin-logo.svg @@ -0,0 +1,9 @@ + diff --git a/frontend/public/assets/tzBTC.svg b/frontend/public/assets/tzBTC.svg new file mode 100644 index 00000000..484f240e --- /dev/null +++ b/frontend/public/assets/tzBTC.svg @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/frontend/public/assets/usdt.svg b/frontend/public/assets/usdt.svg new file mode 100644 index 00000000..908769ba --- /dev/null +++ b/frontend/public/assets/usdt.svg @@ -0,0 +1,3 @@ + diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index 41893d34..af335d0c 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -1,15 +1,100 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { + cleanup, + fireEvent, + render, + screen, + within, +} from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { App } from "./App"; describe("App", () => { - it("renders the TezFin application frame", () => { + beforeEach(() => { + window.history.replaceState({}, "", "/?state=near-liquidation"); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders the complete approved dashboard state", () => { + render( ); + + expect( + screen.getByRole("link", { name: "TezFin dashboard" }), + ).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "Skip to content" })).toHaveAttribute( + "href", + "#dashboard", + ); + expect( + screen.getByRole("alert"), + ).toHaveTextContent("Your position is close to liquidation"); + expect( + screen.getByRole("region", { name: "Supplying" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Borrowing" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Assets to Supply" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("region", { name: "Assets to Borrow" }), + ).toBeInTheDocument(); + expect(screen.getByRole("main")).toHaveAttribute("id", "dashboard"); + }); + + it("switches between the approved account fixtures", () => { render( ); - expect(screen.getByLabelText("TezFin")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Disconnected" })); + expect( - screen.getByRole("navigation", { name: "Primary navigation" }), + screen.getByRole("heading", { name: "Your TezFin passbook" }), ).toBeInTheDocument(); - expect(screen.getByRole("main")).toHaveAttribute("id", "main-content"); + expect( + screen.getAllByRole("button", { name: "Connect wallet" }), + ).toHaveLength(2); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(window.location.search).toContain("state=disconnected"); + }); + + it("requires an explicit debt choice before opening a repayment review", () => { + render( ); + + fireEvent.click(screen.getByRole("button", { name: "Choose debt to repay" })); + + const chooser = screen.getByRole("dialog", { name: "Choose debt to repay" }); + expect(chooser).toHaveTextContent( + "TezFin will not select or batch debts automatically", + ); + + const usdTzChoice = within(chooser).getByRole("button", { + name: /USDtz.*2,000 USDtz/s, + }); + fireEvent.click(usdTzChoice); + + expect( + screen.getByRole("dialog", { name: "Repay USDtz" }), + ).toBeInTheDocument(); + expect( + screen.getByText("Offline fixture — wallet and RPC access are disabled"), + ).toBeInTheDocument(); + }); + + it("keeps wallet connection local to the offline fixture", () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + window.history.replaceState({}, "", "/?state=disconnected"); + + render( ); + fireEvent.click(screen.getAllByRole("button", { name: "Connect wallet" })[0]); + + expect( + screen.getByRole("status"), + ).toHaveTextContent("Wallet access is disabled in this offline design preview."); + expect(fetchMock).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 117e35b8..fbd44e40 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,18 +1,856 @@ +import { + ArrowSquareOut, + CaretDown, + CaretRight, + CheckCircle, + Copy, + List, + ShieldCheck, + WarningCircle, + Wallet, + X, +} from "@phosphor-icons/react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { calculateTotals, scenarios } from "./fixtures"; +import type { + AssetId, + MarketFixture, + OperationStep, + ScenarioId, + TransactionAction, + TransactionDraft, +} from "./types"; + +const scenarioOrder: ScenarioId[] = [ + "disconnected", + "fresh", + "supplying", + "active", + "near-liquidation", +]; + +const currency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +const compactCurrency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + notation: "compact", + maximumFractionDigits: 2, +}); + +function formatAmount(value: number): string { + return new Intl.NumberFormat("en-US", { + maximumFractionDigits: value < 1 ? 6 : value < 100 ? 4 : 2, + }).format(value); +} + +function riskBand(percent: number): "normal" | "attention" | "high" | "critical" { + if (percent >= 95) return "critical"; + if (percent >= 85) return "high"; + if (percent >= 70) return "attention"; + return "normal"; +} + +function initialScenario(): ScenarioId { + const value = new URLSearchParams(window.location.search).get("state") as ScenarioId | null; + return value && scenarioOrder.includes(value) ? value : "near-liquidation"; +} + +function operationSteps(action: TransactionAction, market: MarketFixture): OperationStep[] { + const fToken = `f${market.name}`; + const accrue: OperationStep = { + label: "Accrue interest", + detail: "Refresh all 5 configured TezFin markets", + }; + const liquidity: OperationStep = { + label: "Refresh account liquidity", + detail: "Comptroller · updateAccountLiquidityWithView", + }; + + if (action === "Supply" || action === "Repay") { + const steps: OperationStep[] = [accrue]; + + if (market.standard === "FA1.2") { + steps.push( + { label: "Reset allowance", detail: `${market.name} · approve 0` }, + { label: "Grant exact allowance", detail: `${market.name} → ${fToken}` }, + ); + } + + if (market.standard === "FA1.2 packed") { + steps.push({ + label: "Permission path blocked", + detail: "Packed FA1.2 approval requires contract verification", + }); + } + + if (market.standard === "FA2") { + steps.push({ + label: "Add temporary operator", + detail: `${market.name} → ${fToken}`, + }); + } + + steps.push({ + label: action === "Supply" ? "Supply asset" : "Repay debt", + detail: `${fToken} · ${action === "Supply" ? "mint" : "repayBorrow"}`, + }); + + if (market.standard === "FA2") { + steps.push({ + label: "Remove temporary operator", + detail: `${market.name} → ${fToken}`, + }); + } + + return steps; + } + + if (action === "Withdraw") { + return [ + liquidity, + { label: "Withdraw supplied asset", detail: `${fToken} · redeem` }, + ]; + } + + if (action === "Borrow") { + return [ + liquidity, + { label: "Borrow asset", detail: `${fToken} · borrow` }, + ]; + } + + return [ + liquidity, + { + label: action, + detail: `Comptroller · ${action === "Enable collateral" ? "enterMarkets" : "exitMarket"}`, + }, + ]; +} + +function actionSide(action: TransactionAction): "supply" | "borrow" | "collateral" { + if (action === "Supply" || action === "Withdraw") return "supply"; + if (action === "Borrow" || action === "Repay") return "borrow"; + return "collateral"; +} + +function AssetIdentity({ market, fToken = false }: { market: MarketFixture; fToken?: boolean }) { + return ( + ++ ); +} + +function RiskMeter({ percent, compact = false }: { percent: number; compact?: boolean }) { + const shown = Math.min(Math.max(percent, 0), 100); + const band = riskBand(percent); + + return ( ++ + {market.name} + {fToken ? `f${market.name} · ${currency.format(market.price)}` : `${market.subtitle} · ${currency.format(market.price)}`} + +
+ ++ ); +} + +function PositionPassbook({ + scenario, + markets, + onRepay, + onAddCollateral, +}: { + scenario: ScenarioId; + markets: MarketFixture[]; + onRepay: () => void; + onAddCollateral: () => void; +}) { + const fixture = scenarios[scenario]; + const totals = calculateTotals(markets); + const band = riskBand(totals.limitUsedPercent); + const suppliedMarkets = markets.filter((market) => market.supplied > 0).length; + const borrowedMarkets = markets.filter((market) => market.borrowed > 0).length; + + if (!fixture.connected) { + return ( ++ + ); + } + + return ( + <> + {band === "critical" && ( ++
++Your position
+Your TezFin passbook
+Connect a Tezos wallet to see supplied assets, open borrows, and liquidation headroom in one place.
+ +Offline preview — wallet access is disabled + + + )} + ++ + Your position is close to liquidation + Only {currency.format(totals.headroomUsd)} of borrowing headroom remains in this preview. + + + + + + + + > + ); +} + +type BoardKind = "supplying" | "borrowing" | "supply-market" | "borrow-market"; + +function MarketBoard({ + kind, + markets, + connected, + onOpen, + onCollateral, +}: { + kind: BoardKind; + markets: MarketFixture[]; + connected: boolean; + onOpen: (market: MarketFixture, action: TransactionAction) => void; + onCollateral: (market: MarketFixture) => void; +}) { + const isSupply = kind === "supplying" || kind === "supply-market"; + const isPosition = kind === "supplying" || kind === "borrowing"; + const title = { + supplying: "Supplying", + borrowing: "Borrowing", + "supply-market": "Assets to Supply", + "borrow-market": "Assets to Borrow", + }[kind]; + const action: TransactionAction = { + supplying: "Withdraw", + borrowing: "Repay", + "supply-market": "Supply", + "borrow-market": "Borrow", + }[kind] as TransactionAction; + const boardMarkets = kind === "supplying" + ? markets.filter((market) => market.supplied > 0) + : kind === "borrowing" + ? markets.filter((market) => market.borrowed > 0) + : markets; + const total = boardMarkets.reduce((sum, market) => { + const amount = kind === "borrowing" ? market.borrowed : kind === "supplying" ? market.supplied : 0; + return sum + amount * market.price; + }, 0); + + return ( ++
++ +++Supplied
+{currency.format(totals.suppliedUsd)}
+across {suppliedMarkets} market{suppliedMarkets === 1 ? "" : "s"}
+++Estimated net rate
+{totals.estimatedNetRate >= 0 ? "+" : "−"}{Math.abs(totals.estimatedNetRate).toFixed(2)}%
+variable · fixture calculation
+++Borrowed
+{currency.format(totals.borrowedUsd)}
+{borrowedMarkets === 0 ? "no open lines" : `${borrowedMarkets} open line${borrowedMarkets === 1 ? "" : "s"}`}
++++ Borrow limit used {totals.limitUsedPercent.toFixed(1)}% + {totals.borrowLimitUsd > 0 ? `${currency.format(totals.headroomUsd)} headroom` : "Supply collateral to unlock borrowing"} +++ + Collateral {currency.format(totals.collateralUsd)} + Borrow limit {currency.format(totals.borrowLimitUsd)} +++ + ); +} + +function DebtChooserDialog({ + markets, + onClose, + onSelect, +}: { + markets: MarketFixture[]; + onClose: () => void; + onSelect: (market: MarketFixture) => void; +}) { + const closeButtonRef = useRef+ + + {boardMarkets.length === 0 ? ( +{title}
+ {isPosition && {boardMarkets.length} asset{boardMarkets.length === 1 ? "" : "s"} {currency.format(total)}} ++ {kind === "supplying" + ? "Nothing supplied yet — choose an asset below to preview a deposit." + : "No open lines — borrowing stays empty until collateral is supplied."} ++ ) : ( ++ + {boardMarkets.map((market) => { + const balance = kind === "supplying" ? market.supplied : market.borrowed; + const disabled = !connected && !isPosition; + + return ( ++ )} +++ ); + })} ++ {isPosition ? ( + + {(isSupply ? market.supplyApy : market.borrowApy).toFixed(2)}% ++ ) : ( ++ {kind === "supply-market" ? formatAmount(market.walletBalance) : compactCurrency.format(market.liquidityUsd)} + {kind === "supply-market" && {currency.format(market.walletBalance * market.price)}} ++ )} + {isPosition ? ( ++ {formatAmount(balance)} {market.name} + {currency.format(balance * market.price)} ++ ) : ( ++ {(isSupply ? market.supplyApy : market.borrowApy).toFixed(2)}% ++ )} + {kind === "supplying" && ( + + )} + +(null); + const dialogRef = useRef (null); + const totals = calculateTotals(markets); + const borrowedMarkets = markets + .filter((market) => market.borrowed > 0) + .sort((left, right) => (right.borrowed * right.price) - (left.borrowed * left.price)); + + useEffect(() => { + closeButtonRef.current?.focus(); + const handleKeyboard = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + if (event.key === "Tab" && dialogRef.current) { + const focusable = Array.from( + dialogRef.current.querySelectorAll ( + 'button:not([disabled]), input:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', + ), + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + document.addEventListener("keydown", handleKeyboard); + return () => document.removeEventListener("keydown", handleKeyboard); + }, [onClose]); + + return ( + event.target === event.currentTarget && onClose()}> ++ ); +} + +function TransactionDialog({ + draft, + markets, + onClose, + onFixtureConfirm, +}: { + draft: TransactionDraft; + markets: MarketFixture[]; + onClose: () => void; + onFixtureConfirm: (draft: TransactionDraft, amount: number) => void; +}) { + const market = markets.find((candidate) => candidate.id === draft.marketId); + const [amount, setAmount] = useState(""); + const [activeAction, setActiveAction] = useState+ ++ + ++ +
Choose debt to repay
+ {borrowedMarkets.length} borrowed assets · {currency.format(totals.borrowedUsd)} total + + ++Select one debt for a token-specific operation review Liquidation risk is account-wide. Choose the asset you actually hold and want to repay; TezFin will not select or batch debts automatically.
+ ++ {borrowedMarkets.map((market) => { + const borrowedUsd = market.borrowed * market.price; + const maxRepay = Math.min(market.walletBalance, market.borrowed); + const maxRepayUsd = maxRepay * market.price; + const headroomAfterMax = totals.headroomUsd + maxRepayUsd; + + return ( + + ); + })} ++(draft.action); + const [reviewing, setReviewing] = useState(false); + const [confirmed, setConfirmed] = useState(false); + const closeButtonRef = useRef (null); + const dialogRef = useRef (null); + + useEffect(() => { + closeButtonRef.current?.focus(); + const closeOnEscape = (event: KeyboardEvent) => { + if (event.key === "Escape" && !confirmed) onClose(); + if (event.key === "Tab" && dialogRef.current) { + const focusable = Array.from( + dialogRef.current.querySelectorAll ( + 'button:not([disabled]), input:not([disabled]), [href], [tabindex]:not([tabindex="-1"])', + ), + ); + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + document.addEventListener("keydown", closeOnEscape); + return () => document.removeEventListener("keydown", closeOnEscape); + }, [confirmed, onClose]); + + if (!market) return null; + + const totals = calculateTotals(markets); + const parsedAmount = Number.parseFloat(amount) || 0; + const valueUsd = parsedAmount * market.price; + const side = actionSide(activeAction); + const requiresAmount = side !== "collateral"; + const maxAmount = activeAction === "Supply" + ? market.walletBalance + : activeAction === "Withdraw" + ? market.supplied + : activeAction === "Repay" + ? Math.min(market.walletBalance, market.borrowed) + : Math.max((totals.borrowLimitUsd * 0.8 - totals.borrowedUsd) / market.price, 0); + let nextBorrowed = totals.borrowedUsd; + let nextLimit = totals.borrowLimitUsd; + + if (activeAction === "Borrow") nextBorrowed += valueUsd; + if (activeAction === "Repay") nextBorrowed = Math.max(nextBorrowed - valueUsd, 0); + if (activeAction === "Supply" && market.collateral) nextLimit += valueUsd * market.collateralFactor; + if (activeAction === "Withdraw" && market.collateral) nextLimit = Math.max(nextLimit - valueUsd * market.collateralFactor, 0); + if (activeAction === "Enable collateral") nextLimit += market.supplied * market.price * market.collateralFactor; + if (activeAction === "Disable collateral") nextLimit = Math.max(nextLimit - market.supplied * market.price * market.collateralFactor, 0); + + const nextUsed = nextLimit > 0 ? (nextBorrowed / nextLimit) * 100 : 0; + const steps = operationSteps(activeAction, market); + const invalidPackedPermission = market.standard === "FA1.2 packed" && (activeAction === "Supply" || activeAction === "Repay"); + const invalidAmount = requiresAmount && (parsedAmount <= 0 || parsedAmount > maxAmount || nextUsed > 100); + const canReview = !invalidAmount && !invalidPackedPermission; + + const context = activeAction === "Supply" + ? `Wallet ${formatAmount(market.walletBalance)}` + : activeAction === "Withdraw" + ? `Supplied ${formatAmount(market.supplied)}` + : activeAction === "Borrow" + ? `Liquidity ${compactCurrency.format(market.liquidityUsd)}` + : activeAction === "Repay" + ? `Borrowed ${formatAmount(market.borrowed)}` + : `${market.collateral ? "Currently used" : "Not currently used"} as collateral`; + const actionTabs: TransactionAction[] = side === "supply" ? ["Supply", "Withdraw"] : ["Borrow", "Repay"]; + + return ( + event.target === event.currentTarget && onClose()}> ++ ); +} + export function App() { + const [scenarioId, setScenarioId] = useState+ ++ + + {confirmed ? ( ++ +
{activeAction} {market.name}
+ {context} + + +++ ) : ( + <> + {side !== "collateral" && ( ++ Fixture confirmed
+The local preview state changed. No wallet opened and no operation was created or submitted.
+ ++ {actionTabs.map((action) => ( + + ))} ++ )} ++ + {requiresAmount && !reviewing && ( + <> + +Offline fixture — wallet and RPC access are disabled ≈ {currency.format(valueUsd)}
+ > + )} + ++ {side !== "collateral" && ( ++{side === "supply" ? "Supply APY" : "Borrow APY"}{(side === "supply" ? market.supplyApy : market.borrowApy).toFixed(2)}% variable+ )} +Borrow limit after{currency.format(nextLimit)}+Headroom after{currency.format(Math.max(nextLimit - nextBorrowed, 0))}+Limit used after= 95 ? "danger-text" : ""}>{nextUsed.toFixed(1)}%++ + {(reviewing || side === "collateral") && ( + ++ )} + + {invalidPackedPermission && ( +++Operation preview + {steps.length} step{steps.length === 1 ? "" : "s"} +
+ {steps.map((step, index) => ( +
+- + {index + 1} + {step.label}{step.detail} +
+ ))} ++ )} + {!invalidPackedPermission && requiresAmount && parsedAmount > maxAmount && ( +
Packed FA1.2 permission handling is intentionally blocked until its contract path is verified. + )} + + {!reviewing && side !== "collateral" ? ( + + ) : ( + + )} + + > + )} +
Amount exceeds the fixture maximum of {formatAmount(maxAmount)} {market.name}. (initialScenario); + const [markets, setMarkets] = useState (() => scenarios[initialScenario()].markets.map((market) => ({ ...market }))); + const [draft, setDraft] = useState (null); + const [debtChooserOpen, setDebtChooserOpen] = useState(false); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [walletMenuOpen, setWalletMenuOpen] = useState(false); + const [toast, setToast] = useState(""); + const previewParams = new URLSearchParams(window.location.search); + const cleanPreview = previewParams.get("clean") === "1"; + const forceMobilePreview = previewParams.get("mobile") === "1"; + const scenario = scenarios[scenarioId]; + + useEffect(() => { + setMarkets(scenarios[scenarioId].markets.map((market) => ({ ...market }))); + setDraft(null); + setDebtChooserOpen(false); + const url = new URL(window.location.href); + url.searchParams.set("state", scenarioId); + window.history.replaceState({}, "", url); + }, [scenarioId]); + + useEffect(() => { + const previewConnect = () => setToast("Wallet access is disabled in this offline design preview."); + window.addEventListener("tezfin-preview-connect", previewConnect); + return () => window.removeEventListener("tezfin-preview-connect", previewConnect); + }, []); + + useEffect(() => { + if (!toast) return; + const timer = window.setTimeout(() => setToast(""), 2800); + return () => window.clearTimeout(timer); + }, [toast]); + + const borrowedMarkets = useMemo( + () => markets.filter((market) => market.borrowed > 0), + [markets], + ); + const addCollateralMarket = useMemo( + () => markets.find((market) => market.walletBalance > 0 && market.collateral) ?? markets.find((market) => market.walletBalance > 0) ?? markets[0], + [markets], + ); + + const openTransaction = (market: MarketFixture, action: TransactionAction) => { + setDebtChooserOpen(false); + setDraft({ marketId: market.id, action }); + }; + + const handleGlobalRepay = () => { + if (borrowedMarkets.length === 1) { + openTransaction(borrowedMarkets[0], "Repay"); + return; + } + if (borrowedMarkets.length > 1) { + setDebtChooserOpen(true); + return; + } + setToast("No borrowed assets are open in this preview state."); + }; + + const handleCollateral = (market: MarketFixture) => { + openTransaction(market, market.collateral ? "Disable collateral" : "Enable collateral"); + }; + + const applyFixtureTransaction = (currentDraft: TransactionDraft, amount: number) => { + setMarkets((current) => current.map((market) => { + if (market.id !== currentDraft.marketId) return market; + if (currentDraft.action === "Supply") return { ...market, walletBalance: Math.max(market.walletBalance - amount, 0), supplied: market.supplied + amount }; + if (currentDraft.action === "Withdraw") return { ...market, walletBalance: market.walletBalance + amount, supplied: Math.max(market.supplied - amount, 0) }; + if (currentDraft.action === "Borrow") return { ...market, walletBalance: market.walletBalance + amount, borrowed: market.borrowed + amount }; + if (currentDraft.action === "Repay") return { ...market, walletBalance: Math.max(market.walletBalance - amount, 0), borrowed: Math.max(market.borrowed - amount, 0) }; + return { ...market, collateral: currentDraft.action === "Enable collateral" }; + })); + }; + return ( - - ++ Skip to content); } diff --git a/frontend/src/app/fixtures.ts b/frontend/src/app/fixtures.ts new file mode 100644 index 00000000..c218a689 --- /dev/null +++ b/frontend/src/app/fixtures.ts @@ -0,0 +1,210 @@ +import type { + MarketFixture, + PositionTotals, + ScenarioFixture, + ScenarioId, +} from "./types"; + +const asset = (name: string) => `/assets/${name}`; + +const baseMarkets: MarketFixture[] = [ + { + id: "XTZ", + name: "XTZ", + subtitle: "Tez", + price: 0.94, + supplyApy: 1.42, + borrowApy: 4.87, + collateralFactor: 0.65, + fTokenRate: 47.13, + liquidityUsd: 2_140_000, + walletBalance: 1240.5, + supplied: 0, + borrowed: 0, + collateral: false, + icon: asset("XTZ.svg"), + fTokenIcon: asset("fXTZ.svg"), + standard: "XTZ", + }, + { + id: "tzBTC", + name: "tzBTC", + subtitle: "Wrapped Bitcoin", + price: 104_200, + supplyApy: 0.09, + borrowApy: 1.81, + collateralFactor: 0.75, + fTokenRate: 49.02, + liquidityUsd: 1_860_000, + walletBalance: 0.0061, + supplied: 0, + borrowed: 0, + collateral: false, + icon: asset("tzBTC.svg"), + fTokenIcon: asset("ftzBTC.svg"), + standard: "FA1.2 packed", + }, + { + id: "USDt", + name: "USDt", + subtitle: "Tether USD", + price: 1, + supplyApy: 3.86, + borrowApy: 6.92, + collateralFactor: 0.78, + fTokenRate: 46.2, + liquidityUsd: 1_240_000, + walletBalance: 512.8, + supplied: 0, + borrowed: 0, + collateral: false, + icon: asset("usdt.svg"), + fTokenIcon: asset("fusdt.svg"), + standard: "FA2", + }, + { + id: "USDtz", + name: "USDtz", + subtitle: "USD Tez", + price: 1, + supplyApy: 2.14, + borrowApy: 5.06, + collateralFactor: 0.7, + fTokenRate: 48.11, + liquidityUsd: 612_000, + walletBalance: 84.2, + supplied: 0, + borrowed: 0, + collateral: false, + icon: asset("USDtz.svg"), + fTokenIcon: asset("fusdtz.svg"), + standard: "FA1.2", + }, + { + id: "stXTZ", + name: "stXTZ", + subtitle: "Staked Tez", + price: 1.09, + supplyApy: 5.62, + borrowApy: 7.4, + collateralFactor: 0.55, + fTokenRate: 45.87, + liquidityUsd: 310_000, + walletBalance: 0, + supplied: 0, + borrowed: 0, + collateral: false, + icon: asset("stacy_logo.png"), + fTokenIcon: asset("fstXTZ.svg"), + standard: "FA2", + }, +]; + +function cloneMarkets(): MarketFixture[] { + return baseMarkets.map((market) => ({ ...market })); +} + +function positionedMarkets(usdtBorrowedUsd = 0, usdtzBorrowedUsd = 0): MarketFixture[] { + return cloneMarkets().map((market) => { + if (market.id === "XTZ") { + return { ...market, supplied: 8400, collateral: true }; + } + if (market.id === "tzBTC") { + return { ...market, supplied: 0.042, collateral: true }; + } + if (market.id === "USDt") { + return { ...market, borrowed: usdtBorrowedUsd }; + } + if (market.id === "USDtz") { + return { ...market, borrowed: usdtzBorrowedUsd }; + } + return market; + }); +} + +export const scenarios: Record- - TezFin - - - + + {mobileMenuOpen && ( ++ Dashboard + event.preventDefault()}>Markets + event.preventDefault()}>Docs + + )} + ++ + + {!cleanPreview && ( + + )} + + {draft && ( +openTransaction(addCollateralMarket, "Supply")} + /> + + ++ + ++ + + + setDraft(null)} onFixtureConfirm={applyFixtureTransaction} /> + )} + + {debtChooserOpen && ( + setDebtChooserOpen(false)} + onSelect={(market) => openTransaction(market, "Repay")} + /> + )} + + {toast}= { + disconnected: { + id: "disconnected", + label: "Disconnected", + connected: false, + block: "9,412,338", + markets: cloneMarkets(), + }, + fresh: { + id: "fresh", + label: "Fresh wallet", + connected: true, + address: "tz1ZkH8f8M4V...7sQ2rV", + block: "9,412,338", + markets: cloneMarkets(), + }, + supplying: { + id: "supplying", + label: "Supplying only", + connected: true, + address: "tz1ZkH8f8M4V...7sQ2rV", + block: "9,412,338", + markets: positionedMarkets(), + }, + active: { + id: "active", + label: "Active position", + connected: true, + address: "tz1ZkH8f8M4V...7sQ2rV", + block: "9,412,338", + markets: positionedMarkets(6300), + }, + "near-liquidation": { + id: "near-liquidation", + label: "Near liquidation", + connected: true, + address: "tz1ZkH8f8M4V...7sQ2rV", + block: "9,412,338", + markets: positionedMarkets(6000, 2000), + }, +}; + +export function calculateTotals(markets: MarketFixture[]): PositionTotals { + const raw = markets.reduce( + (totals, market) => { + const suppliedUsd = market.supplied * market.price; + const borrowedUsd = market.borrowed * market.price; + + totals.suppliedUsd += suppliedUsd; + totals.borrowedUsd += borrowedUsd; + totals.annualSupplyUsd += suppliedUsd * (market.supplyApy / 100); + totals.annualBorrowUsd += borrowedUsd * (market.borrowApy / 100); + + if (market.collateral) { + totals.collateralUsd += suppliedUsd; + totals.borrowLimitUsd += suppliedUsd * market.collateralFactor; + } + + return totals; + }, + { + suppliedUsd: 0, + borrowedUsd: 0, + collateralUsd: 0, + borrowLimitUsd: 0, + annualSupplyUsd: 0, + annualBorrowUsd: 0, + }, + ); + + const equity = raw.suppliedUsd - raw.borrowedUsd; + const estimatedNetRate = + equity > 0 + ? ((raw.annualSupplyUsd - raw.annualBorrowUsd) / equity) * 100 + : 0; + + return { + ...raw, + headroomUsd: Math.max(raw.borrowLimitUsd - raw.borrowedUsd, 0), + limitUsedPercent: + raw.borrowLimitUsd > 0 + ? (raw.borrowedUsd / raw.borrowLimitUsd) * 100 + : 0, + estimatedNetRate, + }; +} diff --git a/frontend/src/app/types.ts b/frontend/src/app/types.ts new file mode 100644 index 00000000..07108aec --- /dev/null +++ b/frontend/src/app/types.ts @@ -0,0 +1,71 @@ +/** + * Presentation-only values used by the offline dashboard fixtures. + * Production protocol adapters must use the exact atomic-amount primitives in + * `src/domain/amount.ts` and must not pass financial values through `number`. + */ +export type ScenarioId = + | "disconnected" + | "fresh" + | "supplying" + | "active" + | "near-liquidation"; + +export type AssetId = "XTZ" | "tzBTC" | "USDt" | "USDtz" | "stXTZ"; +export type TokenStandard = "XTZ" | "FA1.2" | "FA1.2 packed" | "FA2"; +export type TransactionAction = + | "Supply" + | "Withdraw" + | "Borrow" + | "Repay" + | "Enable collateral" + | "Disable collateral"; + +export interface MarketFixture { + id: AssetId; + name: string; + subtitle: string; + price: number; + supplyApy: number; + borrowApy: number; + collateralFactor: number; + fTokenRate: number; + liquidityUsd: number; + walletBalance: number; + supplied: number; + borrowed: number; + collateral: boolean; + icon: string; + fTokenIcon: string; + standard: TokenStandard; +} + +export interface ScenarioFixture { + id: ScenarioId; + label: string; + connected: boolean; + address?: string; + block: string; + markets: MarketFixture[]; +} + +export interface PositionTotals { + suppliedUsd: number; + borrowedUsd: number; + collateralUsd: number; + borrowLimitUsd: number; + headroomUsd: number; + limitUsedPercent: number; + annualSupplyUsd: number; + annualBorrowUsd: number; + estimatedNetRate: number; +} + +export interface TransactionDraft { + marketId: AssetId; + action: TransactionAction; +} + +export interface OperationStep { + label: string; + detail: string; +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 07cc0631..e435069b 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -1,30 +1,31 @@ :root { color-scheme: dark; - font-family: "Instrument Sans", system-ui, -apple-system, BlinkMacSystemFont, - "Segoe UI", sans-serif; + font-family: "Instrument Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; color: #eceff7; background: #0a0f1e; font-synthesis: none; text-rendering: optimizeLegibility; - --surface-canvas: #0a0f1e; - --surface-raised: #0e1526; - --surface-panel: #131b30; - --text-primary: #eceff7; - --text-secondary: rgba(236, 239, 247, 0.7); - --border-subtle: rgba(236, 239, 247, 0.1); - --border-strong: rgba(236, 239, 247, 0.22); - --action-primary: #618df8; - --state-positive: #6fbf8e; - --state-attention: #dcc95e; - --state-warning: #e08a52; - --state-critical: #e0645c; - --font-editorial: "Fraunces Variable", Georgia, "Times New Roman", serif; - --font-interface: "Instrument Sans", system-ui, -apple-system, - BlinkMacSystemFont, "Segoe UI", sans-serif; - --font-technical: "Spline Sans Mono", ui-monospace, "SFMono-Regular", Menlo, - monospace; - --radius-control: 999px; - --radius-panel: 16px; + --ink: #0a0f1e; + --ink-2: #0e1526; + --ink-3: #131b30; + --cream: #eceff7; + --cream-dim: rgba(236, 239, 247, 0.7); + --cream-faint: rgba(236, 239, 247, 0.56); + --line: rgba(236, 239, 247, 0.1); + --line-strong: rgba(236, 239, 247, 0.22); + --blue: #618df8; + --blue-soft: rgba(97, 141, 248, 0.14); + --violet: #b278c6; + --violet-soft: rgba(178, 120, 198, 0.16); + --green: #6fbf8e; + --yellow: #dcc95e; + --orange: #e08a52; + --red: #e0645c; + --serif: "Fraunces Variable", Georgia, "Times New Roman", serif; + --sans: "Instrument Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --mono: "Spline Sans Mono", ui-monospace, "SFMono-Regular", Menlo, monospace; + --pill: 999px; + --card: 16px; } * { @@ -33,16 +34,17 @@ html { min-width: 320px; - background: var(--surface-canvas); + background: var(--ink); + scroll-behavior: smooth; } body { + margin: 0; min-width: 320px; min-height: 100vh; - margin: 0; - color: var(--text-primary); - background: var(--surface-canvas); - font-family: var(--font-interface); + background: var(--ink); + color: var(--cream); + font-family: var(--sans); font-size: 15px; line-height: 1.45; -webkit-font-smoothing: antialiased; @@ -53,19 +55,29 @@ input { font: inherit; } -a, -button { +button, +a { -webkit-tap-highlight-color: transparent; } -:focus-visible { - outline: 2px solid var(--action-primary); +button { + color: inherit; +} + +button:focus-visible, +a:focus-visible, +input:focus-visible { + outline: 2px solid var(--blue); outline-offset: 3px; } +button:disabled { + cursor: not-allowed; +} + ::selection { - color: var(--surface-canvas); - background: var(--action-primary); + color: var(--ink); + background: var(--blue); } .skip-link { @@ -74,9 +86,9 @@ button { left: 12px; z-index: 100; padding: 8px 12px; - border-radius: var(--radius-control); - color: var(--surface-canvas); - background: var(--text-primary); + border-radius: var(--pill); + color: var(--ink); + background: var(--cream); transform: translateY(-160%); } @@ -84,75 +96,2040 @@ button { transform: translateY(0); } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .app-shell { min-height: 100vh; - background: var(--surface-canvas); + background: var(--ink); } .topbar { + position: relative; + z-index: 20; display: flex; - min-height: 74px; align-items: center; + min-height: 74px; gap: 28px; padding: 18px 32px; - border-bottom: 1px solid var(--border-subtle); + border-bottom: 1px solid var(--line); + background: var(--ink); } .brand { - color: var(--text-primary); - font-family: var(--font-editorial); - font-size: 26px; - font-variation-settings: "opsz" 72, "wght" 600; - letter-spacing: -0.04em; + display: inline-flex; + align-items: center; + min-width: 112px; +} + +.brand img { + display: block; + width: 132px; + height: 27px; + filter: invert(1) grayscale(1) brightness(1.22); + object-fit: contain; + object-position: left center; +} + +.desktop-nav { + display: flex; + align-items: center; + gap: 4px; + margin-left: 8px; +} + +.desktop-nav a, +.mobile-nav a { + color: var(--cream-dim); + font-size: 14px; + font-weight: 500; + text-decoration: none; +} + +.desktop-nav a { + padding: 8px 14px; + border-radius: var(--pill); +} + +.desktop-nav a.active { + color: var(--cream); + background: rgba(236, 239, 247, 0.06); +} + +.desktop-nav a:hover, +.mobile-nav a:hover { + color: var(--cream); } -.primary-nav { +.topbar__actions { display: flex; align-items: center; + gap: 10px; + margin-left: auto; } -.primary-nav span { +.network-chip, +.wallet-chip { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 35px; padding: 8px 14px; - border-radius: var(--radius-control); - color: var(--text-primary); + border: 1px solid var(--line); + border-radius: var(--pill); + color: var(--cream-dim); + background: transparent; + font-family: var(--mono); + font-size: 12px; + white-space: nowrap; +} + +.network-chip i { + display: block; + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--yellow); + box-shadow: 0 0 0 4px rgba(220, 201, 94, 0.09); +} + +.wallet-chip { + border-color: var(--line-strong); + color: var(--cream); + cursor: pointer; +} + +.wallet-chip:hover, +.menu-button:hover, +.icon-button:hover { background: rgba(236, 239, 247, 0.06); +} + +.wallet-chip--connect { + font-family: var(--sans); + font-weight: 600; +} + +.wallet-menu-wrap { + position: relative; +} + +.wallet-menu { + position: absolute; + top: calc(100% + 10px); + right: 0; + z-index: 30; + display: grid; + width: 290px; + padding: 16px; + border: 1px solid var(--line-strong); + border-radius: 14px; + background: var(--ink-3); + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.42); +} + +.wallet-menu strong { + font-size: 13px; +} + +.wallet-menu > span { + margin: 4px 0 12px; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 10.5px; + overflow-wrap: anywhere; +} + +.wallet-menu button { + display: flex; + align-items: center; + gap: 9px; + padding: 10px 0; + border: 0; + border-top: 1px solid var(--line); + color: var(--cream-dim); + background: transparent; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.menu-button, +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + padding: 0; + border: 1px solid var(--line); + border-radius: 50%; + background: transparent; + cursor: pointer; +} + +.menu-button { + display: none; +} + +.mobile-nav { + position: relative; + z-index: 15; + display: none; + padding: 6px 18px 12px; + border-bottom: 1px solid var(--line); + background: var(--ink-2); +} + +.mobile-nav a { + display: block; + padding: 11px 0; +} + +.dashboard-shell { + width: min(100%, 1224px); + margin: 0 auto; + padding: 36px 32px 118px; +} + +.risk-alert { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; + padding: 14px 16px; + border: 1px solid rgba(224, 100, 92, 0.5); + border-radius: 14px; + background: rgba(224, 100, 92, 0.1); +} + +.risk-alert__icon { + display: inline-flex; + color: var(--red); +} + +.risk-alert__copy { + display: grid; + gap: 1px; +} + +.risk-alert__copy strong { font-size: 14px; +} + +.risk-alert__copy small { + color: var(--cream-dim); + font-size: 12.5px; +} + +.risk-alert__actions { + display: flex; + gap: 8px; + margin-left: auto; +} + +.risk-alert__actions button { + min-height: 36px; + padding: 7px 14px; + border: 1px solid var(--line-strong); + border-radius: var(--pill); + background: transparent; + font-size: 12.5px; + font-weight: 600; + cursor: pointer; +} + +.risk-alert__actions button:first-child { + border-color: var(--red); + color: var(--ink); + background: var(--red); +} + +.passbook { + position: relative; + min-height: 224px; + overflow: hidden; + padding: 30px 34px 26px; + border: 1px solid var(--line-strong); + border-radius: var(--card); + background: var(--ink-2); +} + +.passbook::before { + position: absolute; + inset: 0 0 auto; + height: 45%; + border-radius: inherit; + background: rgba(236, 239, 247, 0.018); + content: ""; + pointer-events: none; +} + +.passbook__ornament { + position: absolute; + top: 50%; + right: -140px; + width: 560px; + height: 560px; + transform: translateY(-50%); + opacity: 0.95; + pointer-events: none; +} + +.passbook__grid { + position: relative; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: end; + gap: 28px; +} + +.passbook-label, +.eyebrow { + display: flex; + align-items: center; + gap: 8px; + margin: 0 0 8px; + color: var(--cream-dim); + font-family: var(--mono); + font-size: 11px; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 2px; +} + +.dot--supply { + background: var(--blue); +} + +.dot--borrow { + background: var(--violet); +} + +.passbook-value { + margin: 0; + font-family: var(--mono); + font-size: clamp(23px, 2.45vw, 28px); font-weight: 500; + letter-spacing: -0.02em; } -.workspace { - width: min(1180px, calc(100% - 64px)); - min-height: calc(100vh - 75px); - margin: 0 auto; +.passbook-sub { + margin: 4px 0 0; + color: var(--cream-faint); + font-size: 12.5px; } -@media (max-width: 640px) { - .topbar { - min-height: 64px; - padding: 14px 20px; - } +.passbook-stat--right { + text-align: right; +} - .brand { - font-size: 24px; - } +.passbook-stat--right .passbook-label { + justify-content: flex-end; +} - .primary-nav { - display: none; - } +.passbook-net { + text-align: center; +} - .workspace { - width: calc(100% - 32px); - min-height: calc(100vh - 65px); - } +.passbook-net .passbook-label { + justify-content: center; + margin-bottom: 1px; } -@media (prefers-reduced-motion: reduce) { +.passbook-net h1 { + margin: 0; + font-family: var(--serif); + font-size: clamp(44px, 5vw, 56px); + font-variation-settings: "wght" 560; + font-weight: 560; + letter-spacing: -0.025em; + line-height: 1.05; +} + +.passbook__risk { + position: relative; + margin-top: 30px; +} + +.risk-head, +.risk-foot { + display: flex; + justify-content: space-between; + gap: 24px; + color: var(--cream-dim); +} + +.risk-head { + align-items: baseline; + margin-bottom: 13px; + font-size: 12.5px; +} + +.risk-head b { + color: var(--cream); + font-family: var(--mono); + font-weight: 500; +} + +.risk-foot { + margin-top: 12px; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 11.5px; +} + +.risk-meter__track { + position: relative; + display: flex; + height: 10px; + overflow: hidden; + border-radius: var(--pill); + background: rgba(236, 239, 247, 0.08); +} + +.risk-meter__zone { + height: 100%; +} + +.risk-meter__zone--safe { + width: 52%; + background: var(--green); +} + +.risk-meter__zone--attention { + width: 22%; + background: var(--yellow); +} + +.risk-meter__zone--high { + width: 16%; + background: var(--orange); +} + +.risk-meter__zone--critical { + width: 10%; + background: var(--red); +} + +.risk-meter__mask { + position: absolute; + inset: 0 0 0 auto; + background: rgba(236, 239, 247, 0.08); +} + +.risk-meter__marker { + position: absolute; + top: -3px; + bottom: -3px; + width: 2px; + border-radius: 2px; + background: var(--cream); + box-shadow: 0 0 0 2px rgba(10, 15, 30, 0.62); + transform: translateX(-1px); +} + +.risk-meter--compact { + margin: 15px 0 20px; +} + +.risk-meter--compact .risk-meter__track { + height: 8px; +} + +.danger-text { + color: var(--red) !important; +} + +.passbook--disconnected { + display: flex; + align-items: center; + min-height: 260px; +} + +.passbook__empty-copy { + position: relative; + z-index: 2; + max-width: 580px; +} + +.passbook__empty-copy h1 { + margin: 0; + font-family: var(--serif); + font-size: clamp(36px, 5vw, 54px); + font-variation-settings: "wght" 560; + font-weight: 560; + letter-spacing: -0.025em; + line-height: 1.05; +} + +.passbook__empty-copy > p:not(.eyebrow) { + max-width: 520px; + margin: 14px 0 22px; + color: var(--cream-dim); + font-size: 15px; +} + +.primary-action { + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 43px; + padding: 10px 18px; + border: 1px solid var(--blue); + border-radius: var(--pill); + color: var(--ink); + background: var(--blue); + font-weight: 600; + cursor: pointer; +} + +.fixture-note { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 13px; + color: var(--cream-faint); + font-size: 11.5px; +} + +.market-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 22px; + margin-top: 26px; +} + +.market-board { + overflow: hidden; + border: 1px solid var(--line); + border-radius: var(--card); + background: var(--ink-2); +} + +.market-board__header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 20px; + padding: 18px 18px 14px; +} + +.market-board__header h2 { + margin: 0; + font-family: var(--serif); + font-size: 21px; + font-variation-settings: "wght" 560; + font-weight: 560; + letter-spacing: 0.005em; +} + +.market-board__header > span { + color: var(--cream-faint); + font-family: var(--mono); + font-size: 11px; + white-space: nowrap; +} + +.market-board__header b { + margin-left: 4px; + color: var(--cream-dim); + font-weight: 500; +} + +.market-board__empty { + min-height: 102px; + padding: 25px 22px; + border-top: 1px solid var(--line); + color: var(--cream-faint); + font-size: 13px; +} + +.market-board__rows { + min-width: 0; +} + +.market-row { + display: grid; + align-items: center; + gap: 10px; + min-height: 62px; + padding: 12px 18px; + border-top: 1px solid var(--line); + transition: background 150ms ease; +} + +.market-row:not(.market-row--head):hover { + background: rgba(236, 239, 247, 0.032); +} + +.market-row--supplying { + grid-template-columns: minmax(0, 1fr) 62px 124px 86px 92px; +} + +.market-row--borrowing { + grid-template-columns: minmax(0, 1fr) 62px 124px 84px; +} + +.market-row--supply-market, +.market-row--borrow-market { + grid-template-columns: minmax(0, 1fr) 116px 66px 86px; +} + +.market-row--head { + min-height: auto; + padding-top: 2px; + padding-bottom: 10px; + border-top: 0; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 9.5px; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.market-row--head span:not(:first-child) { + text-align: right; +} + +.asset-identity { + display: flex; + align-items: center; + gap: 11px; + min-width: 0; +} + +.asset-identity img { + width: 35px; + height: 35px; + flex: none; + object-fit: contain; +} + +.asset-identity > span { + display: grid; + min-width: 0; +} + +.asset-identity strong { + overflow: hidden; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.01em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.asset-identity small, +.amount-cell small { + overflow: hidden; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 10px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rate-cell, +.amount-cell { + min-width: 0; + text-align: right; +} + +.rate-cell { + color: var(--blue); + font-family: var(--mono); + font-size: 13px; + font-weight: 500; +} + +.market-board--borrow .rate-cell { + color: var(--violet); +} + +.amount-cell { + display: grid; +} + +.amount-cell strong { + overflow: hidden; + font-family: var(--mono); + font-size: 12px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.amount-cell em { + color: var(--cream-faint); + font-style: normal; + font-weight: 400; +} + +.collateral-control { + display: flex; + justify-content: flex-end; + cursor: pointer; +} + +.collateral-control > span { + display: none; +} + +.collateral-control input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.collateral-control i { + position: relative; + display: block; + width: 38px; + height: 22px; + border: 1px solid var(--line-strong); + border-radius: var(--pill); + background: rgba(236, 239, 247, 0.08); + transition: background 160ms ease; +} + +.collateral-control i::after { + position: absolute; + top: 3px; + left: 3px; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--cream-dim); + content: ""; + transition: transform 160ms ease, background 160ms ease; +} + +.collateral-control input:checked + i { + border-color: var(--blue); + background: var(--blue); +} + +.collateral-control input:checked + i::after { + background: var(--ink); + transform: translateX(16px); +} + +.collateral-control input:focus-visible + i { + outline: 2px solid var(--blue); + outline-offset: 3px; +} + +.row-action { + min-height: 33px; + padding: 7px 13px; + border: 1px solid var(--line-strong); + border-radius: var(--pill); + color: var(--cream); + background: transparent; + font-size: 11.5px; + font-weight: 600; + white-space: nowrap; + cursor: pointer; +} + +.row-action:hover:not(:disabled) { + background: rgba(236, 239, 247, 0.08); +} + +.row-action:disabled { + opacity: 0.38; +} + +.market-board--supply .row-action--fill { + border-color: var(--blue); + color: var(--ink); + background: var(--blue); +} + +.market-board--borrow .row-action--fill { + border-color: var(--violet); + color: var(--ink); + background: var(--violet); +} + +.site-footer { + display: flex; + justify-content: space-between; + gap: 24px; + margin-top: 44px; + padding-top: 20px; + border-top: 1px solid var(--line); + color: var(--cream-faint); + font-family: var(--mono); + font-size: 10.5px; + letter-spacing: 0.02em; +} + +.preview-switcher { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 40; + width: min(440px, calc(100vw - 40px)); + padding: 12px; + border: 1px solid var(--line-strong); + border-radius: 15px; + background: rgba(19, 27, 48, 0.96); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45); + backdrop-filter: blur(18px); +} + +.preview-switcher > span { + display: block; + margin: 0 0 7px 2px; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 9px; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.preview-switcher > div { + display: flex; + flex-wrap: wrap; + gap: 5px; +} + +.preview-switcher button { + flex: 1 1 auto; + padding: 7px 9px; + border: 1px solid var(--line); + border-radius: var(--pill); + color: var(--cream-dim); + background: transparent; + font-size: 10.5px; + cursor: pointer; +} + +.preview-switcher button.active { + border-color: var(--cream); + color: var(--ink); + background: var(--cream); +} + +.dialog-backdrop { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: 20px; + overflow-y: auto; + background: rgba(5, 8, 15, 0.82); + backdrop-filter: blur(10px); +} + +.transaction-dialog { + width: min(100%, 470px); + max-height: calc(100dvh - 40px); + overflow-y: auto; + padding: 23px; + border: 1px solid var(--line-strong); + border-radius: var(--card); + background: var(--ink-2); + box-shadow: 0 26px 80px rgba(0, 0, 0, 0.56); +} + +.transaction-dialog__header { + display: flex; + align-items: center; + gap: 12px; +} + +.transaction-dialog__header > img { + width: 40px; + height: 40px; + object-fit: contain; +} + +.transaction-dialog__header > span { + display: grid; +} + +.transaction-dialog__header h2 { + margin: 0; + font-family: var(--serif); + font-size: 20px; + font-variation-settings: "wght" 560; + font-weight: 560; +} + +.transaction-dialog__header small { + color: var(--cream-faint); + font-family: var(--mono); + font-size: 10.5px; +} + +.transaction-dialog__header .icon-button { + margin-left: auto; + border-color: transparent; +} + +.debt-chooser { + width: min(100%, 640px); +} + +.debt-chooser__icon { + display: grid; + width: 40px; + height: 40px; + flex: none; + place-items: center; + border: 1px solid rgba(174, 112, 201, 0.42); + border-radius: 12px; + color: var(--violet); + background: rgba(174, 112, 201, 0.09); +} + +.debt-chooser__intro { + max-width: 540px; + margin: 0 0 16px; + color: var(--cream-dim); + font-size: 12.5px; + line-height: 1.55; +} + +.debt-choice-list { + display: grid; + gap: 10px; +} + +.debt-choice { + display: grid; + grid-template-columns: minmax(150px, 0.85fr) minmax(280px, 1.35fr) 20px; + align-items: center; + gap: 16px; + width: 100%; + padding: 15px; + border: 1px solid var(--line); + border-radius: 13px; + color: var(--cream); + background: rgba(236, 239, 247, 0.024); + text-align: left; + cursor: pointer; + transition: border-color 150ms ease, background 150ms ease, transform 150ms ease; +} + +.debt-choice:hover, +.debt-choice:focus-visible { + border-color: rgba(174, 112, 201, 0.54); + background: rgba(174, 112, 201, 0.07); +} + +.debt-choice:hover { + transform: translateY(-1px); +} + +.debt-choice:focus-visible { + outline: 2px solid var(--violet); + outline-offset: 2px; +} + +.debt-choice__asset { + display: grid; + gap: 8px; + min-width: 0; +} + +.debt-choice__asset > small { + color: var(--violet); + font-family: var(--mono); + font-size: 9.5px; +} + +.debt-choice__metrics { + display: grid; + grid-template-columns: 1fr 1.2fr; + gap: 16px; + min-width: 0; +} + +.debt-choice__metrics > span { + display: grid; + gap: 2px; + min-width: 0; +} + +.debt-choice__metrics small { + color: var(--cream-faint); + font-family: var(--mono); + font-size: 8.5px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.debt-choice__metrics b, +.debt-choice__metrics em { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.debt-choice__metrics b { + font-family: var(--mono); + font-size: 11px; + font-weight: 500; +} + +.debt-choice__metrics em { + color: var(--cream-faint); + font-size: 10px; + font-style: normal; +} + +.debt-choice__caret { + color: var(--cream-faint); + transition: color 150ms ease, transform 150ms ease; +} + +.debt-choice:hover .debt-choice__caret, +.debt-choice:focus-visible .debt-choice__caret { + color: var(--violet); + transform: translateX(2px); +} + +.offline-callout { + display: flex; + align-items: center; + gap: 7px; + margin: 18px 0 16px; + padding: 9px 11px; + border: 1px solid rgba(220, 201, 94, 0.28); + border-radius: 10px; + color: var(--cream-dim); + background: rgba(220, 201, 94, 0.07); + font-size: 11.5px; +} + +.transaction-tabs { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px; + margin-top: 18px; + padding: 4px; + border-radius: var(--pill); + background: rgba(236, 239, 247, 0.06); +} + +.transaction-tabs button { + min-height: 36px; + padding: 7px 10px; + border: 0; + border-radius: var(--pill); + color: var(--cream-dim); + background: transparent; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.transaction-tabs button.active { + color: var(--ink); + background: var(--cream); +} + +.offline-callout svg { + color: var(--yellow); +} + +.amount-input { + display: flex; + align-items: center; + gap: 10px; + min-height: 62px; + padding: 12px 14px; + border: 1px solid var(--line-strong); + border-radius: 12px; +} + +.amount-input:focus-within { + border-color: rgba(97, 141, 248, 0.62); + box-shadow: 0 0 0 3px rgba(97, 141, 248, 0.1); +} + +.amount-input input { + min-width: 0; + flex: 1; + border: 0; + outline: 0; + color: var(--cream); + background: transparent; + font-family: var(--mono); + font-size: 22px; +} + +.amount-input input::placeholder { + color: var(--cream-faint); +} + +.amount-input b { + color: var(--cream-dim); + font-family: var(--mono); + font-size: 12px; + font-weight: 500; +} + +.amount-input button { + padding: 5px 9px; + border: 1px solid var(--line); + border-radius: var(--pill); + color: var(--cream-dim); + background: transparent; + font-family: var(--mono); + font-size: 9.5px; + cursor: pointer; +} + +.amount-usd { + margin: 6px 2px 0; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 10.5px; +} + +.transaction-lines { + margin-top: 17px; + border-top: 1px dashed var(--line-strong); +} + +.transaction-lines > div { + display: flex; + justify-content: space-between; + gap: 20px; + padding: 9px 0; + border-bottom: 1px dashed var(--line); + color: var(--cream-dim); + font-size: 12px; +} + +.transaction-lines b { + color: var(--cream); + font-family: var(--mono); + font-size: 11.5px; + font-weight: 500; + text-align: right; +} + +.transaction-lines b small { + color: var(--cream-faint); + font-family: var(--sans); + font-size: 9px; + font-weight: 400; +} + +.operation-review { + margin: 17px 0; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(236, 239, 247, 0.024); +} + +.operation-review__heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 15px; + padding: 11px 12px; + border-bottom: 1px solid var(--line); + color: var(--cream-dim); + font-size: 11.5px; +} + +.operation-review__heading > span { + display: inline-flex; + align-items: center; + gap: 7px; +} + +.operation-review__heading b { + color: var(--cream-faint); + font-family: var(--mono); + font-size: 9.5px; + font-weight: 400; +} + +.operation-review ol { + display: grid; + gap: 0; + margin: 0; + padding: 0; + list-style: none; +} + +.operation-review li { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-bottom: 1px solid var(--line); +} + +.operation-review li:last-child { + border-bottom: 0; +} + +.operation-review li > i { + display: grid; + width: 22px; + height: 22px; + flex: none; + place-items: center; + border: 1px solid var(--line-strong); + border-radius: 50%; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 8.5px; + font-style: normal; +} + +.operation-review li > span { + display: grid; +} + +.operation-review li strong { + font-size: 11.5px; +} + +.operation-review li small { + color: var(--cream-faint); + font-family: var(--mono); + font-size: 9.5px; +} + +.dialog-error { + display: flex; + gap: 8px; + align-items: flex-start; + margin: 14px 0; + color: #ef8c85; + font-size: 11.5px; +} + +.dialog-error svg { + flex: none; + margin-top: 1px; +} + +.dialog-primary { + width: 100%; + min-height: 46px; + margin-top: 15px; + border: 0; + border-radius: var(--pill); + color: var(--ink); + background: var(--cream); + font-size: 13.5px; + font-weight: 600; + cursor: pointer; +} + +.dialog-primary:disabled { + color: var(--cream-faint); + background: rgba(236, 239, 247, 0.08); +} + +.fixture-success { + display: grid; + justify-items: center; + padding: 34px 4px 5px; + text-align: center; +} + +.fixture-success > svg { + color: var(--green); +} + +.fixture-success h3 { + margin: 12px 0 4px; + font-family: var(--serif); + font-size: 24px; + font-variation-settings: "wght" 560; +} + +.fixture-success p { + max-width: 340px; + margin: 0; + color: var(--cream-dim); + font-size: 12.5px; +} + +.toast { + position: fixed; + left: 50%; + bottom: 28px; + z-index: 150; + max-width: calc(100vw - 32px); + padding: 11px 18px; + border: 1px solid var(--line-strong); + border-radius: var(--pill); + color: var(--cream); + background: var(--ink-3); + box-shadow: 0 15px 40px rgba(0, 0, 0, 0.5); + font-size: 12px; + opacity: 0; + pointer-events: none; + transform: translate(-50%, 14px); + transition: opacity 170ms ease, transform 170ms ease; +} + +.toast--show { + opacity: 1; + transform: translate(-50%, 0); +} + +@media (max-width: 940px) { + .market-grid { + grid-template-columns: minmax(0, 1fr); + } + + .passbook__ornament { + opacity: 0.62; + } + + .market-row--supplying { + grid-template-columns: minmax(0, 1fr) 80px 145px 90px 104px; + } + + .market-row--borrowing { + grid-template-columns: minmax(0, 1fr) 80px 145px 104px; + } + + .market-row--supply-market, + .market-row--borrow-market { + grid-template-columns: minmax(0, 1fr) 145px 80px 104px; + } +} + +@media (min-width: 941px) and (max-width: 1140px) { + .market-row { + gap: 6px; + padding: 13px 10px; + } + + .market-row--supplying { + grid-template-columns: minmax(88px, 1fr) 52px 92px 64px 80px; + } + + .market-row--borrowing { + grid-template-columns: minmax(88px, 1fr) 52px 92px 80px; + } + + .market-row--supply-market, + .market-row--borrow-market { + grid-template-columns: minmax(88px, 1fr) 92px 52px 80px; + } + + .market-row--head { + font-size: 9px; + letter-spacing: 0.08em; + } + + .asset-identity { + gap: 8px; + } + + .asset-identity img { + width: 30px; + height: 30px; + } + + .asset-identity strong { + font-size: 13px; + } + + .asset-identity small, + .amount-cell small { + font-size: 9.5px; + } + + .rate-cell { + font-size: 12.5px; + } + + .amount-cell strong { + font-size: 11.5px; + } + + .row-action { + min-height: 31px; + padding: 6px 10px; + font-size: 11px; + } + + .market-board__header { + padding: 16px 12px 12px; + } +} + +@media (max-width: 700px) { + .topbar { + min-height: 66px; + gap: 12px; + padding: 14px 18px; + } + + .brand { + min-width: 112px; + } + + .brand img { + width: 112px; + height: 25px; + } + + .desktop-nav, + .network-chip { + display: none; + } + + .wallet-chip { + max-width: 150px; + overflow: hidden; + text-overflow: ellipsis; + } + + .menu-button, + .mobile-nav { + display: flex; + } + + .mobile-nav { + flex-direction: column; + } + + .dashboard-shell { + padding: 24px 18px 126px; + } + + .risk-alert { + align-items: flex-start; + flex-wrap: wrap; + } + + .risk-alert__copy { + width: calc(100% - 42px); + } + + .risk-alert__actions { + width: 100%; + margin-left: 36px; + } + + .risk-alert__actions button { + flex: 1; + } + + .risk-alert__actions button:first-child { + flex: 1.45; + } + + .passbook { + padding: 22px 20px 20px; + } + + .passbook__grid { + grid-template-columns: 1fr 1fr; + align-items: start; + gap: 19px 22px; + } + + .passbook-net { + grid-column: 1 / -1; + grid-row: 1; + text-align: left; + } + + .passbook-net .passbook-label { + justify-content: flex-start; + } + + .passbook-net h1 { + font-size: 46px; + } + + .passbook-stat { + grid-row: 2; + } + + .passbook-value { + font-size: 21px; + } + + .passbook__risk { + margin-top: 25px; + } + + .risk-head { + align-items: flex-start; + } + + .risk-head span:last-child { + max-width: 48%; + text-align: right; + } + + .market-grid { + gap: 18px; + margin-top: 18px; + } + + .market-board__header { + padding: 17px 17px 14px; + } + + .market-row--head { + display: none; + } + + .market-board__rows { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); + gap: 12px; + padding: 4px 17px 17px; + } + + .market-row { + min-height: 0; + padding: 15px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(236, 239, 247, 0.024); + } + + .market-row--supplying { + grid-template-columns: 1fr 1fr; + grid-template-areas: + "asset asset" + "apy balance" + "collateral collateral" + "action action"; + } + + .market-row--borrowing { + grid-template-columns: 1fr 1fr; + grid-template-areas: + "asset asset" + "apy balance" + "action action"; + } + + .market-row--supply-market, + .market-row--borrow-market { + grid-template-columns: 1fr 1fr; + grid-template-areas: + "asset asset" + "secondary rate" + "action action"; + } + + .market-row > .asset-identity { + grid-area: asset; + } + + .market-row--supplying > .rate-cell, + .market-row--borrowing > .rate-cell { + grid-area: apy; + text-align: left; + } + + .market-row--supplying > .amount-cell, + .market-row--borrowing > .amount-cell { + grid-area: balance; + text-align: left; + } + + .market-row--supply-market > .amount-cell, + .market-row--borrow-market > .amount-cell { + grid-area: secondary; + text-align: left; + } + + .market-row--supply-market > .rate-cell, + .market-row--borrow-market > .rate-cell { + grid-area: rate; + text-align: left; + } + + .rate-cell::before, + .amount-cell::before { + display: block; + margin-bottom: 4px; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 8.5px; + font-weight: 400; + letter-spacing: 0.13em; + text-transform: uppercase; + content: attr(data-label); + } + + .collateral-control { + grid-area: collateral; + align-items: center; + justify-content: space-between; + padding-top: 2px; + } + + .collateral-control > span { + display: inline; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 8.5px; + letter-spacing: 0.13em; + text-transform: uppercase; + } + + .row-action { + grid-area: action; + min-height: 38px; + } + + .site-footer { + flex-direction: column; + gap: 7px; + } + + .preview-switcher { + right: 12px; + bottom: 12px; + width: calc(100vw - 24px); + } + + .debt-chooser__intro { + font-size: 12px; + } + + .debt-choice { + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 14px; + } + + .debt-choice__asset { + grid-row: 1; + grid-column: 1; + } + + .debt-choice__metrics { + grid-row: 2; + grid-column: 1 / -1; + gap: 12px; + padding-top: 12px; + border-top: 1px solid var(--line); + } + + .debt-choice__caret { + grid-row: 1; + grid-column: 2; + } + + .dialog-backdrop { + align-items: end; + padding: 0; + } + + .transaction-dialog { + width: 100%; + max-height: 94dvh; + padding: 20px; + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: 18px 18px 0 0; + } +} + +@media (max-width: 520px) { + .topbar { + padding: 12px 14px; + } + + .wallet-chip { + max-width: 124px; + padding-right: 10px; + padding-left: 10px; + font-size: 10.5px; + } + + .dashboard-shell { + padding-right: 14px; + padding-left: 14px; + } + + .passbook__grid { + gap: 17px 12px; + } + + .passbook-value { + font-size: 18px; + } + + .passbook-sub, + .risk-head { + font-size: 10.5px; + } + + .risk-foot { + font-size: 9.5px; + } + + .passbook__empty-copy h1 { + font-size: 38px; + } + + .fixture-note { + display: flex; + margin: 14px 0 0; + } + + .market-board__header > span { + max-width: 46%; + overflow: hidden; + text-overflow: ellipsis; + } + + .preview-switcher button { + flex-basis: calc(33.333% - 5px); + } +} + +/* Browser-QA harness: applies the same 390 px layout inside a desktop preview tab. */ +.app-shell--mobile { + width: 390px; + min-height: 844px; + margin: 0 auto; + overflow: hidden; + border-right: 1px solid var(--line); + border-left: 1px solid var(--line); +} + +.app-shell--mobile .topbar { + min-height: 64px; + gap: 10px; + padding: 12px 14px; +} + +.app-shell--mobile .brand { + min-width: 112px; +} + +.app-shell--mobile .brand img { + width: 112px; + height: 25px; +} + +.app-shell--mobile .desktop-nav, +.app-shell--mobile .network-chip { + display: none; +} + +.app-shell--mobile .wallet-chip { + max-width: 124px; + overflow: hidden; + padding-right: 10px; + padding-left: 10px; + font-size: 10.5px; + text-overflow: ellipsis; +} + +.app-shell--mobile .menu-button, +.app-shell--mobile .mobile-nav { + display: flex; +} + +.app-shell--mobile .mobile-nav { + flex-direction: column; +} + +.app-shell--mobile .dashboard-shell { + padding: 24px 14px 126px; +} + +.app-shell--mobile .risk-alert { + align-items: flex-start; + flex-wrap: wrap; +} + +.app-shell--mobile .risk-alert__copy { + width: calc(100% - 42px); +} + +.app-shell--mobile .risk-alert__actions { + width: 100%; + margin-left: 36px; +} + +.app-shell--mobile .risk-alert__actions button { + flex: 1; +} + +.app-shell--mobile .risk-alert__actions button:first-child { + flex: 1.45; +} + +.app-shell--mobile .passbook { + padding: 22px 20px 20px; +} + +.app-shell--mobile .passbook__ornament { + opacity: 0.58; +} + +.app-shell--mobile .passbook__grid { + grid-template-columns: 1fr 1fr; + align-items: start; + gap: 17px 12px; +} + +.app-shell--mobile .passbook-net { + grid-column: 1 / -1; + grid-row: 1; + text-align: left; +} + +.app-shell--mobile .passbook-net .passbook-label { + justify-content: flex-start; +} + +.app-shell--mobile .passbook-net h1 { + font-size: 46px; +} + +.app-shell--mobile .passbook-stat { + grid-row: 2; +} + +.app-shell--mobile .passbook-value { + font-size: 18px; +} + +.app-shell--mobile .passbook-sub, +.app-shell--mobile .risk-head { + font-size: 10.5px; +} + +.app-shell--mobile .passbook__risk { + margin-top: 25px; +} + +.app-shell--mobile .risk-head { + align-items: flex-start; +} + +.app-shell--mobile .risk-head span:last-child { + max-width: 48%; + text-align: right; +} + +.app-shell--mobile .risk-foot { + font-size: 9.5px; +} + +.app-shell--mobile .market-grid { + grid-template-columns: minmax(0, 1fr); + gap: 18px; + margin-top: 18px; +} + +.app-shell--mobile .market-board__header { + padding: 17px 17px 14px; +} + +.app-shell--mobile .market-board__header > span { + max-width: 46%; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-shell--mobile .market-row--head { + display: none; +} + +.app-shell--mobile .market-board__rows { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; + padding: 4px 17px 17px; +} + +.app-shell--mobile .market-row { + min-height: 0; + padding: 15px; + border: 1px solid var(--line); + border-radius: 12px; + background: rgba(236, 239, 247, 0.024); +} + +.app-shell--mobile .market-row--supplying { + grid-template-columns: 1fr 1fr; + grid-template-areas: + "asset asset" + "apy balance" + "collateral collateral" + "action action"; +} + +.app-shell--mobile .market-row--borrowing { + grid-template-columns: 1fr 1fr; + grid-template-areas: + "asset asset" + "apy balance" + "action action"; +} + +.app-shell--mobile .market-row--supply-market, +.app-shell--mobile .market-row--borrow-market { + grid-template-columns: 1fr 1fr; + grid-template-areas: + "asset asset" + "secondary rate" + "action action"; +} + +.app-shell--mobile .market-row > .asset-identity { + grid-area: asset; +} + +.app-shell--mobile .market-row--supplying > .rate-cell, +.app-shell--mobile .market-row--borrowing > .rate-cell { + grid-area: apy; + text-align: left; +} + +.app-shell--mobile .market-row--supplying > .amount-cell, +.app-shell--mobile .market-row--borrowing > .amount-cell { + grid-area: balance; + text-align: left; +} + +.app-shell--mobile .market-row--supply-market > .amount-cell, +.app-shell--mobile .market-row--borrow-market > .amount-cell { + grid-area: secondary; + text-align: left; +} + +.app-shell--mobile .market-row--supply-market > .rate-cell, +.app-shell--mobile .market-row--borrow-market > .rate-cell { + grid-area: rate; + text-align: left; +} + +.app-shell--mobile .rate-cell::before, +.app-shell--mobile .amount-cell::before { + display: block; + margin-bottom: 4px; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 8.5px; + font-weight: 400; + letter-spacing: 0.13em; + text-transform: uppercase; + content: attr(data-label); +} + +.app-shell--mobile .collateral-control { + grid-area: collateral; + align-items: center; + justify-content: space-between; + padding-top: 2px; +} + +.app-shell--mobile .collateral-control > span { + display: inline; + color: var(--cream-faint); + font-family: var(--mono); + font-size: 8.5px; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.app-shell--mobile .row-action { + grid-area: action; + min-height: 38px; +} + +.app-shell--mobile .site-footer { + flex-direction: column; + gap: 7px; +} + +.app-shell--mobile .debt-chooser__intro { + font-size: 12px; +} + +.app-shell--mobile .debt-choice { + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + padding: 14px; +} + +.app-shell--mobile .debt-choice__asset { + grid-row: 1; + grid-column: 1; +} + +.app-shell--mobile .debt-choice__metrics { + grid-row: 2; + grid-column: 1 / -1; + gap: 12px; + padding-top: 12px; + border-top: 1px solid var(--line); +} + +.app-shell--mobile .debt-choice__caret { + grid-row: 1; + grid-column: 2; +} + +.app-shell--mobile .dialog-backdrop { + left: 50%; + width: 390px; + align-items: end; + padding: 0; + transform: translateX(-50%); +} + +.app-shell--mobile .transaction-dialog { + width: 100%; + max-height: 94dvh; + padding: 20px; + border-right: 0; + border-bottom: 0; + border-left: 0; + border-radius: 18px 18px 0 0; +} + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - animation-iteration-count: 1 !important; } } From 0295e76e2438da18fa85e6596f9975c31d50774f Mon Sep 17 00:00:00 2001 From: Kevin Mehrabi <4044328+KevinMehrabi@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:44:57 -0400 Subject: [PATCH 2/2] Validate dashboard amount input --- frontend/src/app/App.test.tsx | 35 +++++++++++++++++++++++++++++++++++ frontend/src/app/App.tsx | 27 ++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index af335d0c..be248bcf 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -84,6 +84,41 @@ describe("App", () => { ).toBeInTheDocument(); }); + it("rejects a malformed decimal amount instead of accepting its valid prefix", () => { + render( ); + + const supplyBoard = screen.getByRole("region", { + name: "Assets to Supply", + }); + fireEvent.click( + within(supplyBoard).getAllByRole("button", { name: "Supply" })[0], + ); + + const dialog = screen.getByRole("dialog", { name: "Supply XTZ" }); + const input = within(dialog).getByRole("textbox", { + name: "Amount in XTZ", + }); + + fireEvent.change(input, { target: { value: "1.2.3" } }); + + expect(input).toHaveValue("1.2.3"); + expect(input).toHaveAttribute("aria-invalid", "true"); + expect(within(dialog).getByRole("alert")).toHaveTextContent( + "Enter a valid decimal amount", + ); + expect( + within(dialog).getByRole("button", { name: "Check amount" }), + ).toBeDisabled(); + + fireEvent.change(input, { target: { value: "1.23" } }); + + expect(input).toHaveAttribute("aria-invalid", "false"); + expect(within(dialog).queryByRole("alert")).not.toBeInTheDocument(); + expect( + within(dialog).getByRole("button", { name: "Review supply" }), + ).toBeEnabled(); + }); + it("keeps wallet connection local to the offline fixture", () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index fbd44e40..9a49415f 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -43,12 +43,21 @@ const compactCurrency = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2, }); +const decimalInputPattern = /^(?:\d+(?:\.\d*)?|\.\d+)$/; + function formatAmount(value: number): string { return new Intl.NumberFormat("en-US", { maximumFractionDigits: value < 1 ? 6 : value < 100 ? 4 : 2, }).format(value); } +function parseFixtureAmount(value: string): number | null { + if (!decimalInputPattern.test(value)) return null; + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + function riskBand(percent: number): "normal" | "attention" | "high" | "critical" { if (percent >= 95) return "critical"; if (percent >= 85) return "high"; @@ -516,7 +525,9 @@ function TransactionDialog({ if (!market) return null; const totals = calculateTotals(markets); - const parsedAmount = Number.parseFloat(amount) || 0; + const parsedAmountResult = parseFixtureAmount(amount); + const parsedAmount = parsedAmountResult ?? 0; + const invalidAmountSyntax = amount !== "" && parsedAmountResult === null; const valueUsd = parsedAmount * market.price; const side = actionSide(activeAction); const requiresAmount = side !== "collateral"; @@ -540,7 +551,7 @@ function TransactionDialog({ const nextUsed = nextLimit > 0 ? (nextBorrowed / nextLimit) * 100 : 0; const steps = operationSteps(activeAction, market); const invalidPackedPermission = market.standard === "FA1.2 packed" && (activeAction === "Supply" || activeAction === "Repay"); - const invalidAmount = requiresAmount && (parsedAmount <= 0 || parsedAmount > maxAmount || nextUsed > 100); + const invalidAmount = requiresAmount && (invalidAmountSyntax || parsedAmount <= 0 || parsedAmount > maxAmount || nextUsed > 100); const canReview = !invalidAmount && !invalidPackedPermission; const context = activeAction === "Supply" @@ -606,7 +617,10 @@ function TransactionDialog({ value={amount} placeholder="0.00" autoFocus - onChange={(event) => setAmount(event.target.value.replace(/[^0-9.]/g, ""))} + aria-label={`Amount in ${market.name}`} + aria-invalid={invalidAmountSyntax} + aria-describedby={invalidAmountSyntax ? "amount-format-error" : undefined} + onChange={(event) => setAmount(event.target.value)} /> {market.name}