From 3bff2510108b4a16197dbe7e46c4e27e7d529ac4 Mon Sep 17 00:00:00 2001 From: R Midhun Suresh Date: Tue, 21 Jul 2026 22:07:28 +0530 Subject: [PATCH] Auto Collapse Behaviour - Collapse left panel on window resize (#32964) * Add functionality to UIStore - Make it possible to query if the window is currently being resized - Emit WidthIncreased/WidthDecreased events * Change resize behaviour of panel So that the panel does not become smaller when the window is resized. This is consistent with the old room-list design. * Introduce a `CollapseHandler` object This should be used by the collapse behaviours to collapse/expand the panel. There's a good reason to not have the behaviours depend directly on the react-resizable-panels API methods: We dont want the collapse/expand calls to conflict with each other. See the comments in the code for more information. * Introduce a base class for collapse behaviour logic Behaviours should extend this class to describe when the panel should automatically collapse and expand. * Add the window resize collapse behaviour * Create a central file from which to export all behaviours * Add a class to orchestrate the collapse behaviours ResizerViewModel will only have a dependency on this class. * Collapse panel on app start if necessary For eg, if the app is started with a small window width, the panel should be collapsed. * Wire auto collapse code into the viewmodel * Write jest tests * Fix e2e tests * Fix lint error * Fix e2e test failures * Expand the panel before taking screenshot Fixes incorrect narrow screenshots in RTE.spec.ts and CIDER.spec.ts * Make comments consistent * Move tests from jest to vitest * Fix lint errors * Improve comment * Remove variable * Remove mock * Fix comment formatting --- .../web/playwright/e2e/composer/CIDER.spec.ts | 3 + apps/web/playwright/e2e/composer/RTE.spec.ts | 3 + .../room-list-panel/room-list-panel.spec.ts | 7 +- apps/web/playwright/pages/ElementAppPage.ts | 25 ++++++ .../composer/CIDER.spec.ts/narrow-linux.png | Bin 2771 -> 2724 bytes .../composer/RTE.spec.ts/narrow-linux.png | Bin 7782 -> 7747 bytes .../room-list-panel-smallscreen-linux.png | Bin 13343 -> 26520 bytes .../structures/ResizerViewModel.test.ts | 26 +++++- .../viewmodels/structures/ResizerViewModel.ts | 36 +++++++- .../auto-collapse/AutoCollapse.test.ts | 72 +++++++++++++++ .../structures/auto-collapse/AutoCollapse.ts | 72 +++++++++++++++ .../auto-collapse/CollapseHandler.test.ts | 54 +++++++++++ .../auto-collapse/CollapseHandler.ts | 85 ++++++++++++++++++ .../behaviours/BaseCollapseBehaviour.ts | 41 +++++++++ .../CollapseOnWindowResizeBehaviour.test.ts | 52 +++++++++++ .../CollapseOnWindowResizeBehaviour.ts | 76 ++++++++++++++++ .../auto-collapse/behaviours/behaviours.ts | 13 +++ .../resize/panel/LeftResizablePanelView.tsx | 1 + 18 files changed, 560 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts create mode 100644 apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts create mode 100644 apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.test.ts create mode 100644 apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.ts create mode 100644 apps/web/src/viewmodels/structures/auto-collapse/behaviours/BaseCollapseBehaviour.ts create mode 100644 apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.test.ts create mode 100644 apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.ts create mode 100644 apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts diff --git a/apps/web/playwright/e2e/composer/CIDER.spec.ts b/apps/web/playwright/e2e/composer/CIDER.spec.ts index 3c78beec43..d50b24f3a0 100644 --- a/apps/web/playwright/e2e/composer/CIDER.spec.ts +++ b/apps/web/playwright/e2e/composer/CIDER.spec.ts @@ -85,6 +85,9 @@ test.describe("Composer", () => { test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => { // Shrink the viewport await page.setViewportSize({ width: 500, height: 1080 }); + // Shrinking the viewport will collapse the left-panel, so manually expand it. + await app.resizeLeftPanel(150); + // Now take the screenshot await expect(app.getComposer()).toMatchScreenshot("narrow.png"); }); diff --git a/apps/web/playwright/e2e/composer/RTE.spec.ts b/apps/web/playwright/e2e/composer/RTE.spec.ts index fc79208cfd..a2f0990907 100644 --- a/apps/web/playwright/e2e/composer/RTE.spec.ts +++ b/apps/web/playwright/e2e/composer/RTE.spec.ts @@ -224,6 +224,9 @@ test.describe("Composer", () => { test("renders in narrow viewports", { tag: "@screenshot" }, async ({ page, bot, app }) => { // Shrink the viewport await page.setViewportSize({ width: 750, height: 1080 }); + // Shrinking the viewport will collapse the left-panel, so manually expand it. + await app.resizeLeftPanel(150); + // Now take the screenshot await expect(page.locator(".mx_MessageComposer_wrapper")).toMatchScreenshot("narrow.png"); }); }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts index a04f8e851d..1630e44a4c 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-panel.spec.ts @@ -11,6 +11,10 @@ import { test, expect } from "../../../element-web-test"; import { getRoomListView } from "./utils"; test.describe("Room list panel", () => { + test.use({ + displayName: "Eve", + }); + test.beforeEach(async ({ page, app, user }) => { // The toasts are displayed above the search section await rejectToast(page, "Verify this device"); @@ -36,8 +40,7 @@ test.describe("Room list panel", () => { test.use({ lockLeftPanelWidth: false }); test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => { await page.setViewportSize({ width: 575, height: 600 }); - const roomListPanel = getRoomListView(page); - await expect(roomListPanel).toMatchScreenshot("room-list-panel-smallscreen.png"); + await expect(page).toMatchScreenshot("room-list-panel-smallscreen.png"); }); }); }); diff --git a/apps/web/playwright/pages/ElementAppPage.ts b/apps/web/playwright/pages/ElementAppPage.ts index a7394f8282..4cd8ff0f76 100644 --- a/apps/web/playwright/pages/ElementAppPage.ts +++ b/apps/web/playwright/pages/ElementAppPage.ts @@ -96,6 +96,13 @@ export class ElementAppPage { * @param name The exact room name to find and click on/open. */ public async viewRoomByName(name: string): Promise { + // Expand the left panel if necessary + const separator = this.page.getByRole("separator", { name: "Click or drag to expand" }); + const type = await separator.getAttribute("data-separator-type"); + if (type === "bar") { + await separator.click(); + } + // Make sure the room list is actually present before we try closing toasts, // otherwise we may race with page loading await this.page.getByTestId("room-list").waitFor(); @@ -387,4 +394,22 @@ export class ElementAppPage { await this.page.mouse.wheel(0, 1000); } while (await needsScroll()); } + + /** + * Resize the left panel by a given number of pixels. + * @param delta The number of pixels to resize by. Negative value makes the panel smaller. + */ + public async resizeLeftPanel(delta: number): Promise { + const separator = this.page.getByRole("separator", { name: "Click or drag to expand" }); + const boundingRectangle = await separator.boundingBox(); + + // Place the cursor in the center of the separator + const centerX = boundingRectangle.x + boundingRectangle.width / 2; + await this.page.mouse.move(centerX, boundingRectangle.y); + + // Drag the cursor by delta pixels + await this.page.mouse.down(); + await this.page.mouse.move(centerX + delta, boundingRectangle.y); + await this.page.mouse.up(); + } } diff --git a/apps/web/playwright/snapshots/composer/CIDER.spec.ts/narrow-linux.png b/apps/web/playwright/snapshots/composer/CIDER.spec.ts/narrow-linux.png index ad6104ee09fa706173e7fb34a151fa511a564f78..7fece3ebe270481ab0a53e7988ff3b8f32ddf22b 100644 GIT binary patch literal 2724 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 literal 7782 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 diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list-panel.spec.ts/room-list-panel-smallscreen-linux.png index 8690f3cf316df88fb153db14e3fbac17e877851b..ca488e79d2600d41a473e613756cffe24111c85a 100644 GIT binary patch literal 26520 zcmce;cU05Qw=Nt|Py|F&dQ*z1l%VvkAWfQxbO^oo5_-TwuPV};^xk_15s(^+B=p`w zAfW^Tfxyl8ocFihd)Mvf-gWdqg>2Lby^+gR(Az=FlyS}HN@#7VRYUN+819dn?LK28wwk;y|R6!b^9%VjFFQ_ z<=yQkf3A~5nm&Gv++4XZPy{9qqxo$;mM>X-%!P%8zxCv*fi}l{gr+S300A>f4uV8# zUoZs0wqE-a0RYF3Iskx=eqTWVK(~}K5CAY^&8H*Oq?#lL08(xtNC1G5|Gav2#H^4o zb!L)X!-jWW(44Qd0y#O}Ifcu-jx4$&n-l=p;i=0{)=*;HRk5_Bqhhe!J9UOsYb7Ox zSSsk-jJyK?=tI;pm|`W~n-)RRFGPD)T0!l_dU9$3H`MUU?%^H zW`A+=sMn{I=mr3OGT}9WnPctK9EM^h1SNE5;$$82a;{udKTHQ6TGceSrYk8O1z{7- zN;wdIG`vQHuE$kmk)V2Ov|{r*{mW^LK2vcsU*qV8tD|L;H7MXe^6%vl}K zbm!(*OL36}E~}Rpm6{4&gwJFK30f2;-pA zgJ2!Jytwx~j4lm!7r(D!=YH}KMBd3K$ag{eoVJ*m7|_jvW4N1Cc&Wa{s%ND_(24}Y z_D+p{nCW|z_4;b2i2&Unr>`GF zg!IBvJ(WWXxFiS``O^_A^k{KtD26Y<*zq6L76iNn&H;rlU3&R(;k|wO1hwqIIhhGq)78<93JvAD0AX zt#8^>#)x1~E^0$Ckr+e-|G(K@oC@36MEv^r-;?uO-}v}WI4EAI5vief3L2;vGf-PyQ)UN>hF>7>*In~>cFO_ zPY?(g3|3j0CnYVtrKlj&XtoqV{g2ahFX{^sB1e$c(;sZX-!apA%XW;65zpLO7K*ZP z`{YnAF$sz9LC+HOT8>%N8|VQ53VpmJ6s~MrZ;n4O`^kzVkm3-fXH&Hs&v!QbG$h z1%}Og?VeuY@fC&@u(R>->&unHlu*bs3W7HXe8BK^@Ppbh2YFik_Mf^PjV6^?S+km4 z5V%<|EJzY#@aOrnXXp6c?YD2=#tO!>NWXzkUda9X1eO&?x-OP(^^@JTJ?rW^bK0A6 zr!0)%kD`@}_V8qC@*LQ95}6k}?!Yz?-2S5^=5fcvJDc$chJFMk(v(%&pTZf)`9y&{ zzt2VgntOebEodgVqcDtM4qku5F>mKDzPMY=)py&jB~OvcTaebz=p4 zCA_#(T_rKe7`^3vf}u5KxbxMQ3w2ni#9vAhjZA2kFR?$ z2=ID;>=LhTStS?1jp}(+>Cs)#h{j;sbFFIeq%@``3BI4Y3;9hv=ZRhtg69ik!uFPd z#-A;U&J^OkKpGij}Zu0@?d`9#s_IpFG#Q*+|RGF_pqkCPXHqm**pqF-H_Ad zm3V$;{?aPvM>el`%(w4?0wK78n;*pqCVpdXf4(W(6Ovb(Z#eH(RS_yZE-VB}(3Ktt z&MO%#r+Yynz%W&m92*-;Mt6gLEo<9#B7UryDs}RW3o;Eve=JJeeX|gaO`|^gsaxi3 zH!kWP;|+Gceup&S50|Io0E~0Ynax=BvvKUo<(-N+%Pj=r{D=jr2{s@ly@03~4yWah z-~^0IsvKUo1Su$>+McKG*3m4R-@8TO2C);buXT`lmS6k+#YjdMSdP3($T%shwz2UE zKpxr?uVEr(-~j8aMC~wog~}$g;x>ec=(`CH!_Pm~A4z_Go|I(6jCxHDl?+}IlOE~| z1yH;YEk&Mog6k`{^e)T~(}hn;jPAnA4u`h{+)f^cggoG8{q=*I#g--T%;%8z4!4`DHY;mTwXBd!SbGlOY5Rt;n^hJyjOtF zQE-fp?bvNDfP5suY|!IeM`cvTuI#o`7JHtmAj+HtIaPys4qp z=wz8F2l+k2>eMDfl*w}x z5>5CGT3)W6I^!Amna2)%mVy!h6kOXu?ZqwTNyD#CG&@=!qgS|y1r9L#fmQD73$mBn zw;Zny*fW#fMsJVCUrk+C;PGf(;ib++aS%!wV$+QbHW$t z&M(*;O@v?53)&Et^6krO&q}QT;5PU2)2q&6Pq{|7gjHi-Fd1;P=~(`@0fG|KlKchnlLv%-$IXQ~D(Gy0>>RGJP`uJux<|Xkd8lJOn@cme${$8JNnIH` zuKSc_QTzHmk!mWetc+Vqd`LUV^n#j+DLr3PHs&@J2S9#=5T2aktkXiq5F%zjggW+8w(mO@&r-TCLe=FD}bQt>RPpqS028BQd zb#I6K0%486w9<9IdcozfHbyu3r(b%Z}yKcBo)3b z8g8xMO;vDJ;dP1j{H%KVN?8I-?EejmX|(eZdpDtQgY~DSit$_h{2$OJu+|(s|X;auglld(V1?Q zt?2SwFFZtrUJ~O(y$|27n;1#XW0(AfC#a=00r-s_o9j!sDh(I>_^!za4wbQYDJ+sa z?Y6EMt4pN zpQNE!E6R|;O@e@Wk5ez-Gi(pWyG$bhwW5FkzD%7}KsYy1h`%x+(D$iE8`0HkttWMnXRsb7_zRMx-}j}~2s z0X6~)Y4}$oocWc5UrJ2`q78ppslL6_SD@eq61+^4L7yR8U@*JK=Ej_YO)IR9-yvNwp6p8mu z0R3&gaDkP>n{{&if!~Ase5<5n%Z!BwLgDTV3Mj;-5zE$(?Jd>mI%3JNgu=^T_V!3= z$>jr;+f}BKCf%2PkH<1x*tUIdiRBqmNWb#$>9*xgL7^}EXhILOgs`7;AAg0fHtWsi zNRJmA|ESVuu!FDOE>3EsYEhw<5qouIm~kV^Z!;0F7qlZ<&rE#7tC&+**`601*bXUz zA0(lV>tky?;mM^^NV$3K4r1c|wH>nVcgjFBKxLg~h%2(ga1IC0Q7wZMB|nn-<Z?t`4^J2CPgdA0lWHF*~QS zym5E#3OwAAbnHohc3}>eM%rj*-aT77#+8z+=3Ei`^gfX9Wi>kK5B?p+pA0iT!&=Cn zW1je~Eh{kQwwn3kgxK&SgFT+Q9iAUx7$XHX$Zi2t@JL76C4LVh z%9Q26BOrxqf_8lW;r;scNQK9;=Tw^MoW4&scNfxQ>lxm-|{JsCL{CPjm5Bo zHIdGC@3w=FFFksvXu=kAb+_0SPXB(FEo2~81FxnhzNkUUECPpGa+jauc51F&aEq4T z%B~6%930&#`Rx4`sAWM=peLpF%x$BiraO$#*##a?^c@; z-5cj#r0^uKJ5np1Bf<`earnv-xSj~S!#KxEVRVj@%)UluHf7<}^Q)YP_vT_ogRd%! z=V$CR8?mu^o%~OnNl0Z|R%7F@PvWtrri>FJxa^HU+|fnR{ASE^+l#H!9!sVJ{YxV@ z{Q7a1Yapt|+n`MB3by64tMUUgu9qs5A0AJc+9BqBimBFr^WBw>;cIaPaUmMkeUhhfHFB@+xGAEK}`8u7Z zcd4Fn4xGtV24V+ubcp(+y@ZU<{D&`6E9BWbbWu1P&o`bHN_;^!(`&KO$!L){te)+a zrS{vIWS+K`hkY}%Xf$r%!BRh+a#>;*WS3n2hsZ-)=KRFe>LzdeddZf#z%IEd{%}w) zUi6lnU(!=kmd(kdal!#nw#Yg_LMr+VP(vTPA*so~oMfr&iJ+Q4t)%c9YP%ph@Oig! zc~Q}H3O>FWr@E5M&0(a$xqYq}8sMDF*hTp_YwXW=X^}>}wfe()IoJ_b=mP=CIc$)V z?B(t|EOFRR6Vs{rQohNIg{>c z2I{4NwyrjQ**Fq{S2YEnn*K?rk>m%c10H^3aia zsxFCqIsp<}r&n%{fx^n^1D!rQm^%le^`sKF6mN_14QGnntdd24@=H1 zuArIp#;pmUB})YtYI?|T7#hwDdA7B~lqC1bIlUi87sl^j#%3WqR`6_sz#l4}aj4nO zk`}{4t&OdwOy&IsH}{XK+ut*9&;jk}Rt)GKnh0CYohzP6_++dLH&#H$XW>oje#BC? z=$eX(yf=&EW>OK3L*#Reh7kMELd(=Jj~jGh4NjU($y+bUve2J*J|;;IS!9v}QlTzyJmN*nqIa)m{2I?}x%fkbbxW6@*-_2MpsiH@0)6L(<3$g}S5P-I zzYy4|i$^qQLRY!qjyj;I_=LPM6OGxrOC>)>*2qxy633%z|0{FY)5^I*^~yODM7cZE z3E6achs^-7+^Gk9Z()vnXMR>X2V`Z|sU{~^PjYLw+3pyfkAO>fz!&?7(6BHMd>rCr z!nB&5%TgO%)zCv70=&JkktLEf__YOH@ASGpJ?Q;{NK~&k?pI@JT}u;N5SXwJ-?N=t z8xXg}zVze%EidAf{RYybSQ8dX_i&V6uy z=41hk4s0SJ<%mnDs`7*a(|H8YajS8ZM)WT)>)mJK*b_up729ID5)Bwq`9{OW9+S)W zd*e5Vs_|wX{|q}e)A;1I;rVmjr()+7bh8J}%Zj$5_N~C}@N&{#kF_HYj~wB4LniuI zg}H{ZO)GADL18MF*3FI)d{+C1l+^V)4Z~Rq1q;!g>AS*9|sDXA!Tv1_Fx}&WA)zcryq31|qdz z)1qV|nxa2!P5wZg5Ii0ZqW8Yghxvw_iv%a?Dtq2BEVJ8ZqNn7VBkOOxyP=IV;uw^6 zvA7_1rHO1~CjZ&|NWvE<7Y?4V@^-|dOtS)7+oQKj@(1QKYel@j*D>&r?>7~_{-(WD zcDyh$%x9&A6c{-gk*9l3GZ|A313@D~d-exfD7yD=P09HalVkVxa zv)ju-w1;f{>Qo0xwfVY4V9%=ikex5Gd`7^hSZAhp`t8`Z49UvtpZ9{Bs+{$6AI+2_ zrp*KL2d)!l`?!dNYc1^qsG_1DV(rUYj1GQ%O$x>z8EXYyO(ZRkKK@+IEho`QM7nbN zMdfkFM}vP8cL&LuMSd?Vua-h<^&f?=G`iOa|!jK@K8NKbY0MT~y z;e9enzVJLr!4mQ9AOY`$dQi88npw!t@v9A_>AbZ6x$4M4^C|Jm|& zpjypNi!Ci_%F8ws^LDoKDXs-%$uH^88dEjVk+HUWL*h^XX%% zdebOr*Ba7wgpJ+onxA}+C>=dFztsT_O7om4j0a_*uzBHKe9pY~SWn3deLJ=2ncuXEu8>L>rwW!aw~)@|e=QW!iSs zZezXym;Aj&oYx#hX%}Ilm!j$3EK+!Dx#d#Hg_=5%{RdOTdgH{IbENt+v8h)xyYazN zm}$?)CP@S7nE8FBxPAOiLEoy(=bFa;?b%qC2luYh^<>^sv#I#61u5tuPXj$T6I`GP z`;Fl{w@%K1fv1@r+*mIDhYL`7S*XwJeAl)M=Ib4CY3J@&^FabM(S?OiUh4lCEL%8_ zD>Nj$6qEA){omtb{2#*#|IYS^CcJFn-cJVD&o+d(EoK?E*};k%n^K*N-Pl|zgng$W%z$8^-bGj$x1Mjh5oa0JJaM~; z=t$COlR26nB}aAc@{9C0x9PL;fFd)+zf;)EJwGg|3!Ua@|6E8Kzxus%;zuMjt37aP zB4C$Sb|7faRy#=>a)oc2gd)XW^XPJ(oop@=Lqb~VQW6xCYBh8J-rpK8i5g=SkM~20hk#Q5aHt*uLrG9xsLWAMW5TZN$0?IKTZpUxOw(d z-gq=<46uk#xbC(WmjrPb)IUS4G@1-fD)N~B-J(f2iS^z6zHfxhXxCttPj&+B*{Emx z?~lx*S@>Y?Pk<(!{1Hn2sztnF>E37M`i3T&VsUxrpW;WND7~!Uri&dX>GFwT3YFQA z?ddwJ1^M6rnH1<6{Vu4VQQwoSK1#7kyh<5VP*d;WMi!UG!n9$rxX;M6={GPn3EeW` zYIkR)jt?05~ZqQyPim!AcgAlRzikkgc;@ z;WSoWad16q$_f_|r}I8&+-U`0U- z^1{)wYj@}-`7;K&RSPKpUDRCZgEnm7w>+3;9dBe|>p^l}sNZ*mGP+Uh3K`xw;sYqS z;0>rs6X@Ka=5Nwvri0KafW@-j(@9p{z_|5#kkP|0?vlo0it4f0VTysbccXv0O|mwvwFdZ*Tk^^mx0m%HN;6 zTWNeBSvNZd{HWQhq~mqj>pUB_-cUdjO{?RzGt6FN)r0GkE0Te~M1a&G0bT7a4GoPF zw%0P7J9kwv2XFm^1{L+*BH(nml!{E`pKmU@nL}vrZ0?(JZfv6OpH;xnprnQ}a_C~H zrEiU`%;lQxdRbi^m)H4T@5OJ0M5JCgnczG0gpt@#2< zu3Fi*-O|4E8gz9Z;7bf^@#_#AKd2+@Ibzminxg`I6Llk>E=7|=I)YS5gBIf5xb}CV z9&q&@J+@q!IIHA?%yYE*9ko*s6!(TtvY7`Q|Dkic&1sd~9JRi%5MiuaCT3c3SZDbR zIQ*l3TPHbbTEmmAB!Nz6Ql82&rpe99b$M`($JWPs{L3TTwab(cH_@!hqu)LyLNb~` z!BTen3kJi-55%gK)GdzK=&wr z(L0FFcRle@6WLda+DUqb3T*C&S*tw-C)GEW5qZOJ#T*wWONb@e>Dc_QhskxinCJL5 zd~_Oi-K%uz3RqxD$%!jDA$$>I2=gFC}P3^tl(^f#pM-p@lqC6#RYxxy%2Qo+dK}BDkQr! z!~umx&&|n`!UNivmSwXnC6l#*suvn;Cf*5bCRn3jAD&puOedALs&#lgb4oiW?2B@I z=F2zmKMbmQxp>9eGS#Whb1kpTZpgE##^@5=i+_}y{wzhymEX#Ob&}S%%xi^5CY4-aGJc3&UTg1c+k}^ z9^4rC%Bl$jrxCKArLzi?KOIt9#L)2YB&WWp`|M<|ZLV@kqb>@yIP7mG>oD{mba{CTaj@$dsu zwN%eawGSoG599bx>J+hhb51toeVA^9RzYgSnTqi^L=-qDBw%k z6?F({pwXmf6md14f0TUHF)4ZCpY6G5E7f=igm$zUvYGs7V2Ll%EGA7foN5#aAFR=m z!=k!y;~n``6>}QKEi=pG8-Hj*#^Pwl;RW}yt6(s_ZF=vL>5ZQ|A15?)jhE=n_tv#p z$QG!I13snJ_p7tLMqjOIFX2+s^R$gGrG;JSR%${wla( zV4+W7`D+7+wyM9@cC|+q^^k0hDF`@vwG!(D;h7R^Vpz?oyJT)dVlC$5?EE4uofYC2 z!EcJDb|Z96O8&B8%Bb+`dGajTx!QVVcDg<~j8JPU$GBQ&r6cFaa^0fbhI=n_dxNUi zp$9dRes4{@LCd~7UZJ|bc8s@w0w9Y8m6PLdY>-klCo`k0s~Yi``wDpB(DzDb`B*RT zbg93*J&41o6u$TEv%8c>%bYK|vI@grhP;r0V-rlCNw~lDxO!V~8D+R=?U|Z+!X^)D$%2jNMVXM__ycK6i?ozt;{k&mpXZXVq6KJZ))IfTny*9Kbvhv1WDFY53F^ zyNg7n5I3uV5zCfXhKaU^8+TYujw+irx>xJ!*$Uoj>pkXM;NW{m!mHm^A1|#qD*17p z=F!6$Z~8bWD`Ly5DqEv6;A0c=nR{O%yUL~&mYKm>#40wOx!Cpkul5xvg57T@cis4_O8 zD4bq8Dfk%qsAEOOtmT&U!_4FTtl@(2+WFW&wQJ%NPuk`b{j|U)&CxY5ZM|naQ$Cz- zzTU?EPd8#2fGf0HuyjNENse>p15`ia`#k?;>_Gdl5?pJ857hOE;M z!vao;gM;V>=9YqN3oE=Qc({RFI1N5asDaKBgL^(ULkn`IHj(y#fEVPfH_M)lzt84> z**vUG`9p8x?Y`Z5f72d6EwnLur-7A&!i$=%xP0W<(~_sT;U20zUs%Hetq&FBezsL~ zHnvt9Zrj?<*vyIW2vZo?ReqxvEX>gez6oE3!y@*6C5= zcYhDPMbxq6*m@ylVt6eAxq0(9e-29s4ws7izE_<>ATaKL))1z2(R2^b-3~JIULnC4 z^Y^M7$vis0`%3(#J-D8Ouq3#SmCkBLX&xKK{TwY*oqCqS8IBu)cF!Gz+dL zOOsgCyWYLFooyvy2&bMqx*>&Gw3!vZ!@?jwn?7JOPu8I{nUz(3jCtqCcYrj{z-2b0 z5*F>6LNwBxjX7RWJP*JFv)eoOBlMSZF20cdEpng7S%#~8P0v={ zwaZRw^ea5P>DicBGpehd#4%RJ<>Rg-J{q8+{lmNY_nEq!?4N@QO_0ARICw(6m4QeO z;sMKs%8cVkWq3>2-DH2FNU)I&O5>B&xju1vP3Z&oLqZic_NjGrqdU)%BZkfG87WMS zDsIq*;264gYn5(4X{+W8qaNmW39AX^?HnIH^liAJ@4@0I%Kn7~fXV1BnR%+dB|pco zDDVgRv}G+#OQFYSK7Mx=-`xXVCpNj}gCPy(fl@V=6L&fB5Bw}MG9$`!8=oRNG|QDV zjIe=jMcmqW9wB)bCOs^NYx!`P$NpyDRScXlhqGC@CQ;sohJY=SR64>z-~ zPm={J8-MFRG@p=Qhqk7-(hZ05sLYWAl&gXNY?u4=ww{uJ(vi=`0vC1MCOTcSBd^P4ZfIkk8?Y$Jxw-f zMamSJ6qO%)WMYco%VL`k4`y&AmxGqPP*r4Y*O}bz%g-iNrN#TBJ#kYl83XCq_kD-b zz|EP%Kcmmrc|2AQP7-~S9!Uv3vNhGHSfyOJtXkhwet5h30ssa!yEgw)vQZH8%COyT zxjg4`CmIXWRz8{N^EgOpvM`W)hfJ|gN(gUiTkT%okHdk^nW`x*fwScCzGAhE(}~kGCqnAne1?Z3TYXs&CL4{IGICIph(o`aV{TcQ0Q0x~ zesz-$@4@%kE>N>|KiMAw*_DgFP?w|28R2hvZ7c$7Yb<}IPN8$aV!X;I?^R4nEGBXH zS|Mhl&pqH^;shwQ5SRs+cZUrrXomVXGoA_zTd!#k%j2Ee=ATxYz21{()%pF7v4%XQ z%ghf@MOF3T8hu}WnasPK6uhsl(@-csjpe_{*ko;ev`X#TT5{3=E_KjhanERwvdOyo zfetGja!9P$&ZY6RZT;HTlE++O4y+hQbD31k-7{fs`lw_YU|)HNBo4@T+uSW?$V=zx z#L!e2OO1A*@zvv0wCd{WR@G1c#U~d~HbH#jyS}G4F}b+s--EH(ay zHIMVjIn}bdtShs%Em9gy9b_8Tgs}wcS;LW5&)R-7aAcM%AD>&@eULZW4x+F93KF<2 z^t5W}uj&5yjn(NwRGOMg1pl0ML*V&dDhB~e5I1x4I6E6zm+&t1Oi&iD#4^Kbjj?V49b9wvahx)ws-Ci481A}`Qk z9Ec2gteN|2xW$h!6o&JTI1&9E=i9(LBZR!9!Xk%aZh-uJ1Cs;s76IY^pJtu?*NQiG zypa-XIp`nwex;pE`|=#cdRN0s0|BHC;KA?M5OOqnww7?TX=oqs|!AMI17BaQT-$?UZ0IoAW;=S%0(a)9gm!zLM%_$ zJ4~-!J!!3zlP@_(xgi6dlymo3sn?&~n{!Yd=0u2xi$s!Fr520Eu1jeCxX0wN{zh$* z&R!IU`Jj7{5u59Vg*lbBfRg+UH(={Zf({f+wj2xhI;56z_;asLQZhJ!-nQjm^Bomp%dSN}Xxj=!aHO`=(e1gIWtj=#0O zFXnYciGaoYs2K2eZOwnF?p0%a-RxmBwJbBUZwcRjsr##$EKp-uIxazAY&4CKH@FMx z7Kp~PG7mGU;QqvlewR5cTbP3Q-W`7S{HgvQSk$R&qy(4axLzXrdp*ARqg0k&x-LGZ zdwC2zN8Sp1gDr*G^=XXtFQp~ioH3Il(57G`?O+jfceLNel|}g3?ay zCgtq9tK_25pZ#(z+XPu`e#g@JR)1*mdvB9BH*>n`f(D|- z3M!<(n-o9penSAs!%{`&zF<^WQrsbiSvx;+iR~#XG~ZN0ja$~fR5PqacSQQpQX-GUC%^v-YEzhJ*F~F+gTsV8F1+;C~-fwT&PfuK7IrD zbEkr0Y?oUs%2vih{+{+Iky zFjKjcvumF^vz1pQh`37bi3VQ|Uv7_4HmGheL4>Tfkd!N55gN7nV6dRv${^G2t&_v$ z5f`(9!)fG*A>>5?ou<>MjH$ovs5Up5EW7zcgQFge2LjAWOBy1<6P zsu7^9mA)ppvi9+@N2KpZ#H^`Ymnmq52094vP$x60v8T zeA{VpBG1P(OdEurdwKt9S?sSmqDgKH4@`#`*wRSZW~^!SMY${f;pm)m&<1R6E7u$u#Cn-StUDt*?6`Lfh?tmxAJ0Wlq%sy zWiZ!+R2C>*7dz5#;Mc1V%;%gnmpo+Kn}GOJsjQ}OTQu!U^ZvPN11(UUH}Cp4Xjh5* z`utSJZDVcfnIONcTghB6)O4Qy2=gw-y>F=vyS>)}Gp5y4HX@Fc>Uia_Q&I};NJ+S; zezXzSAoBN*s_?InKj6qQsQ{+V@XnzDR9|?av#Y#k^xwZQ@C3cCQ4+uC8^&l&BN9Y# zUheYWH%-!&HSQiY+o9unIy~xpFDSJCmI&Sr`e7cJ0*3FwbsuJ{RTb#)chNT;Ga6nz zF7>L(|HYSkeu#oQRt>6-0i&sW;)9PyZ?_0Fsig%ylWh5n>a2CyN^pE@9SYx5AI=SJ zgq@yhRN|iVI6ij{^phAgX2BpUY4GY%{)d-Tw6b1>wl?cBOT`mKTjx{7t1n*sFE zmSCr->2I)Lmi5ZYXOhjQzUFOBpBq1T+e*{W1Mq~+Ok59`5OiM9p07FV>2#BcwnD`u zvAU=WAindy71YL{%XC`a<$yoJGwZ1Fq&bBM!7DxX=hKRqlqY$iegDnrYxV-zG~ZWNO)l<&L6XJfV(r3c`vJ9TxBj6I;Zl)f&weK? z_xTH#zV{#Zp)H4SrUhuR+<(Y21?d}J5@dJR0<21eHY1dlzxg9z^^1u5!P;=m#!7Z+ zN!6F;x*ssDBZpz|%?CTK1qNIKX{{Y_l@ zIolh3$LOl$%hMMZmz$Mw=gS1KZR3uE#1eEj&-eLr>2|pKA68U#*hXCJ|QrwmUTK_GF`a<^iO0x^i)a-}Sn);p=;l}eOk;eEr+y09?CibCE z9DV*Oxpw)gK;TmeK)SuSxtf3hg~SeSh{-3pOd-GPQ%aKhDpft@CV|a2{O<9SbjT0x z{9HBzm9F;8yMJ@FZ@sU25PS^S+>$ZNn`uv+#lQ!NRO_3jWIb5&l-`cUo# zrplKT{MEuQ=op}8c)FQZxoyfT+j?rITN%eDXo-ju|CM(6AeFDoq4-{3aECM^Hv>t_ zj2@`^vy_={wh+{Pq9;%NOIWv{fhP zhV&g+r}1*l+-!D8{chLwQqYzyu{;snommxp9(EXIK!g*Vb{Z0KTK2fs6mZ4O)7=~) zCD2*~jzhJT*&tpxfn3Lkm6lNrh`&7n2K6r~mm4lj6zK)vq`nbn8hxQ8FsiMZ3pSn5 zDjlX-B79C#m}SxUeQp7vVk>S~;jHtWYz7)Tsc)71*acL>MSQNG+X3Ml90?;wM+{5I z==~&PJ|qMi zB)J+i8?E@Cnmg}sxcYAIOGFY8C5Rd=LBeR!`$X?VPY^X)7^3$Yy(M}ldL1Pgj2^w$ z=tlHTFpM^w?Y^(~d7g8w=RMb3{yBe{%WQl0{_Sng`mFC---Qvx3s4>|KzX%Ceicgz z2>>ttARl^v@`nG8gSfljR;0_bn)7I&Cr+d3aB~F_#Rd@P7M9uJn1lo7$UeRVA%shh z3$6MWb5>P6b7uA}pgz&FdUdCtJwVt*?j5{Xu69bidGN$D|EWh}Sf5K8&< znj=&(*|9sB=saF~3tD{5c!$jK8C3cV9&u_%jvlB^rn9+w7lq}WPB;+mopk7SA0t-c zgyi4)oET2rPtwj;yg1-``3R^Ezt6ssB;g+3oz#B$@WpzY9=G7$4mj@`E$O~O>;Cl) zvUTj|vI@Qwa6-1zb!sWMQq8}`YrA-TE{PV>q|SYZL}1fYz#idxHP_D}1~8)U2;q;D5nuz!X5f9OSTO zx|Mj;+(K((G4TY0j2Os!xelcMM#&+M>`tk?-5r@1BM}*RX{z=g4lfT}t^oqZX!W!Z z2oN*w-|a?$(Z&UvpN7ZV&PT$)kOY8FBG6NWn}W zl>x*p|2tTlh-R%;kdJM;*|qj&NFp$Q@2g3DnYX)1Orlf27bH-e8Sct?*wb5;l9ADE z7bbg4YDC=(3|_k9cB$`dc8GWxk2K|kVAdm2){*H4559pfG1Wu}N!x`@waGM=n(RIWy^B;{%B!-aDV z)6723T|r#C8W(&!9L3M53pFXO(-L-8JHK}buhiTKTRuC}&xq>nr?Sm7Ng>PFbIAcpE0oYAZKn{EPOKy=e7D6TPZG@6t_s@M859XFgmhzd>NoXfXJ5<&itu6=6B9CeZ2IX>yZRHhu9mAB}>G?bS zZ0^~7bBSrq@zLn>FN^^0mw~qB9H6meLrAR+Cnuvf~@r*P)m)Ncy^CM;*^`~ z;5?kgD7HYe7H3XT+0$NWnK%oLEBE6a%hClj@I7K`?^Bwgw^%DzXDWx-S4 zQwxNzP4tNsl9{w;j=#hvKoueM23N7y>4?-S4wi}UW3sX0BtZeO& z>R(Cw1NGu)ZF$ZyM<(Q9FVneGE`u6tc|UA3!OJ^zwaY4qmOr~vR&lIZi}p6eA)N9| zn0D8;<9@!?Z$ja8u@py@fqmFKDmP^91A^@L#&rK5B>07e*41uIkcS8hRCAk+^>RG7-UZ%6TC={~r=sXH-S;QlF*^sXdjnh&`LaYSyUK!}DPy)~YT63pl{Yi1YW|6@x z|3*`$ zy}CksZ?3BBj3r!2U%GzKociXy={<3ODr`Zx&DcRm_~*JkcDhN-m;B1r6i%-SwOsRF283Qx#yKO$Yngv?R2b|aCEiW4g1BIcq1t=13&R$s6;8<;bx;GXfRMcQvf0*N^_?TH|qnz0AReA9< zRnh&RNyUT_kS{FBk~lDiGF?qdTlGc3FFX&Bb0AA_^H{ZlDTQwHgG38Qm-Vkk14{_@ zOp(uqx?)DUj$73iBr*@Q@zIQx!t;zcGgO)8ppe7Wt;>XW%1@f_!kZlP7Pw)*6G-3Z(&z}<>g;QWDpR3hL8LU~l%^$v!U-^@xn2Y-9UH&%IKx&I)?m0@x)}p_ zkd!I(vnQ>{wbp8;qV{%(g95o=&E~Y9$EA8X4$T$dS6jCbj{W7Ouj2A|6jQPr&{mEB$Q5v|As#eHH2(%G(Ak`7mBna7d-2Nc*}gL zkW5&f7fMvhbA+ahA~hI~8^x;#srwz!v#IgE*lr?AGA&`BLx^r9bRr=yQ@DDmr&pZH z5_~ttnY7bODTahKl8eM!g#9&SDqmrH(vAkg{lmu-?JPCjc+bE{krq1oOOB0tzWT;6 zA-|UWyI67!?;uFPJ)z|bZCAc^cQm)0g|*@(!>40js`79jSFlkS*Wb#CJF(2{nF4kxUnYFjI{%2k7i+7bP1grilq5pGH9zB~Jlvct1)o!9m z$8JOcgTNZ~9HAWnX*`*I%JnJMohq>wQVxAJ*k;anaH!F$k!^FNSBev|S&|eZ>B$(* z{VzP1&DY0S2bKqS+rno|<0dA);(J0xwKF3}Dj=7_F989iwo^yV8t>doKsuh z`S)`D0ei@$ld2mASqQLg;fSZaXrhuWAUN?LJpjYL|65;1T0mrzo|%5)n}wTs*Fge+ zfb-)800M$k{OWH7xGO-|Gqh+gXq~^+>_9XRDQzs}FoKvZT zM%vE?ziZ>$8aIQdQ%Xa8-Awy(Z$urkF--7`{%}SK zFs2@m${BCIR^MJ!$>qb#Mv=Y2XJKWzM!ub({CXV}3Cc^R8N(S^F4cq4pO1^1V%;G# z&WFSIB0lgu)!yRaLVEbU-xW;^1?+gXtOEh$!cHOwu=YUX{b&QYfXFv1tog?B81rwA z-6PCwjBI8j`?+~}GhX@vpjZ>UHl+O_lqg6z^e*ehIsgXav9DHvb&q{@$i%^Zi_67X zfNu_D%BB!IiRj}0x79;$gWx`^He~~FKMs@eWhm3IH`QHSria02fuw98d$ZF@LeS88 zJHA0;NjVXQA?-wy;b#g{lb7B`7|f=H#n`i2>WtZJ*;dMSHEWOo7YhCy}-I&X?0qZWAvY{96>Z zK;3Ttx(h$ld$ffF>BBlMO+u2JY0YS(Q_bC%prNN2>W)I;g|d4vHd7>O=2sG0ySlDV z+(8t!*++!xHBT<2=nc!Cd9u3n_h%e55l7P%(KQxUY?2jRHk}C93dkM%i^!&Lt_J_) z(>h-Cm@(P83^jGqyB_Fx+g2W(---dicAK2W^Uzd6#X0=Ut9>qa6xz)Wc@Vt}c*+n1z#6mVo)B90?M92j$Xtst{)5ftg(31!buTxan%HVKxujoOPp>n2l@4zP-*$X_it zy4H6iqWH5PV{=ze9H#H?#Md*tPdzQjBhOHv9ORXf?shx+FyoYG8iVxTh0Yk;{Du#l zi?ex6i}1P}HzMO$Ukk`j9Xjv^-j{NNH^jiz+z^vr8e-)q(kaH_%&ZM>$6*-V2zEqw zD1n>W>^_8{i$=8+%qaTp8NK3X0?9)+A*<4FJ#tJ{9I5#e>kHF;Ef-tnG7 zP}?0~rDL*WV{+k-p$&Nj(A)6 zyovq!HP4lx#rYfU#!&vH;lqPm?MxXwfl!G z`u=n+VbG#()XB1JIc(Cjj>XC=URhn+s5GkL*_W-mgAXtvk>c*9`EI@QCN$E|SO84z z@a3s4D`HXzopGa8keIt5$tzEz;WTxgU@8ik$6de{LJIkr5PdHv6nMZG0jBLM`Xr|g zYkHdc4IVZN&ye{zL3|K3qJ2>6zfnpQh$G&Gqu2S;VK}d2>WJq@>0@{-gwcv{l3^Mr zXLn3rz^T>5^K%H~gPC(*IM%1TU+{pah}8|cUCDkkYH8N%wDzavDrz1C&CidbKCjoh zAbac7k|wR1WZAIS6y{T;MYyk134^^?|#{Z}8!q>=! zExhyV@9}j}!l&qbM)(4=b(%$6qV54In>$ikYNa>66ISp5BfXjKwO#K~NJ1rJeK$<< z*(e30<#GP}pY75>ePA^PS$g_;?q$eFzfc>(Uuuxy*A4IaFwrwi=_1~aYxyaZ6VVo` z1*^;DX{zR_(fL_GZZ=4_O?|gigAqDHs&2rnJxSmvqTO;#`Drze!bp4-E@__8gu5tl z!#vGjfs1t}6C~D?&c`{+N9zl=@gIK$o~l$*TD__{3h%QrFH6W@4=*n~;lYVw*g=Uc zF)N|{emZ!S`)$dunLd^Ly+lH6l!<~Upu=Ia3~XUhv|}A24N~cu#ywwW8*#~>%QfI7 zQAm+bIjCOzIH%K^9a|P2m6BuDt>YbOy3_q;{MY?Vi_|Zv8`XP5a5mZ8$*pn7PG+b~kb3 z+WveEbzN^b!!>R;M8=1RGU;5=8O3%-<#cDT7~FVIR#5^e#>tUF1<_Qr^Q!U8MT<`@ zvVI2PMJec`l#$N-vV)HQFuK*Qi`|c9Dyd@g9=i)&NwLOwy-4NKedzNMOo3s2COJ@# z9O#>DNbE>k%4Uji*X6nHlipqwy4D9dfT%{!I;&V2L?|=V>&ujgoeH|an**MW;=+Qf zVw=j!K6F*MGeaRJxxEbXv(Oq|_pMJVLQ!SRJ8H`LL8Lzm7Cvw*Xt-$jZqs)H1be)? zvQcj9Krg)E`%niz_k(+wVD@+c)l}QBFILkSwY@*1EIZd8NRoHGwzt0bIs<9xl=n!p z0aBJo{L%bVC^}%X;|*Vw5lOfAQINoKw#9}qv@w{iWQlkG;_lAzGI)^_HOI~uVoH|$ z+VM6*>F%?{_d0bIPJh)@c6G74MT2w9Y*Sc9R))pXHRNY&1$OaYjJ+QJ(&aun2bEqZ zOH(hWo5$EvN-GsMaw-YCq466}H~%J)2gGOt7+sjR7cY?|=F@ za=WpSUXBGYWVLp6&$(f+#0sZb1AWd5UI;hm#0|F3I%w>mD9}s&j+?bVpCJELT3t1Z zf%+<>!=hw0_Q6I84i1=|PMF|I+3W0=llx2zQFF>m`8vdtNYx;Pe zOz$nqzinl$q8@3x%SYqUggWYySj=Pr#b|-J1*w`wLx~dEo18aWs1KTv9s4Sq#^q8h z-+g?(t-?gngFRBY>>58LUeh$Ce~W)fC!EC>dC#!e zpNiAetg+k|MEcUZ0rb9ziADB3C_#(=jA6hac~fDm>X=zan{HU1IMH%iS=G;hkns=b zk+aO+bkj<9AHu=R)A0M-mr95hF9|Wt2Ktecw>lm`)L}4x?mlo$^gP8`OPP8eKEGTF zwY0tT@%T~EpVOExjPaHp1LFbK?Hqy6<5LH=b%l;6kD~A&<1O{6qkPC*ijz*mcwSXa z=2Hzq3x2VS4Y=+MtXLLNfjlYBLpzKKpYU^a6|r&o_zY+_j0=8s8ciQWtY^l>C9y-7 zjsl02%OsQC*ci5t_ZFEw2A)N7+xf`D{rAA8-fBo;qrp(BqXb^fbREa-H``9e2h>Rf zg6)g<7bhN2hM$_-aDGT`>`OneDDM2e$fB<+=8dc5bD7O&RZF{iy4d{v;L3%2y;)H*)5zgnFXV9&b&N@*p&uMsHX@7ZG9V+}S7g8z4O3;uh#>((m& z)(dc)($qxep91E_unSP8N-2u9ep~)}ywUJ4cn$TWwXVx!0Mt6`AO|fG0_DG}VOOYR zAl~#r7>_k=g)i!Ab8!A*#rKWKk$H^j%l2=QbC0JKed7Kuz-0->6^j2edr?uJKtcjw z;`f8oM4A*2L8BL5H$)s<|HU0Z@wqzVg5A2L_nM}ANpXuDP@5`K@}!w9$4gHd%mpzE zN%d{@YI}~cE(snu6UOxI&2k5%zyARihv7**$@7g9UgT5E@m}teuSg4$u%gGW(6=Vw z<;kV{HK^9CW@%EXOz})vz?AXg=SL@YHz|g#L~k(Qd>+K|uL%*wN&9Jp6+4uCgEv1I zD@>uEaPcMp_#mPq0b?QB_(U*q5HGshdhGvWk?5lvrJkLi4`0M`9xEwPNXngeF zzLR{JuSnQjA(#Oi_dM6}WE0@2+O?T|bcFM(hVO5OcdrF2gNvo{8S`}+(PhKoxmf4# zPTuP3<1%y3!q@LKPEJxVAAAkEuaZi0&H*g-rVtJVbBAN|V&dUSTTYW+Qoc6wN}$8> zA7-u?7?=s6x_DpHdv?)3yH(79>+9RhW#=v>LV>spGzUMEzFp^`4zzyG2br3d9X>gJ z7v~O(X76>)bDr4+C()J)h|%?{+lde^`K2BUb#@LnZ7-lM1&XKds{|2q{q3`TVTjER z8p4b$w@En09$k(K9k5HUk3ZnrJ8mDK7SNrc&h^3Mco#9_w1fmbebhE3KR)2Ranbj2 z>lYKehYA`ZeWl8lzP?&xr2w>IY~ph1Tjlguo+;%P@d3jsfQb?x(fXo9O+tgw1|^LS zm0?z4$_}%rfZMl%dbjo2MO(JJB2#I+VL7=PW@)kBvdF80%>C-=Yb4Bw>h7@J@QqD< z%9g!r7w&_RsZNf|?GT|M(&`g?U~&GMTj9-N z*D_Vphll%RV~BJ_7li}5z0((y&vYn*qps$I1HYac837Bb#i(3PPKS^5UK;sr37o?B z1I2m!v(!L`V0;?HHK|%(UpY*4d!N^A^k@B)mBu9(-ta@W&xo}5e{IV$;11VRio95B zX`FFK9oSyaOs2JS|G{Abp0@#RKDsjqMA?aO1yih-_3NJmz%-dQV~!8ocLA?@U%H=- zzz+-zhd2K(qh^9W-a+O`}jb*C}@0=R*YuxM)dUe&<8mTEQ=WhfA02jJ?DObGx+Ux zzyD6AL%+z7SH$Y^qJCL_2jfuC{;|lC`^E)6otT&0ooP;{JN+;8TOO@EAb$B@4+Oz} z+Bqn{jRnK~6VQ}NA2hmq#53jVXaW%2b zm2x)=E3}(~4e*vFJM48u)gIF$bBOvEL&*&GqFEi3kWYpxWl!%`sB3wheay6&k%>HU zX)dqyzF{>nd_{US-EjvD0;M(8^FT~$-d@Ws3%cl{!Od&jIDphVtEy2;z&`&6KFr}sp+vr}e~uI#>6Z*Q|= z_Xql+G=J6`tI#2CyO61f`9`Oyp_(AZyQkm{s^OX)CT9i=&v+8-m*#sqa{j_aX!hCN z?;CY@XFNQ%6nMEuAHKwJ{Yi3gw11V7Ac1fjDHWi#@MO?c)>NhzauV(DgCK7n|A4YM zx8jdjJq^}BEGj6;!h1ZugaHkM(p6!;J_rQ}9*>TiSXx=q1$V%n@(82;Ox+&ZuA1>c z*xsc)JC+D0qI$-wu6F!qr33i%=`T_`k>T~j!H2ao{QHkmkwR9=zBF4U1nGkK?$YZg zyUCR?nXH~a#rx2qqq`@;=CwnB< zIq457fZm_2KP`?=pCLWg675fLQw1uh9DfOyx*z-I1fD>p z79)DL`H!zh%A!Q{wG-ez1N!6*ZhLoxQMA2T6m$HY`j1G`c+EoTA?e224e*SD@u|Mj z_^#@PyoWbWZl~%N%gV~Yr}~bL#~hPBd|qz%mn(=PoMr_^T?>t0o6J{AFIk?SZJ)f; zRi)kxSO=ohV{R6Bh2B1)xy7K5fsiV+JC}()CX&)>wK8~TxB@!(gqA@)r>kT! zDJB0E{Eb7Twf^bFZ{l3Ft*yhcF5E_h=%&gs83sPSz6o0Wpi?x zr`0dkObNYu{xGN}zW%+2s^-*6@bE^Gf`HR`M8oZrYHdMLQPHoeo0!CNie(tFBC^^( z$5wJgCEBjiYUww;@nZQ0?nf^Zr&XF<9E>;()zN&>kP#nv@$hiPwLL*7qS}chRRLCn zrafNVy@MKF67O!azx-X2T=|LGY>-h)-Bi;03WS{Adayi8P4JK;s$aCy-ua4=J%6CM|2G0K;3@nRG*t79g z^lsQ=6%u$lVo}Xp-`L`r6ANQmfs`^djsdg@{ywkE6JLMV`<2=eJ;!wOJ{6(;9u4Ok?ed5432l^>^a=TqGG( zI!O4~_lM4#$K9+tA(~-UMYXjqn`H}VjwqDP<;AJ~?fUFm4&!Q9R9KXFoWZAzChsm+ zVcl5{TkoeyqZX@jXX(}XHe1DV8j`n8$dMJAXW%k`Air}u5!LJiWlT4_fS?%H*UQdq zsV{S5jlOQi#^wH(mTE<73Ji}!(3nkvS9ev&Zd6gOHoWV-qsPLo>j?#N7waY8@kvP+ zFSt=mCGNv3&8DnY+r9B6M-GCtu%y7iNUeA35e7bo>zGc&Qll%!&i2Hjm>34|x1CQ} zH5mb2ua=m*zbGw%b8wFA$H`=tD!0eE-)f)id5$q$4VZ*1f#(zSJH40m&6%i1mgO?1 zSn)e1Ve2ZnZwIIY2ed?3r?-_Hti2xI-i4F}&bS(AJK!mM0_%`wMo~%He1`P37lipg!>FVrXdZyzs>yShdw}c$QJXtGDEr8YU)u zReL?<_;q!hke0?mf4}!`%O{`9_UIfncaQ#0{AVlo?3cKzWHEA=5lLe0%`e=~7W)ZD z!TrgX)R_G5MH;s}x-&B?AM@RiX_H;UsUDgZQRy*}V^2ig_a|@na(!>U{yByC;8+x5h8g1Re((Inf_ugs@Cj|Js*#dK&~<*NY2V5ZWGGl;P66T$u_hCEsK zIQk{H;Y4JUJd!!`wN2Q^0~{i;!t4k7Bo6n`pm(-qtmu_=$D{&-Oxj=)2G7B zI%tr!p=3YQNvhm+e_ho6t5iXloOgav)}P3d_v#X7wk5$uMV#XMkqSz9q!pi7Ae4`L z}<@0GK1Ddpsa_@OdVz^L{yeNjsAT- zIn4iKBn)P#IS(+H+WJ|`9FcVLzVNZGi!E|dV=^uzq^CV(6){^MvgacxC@3BIv~!%Q`UgoK2J zfdL{yB6*@M-4D#lujZzw?X&{(oI*8iKnonqLjgg!WFN9Cbn7hoa{EifBi#;_eV;33 zu#3|x)000lo^P!m-AEGhKyO;`n2Pd=j>Mgou`l^tuMjQ!kNveZ-z4hv9-i`{OWh33 z@A=82OQvwqqz)6eO(;OYZrZ$x;asnWjiVw7EvA(}xtW`X#|n8D4ZwWUsGQiqet+k* zTi!6)Q8zy}0xeA;<9eHp+EmV9pQ+my60Wvq&`h?nJU<9tOD7$B%E6;HmRC3SMKSR} zL18pnLkTi$kChgODv`&(raS}`cC8O8s+NLE(E2_S_TmVA8!^=`UUiU~8CsU&cj~N5OW9yyPVSu9yt(?gG^|99sRvWepf-t zP;8(^LDUFg81g2ec`lpTUSI7B;y3jm@hxld7F)1F?vWiz;_NlSlY6OkIpP!Gdw#OY zW+(#3D`aCHaSbtUz1O^``}#ubiljEd^}1bo6%}DXUcFb!U^OJF*gYeMMgo;P_ng)` z^|gxz%=_mj4)#)(EWxh84(=x}^mUw_NqHfk;T+ITqBk6S3!HnAn1A3sLOioJ(!+d*RnF zh~x6XiM*F%9ji#l(Kjp}YS` zxTN*9vIh;Nh{2tEIH^58oGzYe8soBO4hgvjivhhUdPLxi>Z0!_);hgmda>+71+IJ@ z>HVL?t;$7Tx(8_jqWbpgWhu{!i(K|X=9+G0F%Uk)t`?fM$)T1#siAa2_HpXG*B`cP z-##+nB-=!!I^&`idqPColVo<_%rl6n&@A-uw+|G4>`{;5is?DdrxU155gV}`hie9+ zvwlnjKP9RnWh$-LW9;imn0RD=>SrUnRlr1d@O!G*)V%ZUB{dU=r?~TYYa69}quNoH zi15}Omp{HC;`u2VzV2FQcCFJYrM~iR*_TJu+yH(bZ5@xOn~U@s1K!0WYlPT`wzlAx zPrO#&3;{(UvbP4i{43g!&Iy}!eztiYKH+5J{Iu8sw3WH(9#eEE4V~dzK9o!sk6K08 zboC8WMH2?f%gejY%PZv~$g6uy!p$$MJa066eqG5L>~ZPIrzw`5ZA(ud69n_^nZe1e zJi+@sHU0=^ghxNmuW!}$BgzERJ({8|h*^hn?;wg;AXZd^pV_GR-4hG7vIt^Qvf3Vw z=+@}zmtAZ2?Hi{x)iCoJ{A(6)e7E+xqFU#kDVB;&nERCfnlf>qp^Fi4cfbnBhhXrQ%)%J^bM0F8Mt@5&GwHXHEDt zA5BrExHmRC%1h8R;azQIASSmkO!l00-x`Db13OHEpZ@9>VJ;C%e0=;n?qxIJgrx=@ z=3%yTLqb6(I(uhfWZ??iL!-<6X77K&x3$3e5KPVK?{@e9YB2s5lXi6s+hYJ?@&nvm$p99k4C*$ip+%*h1vlv!Yd0fCMn)q>zmSS= z++5QcZ996GSu2(B?^$@f3NRsd-r|I^FJoZIBhLc&jFNFFJz8{c5qJS6pZfZ=cIc7$ z_nRZlsW-X?$afZ|d6JkN1-Df?Xn4u z)d>Og#`^$n$^*H?#lyFNCf7^w*Kg^NQ`j?Uf9AI9ea|MXM2z7)F_Kh`+ZEV(sK9mjecxd2rTE_~DPuDy8zW?nkwvWJZqCq(C zK3;RPuyR)y4GqoMQtMl3$W~rv>`{A$<%zFE>WVb;ZzNm<`FKm0$jLO_>8>+7{a%lP zLaoo4_t)s%iaDat&@I&K*CqpEVQq)mN{3mtRl{dq!Ha8!`=qCrW0fES1drJGRMpTQ z79gor+1rBVh2wyyBWghx?j$}O@=8Tj6q5t=x8I@pnsVdtc!HBsE#3xsn#Cl}Y2}3_ zuJaRTl9T7_zr@*6xkRR?IGje0+GY@-j{2=N$gvE`)+%y^lG|&tH{CH=e7d5V6I!tA zMjgDF5ufVO06%KY?I%gD?G6P#vt@e=xUn45@MwfrX3dz{C;Cmd!4o?aIi6WrXEsSPjFdlKS(6x{=kT>EziLSf+#_}0hIJGV!Pw^A zkNTBaZ$CTQolG$&F9?k-QDEjIVc#@H{~#9~S6DeT7ZC_CrrMH^-X&^!da#!px*kML z3r+b}RYNOwkBY|T640fFVSCT{BlAvQs%7D#V;(`Km`TEQ-I>&6-c2FiIX^wc>}2CA z;P)(4z0>A9C>>z!EavEWw#P7(=>6*DWkg1XO8V;pXs{kA4PvpsZ0Nzwp{udyu-DR) zXJ;e54*Kj8;a0QPV0Jko?&^cM z?MobpWe%UiEbN6UIM-I!(GbQeW#)aXp(bW-?~h}6cW)wUQJ)<`+4FV~@NA{B;7~rn zz4DoA+I3qfq6u-GqBq|*6tLD4f@vM*J#t`3fQ=h~W@7XAK+-lT#Dw?FMpEkd}z@T>FnA^cpnwQOVb?4XhN$!_J8z2Qi z__{4ljSAVE?yu38MK_HT-IZa_7Li=B4=*mus^MWtgp4tjS8W4N2WVd$Jq;120*$_> z;Ip4kH-B_1aC*H3sklKx&CH@9W)H&c;~}5)ZzybCgs)8p7j)-}?5c%6E^y z6ym3m!&80=PCqJ^!uSl|1w#?+;p*n4aFgW1kxj$D)s-rDm4&099t*Wc7T3pFE- z^ggxpMp5J8)ix=<8@YY=AvJAyd#_x;d?V1N*c9D6SLJlt@6P(L_sZ0(!DPVAcY$NV z`nEM@pig$nI+=EKr=j3Xc~;rmR-)ctE(mdabC9%96@=F$uxj;7_GcJ|VXVF1@rs!y z;UT{w#%^yMvCl=zRp|4X$C1F-Tf%K%XO0z7^FT@Q_dW@(m~j|4FYj~+(lkY1VXCxnyk~hd{-LjAUv3GF>L%dx(#yw* zPu7UBskOau_NS?R8{5n9kPvwZ6#``W9u<`<1>vqWY%)k#Dhupix2I$uIdNbF5z{ug?GX>>(>T)JJMq+6T1?yIfyO@E^4j3V(mF6}u4Ye< zVluTW7Ds_VTTm7>$G zu&&uwS(7=0-C}9$hH8On)V^=y7vNf=T84JRs2bZ73M{QI&)uHX^;PX7#TZ1Da>TO% z^@xeQ1de-Ovc#sXnW^vojGylt;3z3nDT3~P@}>Q@bir_k@0wqeEpshD z?v`VT>gQtU?r^!j?-DTaT^@X=Keg7ZfapP>o%jC-cY}3I!C!48K?@&dB1TzR__urm zrow;@6R+87Ras>@gww_AFCW_1IaV#(z4qC9uLZjQWnWe{79psvsheK{zKNlb`C_BmGuuR#WMH zXB;*%()F-DFAP7JtVQ{o?* z-}JJ)z7jxJUUNFr`3<&mQcx^iqkN_L&rQSL_5&{VJrC~-5#hA&>`^{lSYp6^dY?Wf zZhGv&l#rbu&O4KDZl!d`HmS=Bn>&{DK71Rx_fqdISxkWFpBKHg%G{22@&&INm_}5X zd&E|j3!JGgUfky8?s8ZoE#h<4mpBBEdV|{J8nfAkK8DYvdDXP;HfV=!psRA~9-HNL zyv{e%>w^%LE_i>)>I^&~yA`A600+56=sW4(Kx~z#3enR5LfT!CK-|cXUwl@PAAloe z>XX;xp0H~WT0Pp?_*k4jr-wFYCD63(sQ*VX-}C-YFc~E;XN;Wfa*e+;SC^y8#KJdC z_?D;Gc5O@T(o~^Ho%IlA$_4CkY2JGq8o#aEf3+EO0o2_+DNvuFTw-HHg;ug{!d^IO zptCA&YFjb7gRj##ApQesyi5$QUcB?_-|czn;u_guQ1&9@x7DiK$@IYNGfIm_!v@$I z`sA3|62C|2dWp4Rsaedu;HkYG`iwkf$i~Mseem2ssp0EB_`~dHXV1-TTkd(ud7?4p z1=W*ZwJ(IG8Ez#F=cD_>g}HO5dxF5_GPs`Ow&igHOS$gRY|vn_Q1luR_sSn?UcT0B zPIoiI&Ir}``XJY}_Cqr8AX$|uVSv;xi1ux1Jmmw2_;?7SI&)#Nm%T+dCC&aCtX{4R zF^Ag_K}W)xHA`=CKjs#UG!wBrN~)#WD>32^E-NAeY$OH^U2UyCpzvbv<=8c#N!Iv6 zBjR#Ytkx>py?$+FIO@P_WXkGVxKqzz9FuVvwv_tJBDQMhxQ5G&jlqold~HBwF|^no zZ*Y?}nICxj zs&-kmI54_%zH<%~m*!dZ5&3Cy1td!6O~#Og6JPFg3wrYz!e97#AY4u||0h=!@XRJk z_e|;e^=SX~#X;ux97jXob0JGEk)>kk^^VQSoBGva-J?nR$(MuoDRj=BiaK7%N!w?B zeCZNH=-^sLC2(3U1Fv?od5+2vqLQ?Td~SA|=%7=zUe2wy*sQ5O*6FzGsYCHlYEbN9 z&YBL`z3q#dy2nXitLnBXyY)JtRk0&ibg5*S5Gh3@ax41+L3VcJqJ-cFl=gT zea*&~xk@ewZ<1bc@#mb!{_{U_Y?vX^i}m*9;!?4RhC0#Z_vQ=Y46oeP^!oKpj18OS3O_*lW&@jwt@TMD3{uXyW0?_oNcBSO~r{mdU^wRgcny!`nR4 z7JcQ<1`@B_FqyX}zfNNwAZY})*N?6EscI|fcSXBX?F{!Q$wVXC!Apo6fop|7Pu?+k zNS3FTPc+ukA!i;$8DvaTQ$U*cJz#Pxv;rQMYsX?B82VHNuGG!a3jpyM|ESE%{rRq$ z<+B%B*;A0(&581n=}b=(qL#@i<;71L?ymc|HNeeJgL6%k@3yla;~uv|;#h!?5* zOd{jQs;A`Od6cY{R#>ov_G}WPZt9P_gJ(MBA4smu|1}HnEQ(&uoW*giSt+i4f)=B%*12zu;sy@qk`QMCX`VD^H z{cMOBXf{X{TIf$MyibjT+ic7{?qbn(JFJQfs|D)jSFYygWdemqnH(23yQ=vq4V3yA z=+yF}_HBU(V*I#*1n*>@ZKdFGl2Ucctwe#v4mN!*6306=bs>QM5F0(=k_Iq;LMsY~Io? z%Wow)O&lkSW9+g?gNhA#f^Db$eabX?8_mW>`JmTIg0~Z&-UV77gi?1o)(9y=YAd(e zqr=}WafO-TnZK39!Qfq3>s~18se=DVk@hP+O1so22Sx4HJ6QnhY5Bwk05&Eyk|X^y z)(QdlF3)w~G8~X*r8zVG&R4Kxl!knCO z)vC=-Sk}H)m?qpKKu+0OysZE@XZDk3d5y)zBFsx>D1{?(jxPO?qs(LAf6zRefh=O$fMK{4 zTYt{ee)4QD*p2Ni1WSal4g4$}{QUwns}@dtW-sjwIc+YyFZ2MXJ#6rWzartg&yZ7k zeIkcF_LsvfPYQsSRd4V}bz75Tqpow5Z{;$o-v7tY!ws}^mi&JX#s58={~_K_j^rSx z;dD=}Zfb6~jgCF;MImn!DJ;LS`nll3S8|b~O14Ns{Q*^1i{hPXPfuVoXi4%f2z$e1oEVk(jSA{!Lfcr_H|{mHHCooKAe6><6!p-d^Up-^RGGI% z=$`XbgpeBK)HW_)lLGKIXZZW z(Cc4}HRGuQcE_0PFVH9pZXNKRPK(d3wZ!G%3|h6je>6gU{0r+NCSwAQjhEp{sK##*$=W17tJm! zbfsez7unVsp^%FO(MLsVEM^Z6MN{q0@Pd41(K~HAAJSB8jIAGYxxVloihhsRb>^RP zNFP;RzENd0PYl#`G%j}J#P0WJoV1yYz0t+XXMRe*?;JWHS0C~5c7FoR{=GlE2hA4C z2ztzrEv9$^^+6ZpeuMY^5u{rGCux}xH0y}bwgj^ZCqGWNyRs~kTpQtMvvdk3osFb9 zc-AD~VnIssoPx(@GKC6gfCU0-e^fNIgfaQH;icl<3reDA2~FGJD-7=rg-NYMw3-a& z*V0F=Dvw)j!Mp7GhokvG&wnG(dA*I305*m4K;>Zp+xAENuBd@rp1QxFG_s55G2l9c93}+6_s^nB#pXm8BZ4SQsKAb)HD&f3)~eDmgWqOca#^CjA3 zr<=g}{to^xYu&*&0NgSbWIc9X*+5-wcFoU_l$pAwcW%4Wh0&o`j$M%*B@mWPWfJF> z#inPlYIj$QFcBuD$X)0n?XmmhR0#IKlO3D5T}rXwU2G3{`L2p`}aK z;iKHI?O%Tb*yv-+`@}Imx8Xqqj~gMTxwv<}^I5VCJGLFh)?F|3!FGKn{qCl~Yf!#Z}PJD4_HzSVU4%#7FcAjTIH;o(-^f zkXHVqy4y#(HGlSOEO)pEHRRs@EWBLh1MwvzN)SDBziiUOj2EHji zukDDRQHW+*KwKPM?Njr~VZ@<6YSoTrE2oNGA>FeJ{BEc12RGADTkJaiPciTh+4Eo3 z!v8R3yJ6ew!L;v<_kqkiPx$bVAIyXkV8L2Urr&C;=5 zT-_o-^$R-{gYLkGzp0h17;25T>S%LU#oYm~fZ^Gu{huL5cy1YB=v^$(oGd8(8j@Yp zRRcUfwG7*-_Y8!utzJ&CNbM_do7R||D!1}ZauC5-#i1DEQ`HMyW@kllh3;?p11V@jU5b^ z-!*wA62N+e4otudeQ#N=}hG$3nB((p<>bH6xqnj5R|{#r}syQ>=Wjy;ro z;}jD`bUP!b;r6Z_J!?)ecIJQI`Ts&l{vA92qcph+npNEIRh)%;*+#k#k9Uk+JMZdj zqp{h>^Tz6+zvmf~E*5nN;MtqXNb8%;z;_KlKC`)b&BVW^KBeoCpqMps>j0($P@+2q za}7vTHp?yRB>K!8R-MblE-HbQk+boE0F<=Rb^4pZviisAiHv(^DfdC^M7$`s2Zbi$y-=G;UPt)pnd6 zse}7%Qew5i&ET(DQyo_DY1!t1p~b9B?T zORJw#uB}u~@&iBFOklDyq!x3zew*}DZ%xD{G(ELE)n;JsFYZE)=6RxjT}IB=kR;D6 zNp#mduH?BCb&IWE*~2Bw45aQPVZoHTcl@A$Sa z`1cS&nk2+!zGnW2Go;o}LpKqHl?7BaZqy@=tWfpWef9czZxszLB`~)9i?f))2T1<_ zM+Uu53Sj^ntS#73iXPiULsj`i8jr>oy*Jyxs>$HGWh?38fM&g5)zU6tIs~sXOBkWl zcjAiTItY&`s^*^t*}t?rqtVv+uiY)MG1{@)Cd@#{|Je0?~Gt^5p0 zO-{+%f^)4-GMro5XbP*24`7}uT}=99PWFV?r|=>-ON=($Q{Fxw9b*~JrVrV^9(>2P+;K*Y zZwRTHl{vJU^h;HK9D^Oi59~d_R&R7yz4T6}C`?3QJn#k=Gz)L6rg=lev$Db+r~frm zl+$SfOHe}qo0=jk`#v%8vNXl$`$K7u*rXvwe;J+a(K6pux*w*9WtC4(m#QAK8xxf3 z$N3K>Kn1u6cMdR~D>h9@uvt2=&;`xXZe>9?Ch2@`GM5=Q`@PveC&qp=>cYLkJonVu zp}Oa>R&RE_ORuJ6bqaIZ8qI@R?*B%XtBmD>9N6pNU&}|ROo0>FvRIN9P)SIgZLl9m zW25al00i4YPE-3CXz98$ftQ(%wgk(6EU?Ot{kct?w!QAu8yV>bBh+!J3JM?9>y_|h z4Gq=9%$(xu?C#vbDgWyk0f@`c*d+&9YTqy#Im&nR(3vqsqyx)qPSaMlM5~6YXV6>q zzbuh$>--@ZVEVAA3kno&bej1|(wmeZ*;)+rL>`(8J6X8eU7a?~mqsXtqlGjjApo0a zZhnO!B(Q2>v47G|{|i=@xBdUwX9xZvCjUw5-O2q0Ko!A{?GL*KL4#0o;ftVvD3Y_f z4=l3S-Q6ks+q*fu>HA{2c637aSJRw3-`n>!ti=t&g=YV9KFW~Ov=b<59(_6zcWdE$ z{vXP~qweq3Xg!q{G}4Gc@?Icua`l%=`UbCEJ(eSNQY_?P)wFmKzQXM~Z<&VK{st;O zSGyCtiV}pjc0zPQcHH^p^q0+II#~5zRiYA2i7?tU=^Ji%i4}?1Py##C;xi1XwRWu5 zq*-f9g+4A=YkT-f+Wth=(Tby$@BE?qU*OyL$Z4`yu|N5`H;0TTpETynnq}xzGo-XQ z9!xsk2u!Zr?Yt`AZ>`p%*-Xj#hV721tZ0cw{P6MVbvjLFI-!D)OJV9 z>ODk6qi$%xxgB5ytNM|C-=psjUKUtrtWM>osvV8|At_2qI30F(w%`z?vxx$r9%B7X zZE{4&dxYk2E-;xqF=;vr*tm)<$^R&g`L|T_Z$9wfz;9ge+U+)2kTk!7ZA!Bva;xTw zl^d1ohSE#7KdRY3&cYr9qB{li$(+BD&F|dKelG5HbgWD>X5Jb%(aVhZ5lu#xNbvDK z6UX4gr`J9LeoDOuhp<=`^h`Z_XlRlSsIgjcY4$Ry3hMtp-w2zQ<+SHb6>U2qUQvMV z2!)gUq7xxY&qq1kh-07bxxEcjDxkn#;Qm#+ICnld*dsf(dVgVGxg#&FBvmH)<@^5v Dyh*=G diff --git a/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts b/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts index 97bab42366..dceb0a9391 100644 --- a/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts +++ b/apps/web/src/viewmodels/structures/ResizerViewModel.test.ts @@ -16,8 +16,6 @@ import { ResizerViewModel } from "./ResizerViewModel"; import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; -vi.mock("what-input"); - describe("LeftPanelResizerViewModel", () => { afterEach(() => { localStorage.clear(); @@ -79,6 +77,10 @@ describe("LeftPanelResizerViewModel", () => { const mockHandle = { resize: vi.fn(), isCollapsed: vi.fn().mockReturnValue(true), + getSize: vi.fn().mockReturnValue({ + inPixels: 0, + }), + collapse: vi.fn(), } as unknown as PanelImperativeHandle; vm.setPanelHandle(mockHandle); @@ -97,6 +99,7 @@ describe("LeftPanelResizerViewModel", () => { const mockHandle = { resize: vi.fn(), isCollapsed: vi.fn().mockReturnValue(true), + getSize: vi.fn().mockReturnValue(0), } as unknown as PanelImperativeHandle; vm.setPanelHandle(mockHandle); // Simulate click @@ -110,6 +113,7 @@ describe("LeftPanelResizerViewModel", () => { const mockHandle = { resize: vi.fn(), isCollapsed: vi.fn().mockReturnValue(true), + getSize: vi.fn().mockReturnValue(0), } as unknown as PanelImperativeHandle; vm.setPanelHandle(mockHandle); // Simulate click @@ -131,14 +135,30 @@ describe("LeftPanelResizerViewModel", () => { expect(mockHandle.collapse).toHaveBeenCalled(); }); + it("should ignore first resized event", () => { + const vm = new ResizerViewModel(); + const mockHandle = { + resize: vi.fn(), + getSize: vi.fn().mockReturnValue(0), + } as unknown as PanelImperativeHandle; + vm.setPanelHandle(mockHandle); + + vm.onLeftPanelResized(50); + expect(mockHandle.resize).not.toHaveBeenCalled(); + }); + it("should resize to nearest whole number", () => { const vm = new ResizerViewModel(); const mockHandle = { resize: vi.fn(), + getSize: vi.fn().mockReturnValue(0), } as unknown as PanelImperativeHandle; vm.setPanelHandle(mockHandle); + // Initial call is ignored + vm.onLeftPanelResized(70); + // This should be processed vm.onLeftPanelResized(25.515); - expect(mockHandle.resize).toHaveBeenCalledWith("26%"); + expect(mockHandle.resize).toHaveBeenLastCalledWith("26%"); }); }); diff --git a/apps/web/src/viewmodels/structures/ResizerViewModel.ts b/apps/web/src/viewmodels/structures/ResizerViewModel.ts index 2d9da268db..3aadd62cb8 100644 --- a/apps/web/src/viewmodels/structures/ResizerViewModel.ts +++ b/apps/web/src/viewmodels/structures/ResizerViewModel.ts @@ -18,9 +18,12 @@ import { debounce } from "lodash"; import SettingsStore from "../../settings/SettingsStore"; import { SettingLevel } from "../../settings/SettingLevel"; +import { AutoCollapse } from "./auto-collapse/AutoCollapse"; function getInitialState(): ResizerViewSnapshot { - if (SettingsStore.getValue("RoomList.isPanelCollapsed")) { + const shouldStartCollapsed = + SettingsStore.getValue("RoomList.isPanelCollapsed") || AutoCollapse.shouldStartCollapsed(); + if (shouldStartCollapsed) { return { isCollapsed: true, initialSize: 0, @@ -49,10 +52,27 @@ export class ResizerViewModel */ private readonly mouseClickHandler: MouseClickHandler; + /** + * Orchestrator for auto collapse behaviour. + */ + private readonly autoCollapse: AutoCollapse; + + /** + * Tracks whether we've seen the first resized event. + */ + private firstResizedEventSeen = false; + public constructor() { super(undefined, getInitialState()); + // Run onSeparatorClick when the separator is clicked. this.mouseClickHandler = new MouseClickHandler(this.onSeparatorClick); + this.autoCollapse = this.disposables.track( + new AutoCollapse(this.onSeparatorClick, () => { + this.panelHandle?.collapse(); + this.snapshot.merge({ isCollapsed: true }); + }), + ); } public onLeftPanelResize = debounce((panelSize: PanelSize): void => { @@ -61,6 +81,19 @@ export class ResizerViewModel }, 50); public onLeftPanelResized = (newSize: number): void => { + if (!this.firstResizedEventSeen) { + // When the panel is first rendered, we get a resized event. + // This should be ignored to prevent rewriting the setting value and + // to avoid confusing the collapse behaviour code. + this.firstResizedEventSeen = true; + return; + } + + // Early return if we should be ignoring this event due to some auto-collapse behaviour. + if (this.autoCollapse.shouldIgnoreResize) return; + + this.autoCollapse.onLeftPanelResized(); + // We don't want the panels to have fractional widths as that can cause blurry UI elements. if (!Number.isInteger(newSize)) { this.panelHandle?.resize(`${Math.round(newSize)}%`); @@ -88,6 +121,7 @@ export class ResizerViewModel if (this.panelHandle?.isCollapsed()) { const lastSize = SettingsStore.getValue("RoomList.panelSize"); this.panelHandle.resize(`${lastSize ?? 100}%`); + this.autoCollapse.onLeftPanelResized(); } }; diff --git a/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts new file mode 100644 index 0000000000..5aa49f939e --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.test.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { describe, expect, beforeEach, it, vi } from "vitest"; +import { AutoCollapse } from "./AutoCollapse"; +import { BaseCollapseBehaviour } from "./behaviours/BaseCollapseBehaviour"; +import type { CollapseHandler } from "./CollapseHandler"; + +let instances: BaseCollapseBehaviour[] = []; + +class MockBehaviour extends BaseCollapseBehaviour { + public constructor(collapseHandler: CollapseHandler) { + super(collapseHandler); + instances.push(this); + } + + public onLeftPanelResized = vi.fn(); +} + +class MockBehaviourWithStartCollapsed extends MockBehaviour { + public static shouldStartCollapsed(): boolean { + return true; + } +} + +class MockBehaviourWithIgnoreResize extends MockBehaviour { + public get shouldIgnoreResize(): boolean { + return true; + } +} + +vi.mock("../../../../src/viewmodels/structures/auto-collapse/behaviours/behaviours", () => { + return { + get Behaviours() { + return [MockBehaviour, MockBehaviour, MockBehaviourWithIgnoreResize, MockBehaviourWithStartCollapsed]; + }, + }; +}); + +describe("AutoCollapse", () => { + beforeEach(() => { + instances = []; + }); + + it("should calculate initial collapse count correctly", () => { + const autoCollapse = new AutoCollapse(vi.fn(), vi.fn()); + // Since we have one behaviour that tells the app to start collapsed (MockBehaviourWithStartCollapsed), + // isAutoCollapsed should be true from initialization. + expect(autoCollapse.isAutoCollapsed).toBe(true); + }); + + it("should proxy onLeftPanelResized to collapseHandler", () => { + const autoCollapse = new AutoCollapse(vi.fn(), vi.fn()); + expect(autoCollapse.isAutoCollapsed).toBe(true); + autoCollapse.onLeftPanelResized(); + expect(autoCollapse.isAutoCollapsed).toBe(false); + }); + + it("should calculate shouldStartCollapsed correctly", () => { + expect(AutoCollapse.shouldStartCollapsed()).toBe(true); + }); + + it("should calculate shouldIgnoreResize correctly", () => { + const autoCollapse = new AutoCollapse(vi.fn(), vi.fn()); + // Because of MockBehaviourWithIgnoreResize, shouldIgnoreResize should be true. + expect(autoCollapse.shouldIgnoreResize).toBe(true); + }); +}); diff --git a/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts new file mode 100644 index 0000000000..855625b45d --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/AutoCollapse.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { CollapseHandler } from "./CollapseHandler"; +import type { BaseCollapseBehaviour } from "./behaviours/BaseCollapseBehaviour"; +import { Behaviours } from "./behaviours/behaviours"; + +/** + * This class orchestrates all the auto-collapse behaviours. + */ +export class AutoCollapse { + private readonly behaviours: BaseCollapseBehaviour[] = []; + private readonly collapseHandler: CollapseHandler; + + /** + * @param expandPanel Callback that should expand the left panel + * @param collapsePanel Callback that should collapse the left panel + */ + public constructor(expandPanel: () => void, collapsePanel: () => void) { + // Calculate the initial value for autoCollapsedCount + const initialAutoCollapsedCount = Behaviours.reduce( + (count, B) => (B.shouldStartCollapsed() ? count + 1 : count), + 0, + ); + this.collapseHandler = new CollapseHandler(expandPanel, collapsePanel, initialAutoCollapsedCount); + + for (const Behaviour of Behaviours) { + this.behaviours.push(new Behaviour(this.collapseHandler)); + } + } + + /** + * When this returns true, any left panel resized events should be ignored. + */ + public get shouldIgnoreResize(): boolean { + return this.behaviours.some((b) => b.shouldIgnoreResize); + } + + /** + * Whether the panel is currently auto-collapsed. + */ + public get isAutoCollapsed(): boolean { + return this.collapseHandler.isAutoCollapsed; + } + + /** + * Returns boolean indicating whether the left panel should be collapsed at app start. + */ + public static shouldStartCollapsed(): boolean { + return Behaviours.some((B) => B.shouldStartCollapsed()); + } + + /** + * Dispose the behaviours in sequence. + */ + public dispose = (): void => { + for (const behaviour of this.behaviours) { + behaviour.dispose(); + } + }; + + /** + * Should be called when the left panel is resized. + */ + public onLeftPanelResized = (): void => { + this.collapseHandler.onLeftPanelResized(); + }; +} diff --git a/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.test.ts b/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.test.ts new file mode 100644 index 0000000000..a217d65eb5 --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.test.ts @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { describe, it, expect, vi } from "vitest"; +import { CollapseHandler } from "./CollapseHandler"; + +function getCollapseHandler() { + const expandPanel = vi.fn(); + const collapsePanel = vi.fn(); + const collapseHandler = new CollapseHandler(expandPanel, collapsePanel, 0); + return { collapseHandler, expandPanel, collapsePanel }; +} + +describe("CollapseHandler", () => { + it("should be possible to collapse and expand the panel", () => { + const { collapseHandler, expandPanel, collapsePanel } = getCollapseHandler(); + collapseHandler.collapse(); + expect(collapseHandler.isAutoCollapsed).toBe(true); + expect(collapsePanel).toHaveBeenCalledTimes(1); + + collapseHandler.expand(); + expect(collapseHandler.isAutoCollapsed).toBe(false); + expect(expandPanel).toHaveBeenCalledTimes(1); + }); + + it("should retain auto collapsed state on sequential calls of expand and collapse", () => { + const { collapseHandler, expandPanel, collapsePanel } = getCollapseHandler(); + // behaviour X collapses the panel + collapseHandler.collapse(); + expect(collapsePanel).toHaveBeenCalledTimes(1); + + // behaviour Y collapses the panel + collapseHandler.collapse(); + // Since panel is already collapsed, we do not expect another call. + expect(collapsePanel).toHaveBeenCalledTimes(1); + + // behaviour Y expands the panel + collapseHandler.expand(); + // should still be auto collapsed because behaviour X hasn't expanded the panel + expect(collapseHandler.isAutoCollapsed).toBe(true); + // The actual panel should not be expanded yet + expect(expandPanel).toHaveBeenCalledTimes(0); + + // behaviour Y expands the panel + collapseHandler.expand(); + // all behaviours have expanded the panel, so no longer auto collapsed + expect(collapseHandler.isAutoCollapsed).toBe(false); + expect(expandPanel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.ts b/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.ts new file mode 100644 index 0000000000..e9a897ba7d --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/CollapseHandler.ts @@ -0,0 +1,85 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +/** + * Contains auto-collapsed state and methods to expand/collapse the panel. + * This class is used by the different auto-collapse behaviours. + */ +export class CollapseHandler { + /** + * We use this count to control the expand/collapse calls so that the + * behaviours do not override each other. + * + * For example, consider the sequence: + * 1. Behaviour A collapses the panel + * 2. Behaviour B collapses the panel + * 3. Behaviour B expands the panel + * 4. Behaviour A expands the panel + * + * The expand() call made by behaviour B in step 3 should noop since the panel was + * also collapsed by behaviour A which hasn't yet expanded the panel. + */ + private autoCollapsedCount: number; + + /** + * @param expandPanel Callback that should expand the left panel + * @param collapsePanel Callback that should collapse the left panel + * @param initialAutoCollapsedCount The initial value for autoCollapsedCount, defaults to 0. + */ + public constructor( + private expandPanel: () => void, + private collapsePanel: () => void, + initialAutoCollapsedCount = 0, + ) { + this.autoCollapsedCount = initialAutoCollapsedCount; + } + + /** + * Collapse the left panel. + */ + public collapse = (): void => { + this.autoCollapsedCount++; + if (this.autoCollapsedCount === 1) { + this.collapsePanel(); + } + }; + + /** + * Expand the left panel. + */ + public expand = (): void => { + /** + * Some behaviour is asking us to expand the panel but the count is zero. + * This happens when the user manually resized the left panel after some + * behaviour collapsed the panel. + * We can ignore this request to expand the panel since we don't want to + * override the manual changes the user made. + */ + if (this.autoCollapsedCount === 0) return; + + this.autoCollapsedCount--; + if (this.autoCollapsedCount === 0) { + this.expandPanel(); + } + }; + + /** + * Whether the panel is collapsed due to some behaviour. + */ + public get isAutoCollapsed(): boolean { + return this.autoCollapsedCount > 0; + } + + public onLeftPanelResized(): void { + /** + * The user has manually resized the left-panel, reset the count + * so that some collapse behaviour does not override the user + * choice. + */ + this.autoCollapsedCount = 0; + } +} diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/BaseCollapseBehaviour.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/BaseCollapseBehaviour.ts new file mode 100644 index 0000000000..e54fba2a0e --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/BaseCollapseBehaviour.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import type { CollapseHandler } from "../CollapseHandler"; + +/** + * The left panel should be auto-collapsed under certain app states. + * This class provides a base for writing such logic. + */ +export class BaseCollapseBehaviour { + public constructor(protected readonly collapseHandler: CollapseHandler) {} + + /** + * This method should be used to remove any event listeners that this behaviour + * uses. + */ + public dispose = (): void => { + return; + }; + + /** + * Whether currently arriving left panel resized events should be ignored according + * to this behaviour. + * This can be used to tell the ResizerViewModel to not process incoming resize + * events. + */ + public get shouldIgnoreResize(): boolean { + return false; + } + + /** + * Whether the panel should be collapsed at app start according to this behaviour. + */ + public static shouldStartCollapsed(): boolean { + return false; + } +} diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.test.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.test.ts new file mode 100644 index 0000000000..d9aa1b7241 --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.test.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +// @vitest-environment happy-dom + +import { describe, expect, it, vi } from "vitest"; +import UIStore, { UI_EVENTS } from "../../../../stores/UIStore"; +import { CollapseHandler } from "../CollapseHandler"; +import { CollapseOnWindowResizeBehaviour } from "./CollapseOnWindowResizeBehaviour"; + +vi.useFakeTimers(); + +describe("CollapseOnWindowResizeBehaviour", () => { + it("Should collapse/expand the panel when the window is resized", () => { + const expandPanel = vi.fn(); + const collapsePanel = vi.fn(); + const collapseHandler = new CollapseHandler(expandPanel, collapsePanel, 0); + new CollapseOnWindowResizeBehaviour(collapseHandler); + // Making the window smaller should collapse the panel. + UIStore.instance.emit(UI_EVENTS.WidthDecreased, 750); + expect(collapsePanel).toHaveBeenCalledTimes(1); + // Making the window larger should expand the panel. + UIStore.instance.emit(UI_EVENTS.WidthIncreased, 950); + vi.runAllTimers(); + expect(expandPanel).toHaveBeenCalledTimes(1); + }); + + it("should set shouldIgnoreResize to true when window is being resized", () => { + const collapseHandler = new CollapseHandler(vi.fn(), vi.fn(), 0); + const behaviour = new CollapseOnWindowResizeBehaviour(collapseHandler); + expect(behaviour.shouldIgnoreResize).toBe(false); + // When the window is being resized, this behaviour should indicate that resize events + // should be ignored. + UIStore.instance.isWindowBeingResized = true; + expect(behaviour.shouldIgnoreResize).toBe(true); + }); + + it("should return correct shouldStartCollapsed", () => { + const collapseHandler = new CollapseHandler(vi.fn(), vi.fn(), 0); + new CollapseOnWindowResizeBehaviour(collapseHandler); + // When the window is smaller than 768px, start collapsed. + UIStore.instance.windowWidth = 750; + expect(CollapseOnWindowResizeBehaviour.shouldStartCollapsed()).toBe(true); + // When the window is larger than 768px, start expanded. + UIStore.instance.windowWidth = 900; + expect(CollapseOnWindowResizeBehaviour.shouldStartCollapsed()).toBe(false); + }); +}); diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.ts new file mode 100644 index 0000000000..c6d2a3085f --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/CollapseOnWindowResizeBehaviour.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { throttle } from "lodash"; + +import UIStore, { UI_EVENTS } from "../../../../stores/UIStore"; +import { BaseCollapseBehaviour } from "./BaseCollapseBehaviour"; +import type { CollapseHandler } from "../CollapseHandler"; + +/** + * The viewport width below which the left panel will be auto-collapsed. + */ +const AUTO_COLLAPSE_WIDTH = 768; + +/** + * Implements auto-collapse logic that collapses and expands the left panel when the + * app window is resized. + */ +export class CollapseOnWindowResizeBehaviour extends BaseCollapseBehaviour { + private isAutoCollapsed = CollapseOnWindowResizeBehaviour.shouldStartCollapsed(); + + public constructor(collapseHandler: CollapseHandler) { + super(collapseHandler); + UIStore.instance.on(UI_EVENTS.WidthIncreased, this.onWindowWidthIncreased); + UIStore.instance.on(UI_EVENTS.WidthDecreased, this.onWindowWidthDecreased); + } + + private onWindowWidthDecreased = throttle((currentWindowWidth: number): void => { + // If the panel is already collapsed, we have nothing else left to do. + if (this.isAutoCollapsed) return; + + if (currentWindowWidth <= AUTO_COLLAPSE_WIDTH) { + this.collapseHandler.collapse(); + this.isAutoCollapsed = true; + } + }, 50); + + private onWindowWidthIncreased = throttle((currentWindowWidth: number): void => { + if (currentWindowWidth > AUTO_COLLAPSE_WIDTH) { + // If the panel isn't already collapsed, we don't need to expand the panel. + if (!this.isAutoCollapsed) return; + // As the window is resized, react-resizable-panels is also resizing the panels. + // We'll expand the panel after a second to avoid racing with the library logic. + window.setTimeout(() => { + this.collapseHandler.expand(); + this.isAutoCollapsed = false; + }, 1000); + } + }, 50); + + /** + * Whether the window is currently being resized. + */ + public get shouldIgnoreResize(): boolean { + // When the window is resized, the panel is resized in various ways. + // These transient changes should not be persisted in settings. + // So early return if that is the case. + return UIStore.instance.isWindowBeingResized; + } + + /** + * Remove's any event listeners used by this class. + */ + public dispose = (): void => { + UIStore.instance.off(UI_EVENTS.WidthIncreased, this.onWindowWidthIncreased); + UIStore.instance.off(UI_EVENTS.WidthDecreased, this.onWindowWidthDecreased); + }; + + public static shouldStartCollapsed(): boolean { + return UIStore.instance.windowWidth <= AUTO_COLLAPSE_WIDTH; + } +} diff --git a/apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts new file mode 100644 index 0000000000..216fae7291 --- /dev/null +++ b/apps/web/src/viewmodels/structures/auto-collapse/behaviours/behaviours.ts @@ -0,0 +1,13 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { CollapseOnWindowResizeBehaviour } from "./CollapseOnWindowResizeBehaviour"; + +/** + * The auto-collapse behaviours used by the app. + */ +export const Behaviours = [CollapseOnWindowResizeBehaviour]; diff --git a/packages/shared-components/src/resize/panel/LeftResizablePanelView.tsx b/packages/shared-components/src/resize/panel/LeftResizablePanelView.tsx index b2ab2fb62e..9b3ac2fe7e 100644 --- a/packages/shared-components/src/resize/panel/LeftResizablePanelView.tsx +++ b/packages/shared-components/src/resize/panel/LeftResizablePanelView.tsx @@ -66,6 +66,7 @@ export function LeftResizablePanelView({ defaultSize={defaultSize} onResize={vm.onLeftPanelResize} panelRef={setPanelRef} + groupResizeBehavior="preserve-pixel-size" > {children}