From bd94975521b86d31f6d6cac1f8adcfce67ea4e3e Mon Sep 17 00:00:00 2001 From: aXenDeveloper Date: Fri, 9 Oct 2026 22:22:11 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(view-transitions):=20=E2=9C=A8=20Imple?= =?UTF-8?q?ment=20view=20transitions=20for=20navigation=20and=20logo=20mor?= =?UTF-8?q?phing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/content/docs/dev/configuration.mdx | 14 + .../content/docs/dev/sso/custom-adapter.mdx | 11 +- apps/web/content/docs/dev/sso/discord.mdx | 2 +- apps/web/content/docs/dev/sso/icons.mdx | 63 ++++- .../docs/dev/sso/icons/login-sso-buttons.png | Bin 0 -> 78849 bytes apps/web/content/docs/dev/sso/index.mdx | 11 +- apps/web/content/docs/ui/meta.json | 1 + apps/web/content/docs/ui/view-transitions.mdx | 255 ++++++++++++++++++ apps/web/src/docs/docs.css | 84 ++++++ .../examples/view-transition-enter-exit.tsx | 34 +++ .../docs/examples/view-transition-shared.tsx | 111 ++++++++ .../docs/examples/view-transition-types.tsx | 77 ++++++ apps/web/src/docs/shell-content.tsx | 9 +- apps/web/src/locales/@vitnode/core/pl.json | 1 + apps/web/src/router.tsx | 5 + apps/web/src/styles.css | 1 + .../copy-of-vitnode-app/root/src/router.tsx | 5 + .../copy-of-vitnode-app/root/src/styles.css | 1 + packages/vitnode/.npmignore | 1 + packages/vitnode/package.json | 3 +- .../vitnode/src/api/adapters/sso/discord.ts | 1 + .../vitnode/src/api/adapters/sso/facebook.ts | 1 + .../vitnode/src/api/adapters/sso/icons.ts | 2 +- packages/vitnode/src/api/models/sso.ts | 1 + .../src/api/modules/middleware/route.ts | 8 +- .../src/blocks/built-in/links.test.tsx | 1 + .../src/blocks/edit-widgets-host.test.tsx | 1 + packages/vitnode/src/blocks/edit.test.tsx | 1 + .../vitnode/src/blocks/page-save.test.tsx | 1 + packages/vitnode/src/blocks/page.test.tsx | 1 + packages/vitnode/src/blocks/renderer.test.tsx | 1 + .../components/ai/ai-field-assist.test.tsx | 1 + .../confirm-action-alert-dialog.test.tsx | 1 + .../src/components/form/auto-form.test.tsx | 1 + .../components/form/fields/combobox.test.tsx | 1 + .../form/fields/file-dropzone.test.tsx | 1 + .../src/components/form/fields/files.test.tsx | 1 + .../form/fields/multi-lang.test.tsx | 1 + .../vitnode/src/components/pet/pet.test.tsx | 1 + .../switchers/themes/theme-switchers.test.tsx | 1 + .../src/components/table/content.test.tsx | 1 + .../src/components/table/expansion.test.tsx | 1 + .../table/order-table-head.test.tsx | 1 + .../src/components/table/reorder.test.tsx | 1 + .../src/components/tiptap/panel.test.ts | 1 + .../tiptap/quick-ask/quick-ask-editor.test.ts | 1 + .../slash-command/slash-command-list.test.tsx | 1 + .../tiptap/submit-form-shortcut.test.ts | 1 + .../toolbar/actions/quick-ask-action.test.tsx | 1 + .../tiptap/toolbar/tiptap-toolbar.test.tsx | 1 + .../src/components/ui/alert-dialog.test.tsx | 1 + .../vitnode/src/components/ui/alert.test.tsx | 1 + .../vitnode/src/components/ui/button.test.tsx | 1 + .../src/components/ui/calendar.test.tsx | 1 + .../src/components/ui/carousel.test.tsx | 1 + .../src/components/ui/cascader.test.tsx | 1 + .../vitnode/src/components/ui/chart.test.tsx | 1 + .../src/components/ui/copy-button.test.tsx | 1 + .../src/components/ui/counter.test.tsx | 1 + .../src/components/ui/date-picker.test.tsx | 1 + .../components/ui/date-range-picker.test.tsx | 1 + .../vitnode/src/components/ui/dialog.test.tsx | 1 + .../ui/dynamic-icon.client.test.tsx | 1 + .../src/components/ui/input-group.test.tsx | 1 + .../src/components/ui/number-field.test.tsx | 1 + .../src/components/ui/pagination.test.tsx | 1 + .../src/components/ui/progress.test.tsx | 1 + .../src/components/ui/sidebar.test.tsx | 1 + .../src/components/ui/spinner.test.tsx | 1 + .../src/components/ui/toggle-group.test.tsx | 1 + .../src/components/ui/tooltip.test.tsx | 1 + .../editor/block-shell/block-shell.test.tsx | 1 + .../src/editor/sidebar/sidebar.test.tsx | 1 + .../hooks/use-intersection-observer.test.ts | 1 + .../src/hooks/use-local-storage.test.ts | 1 + .../vitnode/src/hooks/use-mobile.test.tsx | 1 + .../src/lib/fetcher/rate-limit.test.ts | 1 + .../src/lib/sanitize-editor-html.test.ts | 1 + packages/vitnode/src/locales/en.json | 1 + .../src/tanstack/admin/tab-presence.test.ts | 1 + .../src/tanstack/breadcrumb/pending.test.tsx | 1 + .../src/tanstack/notifications/bell.test.tsx | 1 + .../notifications/state-sync.test.tsx | 1 + .../tanstack/pending/guard-pending.test.tsx | 1 + .../plugin-routes/components.test.tsx | 1 + .../plugin-routes/mount-freshness.test.ts | 1 + .../tanstack/view-transitions/index.test.ts | 102 +++++++ .../src/tanstack/view-transitions/index.ts | 23 ++ packages/vitnode/src/view-transitions.css | 26 ++ .../layouts/sidebar/nav/item-content.test.tsx | 1 + .../views/content/form/status-switch.test.tsx | 1 + .../admin/views/content/table/cells.test.tsx | 1 + .../cron/cron-scheduler-status.test.tsx | 1 + .../core/ai/actions/actions-content.test.tsx | 1 + .../ai/overview/overview-content.test.tsx | 1 + .../core/dashboard/grid/widget-panel.test.tsx | 9 + .../navigation-list-content.test.tsx | 1 + .../system/files/alt/file-alt-editor.test.tsx | 1 + .../users/detail/user-account-panels.test.tsx | 1 + .../users/detail/user-detail-content.test.tsx | 1 + .../form/password-reset-form-content.test.tsx | 1 + .../settings/ai/ai-usage-content.test.tsx | 1 + .../settings/devices/device-item.test.tsx | 1 + .../views/auth/settings/nav-content.test.tsx | 1 + .../passkeys/passkeys-content.test.tsx | 1 + .../sso/sso-connections-content.test.tsx | 1 + .../vitnode/src/views/auth/sso/brand.test.ts | 17 ++ packages/vitnode/src/views/auth/sso/brand.ts | 52 ++++ .../sso/buttons/sso-buttons-content.test.tsx | 76 ++++++ .../auth/sso/buttons/sso-buttons-content.tsx | 57 ++-- .../src/views/auth/sso/providers.test.ts | 18 ++ .../vitnode/src/views/auth/sso/providers.ts | 44 ++- .../confirm-email-content.test.tsx | 1 + .../src/views/error/error-content.test.tsx | 1 + .../theme/header/header-content.test.tsx | 1 + .../layouts/theme/header/header-content.tsx | 10 +- .../theme/header/header-nav-menu.test.tsx | 1 + .../header/mobile-nav/mobile-nav-bar.test.tsx | 1 + .../mobile-nav/mobile-user-menu.test.tsx | 1 + packages/vitnode/src/vitnode.config.ts | 1 + packages/vitnode/vitest.config.ts | 3 +- turbo.json | 9 - 122 files changed, 1174 insertions(+), 58 deletions(-) create mode 100644 apps/web/content/docs/dev/sso/icons/login-sso-buttons.png create mode 100644 apps/web/content/docs/ui/view-transitions.mdx create mode 100644 apps/web/src/docs/examples/view-transition-enter-exit.tsx create mode 100644 apps/web/src/docs/examples/view-transition-shared.tsx create mode 100644 apps/web/src/docs/examples/view-transition-types.tsx create mode 100644 packages/vitnode/src/tanstack/view-transitions/index.test.ts create mode 100644 packages/vitnode/src/tanstack/view-transitions/index.ts create mode 100644 packages/vitnode/src/view-transitions.css create mode 100644 packages/vitnode/src/views/auth/sso/brand.test.ts create mode 100644 packages/vitnode/src/views/auth/sso/brand.ts create mode 100644 packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx diff --git a/apps/web/content/docs/dev/configuration.mdx b/apps/web/content/docs/dev/configuration.mdx index cf620736db..8bab78e084 100644 --- a/apps/web/content/docs/dev/configuration.mdx +++ b/apps/web/content/docs/dev/configuration.mdx @@ -98,6 +98,20 @@ Pass the component itself, not ``. Keep it lean: your Vite build executes this file too, so a logo that imports half your UI makes every regeneration pass slower. +### View transitions + +Navigations crossfade and the logo morphs between layouts by default. Set +`viewTransitions` to `false` to swap pages instantly instead: + +```ts title="src/vitnode.config.ts" +export const vitNodeConfig = buildConfig({ + // ... + viewTransitions: false, +}) +``` + +See [View transitions](/docs/ui/view-transitions) for what this changes. + ### Enabled plugins Register a plugin with its own factory: diff --git a/apps/web/content/docs/dev/sso/custom-adapter.mdx b/apps/web/content/docs/dev/sso/custom-adapter.mdx index 6d351714c7..d8b6821609 100644 --- a/apps/web/content/docs/dev/sso/custom-adapter.mdx +++ b/apps/web/content/docs/dev/sso/custom-adapter.mdx @@ -102,7 +102,7 @@ export const vitNodeApiConfig = buildApiConfig({ }) ``` -A **GitHub** login button automatically renders on `/login` and `/register`, and `/login/sso/github` routes incoming authentication requests. It is text-only until you give the adapter an [icon](/docs/dev/sso/icons). +A **GitHub** login button automatically renders on `/login` and `/register`, and `/login/sso/github` routes incoming authentication requests. It is a plain white button until you give the adapter an [icon and a brand color](/docs/dev/sso/icons). --- @@ -121,7 +121,12 @@ A **GitHub** login button automatically renders on `/login` and `/register`, and type: "string", }, icon: { - description: "Brand mark for the login button: inline SVG markup or an image URL. See Provider Icons.", + description: "Brand mark for the login button: inline SVG markup or an image URL. See Provider Icons and Colors.", + required: false, + type: "string", + }, + brandColor: { + description: "Hex color (#RGB or #RRGGBB) that fills the login button. The label turns white or near-black, whichever reads better. See Provider Icons and Colors.", required: false, type: "string", }, @@ -148,7 +153,7 @@ A **GitHub** login button automatically renders on `/login` and `/register`, and }} /> -See [Provider Icons](/docs/dev/sso/icons) for the accepted `icon` formats, and for registering a React component instead. +See [Provider Icons and Colors](/docs/dev/sso/icons) for the accepted `icon` and `brandColor` formats, and for registering a React component instead. ## Profile data diff --git a/apps/web/content/docs/dev/sso/discord.mdx b/apps/web/content/docs/dev/sso/discord.mdx index 8669f2b12b..b54a4c46cd 100644 --- a/apps/web/content/docs/dev/sso/discord.mdx +++ b/apps/web/content/docs/dev/sso/discord.mdx @@ -177,7 +177,7 @@ Then open `/login` and press **Discord**. You should see Discord's "connect to" authorization prompt listing your username and email, get bounced back to `/login/sso/discord?code=...&state=...`, and land on the front page signed in. -{/* Image prompt: The VitNode /login page with the email and password fields above a divider reading "Or continue With" and an outline button labelled Discord beneath it, dark theme, 900x850. */} +{/* Image prompt: The VitNode /login page with the email and password fields below a blurple button labelled "Continue with Discord" and a divider reading "Or continue with email", dark theme, 900x850. */} diff --git a/apps/web/content/docs/dev/sso/icons.mdx b/apps/web/content/docs/dev/sso/icons.mdx index d9d464e818..aa17047400 100644 --- a/apps/web/content/docs/dev/sso/icons.mdx +++ b/apps/web/content/docs/dev/sso/icons.mdx @@ -1,15 +1,26 @@ --- -title: Provider Icons -description: Put a brand mark on your SSO login buttons - an inline SVG or image URL from the adapter, or a React component registered in the browser. +title: Provider Icons and Colors +description: Put a brand mark and a brand color on your SSO login buttons - an inline SVG or image URL and a hex color from the adapter, or a React component registered in the browser. icon: Image --- import { TypeTable } from 'fumadocs-ui/components/type-table' -The built-in Google, Discord and Facebook adapters already carry their brand -marks, so `/login` and `/register` render them without any configuration. A -custom adapter starts out as text only - a **GitHub** button with no octocat - -and there are two ways to give it a mark. +import { ImgDocs } from '@/components/fumadocs/img' + +import loginSsoButtons from './icons/login-sso-buttons.png' + +Every SSO provider gets a full-width **Continue with _Provider_** button on +`/login` and `/register`. The built-in Google, Discord and Facebook adapters +already carry their brand marks and colors, so those buttons look right without +any configuration. A custom adapter starts out as a plain white button with +text only - a **GitHub** button with no octocat - and this page shows how to +give it a mark and a color. + + ## From the adapter (SVG or image) @@ -28,8 +39,10 @@ export const GitHubSSOApiPlugin = (): SSOApiPlugin => ({ ``` Use `fill="currentColor"` and leave the width and height off: the mark then -takes the button's text colour in both light and dark mode, and is sized to -match the label. +takes the button's text color in both light and dark mode - white on a +[brand-colored button](#brand-color) - and is sized to match the label. A +multicolor mark, such as Google's four-color G, keeps its own fills; give its +adapter no brand color so it sits on the white button it was designed for. An image works the same way: @@ -102,7 +115,33 @@ copy `.svg` files into `dist`, so either copy it as part of your build or keep the markup in a `.ts` constant, the way VitNode's own adapters do. If you would rather have a component than a string, register it in the browser -instead - that is the next section. +instead - see [From the browser](#from-the-browser-react-component-or-jsx). + +## Brand color + +Add a `brandColor` to fill the button with the provider's color, the way the +built-in Discord (`#5865F2`) and Facebook (`#0866FF`) adapters do: + +```ts title="apps/api/src/utils/sso/github.ts" +export const GitHubSSOApiPlugin = (): SSOApiPlugin => ({ + id: 'github', + name: 'GitHub', + icon: githubIcon, + // [!code ++] + brandColor: '#24292F', + // ... getUrl, fetchToken, fetchUser +}) +``` + +The value has to be a hex color - `#RGB` or `#RRGGBB`. The button picks its +own text color: white when white text reaches a 4.5:1 contrast ratio on your +color, near-black otherwise, so a yellow brand still gets a readable label. A +`currentColor` icon follows that text color. + +Leave `brandColor` out and the button stays white with a border. That is the +right choice for providers whose guidelines ask for a neutral button, such as +Google and Microsoft. A value that is not a hex color is ignored the same way, +and in development the console names the provider. ## From the browser (React component or JSX) @@ -133,6 +172,12 @@ needs a wrapper element. $1zZ`kTnT}I_yqA& zSU|}w;}{I1hi*#z@ti}!wYPFGyqz1#v|rzlF`#$V`)Ti#UTS&hBWNiY$<@`BYE8kc z)H8j@lYWuNJl;d{Y-Rs$_b7UNkUnKK;mz~RQ#4|bRN@1Nl%(u*jl$kPCl73F?9N`j z1|5e37&J6A`FTC&RB4svA_8@Fb*u>W;<-`87IpWQb{fZUN!4EvA?XDLMMVV#Y88yb z^MiwrtgDft0bfW-N%K1D>ZKR@R5eUguwF~+FQH_oKM%ARz_$EtXxP~?s$Y)8Z)^8- z^KffvYfJpj{~4G|nMzzPHC4T|wDbV?KwQqFa}5N-_b*d(RfvQL`TILDF_8I-&DP=J z-u}_J2mZToqdDX$vA=((ruyTUGU9l7d98w1!`(onFyCndV8vx+hXZH+ZSC*x9~xi? z10N->k|7dfV^dR-q1N{H{2wZb!IOQ^1QIs(7Z}U9zp8q?DA@PiYbSifmuM z5Knp3QnGi|&5BD-hsMP82gWoH957EoXJ~oO9Xa{?3(y}|{{TjEA~ZHJ85|l`pD^L) z=PxFU{FezPSG3_ohJ!Qmcpsr{N=sWC>IIt7Up!cEuf@;XFX1V}pl@*zl>_Tt6eloA zFiE67R4>xS#OVqTB+6u|+K3fmGWPcKr!-rtlxRa%-8K}U{J69)S-6p+ z!i|3$Sly#BHsstSWn_}aY`b`_qkyT!oj=WfnolInz}QewP?#{OSMoM|`0s}Ir`bqR zGAK<4353_ul;R4p?gzyGg!#tL?^`&p6f>msJUl-?IcWCjT@M8kHdSVH&O%l~ZgZwa zMtWo8bBgbq9~VwiL()XjfxC-t1y~CFe?u^F){yiZ#BFP7X>s1Zn_t}6`%^!Mdp3!Q zh#Pou;0Y1Lx0~BrtfcW6{@3fi zH=Vl7tCzd=XdFH%;Aw*VFQn9b38PNOOH9M5d9x4u>klrDdvDy?kkO&kSisul1?uYS zPWFt9O%X}+5C#q3=Vo?V`t-yp^zpwd5Mshgv9QD~zN;;kVo%c9d?(48Hh`aChG*i0 zmVEvE`EweZtue>d(u|Ogk53*mm-pkuvZHA_ySYNMjkbWW(u^AtFwG_&JJO)YC$v)N zbh$9VN?C(3zmqEDmz0#Sm`;m|YK2EeIypO5YPY*&t~BDaS*ZOYU!hU03Jl5Sb3N6v z($Y#8ib`)b(`l9=1i=7C4VJ!swB)y;Q)yZ}tO$3Y+pv!z;>AKkOU=q!EBLuxQ&V(v zwAife7h3WG(5JWvGYd-!t@`89@p_Zh@}XO0dHGR|zL0G?bE{a)smSOLZr9Vt$Mn?k zWV+^Ywcg%dhS#%)Fm#HMHv(Q0g5(Bmz3pJKJP|?U- zy~)e-Ga|!^&eOnl4DZ9v_U|6a`{OHyh3a*^y9yp29wM*v*AQZlKV~}|jA%)p01b%K zp%8puDpy^9I`#cy?t#AG)~wgtcY$t0#9?FL38PUiBbM(bVP zXB}5rl!|T=)g6x-^2w3m;g4Gp)qt&DT)-ibmg$)xc|TuJ5blQr1w|2aus7(NiF!N& zD)!B0S*%)S{q}9QH-zZnF5CTMfIzDI*3M{kch-TMEmvlh>zNK9=*9IlkLT@b8k=Q8 zLITrUd3AR$fxOFW+FdgGVrZ(Lj(arv%l z9)UG!>T}gN%OIczODamUPYh`F;O}3h_FcNX2C|H1%f-{DGm{na`Uo^MRQ_v9JYQ>U zNm*IldY2PxMRLG_w)D`c(Uc2*|NdP#o(!7wy6~L_EJ&%{r|9(bl-T?JHHs`4Lpah-S-V#rQIIt@TWw+f4Sbbsl0r?QgJ^;*9QRssX_C8 z;(+B>XvuHDz)MK!v|2`tn3t!_XIn0i(!lmu~ z^hUT!)~ea@45ezNGbx{x$Rnq zy3eA^zoj*ssZ5^@Y0uY*Aj;>c zRBDDZ&sSNuLseDkPIqDbj*q7WtT607*z^??7E+LtuS_B#T56nE5@5G@J=F1_IkF9% zmhI7{ML;#0>5M=P&etgm#G&1A0)zx)#xPWOqJUL(KXX%cmL`sd0WGAeozqPe>y!W?C8+0ptDKlTLIc+23<<-Fx zmuk+BVFx51N+~(JnlGfY81D*V^heQV1Jco?iDPdwmv8UuF(M=+Cl%fI5x(%u@Ae89 zBL=k@-|YoIp_wB9-l_ciaK0Qg`BPSw-}Th1x7lIBh|OYWRo0H}3Bq;fpS*ldz3DV3 z%8sOP0U$0Q%qke{?d^SHLd5UWv7yQ7qNP0K;3yS198H7{!{xOT3dn)?-$q6AevZao z$T|3Zp2j}ens5qkU)FAP2HiF^IK_11ZEH9kZ+?f#whwu?=hJ?+@7w)00MQEA-UW#hjqVt&0v!MrweItjITu8S zHBM9Akro$MTyCB(imcge^G%k+>`%`HE!@@XtLZ{THsRUHzZpYWzMBR>$S?0}GTxg= zWzkt%6VYlBy*UIn00_7J!6@n-Mx`gp)!i4#5fSC!hvT`qxoqFf!8a_1+V*TJMRL&V z)v{?;*Y;GY%Xr8AEKR7TE7(~qUowCn&h{?+~Gd!Q0+nH<>l?Ze^w0W)#G@}Y+mOH62|gbxga;GUuN%gcWZgO z-oBgeKId^%j{BZSFrCBT^k9E4zI6j6c3FNoLbtr#- z$g9`KtEffYa@Fdr?MAN5)4g;G8*tr45xy7zOpy(MXjLlG-ek>TyH;Z~I=kHc(&c`I zH4Fnl<`86j4^ZMLuZI)Eb;Y?EQ6~FI6|h9_xuVI<&CUI3U2!Q13mm)^ApIuOY5r~G z^!FAN%mZ74!4dL^UrF0uovR=YDUYt(96T7nkXV4R)qhI}8X!^!^{D$YPQeg=rEDDFtAGecVXB;&1Wru#lpkG z=lwoyJDO@<^U?-x620FPESAkq<+6Uf*dC5(=>X!cqAV{HV0S^$GJ?VdAK(3rGj(!L zB;xgHYrGnTkV~t_0B{wuU{ufN3o*$*2T4Gg0l--KZ?3yzNkE1q=5eFM+S;0(n+y2$ z>sLTPnQHl3y=lzt3FzSFcuGVVuOG;R0Ip_w2LJr;KQIOG`9GKcU-DH{KxxY5c**_s zIp~nB?Ct<8sq`}fn?YJq(sI7cxZXrRFE5kN(Pa9xldIEbb!jH=`P;W|o7FKfvOb7v z$RaRG|HB2)Cc_|Ni->3w@p+lOc-#mFDae* zrl0@)`v-+}zn}e}nwM|6P{9O5NO`GY*^Je8SKS2isB$YmP^}}jLu7V$HXhXKZV8QM zlg%79XU_|_Z*QTbylB4t(6xD<*@VeeFmifde?Nm(q_~vS00F=5y2s67a1h(cOn$pe zalQ8a{RO(3T2|Lx)EY-nG)zCpDhWGRM}GUQujD|CMQc}Rw`5^vZn6OfXVMpm zAra%@;FvZ4P;t3kum(w!D}vjcKPWTB#ijK1%eJ?+V%k3>CjQm+$@IS5p#r4>)hK*O z3;~cCmTI>XC}bU6T~({fbUL|62_(4xbvqfmoF9+6tJ58$kG6 zfY%;0OLlg4V)!3;=;=}B2)UbV!DY&mX>7I5+Tr0V?WPP+tD9tE{I5Ip#n~Q$t^GYc ze%M*Qw_`~(V=5Z{V^5>UOI<8`hx1k1EI1n_ALK~Zy1d2gFINGNq&{#ur^WqA~xE*eB|tuPUR^DLgSbhM#D<8WyHeh z10t5F*f0U7Lxb~?%2;ElbPJrb+`Ao&p?c=3U^ z41h$UWaz$0iWLZ}7uyPw-i;Q$O$X95?XI#7@&*R`Q`)~mLnZv5w#!H>%o7N?U39v< zzHxF6f0&k@zk%*<1n+{Aczd9;2*b3j&yH$5rdXV6-z@!5X3!rnsv zY?p0Dd$gSI2_VgrXaxocB9x~~u;;B#CtiAHrgpPUCSW^_)^lw>-V}roakQv}TpiX= z(c>u$01|q*cOye~I+(>B2Mr?f)V{rH{t5#kGA|36<9Ko<0O3166GllGEz)bZIUkWS z1gBaqR(3m4A>0wn=J?Fj6m@#s+CB0}zDZ!l#l_L6R<_!08UUQZj~_oG_9h>1kG9?P z@~noZw9WoTw_#x7W2<(0Gy=)-OX&XMBNj1nxBJxsnOMxw;9z@e+d{c2Hch?bzRc^Z zPaa&&=RF2B3{1@3y*+mQXSMa!Vxdo|@_j|B3I9{Mm#wXFOQU;1q86H+!9n~alY^q8= zHd!t@Bi*1z`|@;Sz1ES+%+JnlGrmyiCb48?XZ^#E zpYMRz(*+d;rKvpxNDG{Fyq!QIa+7dT1<)3~S_>R{vvjMnI)2j`9Nud*0dKGd-&&BW z&TBR$(!%*vW_#_fZkGd0x&fH0@o2Gny<8QwnEz||4I60%@xF9=(+CZ2HFkF5$D1S9 zqlNhcEC=re*<8)z82*>Y80aQ0eQ@~B32f1tbI1u=p;94Zk1{5ZAmS*_31^C=EseXh zd=L>35a7-I#sO>|L`ZlQLYeL)o!wEB);GOg}TdAncuR39k15bOn3aK791oa+mT zplw**MKE*&1-#r|1{Y@Z_~Q-!P$cgBT<$jj#Jq}rZ1fZktPwg{*|MBrmn{5Ryy3$b zmzEZ8t1Qgw&UYK#lF@DFvlVy|fkh|kp=jeU;t~2X|GPY26l`?YQs`TVaGd`3V9MtL zeaT;~raydjza6t=+%hmQu)hCH;>sJR-GSqe=n{zic-L+}z}S9_gX#QGKixf`+VLza zHIn&}-DbsWF|#=&I2hx3;5_l%&hz$I#G~DGh*dY80&rylm(-ECC0SE|w)_qvRH?5^ zcfU+jy0c!Vm)-U;G1*Je)xQc=(m^9V8%-##(bvnS=?pAYHS<6JftFC3MAdl;Pbz0*GW&zvD}(cDcIBZ$vgT|R+v8=LaK$8g21sbAYUva*lYp(JNH0Qdx>c5ZNc& zI}NM*&XhA&7ZUl@~2%&nLQ)|ZB`OXDqSoStHz0SZT z<@Cm{YVF|-n0ntAutAel1|$8~uOIkb7Av$vW*3{RxuwWieCnDlmh%*gqbav@shbRT zEsRQU@YzWZRu(8EfB;7koz3HTYgMVy_?{M63GneFvTwv~N51jD++PKtu9-ds;14nu za1=E7ygag7;b?Sfust6qOA1&nR$blP^j5nV>ucSdxUL&=1T@3bpcj$}$M%aw6MVP( zq>i4tof~q>pr^$0tuuGRahi8dKDMT_Ne#0lI5*c#t|KpX-OCkpl5kZ{i-jP2u(;ek zeo!g&wNbidDiV!F6u=-!+JwDEw< zagDvOEs__{U)N( z(`_a)-rwQjGMSSV2iFp)4T|uLH92#e`Lb_ajkB|}XyAn^?HGv1r!&W5nNCIZmKI&I zd#TA(8j&3?XN!E9;1mLM^nj?tKe=Qh3fW%I%YYiU4-Xx97iH0!)t3z1 z-Q9i%M7O>br4SFkF5Glh^T=vU_r+)i;lSKh$2Z|}VvUHyg-XqGl}V^9B%e59eiGy1 zbc*P+rP=@<^chHQqUW7K)IZKg>^TC8U5;LcRa%W2YN&a5EFOs+jHlg_`!Z%jeJOai z`D_+{eSCrF>kY+hFx zcst`u3Cz$bw=c&dwJC%AwWfkN$ksUF?R`^*n|*IXdngmZD)?RjhGGueQDliK+QK12 zQTWCt#<_$K_qFH}e*Nk9C(Zm{N;Yk3W5if$d5EWRqm0kG?nrKbn7w7jPs90!O1MFZ z!-w9txC0pH7;B`m2a~Gj=HCo*yDWzNO!D1|rR^4cvpJZWl@+7BL~U~NXrYqd>mH6g z7zvlv==_U2`DuaYU6t?TljqUG$piS)e4I|FM>JtVecYE!G8(eJJOlq74u_qnZ)P{i zn?nB05ar@NPl0TeZJsUpo1ckktZFIs_FXkQlJ4RC;ajN>PPWP36wwF?S&sH~FI2Y3 z&NrYr<%u-md<=8q2oI1qG4b)6)Z+Mgn7;B`fkrG`AW>v>UJG1L|H@~PS+lQuXUG2j zx4CKm=6&~JSLOKJGS~CYhS=vijIHMNd}2;E@p44TczC~+US&m867T3M<$ zMO{&--hZUp!lFrW>#Wo@UJ5^MA}&i?3d==^1O1qxj7`%F7gG~3>cv*y7L5x08E0J;4}heWBx_JM83z2010HG0Wg|^L zp-h`G>1~jYPnX2iG9Yi@%}AL%*$EA$*+63VIJ@og*8LB0#-?G4TyHt^4IBWagS0!gmKOU47riyZp zXq`~S zVkJ9T90P;9-%{UtLx{W2yHjS8qhRZ-1*!d3>td96-TwS&FCU5`Oju`f>BCDB!*DrSi9&@zuv(fuhJy5wLHf6jieAD>Q`!6wcZC}+ zd+A?^fDsAAHOb^=Vtufc+&0?6KlH@rPE7pUozZ5vqg>BntG1F9B%+4U18KFGc(23K z;o9AG7m!hV33%*rqBi+c$}Z>N3!Xs3;3A<4Lz+Yqb2<_e?mFgu0i~-CctN(+rdFx7 z7D_cJFgREb)t~Sob%pSwx96O3_j1Srqv0*MZkd&Az=8Vs47d~C+-^Mu!mBmI% z`SvF52xW#Dv?JIzoDPJ-Nt3Zc z%zlW&?x~{TB@^PSA*___Ya{xzw$sA1Ld^XgiGWO~Cmxk%00o{c_!%!CT-hIe0m*fr zusIjNWxoc zB44(vV(>`BEgw&+x4WkN?(H1FF;Hha8Q0hH>iw8$_=!#%24R2wZ$_mxwc6-BG5q%$ zLXj711))HJgHmmm=*9@zkwlg9_yvSO%Rv#EJ64y@QSoz=TG=(uM%kz1unXgGEc)k` z33$6pNIQ1zx0>=;sMWBH>?DULb0B~vZJPvMXMUzBfUZs0dwHqe&L0?(Sk$3qTo~AW zt*AJu5pxQR{VJPNM^2mZKU~0O{{hsP7p*z=2EgK;U^T&&Li^9?IVB@GU49Oq?VjbC zu2-r2wNDn12cEm77H+!&inPekAD+@0cnWeJQWMJU>EknyB2evHa=kk%=~_L%oyI3& z2*vH}|^{dzwsGqIOLr{o&%f%%m1h4)?9?S^}30=0+F=u-%xn;IZal}Af z>fYeZ6PSAJxNNefPvjMRddXx@28ckip45p#u4EoQ9XuX}?j|>5pIjz1O*ZN_pfGE) z7IC)X$7D~Bn%h1+FB%^XPG<*5$h#33%A$Z@pEyM#vz< z2(&X_tU75qw)zkO@PYY7Pt)C3*>2A>&o=Q{StPP8tz7)Rs*w9P)E6W+LDb!zDoqW~ zO?DW5ot>TW35wx$L9T~iVZJL-8dm8fNlH_C8K5I4d!&U>MvS^D_n0VlYA`}cEc+CN zLmAVtw&%Cb+5eE>9(=^3a{2~pwRyKTn*Xc~i!=PasL1>WoJ3s|245ywmm}Y9Md7mv z!kjMEqTd&Bdut$knzRCV2b&rso3$ou{NjMd75db519od zujE`O;#4ss5H_bGyt=0H?<)Eh1Gxvs_R(DrZ8r)yuoz3x^$vJJk4GTtG>O8a{MNRu z+8XjSh=}%n#?sju(ukkt^%`IxK<1FW*7^2qxmH+G!s+?2Vx9~Yo3!9o z@rjHljl(t=B1Cd~2zkbB(||QNICzQPk3H_+Kcr_qo0H?5^1Sn#@rOaLgV{m=&|ucp zoHYhz64`uqKZpm%8y8+z|4qK+IT04wMN%v#Nbj~>v?<^?cF1zul5gL3PW)&bq~+7% z@C`4lI5fWz{^<4C|LLsXhJla7XE0p(>5f>f)1z{G3B7M+u`ahiH&7#bTB7W}isGr(z+RaWY9&@+GJI{QN7B;HQ7Pp}GTmBLIb? zKR;SoQfh8aAtUi(^`{~24$XVVbE?{hHYUsLDfCh^ZMOnnLLKuE;Qiv(811hn(c5+i zbB38DF^?>T#l1be&^9hLM{ixL#%_d+B0A;DGm0)b8~bF(VcQzb)>dsG-NoY+i~C|U zpH$hMz6YoPhbcjv6&8bq2I#J-P+qH>|GoQF$oR2`(qg7 z!^PosUe8-1K8L@j)H~f|C@hVG_Oz_eYM&SA1`bZ&R$DWAR!ZQQVBTx2PqcR;yhbO! zD>eEhV0fL~=>3wD+=&A>RMjf2I8>oT>;xDX3C;X%SvY-Ut~}Zia9NGDlT(R%HbOH$ zvL28n0T2yFg5uj~7?(I10N|6J|2zj1N`p5V%W zCF)~Jp46=Yj^9PbRV$QkF9^9MVnV@9+nlWOTr17C-PaXd4GdTLKY=pRdJ|ul2z?1_ zb~BCbh!O2GUfx6^s=kXfmoJbdw7YXx%s4@@`XbvlKHOMj+cWgh|Jwz%}hw4|e0?7QO%*O$ADY4xE^ zj1sd-93C~iOvF*c8DGishy{Zn_8_1PH9zZmq5Rrlr7XVaQMzv^7t0#^INfbfoE0A* z?%9K!La*6n4MNc;1pVafGTK0fP7C_E{ud}nOy~CK9PXxDTJ9?~o5>pCj?6y?qu|nx zX{2^2z>p$t?R9j&iIAb4ekgaHUh}T^d4-|r1%WJ|TbRb;FZG;ZQHca!e%NsK(eD7q zl-WMFFoy^(t`VIh4W+KRh1~S?r^Cd)Lk3H|8+`IcFDK2Mm!Iqb#*c0zOrKY_^GJq+ zgQMa1+x^^hld<6HJn(Fj+l2y8gM(-t)JQ;1a5mq|D=aaI9Vlz%=zNpx%=etB%;|nV z#~&K;^mcQ1m+j@W6pqx5d4$!r^2>D~=GMn=L$3cWUw^8>R=^ykI%fi<7~7h4k_Q`bmZWdoj%Rbv7wC5pvO zJT1nEra}bj`y46LCIR5!xm@Jf*DaVXT%suhzvcg}Pwf-aU%;z(>JFlaH^!Mt~MYZLP z`>aJei`e^{1}8LGI6+XBW1eibj$76nozAbwUy(Ew>9I3ra-y0&MD|+)7tt^zVnD-0 z``y}sWHN06>cT)YmOuf}S`qLOoP#u3#tCkB79sEdpnylOb~4TLQs9NDgUyFX$w3O* zG-wa{A~o@wZ@vn-LGO~PerJ!TfTMVLCI7;Ewjf=8TR4H2e#uI%PsN8s3WtBnrv6~I z72StuXVVZKq(s?FS6|D{QT~z zxndGa3k^UzLwP@4N5@1AAA-BQVd-6HaEVD*bJG@OLF|=iV7umf(Rk0e?%TuhOGiF; zYSZf*MT&kHWn?5fW%=K%C>CG4j?>s2i8k=qEGV;;W@=xQnC$ynfCfk-G>ofVLB!Kf z^aDP-hfto%i3c8TIlr$+hm{Kv{!MDBhr?3q}rJiS3K zBpMLe`$N(Cr}pL&m)o4nOI^XW4)Yd&F?aq2Cn>hO&|xrWVp-?NulDEob8HaRz-lyF zO!oLg2RfUnTOm>I<<8UtCn+~O{}!uJRmQuou)p|`PUKE~ofhk>(k@?Es?A5_J8P#q zo;pjl=U6)}S2e6fL`6bMJaUnk7jP6})T>WrcK@tW$MYB|v*Z4%#z{FhM&X&H7mqpiNCr z+in+0&;D6nNQ$J^==8V|jlm0BM{+?dw|?13hs=h1{=wyWo8guC>++Gy^|ZnD%*Si> z@$QVK#NtnFXNCkcNcPv)nYlUWr^URxUkyP8p^@=PnacGh(kXoi=4OMT5iznd5|@{k zC~)Tt#eM1d%Xvq+75FlzLpY!V)NY!#IEuU>O zaT*d5NV)#nDlC?9a9r@mKrP!-IoloujbDi=~>yy6b$6me>c0JId0c9>F}1qI!B8Bk7_I z=5rdKaZJdXL4#&+jbh;e#76w^`9Q6916P8X1MYh)E60{5CY0G&d(@n~YDQ z1AZHduwj6aMZ|@QDo;1^r!KY2`1jhRv;?+<$DBmuTxqwRF*bNgx1nM!*895P=X>9K zlFSu(F#aRX(PENA`jmtj$xtb8&vFrmaC6d8eM~m9`hMs&lB7r5H*U?}bbVG>Rs!FE z*A;$|{64r5tVS2b-aIYp7m2o*Ov4Gj=2$2#oRbJvboQ%X`tGGQ@xnpIqG)8S9v@G4 zfD3dMgq9?(f`K<2rUpg!52G!z z-^Hg8iO)H(lpi7Rf-6%9ImO;-y<(%&z(YgU@4yyaSpfL068)!nC}<~}sy2LBd{0+J z&tS#vR2-`qPj2U-IwNHj32LB=3RovuK}>mV@?X~R+4*IP>EB6f35T@KrPoRtml_dK z==E=7QZh|nzcP2e?@4QfY%EIS+etNT;HPi>rLl)l8P?ke{1Nq^6KL(>rEGy|oie}l zllXiITr&Bb%S>%87!(n=we(X_iuS+Rv{gfihh@^H#Y&ptimM<@L2Dge)-GOo7BngE zBAb)=3{a%0R*g4tr~7%7G{Cdbj5|Chat4qE1A8Jb@&gW@-*jxu0Nq00YTTBh&-nu0 zWIqfr~#bh#{EG0PK|Na@9Btx+Z7STv6sq5%;8l_snzlG~otE&qN!)K1W zW$KZ~{METw<6rgS*{Xurrs9lvJ_qKX2b=wUc+yG7%{F67hndAMI$E6HW^TzIPcj=n zj7l9}G%+y>z4Wutwaek78UKuJ+pD9}80(SkvoyD;$>FK<7~ZI!7tVw?NF(dXW|gyE zNA?s!9G<2xhUy5k@%n{v`WB1nR{q{u1ZdR1qo=sI$uV6r+-N$>f*IY}r?=@~CVQ%} z=f%FuhtSWZ@X!RxBptCYHE#7Rk(cIh^0SPgr|;rh z{Jb+dR}=-RQ>mO**u;r{@ofJ9BQEQ_BiBrgv$4|5&W;d=!gsJdf4?tCXy7exGhb0! zYb{CDL#I_lBkgyzI9DiU`ZPIjg$5tCxAc8H2ySl5ds+RVgMtIae$W=jl>^+mfe!IObIItz<$_Mfj4|MI93a_i%9eK8eO;vHEq`^AbgFs~-*G|@=8 zw;QGlrvO7qwai?-O8tYa>`1ej@q0VDDS1<}vnKQYbHXOh_PI~42eovD5k7U?9Bn!#IO7?fyg#waZ|C2dab zcdosWp3LqZd9!z|fwgyn_ktu@6cnzMBAd=eeJ)P^ZhQexbyC~>qvN=MPZrY^x0zpU zakR~PED7QEArWt6`n$82Hlu+2DJ zA~?rJi+|bPOIH^u@ygB{52Y+cil)LznCO=3&8@#c(b&}^_ontmm$LctU+z#3_gCs8 z8ZsBF%Y7dOruTJZYk7aqT45=(IHD^{5Qqkr1>%P!3T&5F3KUvKiO`*&QW0G`86`f9 z6VDyDvF3?huHsW&P8`lVK|k$yCK4Ue`|xoYUd&OYJ&)n*6os@?QcwnM1sypFw%0g} z9X^y!+s?4Kw{V*3S2*6{8$=WfkttNqHM zj!frQ22ST(qG*+ON-c!-rJdWAL$Um)mG8mk*`d4R_)kbUY+hU`4fZ4v>nxGp+yg?mxN6zibArnnMDVAGCnoCAC$7gUIAY1tCx2D0~W>f z?K$M-h?s4nQP!2Jekv=)KY5pjVO3=H&+tv~8ogWDh#jTWmUE?6cB_k`O0LLQ?Bip| zsmp5)SxDRM2?;N07LU%a6J=ECJGfGcmj&PTdE#6GS;EthF>Qy#b8V~GYD6IRrI97;Sp_)#mziPz#YvgYW>49+A|1^4y_1owV}^z!+zY zOAS(CFEyTIYxW5J>Yu#os&naaH}|)H!EWy`zP=_;l#K`SfU~82w?Y`zHM0YWw4x4& zn&VQcAcW*a`!r+6=4s#)3J?YPt~3y!VtMA~+4g1Vg}KaOdoo8BJpiIeuaVy)Cd8UR z%uU2`vtjg9S`b-xS^^uUuM7p@51C?HYwHqBi%q81j+pRD4YFO!+tf*`}wTsCg>qTmEnx4bZ|*W%(3rvfs1wNB$CL6g^vwcIGgHLZ@B zCGn=?iBSugMe3~0ff<|pV4bJPjR!LIt=Zbj@rmrYV-4*Ko36)-6oM?HP<#JZQ69PS zy^o0yb5}m(HU!7i6rM0T4tFv`^e0ioKA9#dWaY3&7Ah%O`i);+fSD;@>XP<@!-h|< ztPJO(@=cIm3Tkb%x!Wc(l!HRP6;OvkMpJkDbcVA^h}-LT>uf<3A9o{)+xj8kmVJr7 zGF?5TFXo{2GZ>27UN&Q}TptU~RQJn1eGuc-^JF z)}o7wU4V;m72BNU!v?d9t~9>)Ovbx4l568Ihe@sAKoJ}&DZ+(-+L)oic5OPHj!G~p zBVNaf!Zs4Xtv4K#bKZaKB#+=K?T|7D+BF-W>Qo0F!VAWc%Mu2c$f9B=Mj#MDAT@rg zD8IFiRKpDYBH<1XFI8155K$y5%<6awnyvBg9c+8ZF-uto6LBZ;?h_a26OOsvQ{Z(a zImB(d`M8}~C@r3?Wk;z1eZYiGq(FPEbDh1=%#oS8)A8WA@J}Rrgt^KzT|_&Xn|}rb zyiy)3CHDp=nS7>#ZWiFx?={sJki@?a?bj85nz!2G?eS!5e(jI>Fi?GkMozohl!O+< zWR)Pxzhe&Sh82=W%s4N9)v0= zMcH1iK|D3kHJV?ZD;}Pf&CPNI2n`;sPpeRdJM_DcHtA<2FvOo<9EhqmAoejN@Ei<@ zXF-nD6f}cPx^ELd5S5PhK7M66e*3d#>MBHR3x0sRDnBcvluj*QFzvd^!*(e&3$ey9 z_~A%Riv2mgrm2f0PS0R)Wun}E(2Pnn%Cvu>qT08uNe6{6N#zUqql+t*w4YD`j?u|p z82enx+Ne8bx~ma`R@%eRm5y0Fk!SuHmiHYY_{0JB2$w)vz zPGYEAZb5j>(8t~22k`zu{ob4@3!3E)L`oXA$DYoP>aFy}UmyNB z$y!nOtwg}!|6Voe^bo@O1rDip&+l98D0Om4xLT;hE4LT5DS0$9!DBAxd71W2bt@*1 zbn4F}JLkRaHY%hqFCnUUy9~LO23@hzY-55GJsD}(8LdpllCF~X>lWb4=3`Z}+1p)a(2<@b;MoVZ=b zAdKM|616Ap4#m*hRElu}baP53Alil!y3pm0Nn4=`3lZ8pWo3Y=e+5w@mhk7oPrFR5 zNUKvG!yj#0%Je&-QT5+@pDSmn`u};Dm*sprUCfW|ynkI4)KJT}JR`Ia7aaRVd5~K9 z*0CT+u_#dpHG5I_bDMGYf4G2y?p6=O2 zmkiiOZl}*dOPXgt`$T@)3ixxiJ_gAmL#3Xeh1}C!T&+4;dA8)RDQ1NU7dG>KPYX`E zCVz6ZFY+?r^7_=~;&e$OSk)+L`=?BOD&CUa1SlBqFPfb&c0R}}_W?OVYfe7n?SMGU9L1f0hO^XA=a$>>8JQe z0=$C4vF&GU3PM&Bj(VBN_yIIw$O0jbBY&`2EVW9xy4;T`GEp67?vsmYU^ zFG7wr+tLaSAq+DqGu5q<@-jM8jbc>h5=c|hK14QcDOpa;HG$8wJiG;DcPxlVny}GE z`Qhr-O+<)f?PTbxs0*8WW?x0P2mn0uv!oX+ji-UDEJb{DK#A}jqN;^nWVu+@IKxP2 z`6UOXG}}@4A}om2mq9x-tRQGHLSs99+LI)eklUQgg&DuH1l}DrU#Y1xFEI-32Fl^7 zMJv*&j)z^zos_X>bUfOL-NE_A1BTb%5u1~XNtv#qNB$cH@rW_(lNEn9&sF-KHIg+! zS~1qvetG#pMbOM}aiF{K&sYcL(tkTVBnA2RnH)G&S4rP6_l_Ji<{dmRbk|q;YKUfk zj74J`Uo&U-iIOLbTZ!XHSIjrEFI<9BuM{Ze&^O6h)3&S9Leygt6jA(W+96!M*Mk16 z2G86F3gTg1#IL`8y-~6uL^&zoZO1E?wtqYHJ~Jh{Em1sLxjo2i7ts!*@2LHp&HX?D znf6yPlh9ADC-@M9v?v4af}OLozvRfL>2qE=}nR3GXHdXZRbJJwe}6kt^`Y|n%w*GaK3jNEB`>Obm7C^+*WBr;7aD3#8G03 z<70;VMfk6_H1a}>%RW@XNl{OlEs^@6p_dRThk#OonH`%lLJ%H^mE)K~9dh0Xb^83@%s2eOkF1RIQ^lo!iwhu8P82gilBg?PN%A%$j%E7LFZp*8lR>ri zCatQatN1^5d^-}Ey9WZ0bSYY=WpH(-lC!H{5L9)dffB<_D^gJ3nrwm9+UN(LP+u%+ zlqwg89u!E(l4{;~1oCtPEh^Ke%r++DYKc^klgKwYdxY}pxiHnBRLSpnZPfTUs;bdK zoNh$O*6!sN`yjTh z%DR4Mo3YCWk~s2n3Pfdxs)y`Lg!b4?Xzk1*4)tP}q0Im-%YEM*%`oWI(-(CIct|>x zJMeDYx&gXatD$UU1TzX2`3_PDRv|g^$(@>8tweeD>Uj0_DtLU?NLd0=<3^9-t~zq8 z=toCr{po?F%3_LJBMpki@CN~R_@htr#lGCVlUz#{vrl(>mW)B);evAs?AC~x-)rr+tvYskd>VZLO6t3n_2e{uJg zL3uS_n=d>O0txOOLU0T25Zv88xVt+9hu{`GxVw9TySux)JJaNO|8LdQOii6SRa159 z!RE{Kp5k!@@U4BO>B-wWbOahxoBSenGgu3aF4aAq%5dYoY@$gPH0A zjrPgdFeKPD2I-ws^J>$-Qq#p;Gm-vzx}{lv=rn~D^rY@Fn)&Uuh-#u#%C%l?t~9l} zp3_i$fjft_iHHe;ntiM8ME>tcnOJ3`lAA!sVj&LIeX_}1sV3iyJpGET z+EFG{fqeVTF?-D8@!9|_ zp?V}`nn#aqe^`-wKqcow_IyT=qB7sD5_isQD7TW=p=$;6anI4L0GmQrG`!22T>bI4 zn)rCQBzLe)T?k*W5|j%S`5fMgDSMhdkv$dAGW{AY@_xEfUB%K!b zE29U5-4ZiW7#C}J0UjS_gvj~I;vlXxEj^O&dOpCj+V^UZ9ma_aBhY(F&c`|aU_+te zx}m$w&TZn|-$y({DC?T%Dil9m+BS}q$>!YgnB5z&z|FR9-N^j)W&4q4tfVV;bMzbc zQ{2+85_GIcPL3?O&t&J0M}uKOjPQ+rTFA+oPfBIAocjtrG&p&E#*~Y0Hitjtt_QHT z^>N!#Hkt+`(|m1|%;~}Ox!-0O!zCSpv=UR)U!oIqArPjwF}J*K$+Cg5a=PZ*XD>ZZ ze|xoCYb;7s^sV(N;dF-+&k6m6%xIBgSTL-*>YK{eUg-2c3zN$I^J!TvXI1DGrR!*} z1Z@wiO3p*i-uT!zw+z2(-*air1p9BfOBoJI)M9&Nqak88T;2wy5?^m&7sz8=*SfExx|Y@-?%jKBI1TwLBS%{aSVna^SEKjlkOaXK_i z77@XsfLG|mSQ%)%*5UT6nhGj*U`wHIj=_C{02vv4cWuL?TyAO?4PW)_Y|$`I>i_K; zED%Nsdy|u$gpO$xp?VH8zQ0xby5uku3lUz+a2ZQae}q>tq}~tvnYZ2iXuPLU+rSLh z>-`kNUlH)gueubm@cGS$o zbuvAIYlO&4R#HfG$qc_^l|K1yfJK6vGKFN&9?^#W3r!!R=x4QiRT`t$MG0- z<7R&p{o(vgOX>DQ0UgTTlUiiGwOV<=(qPANG>t6cwX2;&q+0Olk>ye(Nt2<+EdtIK`On5@5|CIcil*_?mII z8Gi8?&|g;~+Zxs)Xzwoli;Lio+gaR;H{uMO7`!Riary#2<$=hR&*#(s1m!G3aOxj$zu$w?z zv1ts!L_IToKukU8e$j?eyW9B~U?PcQ0y9#-^pO&F-w3v9-@{Xl zf{Y9ZLq`mpCrONKco;p@7X`4S@E63QZ{$@uzXGjfC@PCzc>HF1V`xdqi#oG&XJw5D zFU5T4wB7reS6_ZQ=nwTFQyxW*qzxiCEB|!o1sV0_J`7|0^4CzPgxP9l$3vs?ck9=c zl@i2%MI^}&Z>@(9sZ-{+^PBU_9^SIOAB*f$E&}`^I`4#4!%h{n%+vEUL!3&o(?f37xQP5M%M^191ot?)l#O16;G z%Q>zD&N%kC?N+=wED-69yZG?8!4QtjtLw$*S7Vd;*_P9))8yiH4XS3Y)b=*{E*UVW!_(tsJ%pAmi+A=M{d=VS}3=BKf$6xVQ*t z=k;XG{pEDMqLmbRb&!bF)qg-jdi;HQ8@`@cSzFU4Q3VC{@)qZQkT!lp&B;kfL7`SU zo%xdt+?~LZ%wJ!*eG#fM0ATRFf~k7FBRjZEsQ19)HkL~ z34VQfrN}-i1yXzVdz{_qFJB?LG&3Y8VD9qkr(jIT?#L#_ZNelswY=N|@(4{Dg&6TE zm}DGzc{<+WJymjrYKNC5kVn6QhB^aD2mqQWtMo=PWjo~MgDmRbnLQBZ4?XQmUV}{j ze^_|k{X`92Zban!I|SpKr01JrITSK&dSmxcdpyo)WHDjt@SU4=!p9l&0jK*KV zbNygE?;#9_{zMKyZTZ1{3pKQ-hw=PDPK#WN=TqzgtTPSf^ViFdbqv{#~SRn7wCKEm(%oe#U8ht)4(rzR~mXcy(-8h+}7-&;fz_AbVF7X&mvPQxH+aWn2VRv)$ zayJ+M{ii;BppgsYglr>gx-Q+?yZET3Jsmv;>@+nGLZ9Z?8deS`_boIJMn!$nI(A>y z#aky1s)$QXeL86mr@k9*DGQkDMWs+&J3DEI=Q_WTJ2Q?gtpxC!j{x6+Jj`(()7sj4 zr~n`%oDK&V#0?II zyNCI?sZKk8phj#8)bGcoDm6Ve8DrKIOVlq9Y==EhZw+}FN8|zN>5Gh?{muv;x(rXM z%bf~PP#EreN$>$BPce6#Z2D-Se%~7kkN1WPFr~z3TF>@rDqkZipn+sOg)r8&_i1Xi zn0#j!I2Z0$lMiD_8OdYwz|7QgyQSvrGVpUWock)z+{LHysNp<5%{DCZ`Tp259EUY= zSp#rVHS29U_2`1uzA}i5iXv@N5)oxTgcS*eXafeJwN_oh3e}gS!On)>41s5Y@&55) z&ieu1({;0PY`dd{-89ebH*el(yG#~L=1TgZTUi1ZMCaUx;KuURNx-KJsqhNE1R2h6 zWG2DX2by|jn36lY9cLB36z?m^##(+qlBC{vjfT2W>g?I?`UiM`nxB{0CF6IeOD)6k z7B_eG`{U;|uu)Mjx6~{h_ZF&ogfnXn*`_Q<%B0eswA?SN*Pm=UgJYZTFWNWH0S1EG z^J#NB$+DiYNTokVn`*B5_@X0PisbWW()XEWgfwnsxBv=$t|8=x#huBx7u)P#i`Dr5Fw4B;W(u;GZMPqC2E@h0qT}IB=j2(e?hxA8Am)F#9tmjoIt-0wb|a);9nGp4 z;dgBfCiXf4&y4DP`vihEV&taI%$)sx(i}%Nm`n&Jba+`;;PVAqqA5m|HdrI$sb@>7 zK2wN0IkB+9V+SicN~F}d18s7(dVgv)Dk{-t_{VO>S5hAH*$RbJkGq6i7-gHi33jz= zlidr}k51a`-`A@oyU07_UwX;(p`~{o-!S8v&+F<$Y#A%(pO6!Wyd)_u>xl)bbWMY>< z;VBD$@{_U1;MdX7DZ`G=O=5gc@vWE1Ty8W~nr;$$~e>vz6$L=Gvjwd1=jtRIjIs zRMu~|Q#(6=S&>R<3(skVi(89ZoWXdEALS1q3^|f>G+8clEjBu~r{cjk0B7ESR;x$* z!|i*)a6BT>RX=pim~l0qx3K=OxML0W6!K?rR2pIK4{4r*g>tz=<5JuzzX#dvjE>jW zeVQtC?y@^_w7)R5sTm92?o%5)D8t0uCX6h~V4OrZd%~sp9f6{-+v?d1`JcN5^YHSZuVi ztlV4@^185d#H@`Ae`41O$NTH!0FL16ci29RILp`D__k&N{$zi;{F!-0EG3c{{_uTE zDOGF5gQvBhv-|u-h5?|rYZo1Ilb6^W4!&5ez*d`1@_J}A>5-+LM@wEfQ z6oA+Jf}b(%4)hn*Z#QZWyw3&Lh zaXDG^#i+@YV6YgG7T}W3H9tQE40GLAEyA|9OG#mH!|9W6=H+X-Uc1~Gla*-a-XAqu z>Usd+AfC$(OPfvWApyqLAxhrCH5#5Yv8hR0z-GBwgs~R>-8{VSTt& z6nSMtGMGsk^yPxnXvVFFhinlJyTbqhQzSm7uBmJ(!6%Af}rH zY@Ko0P(AK0hATbzk)ob&cQWA#33>;|g-PB()ltOL^dpn1vAwenR-JI?vv(e*R`UpNvoI$36r=Ws4T)PpUxilt}ZSNi_+@Vtg$V`v*iBsSSpIjd% z)O~+{?|Cr_Rny%h)nXsj^xWUm^SeYT=FJ8PQ8RS@r0VrdW_ZuI=wi8^!Qh=h>J8Yd zZCntyzkfccl-k$@s1eE=$(V+Tp_J)hD{LlF$fMisw8{NTqj?1vC_?jtsyKL=20)uZ z)nBb4iv~J!QU2*_0A5V)W|J%g(Os?4Y}*le(<1P!*|^Bm_||(q5J-XNchKSkN|O*U zW+F^Y>FIxKLozdKygrD87d#kkzPBCb(!ECjWoHK8c&nl*zQfgp;P7S6Lnvibx{OWMz>=jU-*+ zd-kSF0Td3f8frs>7wXW^(EN>9etABFJ}g--Tg5a*ha}pFT`gA(+wWfH&P_BTKftMj z<>T87I!C7t)M@LhcVo?+_8Sj{GX0ubXSXYR#9z)_^LTF?99XPcYp32~ohg`_nhIvD zj4yrj&44`9U8}~r3!sJl-}A`9;zHh)8M*l5=I5pNrI`KJ!MC(1lHVqOSi5Ts4GjYV z0+<2+aHqBz3!6g9m*Jgt!&je(w`W|8OpYR*m#jqbno88+NXXIS_K1*@9^SV>p`nFx z-I-o*020-4YoLfwC~U1{gr#l?YvoQYNpbY|9Uz7f`28Y6EUscNf`mi1(Jv8?F?eJc zIIR0NPa^OC?P7S@b9N}fZXac0>E!$?S1Jt`7f*?CI?-Ycr0VD|o>$Jivo@e}iAc8X z6s|6;WHq|b0?jmHAaa`)LG=|d=^sd z_%$AzqIozS(r7t3Nu-YJu{?<2tr$(GzRn}tyv+&6W!Ef%*p7Gothhn69Z*Cz7~}Eu zlz}9p>6FA&9LqD595sIlfQ0aP|%H{52S(7Rw&&34C1H1Zm*Ze$=syzU9 z-Wb-(-Q~InpYjYa=9CE<6xiLdpOjGL@&Pk@wR^*(JErMcmHr$6_)T zqm3q9pMh>y%-%#57@c3RLPm;yk;owY{n(8^c<8e?>K5yp&0CbJ1sg_(* z&E`hQ-rsw$RW)ZhUjv+sY6o#blxPB(98iw%*Rm~({ISsNw9=@V^m>zZM1R)KzVb@3 zHvego>%Zp5C+jPg(qJm(Fv4GPVWOIZPnRLIj3NQ=T8!uO$H~;uQBh~$os5ugWvoeH z$U+%&KxcW${p|FzMiPLTXW7Pl!^6XMA2ao2W&5_|g&lZpun%z5UID%_K>!{GAitMb zNI;JXm%$accdl)25?xo#l-q6#nm&&oEjAFe_fB6OFH0%kXOlzqvf@e?Ly+}uR&?nN zbH|TYpEn+R*165zB7U2(dAcFYWvzB^bm)7~Y_?&-bDlwA$7V9?QK59irufq})#5(X z{&+jxg9=k(@wm)ed1ef`SW}qAw?a*V`BS$`Fc-p0e$%qsy^I z;E?9VA%2Z&_)(kt!YDfN?v5vW?~S+kuOCFGUD%d~Gv(t?)-(c(&eII_3DpjVs?3W& z$ITI_bZib^6Ksukr~OGg@lO;(h4P^{%aCcI~^x>zNcC`{C4Pi9{yLnY>=#pxVGs5%idCe=-EdA~0)w zF0LxClvr%H06tlmq__=+X6=hp9}A&cjTu$z0Iki21b}BEOvNy{bw~i5zUA*3gRbya z6}(7*5$*zRJC%Bqwj1lS4k&yN;6>z`S9?6|PCMHn0PxRD!Oe#TQgGLbR4OsYZDHoa zxvp-&-m+0|cMR;OTb}Nckck&`=n{fw^r42;k5B=Uro48khI@LUcG*4r8Y>>2`#~8j zdoS-i-Q$I)CpooGR(h$20~i`rwc;gWRe_3Im8_nW5*M{5VzL9^Kg zRBw11w~3xe@c4n?%uJQ@dF`?X4g9Ir$*Og5%i~=WcqK75HnxGNC@!BYP^dH>&ryzc z`Y4lpmSp3FwKjAE%}i)JU99G~@*A69r9Fw=;(@%H7TIYXxlQ=}`EI|6B+X5AcR}^bZF;GC{aGiv)(fLW zN~kc7iblQei|3y<&*v1HqS#h4ISW*Ng%`hwfMYdABejmJ)Az@l_qI}$Px~7c-j@yV z79;)rq?=hEco4!jMz5f8G)bUKVBk2D8%Clf6jg>xSPNmUS_@Z!_efmyW+=yb7>ViEbG6*uH3eWH)kOm^ zYLONVBY(!b05Je*>m&RPB1ay#6`j{h*AP>$$RjjmQG zfB;O@mnFAz{)61Zv7`^M3Z9Rwjy(~7YMKc-ITkGIH@CRAhIkkdv!}AfwzWJHCiCTl zvTu~W+cKkmATu92u5`WFvA9Pt^17#dwjIk%usKRzwn(CzWn%b7^h?s8k=6Z{II?lcrlyjWqp z?*9<>D71TBZ4)}%r*0*4#dLfOsUUseWK}{EHR9pRNg@QlOsMkqI zUK-faVW6%xn_Vky-TL%;J~arE(bxy^97WD#*- z-)zuL<+#!_4P#@T;e_H2z3HtW_r>5wSExzMqtwR<88|!+I}pR97##%`U$gNX`)U(` zJC;sAVCL_m)oGq(C0-^z;gef0Jg0^P5>F52Qs4 zl&+*+VkL~ml7MCMh|>-`XIjx_?=lAe8=iGClU~=WBMD()$l zJH^rSR$*^ue{1V%GyoqE<>e6am2D~*__O141E}MrA%RHpIVhp`*|#*=(d2e>0;Aj1 zo_CrpZ`aFZQIH8|r7D~pk+lIZb2+b+-t<)&!Ah%rPIfZ?+M~LmaX?sV0{3Cv}yrya&fU)MOhha zI@8?FPCwx-i^!K_tiXUgVSf3bRctPx-T;oUwvkc7wGJ`461XuqP(U#4!@Y90-=9L| zmlym$Ckp%k_RNKPJ|cJBzjA!^s~{MuxtsB1{=DL{Lc0sgNaK$5zOv z?Dd%b)?AW#L!Thd3uEO{jk+p7=bM&ysJ+NTv}^iUb47iq2D`Yai7 zN@$9T7QASf1s}=sWl?CcU;!*dn1dthle&#);(vWHMY^`SN}>6|S+BrkFi@?R*2V`!1Y70XTx+~s+y2{A!dVwm(8G;LlJcnQ z6UyGNl$6lbLBMo!#_2_%wQE+UT-%#;@gb=Y2OXAg|pSBw@%)Pi|*-&#niD@PzB#87H z|NM$@b@dPYU-j4l#JQs7WCso2S0t8(=Z1uYP}8K-nS8KnBWTw*Op1z<6`!JCz`k(7 z*Rq6nWd2uiHa6Z35+O5S)y25rTGBLvkau=LE`WXc36F^h8%)v}a-4sNt-ZCQBcGpK zX}plI;`-f-PI&n>_-g{JH5EHMJ8b?Zda+&rpen(e;^CV}Tk=hOm24HaS~ey(J{CJE z@pALA&Q|nOQ;%FyBq??!@;H7&oBAWeiNM)be4O`v?x)Yv`&$kU<~u)esBL<*4X8ac z+G*6G6=(z@157iLt# znuK{y4wEvpS^Me0jU^qr`LlvA`ocBhtcu#94@bn`48<+l8d$t&v>E+0y~cqCr;x&M&#;5G37<^Q{g_;3-1f6zVmxF)ItT_&1Y4)F_%nmcHYRut9?461^li%Pum4 zn6Bb)okGZkA8o^YH+3Nq-$rW_>OJK`a+6}HV=i)HF>>WRSNo_NaO?8!wKF}&MgEQ% z(dNWJl?g}7Wg^vo$96j zn*5j7^gfyxc5QidvP&U7=)}!kunq(MpRW8>{S? z)Qe0e0Fy|dC^Ri~k6K;hJ9wqjJ<@Y81A`RUbj3YbtD0T&DYVCU(~Ce0T9F?XFK6jO zSpV*xg>l{S?VL)3Az~He#F&lGC*$71$M27RbD@=dnSWxT_^TTr+r||*m}WTY3+L#Q zv4%Uoxh~9K(Kkpb?yk!vEP>PJ;Sjj12gY_)-SzywfceW|7;3-&_PNixO9p&xBc9-$iCbn$W8yy@`D2v7sxCX^#HzZrz{1PUSWRkSK_JT@c)2Ec8cj4GEOo+sGrpp+!67_N zEV4MBY73M7A}*EdyW~07v1lF+btC5s_yACX3#=8Jh&x1R9F%N+fjT@tT%BWfrp3h{ zR6pmu)GNHyeG^6=RFnmi1h(Z}NH%ave=?8{l zj9M3$pEvdmcE-{TgHnwX^>48hhMg6|;nDbGk&>iE&fONW01~UxTlRZDRKc4@>xQpze3|Y#DEeyJc3JgjSU`#WmuT4X1wM1SvmtJ3q zaJ-Ciar%wS*U7xkuRvd9FZt#=j#_p)j($|2L7|jbM_SByt$lNhNl~ZvI)W+oJ8l@t z@B`6APSMoYS(r1Vb#@VCyppv{)bDv`V453rb^jsHMm{rb+PQ9^zx;dF6@ipbR;Z%;cnl6R41boRoX$AHmL(!rD7(7>qrdtZQ2 zf8nayL9RE6SFnsT#k3I5r_!U$!^qn*8@uV#+jSr5q9hzPzs6V#Zu(jvt z2dlNBl3_~5@yOvO8foE>Nran`;HDYArz*aNjgUl$2tlK*Lt~qQ*!--dl$2z7htIRYZf#*s+M$A@kgut zQLri`)^>>UYrCbAvUb0(s&E<=yR=j)4=hG*7qDIjZ%tj_#aU*_<&h!K)I3RvLTr}= z%cP0eBKR`R1vqp`g|%kvv-*T&oG3CW`B@ zNYe7Cm?RqY?7X+SlNR^xp^?0XYAyPDN2yw;Aw>!^%sSQ2IkkmZF&CeW*dJ6d^LSji zxAy*|ce$AmQXdd&FC&P0ZYpy{;R@ zwISX*Mhzf5(>N%sW+jTaDB?Jmkyn0f@54Knp`f-h?l^Qsh5>h!*XRjSN4QfLRI8cq_J8-Boe6#NBU}h_ zD_%6Rotb@R;#1v*DN38N?=-9x_13+kl=)I*a%%xq-G{vLz|kRLZi_VwRo#QU!WOXg zx-pc*X6qa6tz?X#^J}{KSpSIhhw<_&AC|N2M_J_hvk@OPQS_pvVWp4?zu1Rc6l>FI zQ`@oz)rmOjz-h-vQnZ8W)?Qn}4l%-Wj(JIfvCX&=*+3PWkHc+XM3;HQaXFa7er^7G z`tXiy6z+b8>q?twIF-O+;2yCH?Q#TG_IUK^n61|eJaHS_OCGYvpT3^CZaC&Jk9~Q| zGWMmBe=TPH*k+z9_YG;4VWC`K3VSF`@Qu9jFBtY7hWGfrv)tOp)d#=irFUofMTP$? zWk~(J*|f0UAK>Vo?)R@k2{jFL-|pPYTocVek=TGlDPwnYj&bU7dAE`cF_QcUln`o3-JmMXkXnX?vjq3RKL zu(Vh%Gc`8*dd|TEW4==n8*tvN_Svb3!BZ;V;FZzM6N}?lm~MyLQp5tX-zDTTRc@^qv}~m1FspG4kFUUMI{9gjR-q~n8P#YQlm;Md2Uf27 zRvEv!7apzA$0e(Nwr|Ba4N`VIm^%Zvma9%B*b(tmr;{LmfTJ#UM_Q{OnqbWRiwp-F z$#9$2a$NM%nrwD01mib;5SYtOcat`_KbE0g;Y2haH_q%xgHv-lJEMt^Q|gF-&n6Mc z()eJNrTvG?kH$w3O>G7i<#Z*B4Km}k$<3xXPB20zqiNzq6Gf5rhX1uKIhs*Q$>htQ@m2iF#oMGv|GH{bFZDyS2)WIAWZ07cAGYmU+c+$O&LG_fNln0Lh|4ya)qwFFJCQ@_Y8a$^ zQ8qyydI)=(0crwkcD|ar(kaSY#n65b7Lc_^)pJ5_I!3tEx%O(x)M?E1ID^c5F*?mfCeLU>EHLy&ZRWv>mHIy(zhlaD9AKq_|Q5b=6ok`P;$n8ySvFR zqdw!BttT9dXCzbTRBRynhN9JHJ4{eC)T?c-#&2P6*fOQRal)+SCx_n96y)`4#^7nN*k~LxjVg#m_t7N z-2Fy{4R02mq18nOSckCmf9Sla`?avOGJc!<9r=7yLq3A9z5KQzS+P19PavO(!1KaF zbtH;Mb4|zFULuR(HH*AU(_+@pOKjYRzg^+EQG^ZoZ4^{NnT;3U?>@_pP$bgA%k%r1 zHmI}C!W(OoRLzXrKgY=R?UW|+BD;PxT8XvmF_nR72~?8g8N6t*(!LU$%&p{N zaa-fs_o%0T21|vzZvPVt$kjn#V&AxbQW+I9J_ein*lZM1~8ak2Hmo-%{ z3bf54#asJS>0pG@Y?khLn9glH{p#&xJEZUbs7oNe;iK4df6UgAiuczUlra|PigY!@ z`HlsCm^(pZzuJ-v5ui^wO>`eel~p@9EcU7x+BAtw^G!jxll>`~tZ<^UP^w>~VkBkc zYDBFxwj`NZS=Cq*Zr&1Zoe?*(Wxzseh|OYb2M4@N_bkR-8{3lRP;{?*psWWQ6HYuE zNBSa;M6EJ+?GOt83)<3nW)Bo4sf|+(dC;ATdbsC6`}POVxay5IuAVaW70YD0H^bB` z{*Fz%YB?B>nL(CG4#$?hZZ7Fhk#tST#d|GRwlVSR>(oyRWTLu{r^%J0glQTQals<* zV3UgNrF}78r&!}=SKzcItH-J+I2`6qkN=8e1#&qV?T!xzW>qMk0Onx)sL0qm6SfpP zQq6SKZg4F|Md}Au;@Bn`XJnXg0vjk~G@DF1Of+XXnu$_Ssv1I`1b$*!nr6|uY=J^= zQKKY+7`&UMMcT{S9pcAcX8lx>XIX?KWAgdfx9T>hmS3kICyY ztc`{TyFS6o#WQG2jP)dtAdTNs1ugQCR z(mdBzsGK(Uh!liPFEwnj#K0HYIn4r@zTH@bxO3Cy}irjW4wn;5- zpg0ipVdOGbBZtG+XhBQ{UXzIGmcW?WZ-gwQ>yM%lD+x(6-!WNI{LC$6plmrf*2w0( zZ652|urt(hnR(PIhkNC{MLy4StF3Bj{DqZ0@S|<57_w9D)`G4l@4!&{+8N2(-nLG! zZU*Vb+i~i2))jN6IZ(kdHO5+;e53Ct;3xuX)J{yw532Ln{Co&yX=4-PPMKgW7a=1 zr@L9n(W9%#<>%r<19Z1`!X&$@gc2CAYtxvLfU05q9=t)%x2O_UXP{Y0?jzxB_!cY) z^$P^K*NC#E$EmA2*(S@!fr*#FgXdSgk(^Y6C^V!cP27-B{!b3MB6q<8q8}zShab@o zk)f!CF^$Y}lj8^vhEwn4Vvw%<%!~EQo)`>?%(?>w8Z$|C0J?hjTa9-O;3>V_Ii-;ofSnwu+q<^3cp?3`MX5MVnT$jRP{<{RqcXdbD{>Oynl85{JW8t>y}~;8i?*%_ejkZGNye{vaDb2T zaS-!b9}}uh$Robtdj=wAu|{clJ(K4jO>2UwUhR+`eB>6i!qgZGzpFt!xIwiU+WD|Z zHLvFFQa>t=I69;xv(8mhC>hj!h`M1Vk$P$6;U(humujQv3%UNH z=$IyjweBQOO~bts(x`%Z^@Z^02CrECW;ID`@G>0qQcB=~rQ`T`n(pk_L;?ymyo__bd z1jD;He-98b9n!M5*}I_ghQUMob zhI4DuCUAa8aHhYb*|>9K3q(0h)8DxS>IBL2WbDDrUIhrN$G)apIO{%7<`GuBfC%W6 zX09*=8Cewoh0B4FjFNh(j<()+qeL!{jd8q9YJ_P(KR}s=ra z@OVc+^I;h2ziqy=8F9|CN@%O#ka_=n^kpd3uiKzjpRYh!{Oz=H0^WaozK)2kiz%1~=H-L`ZS<`vHhnUat@&53Ye|e+%qdYB z++_X&b6IX`iMLEJuY(BxU@)*Y^H(~wQ2(H_bvUa#r@w!Ow3k__EI%#tTSuQMDHeKQ z!FuY;u3G#I%3mR1%j7OdD_7s%x-FmfH*~-ULkbSn=PB7~&ugn6FI`GrOp8l74Bu}i zKUmbCCXFBamL<~DKe|+OaH5#SMBSms>Z}6pumDURHvTWyMDA6a^zIDJTQkF%s((Z= z2U2@BwFGTG6(&*Gl6MeziTkbk#OL+@1t-rC)6esKVTfGvWphm@kEu3}U)^B++d%#p zt1X5G=;~A+v2^dxdP+`}DPqhV7qRVOMjyJHEqMe^0#DrFIyqtf#n??5Fr5pY~SBw1rKdsVr+Kg3tji`ZcP{~S7%>}HI z>gve|XrO)xm=ehwJ450@QOgFN$GfE^&5W_2A3q*$w`WgJp+VL>rQIs1jqdrRfnZIW zArjq{A#3G!8+*)%lpigFAag4wCN^JZ4XTc)fh5bAgr%h=&IZT*@!a^_+}yk@pjd-K zHz7eFhbfF#v26NT zmIUy6Y|xRB&lj(`)9Ji-fBpKkH(8i|;;<>ouvMyDZvwIo9hW&#>HXsV2YYWBR7cQ; z3nGCSl0fhP!5xA-BqYH(xLXn|=)qk>fZ*=#4#C}nThJ5SorAk>lka|2d#mpKvsJsb zTeZKa!%R>2^t8VHh=IO-MuD^=a$k3^B7p~F$Vk!CZ|kmP6g z!1aYywbcD`f_qr#I19j||1bu2lH=jAesP%i%bNLdO}SKOj)uhJw}qjoGwLMjT|nQ^ zRzFR~jwf&d=!1C##*#M?p!qiJH4g6Or2+WwoE?l0m7g6<2kuv4bBEt*sm{}XTVs(lK{pBg-xFE z0`GNOVD2;6o?yTGx6R(&)%9kXs^8Vk)uP_`$1Nd`JJNY5|HYd+UUV@CrO8$*B(lWSj`z@SpXXR}@;Fji(a zijgte7hM?bvFr=L!XloDz};&wjA-Enr%cJt=a?ff=WT=hu!uo4&{ zPgO~6Btlct7&99k;4?MXhU*Tvg@Je!03F9+ctWe+JS=pNMJB6rT=8R4)~giq3)-~+SWY-{ydCo| zpTEIQdp*hv9Rk2Hrn9z4i};^}{__Q%-P84U{N>NFT4lH~pd%SRZE3>JCw~*FcFy*N zJuSH|CpA|)!I1ah)I3TgA~BpIXgQo$nr2aHG3QI_9(VfZ!+GW#BbOS0?OC`#7+Y*Z z2F-Y5f1mUv!DTn;F(9G8dP6h%KK zUZi1!Le{Tpz!S*o zSaB)$P$w;nK#S=w^QWbz4^N1MEzm>+j?R696IDue8a7)xpA?<$OgvzT^Xj#mUDp6j zei>c6cC+PrFFAeIzISv@9Qs#4G`O)0GFNX^W+mQH3;*sDc(AwnuPZIFT7F!60#CWn zoXPpM6s2|fi}=e&-^J6O4$L$w^k7eMe3YyVz`X%DUW%fL91_8L3ZKt3a{xigZ_zUN z`EA;~H_9qiER(Jkm`v!+6?0~%^W;AWJPP7iykj}@K1Kfe-nCN|Vh*d6<4;C@X&wxYj0UtUE#*e@0`s+QDbe4&_BTJ+ zj(4YQYPpqu9X%g&L~^_o^?skgg}++$rRHrxv0isb*E2B+mU-f+(m(CYMO7T~alnx+ zFmdHpM5Tk;Ynrmp1$ZQNKGSV<%-2#@UTh-bI6xJ6f!lHR_UpwjK+B@!)#9|!dy(mul4I?gi~z7xQVLN}P&}-0 zt`hNm0-W3#iw3{jAA^C@*R7Ej>GCu6k8~gnFg4V@cL_k??`K|*Dhx$Tik1;UUmM1P zv)CDO7Olp-%0W85%3*e-CkbLNL*Kr%IW3^|p$;aZdpt^K^P}n=RRHEC^2oC39BOk8hhUQ zyK52;SdNuP_$P~eVq!bOD%VIAYBJkcA%36pYBKt>L!>?YNI>rP>hyq{vB=+C{+Hn( zVNbZ4uw-91`)e+_1O(uITq{)>++p2Ia8kpG1hkhlN263^@Cx`o0UgboHxF70<|fslf!k75 zCT;f5udgoNEJs<#p>?-sC2dIB*GSrz3BHl_Jt7%>`qeKfGO1np39BT^-o+vIli$F; zyd$yv>l(e%N|^OWp!Cb2?b&fvX{uB;$*9f7UU47*FDdtt_BZm8t1=qaX(*JQgi!V@ zFlkoH87>P}1@?$~pCs?^L~f`eE)$ZxI$v}Qqgwy$@=ig_j!Te9@TT(Qh?v5?b6Ll` zBy^_&M$1Qn48F0eYxbNGn2Xv1KYbK`ez86W!|l%&r;EXvp2PB?-OVji>@Jx*)ZmCD zYfbOcBU(fg&5X>MDd$1N*C2xLSG}Ua4g6KI3Mc|g%eG#5d3i`kulqF6iF{K8oSvai zsR@gP5{p@zkJr9Tc;D105K1f@g8Gp|2zb|$c>OK?W-xNY(`;GTuIrLz#}7DV*YNW? z2%a3h&t=(@z$4gH6)Z~#+`KXuR| z&E_ABeOvg5QeqX^#XOnib9sG6{LX2&z4LjGWwx0x6Z$1*2Ei+MA_VW?>R+KUW~S4cj{w?M$$l-bjp?T(9Q{!ILnYgooZB>c_sZI?7BmKJi|*A zivBQ9__F_QP$||3h$zsN<*SykAYnov#70I&*3;9=%+xa(@4^1|VvAWfW}CZU`}4P7 zXQ(~4v`AEZwijWkT-$+PWD*0g-m_^|emmSc7@H_Wp`oGaU&(&DCH{N8!C@aA6H^R1 zoAwmZGFyK5ikZSF_JR}lJS5M?d%Z)5k2fq}tb(&a5+-~@FkIzilaB0?U^6Z1Ge*?L@@ z_lY22AY4H=%CCfp!Md*z%iJR@QZJ%M8#(tiKSL6~!`+2H_BZTTC=oNFfXYl-f$?UP zoV%5bX=x%I2S>H8zW$ZLPi!Z1%3R7oHXM9>&oeCmaB|BOnAdIWD$L$d$*9FAvs3&w zS(L9GuR@^V^OOEH@@x4^LO~PCuUdwN>!HH8K-Zz~jQ-}ho78BDoH3Jfmhq-mc8ZW@A}lkyCa$WeHy@5S;+Aq%=%c|*tVwShwYH(e8$J9 z@Q)Z^+0zFm9fgXcsfvw|nV>bVH&yPpq7780lX+fQICyxWoSy3tgS%?;8R6UAKyXtn z{&T416H{DVyUU|9IDdh1p|3)=0CxP-;+V&0q`$|$&DNG_yJFzP7@xhb{=^XH6|bl^XbX;Sp#VThjGy7A6>ddHSU0sqS?}BFHz(L zlmXz!EX_hrN8>=Tla4YA(E^At5ddyMj~%c{e7-rC|L%A*gqRIK7vZp4f^7CMj>gfIbJVP7Zy>)0J+*ldIdu7(h!% zK~2n%0IYb9;)vyAL1vFFaI@-tI=(_mc^RY~Q3H;=>5gI{L&@(@8FxC?Nv+o%&%mbv{u3!H` zmf>ChRhRl7|Nj4fg4!_#7?XdM^#85e&?5L59E|-CsDnL6*2)J<1vDaiy?zz*JQ znM+K8ivys-;<6gv{V(pQKBmry3x=rmf#jCYU)uWd(YqGhuRkf}bxOqm*&Q?|=be~f zK(RZr^$rsSJ0d`+%7u{pd%i&*tEk-&^*@xf;M70j1&|rk+Q-&vvox4E7oH@BkV1=LtDfo z00@%evcEr;>(L=9(~(g1TV}7(bXph~yw~7=PrIp&v`8ig;nJTa&#So8apmo+x5o1= zrEBZz;6VLpZ|gkmhFaGS1S4otT(3ZIBUJFLC$4Ped~c3sSj^ZQRx(%nyR%F1dUve) z{A?{&x8?lqaWz$a;LDe_jJnOsn`L^#!skVh+{leYY2MJlr~h7FTPXq|_b1RMBi917 zL_c(5Aa18xq5(MLWCQm%fh?FLd_PVeNO$hHSHS-Z2n2~XKS-yM3b{T|l-6ASRv7Jr zk=f@d9$xH7rl>v8u-dX5OsE1{F7LChmjg=)eSMG8{=wAWnP6HzY`v?^>c$EaxXk~# zf)5mym4U(f#{7&0?4vF=C_)8(RF4DdZiYy;^J&6^WK}V@ZfxvtWo&5559{PGkr@)I zR=3j$*wOE9Qa2upS$wCfy@p)(+ra^tjtlY_5tqf+2OdYpv#c`&ZjU=8tt6e(S+MB2 zIq(iS7QR^&wv4%RJ#Ba?sj2rC zJU$PDuuq394K)=N`7#w1;KwTeBT{ixJ+4itGO0G5QPC1Jthn^X6uMd+8yzp=5i;c|bA+1cIwA7WQ0LTooE)auFl z+FF&fi`2`W%PTj#9;O;Q}4{~g%GdB%M0uL}KL2UCpKr*Jwu4Ut;U{z}`0w7x zhhd>7(E<$}eX7A>VRylOq2=BalB+lTaI{rm30ekFG>MRPTHucWDF! z3Y4XRSedI z>*V4jasTIY+?X-L`QlVgRt^4`R3Mf{a&Jl!%f6R>7j0}vjb8>QXvU}h63{G&iF2H~i7=x7v zG9t$C3wWlg-im_a*nun_o}On5>1g7=A`@o?)?WN5+BK7Qajt#*7-hTYM?G-aT-q`) zH25T@JIECuB>Ew(MYzD?U!UB~deha}+5aU|!!oQeFtCq3sQ@|Y^k>a@T%lE*y@O4m zykHzW3w&uMl0W6nR96MAVQzAtJ!@ifAH++MlWhNJI(MyY;!EpMWn|GkRC=G{z{KCx zC?)CfB9fx@56en?VJxFW8kw1;I-QlVH2<-z#QgbMM<*q&77IHuIk|Y6FP0EFsj<97 zCzw5v$)coW;`={#(I#I$?yJr9^#I(7!m`8vDwtAnGEuOPeSSNhN5c4!VM!+8#P^pX zqwH)T!s0`p&)nJzGzB_op5zA+q`{8Oib3gZwIBR5Ka(>HjeMn25)D42B_jixrC{^q zT-)%kZ|`7`{hpXW@uDPSmBxN;d0KE0-Y4X;4X`VA|fi#{kWBQg!f{UPFU{wKEj z*4vJUJyPB7LBrHbF(Ia+m<-msv;Z>6%h*Ck4cGLF^bU|53Mr_kR#Gk|ei$A4PA1=x zb7K}fd#(*C;^V#1z7RrU-nDRu?pThC;+{L!0QtHpP-o*9cG z?_bG?Q~MA>U> z z6cwcfw>c}=BWXb(Ltu{_6(66i^#lu%+kCpwXgCE=7k_JGBNyDWDLVDk5(w)kjwP0s zLf87DrFAwIi0xN-Lr8)L2M42ZO4J)lK3v;BLqPDfh)PC?wPbvsjDNPPC@(KB+55Y* zJCxwKS^BjUPtL=`B*4S0tPZz-C@n@|J%5I#MDta)1Q!9}9ZlbmdS1S<@%Hb{ntAnd z+bO5iJqB4I)OQJfD|<-G%Bq6&Ct15ni(fM`BO@aW$hl!xJEaxeW`B7s-`z__(yh=3VybfgN%DvX=pG9%t>{%vQT_;ayUz?Eo_2swcfw$qDvc zek%2fs>{t9AVD&%H}Yl3Yu-jk@^nBU!PR+0_Ne?^ovG@G>{PQ`wOYzmOIfl3874a8)+Qlh1~>4S)f0B($!(a_cDf=5lB za32yl?=c%`j7LI1NK-X<+nTRY`ECB!7w@T-Vf8AL;2YYP4{M`2i>>KucokS)1{4a_ zd|fNxwOLg z(@}@u`D{KdPy;)L10B%`D!q)%#~2S9hb9l-YcF~YA-6z2^Xk_ZSr(l}{k!kNyY|=V z9e%nlZk1f5^lS_@ezG`Z$D%t1W&g7-N!s+OS@M){52*M%YOO7VAC&~sGAFc z&p?UrNk~dSmEd<9yj)b;?nMWRtJ)2Fq|nHG<-!=Sew7CogVx!eS<*i>U*$qT`26kl z1q%4{_7M-b;!V~H-Uc5H5d*a2&wUWj=g*!g(JW(QMPUU70-nA3=d_3VqA}QD9N&C4 zvi;kS)Lo&@nko`@X{0EYva&g%W>+s_ivphbP8K9L9a}FDzQ1B%XJ_E%K5zB%?(=!0 z+{9H}ssJ(r4XIM@_U0zVecRt~Fu~W)kB*9(AzWGTc096N<3EoWf7cAO!5kJi)j+vu z$9SIOJQVDSB}k6?!4nw);VnKA=yRTJo6N28(mK}rmOQV(Plaq$tVjtDCy5aPXk^Xo z%u|pct#S%(;(4rKE2@h))ZZ_KUT5E+{qR&}lQ-ep5g%WDlC_-{862#l2M^Dtr{Pvj z_tAK5SfRdGQBeW1^;C#XHV=2v)vd^2cYbQA!_4*)AOFqyR7{cpC~MmI>e`yRwY5Gl zY$AYE7G*rH=SLmto|!ST*I8r$-1di@sJ2RImrc1`ToQRu_jbQ~e_s`%;#7XPg0A2$ zB_pGlR7NQ`LOO@SBT?&xc&Ff3F+*&;MF5&fD^+Yt$`=P7SJy^lq?=O}cST#A#mdR@ zS#TcN^U+$eV9-zz6q}jMpR~8+Nb{TcFIY!9JBFgcDLQ6~O0Sq0TRIgW_ zog*MDl848UefdD{CoKgZG~;S8!un*?nAKUhDLuc%7?ZR*uH;7{``z-Rqnfy77-(OX zOJKc0nVTcw8~N)c0m(X8j@t2kGCILuj6xz9GO41X%KfW_7sYvf@$uu2d_8hdlBka= zD)ytj@+_c&C@9*gj5h_E=<3leCB4vS(s?M@r7X5l8+-$wKS4taYce~Ex!`o6O3n3iT1OJDPxHl8wk0tdMr(Xq@;!`)fh2|p`g3GefHGs z&iyW(>^(|mVv6a9dJIeOqI4(`NdsUbI9R2zHXpt*V2!nFdnXF|K)#}1UD#*CBeu)S z%d3Pz>(V55jBGC3pBZ}2n3jE81Vei5ZlJzBm;bcK!?rpa38!3BnJL>y?x8v#LK~way*(e))=*e=fMv~U3U*n!7 z@l))51BtN>-iYI~wK}~SMnv%J4*>1lI>GDh*bYw=Wx34$zC%Pv+tp}dGQC<>imyHF zTTCp)kPnj1MYPx+H*UGP(rmj#L`00HS2ls|O%p+D+swEaXyZ?bxCT;g8>G>iSL~5q zdj^`XM^x4ojs@!ESl}#d?9rAY;I*aeO`@2x>1Q}s?hmS_-pPHp8$2uWI9IPG{`K*Z z3wJ(zp*F@N7q?AlK0s)TqZP#REw@GujGRvjOPfp7E4qe-WlELuGmsG8{`~N?uCUq= zrzwc4I~`xk#CK&SRkr8j$DikVw|#VfnmUQtQ!go*VF|f|>P>>0d!@#&FWOdcJ|LYs zHpb6u=E*Z}+gex*g;R*x*w`3cfyu+|>MWI2&pEm;8VA8M{n6vNp_6Pi=7EFmGkuNM zW4L_Rw#t2pPc5%#IP30%Ln|8YEc29mYu?+nP;?jZV`voFf4ZBm)f-50UZDd&_HcCWWLnA>!AmVvsq+&qbucAqlD*D(Tje+2Zb_#xG8}5Ow~pW`i2<&yYjfH$BYGo37H0upeQegGMC33M3u|2FFZozw`IthVVq ztIQO^`g8b9?xHj0S`hJdoA3?{2nGxC&nT2=)d3x{lg*La68^_{T8>NYpBC=l+U>*? z`-Q$fJUq+?fUYs>V6Z@>Cdy6htY+SGO zbMdznhza9Uun+4IxvR%en^q^^WbzC3PP<*aw{yPQnLxfB`s;grvGH4YlJEY&{V-*7 z5*^u)N#ff6{ZU)3mb$W1+&L5M2ig`19X3j8T*6Wq(&&2A)OE-o=nK6G?<_x`Su`JU2pTzL#>z7f2P%^spIH>=*II`{Jp4qUiQ{Y2gD zR%W9A?T%Vf^v20*JBTX(hV(#PIx z>GB)AuldrH-`>A~YvXsGA0dFUX(opiWe&dPwrFqAn^(k)Py`q*C5z{{H)VPoLmOft z2SA|9YHQ;>fLNFq}L%O1cZeUuJ8w{`+1zrql?Y zK49ke8zWv^y2|Zl?5Y3DZP#<>kdO6ZT_ko%NmS8^(V7)1P=1cH!+@u~T&G)8xv-{y z7IduKan3x-z;D9!cQQhHp8Vkn_oM1lAzxOO$Jwc5h4k<3-TMNV)W>vA1+YvnThv*k zgJMxCc_UO+pu;hV`U#k+d9;U0F5=bjy5Z$jxTy_g%U#1Tc}*t~o3LZ!*JS$U=?~?I zEbavRI*$C#v(z;$+y;^yt{EP@mweGmuTu3R-J%V851v>a!?FsYMoYp3dx#K8gXb_K zaj#<0AayBdd2GrpC8WLPnlL2yZ;NIa8&u}%oJF-*;ch$)+g>cC-cYzgV!r?9FvP%9 zphl3og{60PJM_qU6w_@zvUbMeNulbXEbkl+=S@kT{Du0k=9&7qf4%bUt6NqRPbrDs zeMi*CYhvpU`i_U|-{OHiL6gg=*;KjD4HA*H~@!Mp~2bkcGmEFDA_ zqn^?Y%}uxHIy(N{<}}TfNn|eD1t<77yA}S!g6G0^y&&2sBLiLQk0rfp0SD|{yw)c@ zqg4G_tJGh_*%ZMspooabD$bftfwL*B$Yw!l3W2l9-#{u>@S;@?o^EG)FHgf~x0eU$ zc?NB8`;{;c20827Qy8boxH!EDR$W9y1c)>*b0TFf70A5 z3SFt2|M&M5z+eeqT<1bC^Y^(`YvG$!IZzpT#7*0`HhCAz-c z4;5*s6StHJ;>pqxef7js3PYER{sHqSA*d1LW9g3yQ>O~$neO?b-6~{Lrs~hd_bO7m z^0%1iXB)I5(=?)8@28@hEW{=Qv9Sx3fFTQ4WzqiqxMyCa^z|z5&@Mi%etnJRUAlX- zP$%Cr_hqPr=9y+z*kp;Z+$sKovL-t>ysQUR%GhLe<1nJPQsf6^?h^L0g0}e>a8)Zc zdqGQ8KQU7tbDK!Vu4+E$yppH~6@Haj?6e!&J#jKwo3n5$NM5M&cK?FKs4d~d;NDOI zpZHX{-GN0}IW|h|Y#?`NG+|1UV4D3Xq3berRg zkz;{w`XWQH_!RH; zws`xoxS;j$(rbxU#d}F}Yv(UjPq4nXB<|l(^)~iDLdwagXy;L!*>jFoQ*QqzF!(FU~E~Mww?C?2|oNsk+53CO+p6`yVaES=&mt2erA}md3oz@LaiNgT492 zU?Pj)WNqz50M7A!MR;Cr?bqxH6YV;asw@fJg_{=Z$r5db^N6nLD)Yi2r`3^egBg7h zolTgoVS(NpI*DsTr6jbrtnk%_{w;`}N2BI-V@N-Xq>=l$x8 zb{}u*khtrS$}qd&T0@IE@%_^ddZ{1QsVoL@(t+%Ul|`g)o%C_+d_L5Ri3<^(^nE$4 zk}OA;F}duQ84V&_q*OqIzf4bhE&)~LdJ@Nx5kTlnvV~n|75XW`7t1?~jK};-K~(rS zl@YUy@|fm5Bt6sjV|hm$W( zPwOP=l>;q3iu+6N1?Pn&?L+vO(u>v2@5L5Eqi;CftO_3S#7|Y1vA6dA_MMxG&NxyG z`62FaP<~rJSYES6T$PtdnyAlemth|H&Te_fsdv;tg?o2{yTBBE46MUhMr2f|TKZ^& zgQC$k$}(4*qzbOLf=$pNECs=-@wEG&sI%x8Ii-q=XxF-CbOp0;CIa4AT~_EubPY=? zU8`Z)2a;YhQ+?2plwzBzw;P&{ew9RNozE_O@7f;T@px_B>Sn4H5qTV+GWnzVIzZn| z_?t{XuBDXWS~PnHLEOIRolj=1$erSIx$g+S=@GzT*#$QrQHq$ zXDNo+2DNr0m6S{q#Il>*Z&B@D%9V-y<6fg>aN8*6>bvW z-YME$`Q?vQMx-?Or^+6PLyxTQYjw!qy!kvDi+wzn7-(y~K*}$Q=s9`-R$qdt*QJZa zz|5!8Agd(P*ZYk}B*(>o1rTMOVB7+exFf~Fy|4*lI#*v8M!^+ln@?98`wXr3(attL0`IeS5&poa8~3j*n<7? zWgio-6~IqjXq6E!r^@BmWL@iUn@t=#5{cNTcGm{&liAv*;BZ-y!Q|_2n~o}x*mDUI)1N^>PcR))#L3qf%ngs% z2gjd2{h;g0n!jnoUgfh-dbJ3wYNFU9QM38V49(p=BQbKEJplAm7?Q*^o=RsLy2)?iX}ocarJGdUF%qgH!n%3S=XkU{wLB+Ak^xWpB^#>{8|E8@X;3Y2RrWPE1lg@TZ18CRBU zhie&daSqd!SZs9(4s~jb_us?C?clP>N}PX=5-@4&;(rQ>Gj37EV^%l1$?KIxr@xOyuYmm!awmO&>})4V-=Ews{#& zLE>>E9~zJ4&jr16=;#W*K8MRn$;wK}NJ+^`NlTxo6|)BhLZWTZ3ZV4=xuZzH~+9GvF`pi7I2NntdSHQU1d0^VazWXT@tLgqiL~6@56dd zRS2f`3PW@WGZUy1F99sejQ|^)0zM5^Hq7evN`m!}=T1i%5ApqRe;}++=aa8ndRH(p zG;gJ4rd5$6Ge-*I&Gjm5<7P9>+R3yhZ0bi`1|0z*SqPS_J*vj1nX#7e318*!NvOf4+CdT zC-oi@xyr=CWG>gMUmnhOKi%au5-y~Bm(u7|`iU@Kp z!!0IE)xb5vXt_wGt!B}<`e=LmDQy+QC2oDYv+`}sy-Sy=m-z1+tMCePTK?6Fd*u?1 z_b}C6j#XAmbV3fti}c5+Xtfh=Ikq?pF8jBb!dE<{_!KjaPEKxjmlrTy51Z-Gu?ed= z&@X7X^A9A18aDPC<}%YCHRKCRJK2q!5^?iYK+MdN2pLj6?m7V-r}?NaaE~U&etUeY z)vFYk?tpkVS@IvM5(L=Pghn*#j*H$xtZRHi0wFGL^(4-szDVH#6*W~pHfI5h*0!P$ zmREvWn-wXAYo5CENLju@G*|>LPq~m95u-I5R-wJA{`g2)(>>t0u>N3Q{?c8!BeGh5 zWIS`>6>LP_hxrBm=lZ`hmMRl^&twB8zPie0@#$`U)+(_VpVYnA7r1x&e%2hoRV!87 zl4`sR^*3pqwxLwD5N4K+R#1U)kx`(=!n!_$kHWZFl!#C5zE_5t+iw`ZW+SV?^pK3& zy%tZkhE)kDGevSxBSM-Tx%bN|p}2Wm(p1m-B}Kj?>948BGy+lt6}83RWYV-5uA%)@ z6aHcopZ0|+u9mn4RkjR1d8Yb<1z5a&>9$&cj9|CO*{>lGo4rSHm@)T8tw*V9ZEcl| zW{3`CTot|s&Mz0|yh9b}Szo5ta)?aFivWt^B?&GrA?_70<})=aHp$Wf3u^`nF`@H~ zR1W}pc7@k_WaZ}99!-HWuDks|9{a&gs+zTSJB8~*@l8hi^K~{G#o85nsi!+sXA=5a zm5zrwgNg15cPlum97?fK%-aA#$b?y}+X6yHXACi=au5I zyKf5+h6^8`b-Z4^mF1}R-(6knECeJauRcr^T#8)~^Ac)qB8i2PC?mX!t>jZ|+Yao$ z`IGjQ7-Kxo(0y-YDEvh37rJv>%~H`aL@h8ycWc$kc;P;t?Ml<=lgu-D;>5>NTM#zI zZ)r7Vy)c_d*j^8FZNy!&$h95Xt&!?kL~wlKnLc!GzfmLVQBl0qs6BCCJXjE&xo5UF z2|E=iC?wp5+7U#^Wq&A8>DR{-y!^78i23`(EOsHSv0gqcN+#Q(&fTG!y*2+KIiC|u;US40H zZ;ohN1nf9ANJLV5+z!^T-QJ!nraE02=XceyXw^WHX;K}vthBcmYo=N}4%+;#&q>84 zCGV_9_2EA}tIbQJzkREi%4)?WAfWri>GbrJ$I7X-q znZC-@JHq^^oT>l5!G0~3gHSvDy8>D;;YRA%FVl@75xl=;dbuDhq(nx3PtrgR79CXu zBhSU&U#`X@qBuG6hMp;gGDZ5X4}0W7Za5_-Tj1}87KqqgDh1)StWPz)OE8I6`7q1| z&vsc1X(YU4%BL^X{WUxzUr?XYPi12rt)_E=L1J%JA}3;%vNhj;_w?iADJ65ts3RJO z{pL(wg^pu-Is7>nN)KohCX0$^p?WWDcZJG)t;tAjX|LlGae-Q0<8UhJxDr}>m{B@z zZBs{BjNNNHjVho~%GyR!7k;{U!h4S{-$q z=F=bPd>IJWVJI-@U$ujG@3y+E4VVz>C)NXQUi>mh&0gdzJtH0zOj$N7M0p$+;B*kr z(K&8|L7CTdI>&nMS8j7ea{u=1JM@Ff5U;Ma6ulY>3s|~*xFfsKfJ+`FIj3nBOE!~i z4erTJ?)Rw#yg%!@ouk;>O;{L}AAZXd$))ee+n2B3uRk2Q-Jj50`X$(!W1BJ038 zoNEUV?R1>%qUx&w_OZUSQ7;z*+H?!Cw?6~(M)a7e84g32ct(WAbf0pe0EvRzVW-iY z%SnvnOrz5=l=y9)X)N<7YBVeXVC?Pw%z+Jl3)i-C_Ftjf)y$NpD>cTLwSHCGEoZ6N zL<^HYzuEw~6PHo#4FRNDjQxG!(r5{4LBFmY#I(AwXs-rbQnulyQXbq<>Q@tK)Ra`IKR=-vKyk94yY{N?($3 zb32UZ!2~B&3RMREQ$2FDt!>8(OVQjG+y4HoHRzA&AJkg{v0&Q+VxTN&Je?)M3mbt3 znCdS&+kuF=v-SpL`V^DT?gY(Jxj-qN%T97%=vGltapHMJy)eD8bzc;8wtGdekXnYY zMc96TB9sqvZl>D2VYbpw$9c;KtZeR%`b(c7Y(}`wxgTmpH|Pj6RuPFkpkxv}85tX`Mb#DyZB$%)}))Upkw)-)*SRpJi2NJe=?I*cL--*nU9M zO#=%ZokP&W$cMW{klKzdU#M^Jb_8tX$EmB*74~+qn%#{iedlnWDi3??sh5Foe~|40 zA>vb;pPUFrx)*S|MTOHpFR+*K&bf=ew}x?@z224izDOghT7NxAp@2At2MlHmGp?2E zlRKQy3K`WU>9~xIJg%hIyjVyh#uLNLB4a0YniK=mLI%A>-RyRyhv(obR#B}5#|WyT z8hOiJ>F2UHuHUn$-~p#t4$XHa3V{0eoSCsd$mu*8+HzT7wIaF)?*Y7=dAp6zq0ben z%%;AMvLk9z@%H`oS4Zol*$Q;0bRkuWmsTUwjVLuk4k$G$0ZS4_FWt6;BY zt5MMjZ#djEVwt(?FvVz|DswG%8!J8+M6UT({#fX0)0@zO!e1$0OWhhyfhnAY;MqJ; zyv*;UAHAc2S{M9c<3?wHjO?WUrSf?A)$oM2cE|nIul1WU&SsagKGJ=d){R6lBFv{( z)o-%kkmur7pn7TEB7As3U1vFYdNT^gnXdC$f&$6)a$R2p+gwG$#|-~r)q5NEmS*UNq6v*JKZK8c`3wQHMw#sJLiOB zG|r(XAuGE8Ou@1Nxa`%wl1#&#I}jU)ii!doTc00EYk1Uw@VN#SHnwun;BAtXTOCUR z0s=6}f9rRMa#FXm}grUVq#(p2%l*c|5|8yP{Z6=>+JGPhzs>p5tS{${G~RiW4gvtA39KI9|2GzZh#;5O zR!XeGf_c^TXA_dE+hDvlS@d{bkY3k6JbgY;sKh`hb#(rc0e{gmBRX6UKsJU# zlXnFkZF{UN@72;ZS~1-x;JAh6PRE)LC7r1TX#8=^Q(dKkKmkMqz`mIAnx=igKJ0ep z=%gsA5v(~~1n8OCRrd@3T(3s2APQ3ayQ$f*w1y<8u1=;0uObqx(UiNT*qwM;+!xa7 z7qqq86Qf4`{oD;Bou9N|b_&13?0!h32#&^A=+3rT4Lx+{NXs#jLjHq3F)(@!P-mX~ zkN$%<`+qI+#PyYTe}Df2z-q`RHa7OFzAu_3*7UBun5bb+XM~=^U*Zq}zZ+5sfSP+H zVV&69+ds2P{9+OB^{dsJHqS3Z{c3e$)m-chz~f{rA~F*^Cn~6=MOWNX?(;j(vad;B zkdn-De^jqVRRUj<&ge7)-N&e?ac-{C5gEY*sfY-z*{f#D5;H@4zov`S)nfqUit`Bo z6p8-uySlt2pn~APY3;?8^x$Bn<7HsbbmvHPBX94ZY@!1hQBiD>^HNQiRP@?@_ViGy z6KBEuv;a0=`p4GAlPlfW=JpMtp$__vwMugjz`5r%ieI&?0|>eL#G;Qm6YWx#Y>5}O z#2O8f#ie76J%n2}pUIy9h*)dqo8$?-ZtMx~-TqIaj{w}7*KU)$l^4^wVwLi>Fxgl+ zc6L@i$X`pE1}l<{twNtXMI1U30{I7Ll6(t9*_(k^~gM!xX}LUcP!oi`@;O zoHFB@o2&y!ukqyQxVT-*Rq&cIq2x^f!6cRCS|=_nth<#*O`T2a+@$XbVX%x>pW>~n z1Hv&QI2jM+m!hqAjr#FI3IZ`yiOGve81u#Qj%m!hh=iDu&Cq6~Btq+n3Ssj05C2er zSTAv@&$kHT`54=`N|0hB6L&0mvTP+T(ZN^E@I>%{MoN5@uqaU;As|NK;hfiC96~%n zLKX)vnih50&?ga-^tuUW$yU`%kwsMWU#faf1mU_G4Len@V|m*yiwcGzhRMW#c>x$> z!(7Q`5I_=#K`Nx<8VLj+fo#~IFiS&wKk(l_QYa8prWq|VSmM$G0Gt`KJGn5>Fj$(% zjr2~)+rdO8CguUaES5czAuJF6UOW8{c)`zauL@eV$5AUfzfLvl8GRpPy=3L97XiMF zfXs8ly8^Pt57^Vl!;{d`y2!n5~|$}+PF;Pr#sxu z_ibV*V~Jt$dJFfK9bGaHu-5lcx6furGRuUelXJL?OeYGaa%6UZ4|&f?ilB>1v5w2Z z;`_wGGq9h}PS(g*`1AnxJa4pREAi(G`q{C>gGIz5JejBRsqO_p!@B4R%V9b<+*W(3 z>P+?Nc~zOhWUdcs;=>RC@_3Bemj%3{1qKNp`MENEr?~imwY+Uw&_=ZROm*Q_v7X-< zDBQQXs{#z->E4g7BJDtH2D~D|9K#Br?%dsO|7NPO6&z!$nN7O?1N1~dJNnNxAQEJ) zo3D^f&Rnv+vr|ZNTkXK|FjC>aBfzu-0!P-QILalPof#P!t3q5{5KxFeP5obA*M22| z=GE+s10>_CfJkQW@BgCh3@l&Q0@$(n^bPdu*Nj0E#bFWjXU_usLztK84xc^+JT^A@ z1{EgK3fVgAH4P4^3UKcO6{9tO!i%A${@l`oMg3|9+Tv28HJ@u86RY~{M#%Yecce_u z<>9U_{GRiS3N=PRn&UTDIb1f@ac|`}Us~80h{k!qwDKwD42cAX%Kn;xvyBue2LjJ& zFnrK$Fw@W&U7e(I12eutNpT*PCTGrvuOZu~gu=|pshs@Hwi9O~cK(Mz8CVOEFn|X5 zCzMUui{DN_xA$sH!)2ze2f%CJdEkqJOaDu7?*9a5`w!5VPI09*xn4lMw+lMf70JG} z;%o$pxc87JBcoFC-+FTw2qeT>XVw4cB$5H`VeGZdkyc&==H-n|O>p~b05Mw2i3%#B z=d1tzfJtUIC_n;gC=10igMBEYO*2g8A`fq21fB@71>%j8Yki4a_la?F-zuh~Y&P`H z=zyhm;QF8mtRx#ed{Y+GZw7#yTR&vgN9+3%e7Cpw0E!#g%WLh|vGjmrmy9M9m!`g5 zqXPIBs!&n_R;9n-#b3@^!0U( ze?2-nGO_TF$p69KTSrB?y??_Ph=78Eh)79GD@vD&NDtkmq~y>I11ch+Lw8DtAUV`9 zC?Y*{cML;!Gr+Ss=lj%JzdxS!{`J1=ef~MFGsE1m?|onU+Sm07Zj$m*uEWvMQShp~ z$ZtniwIWMObbG0g*M)c~6D~ojr?v`^;9?8#y$T;V>iyvu;cM5DXJ2=4c%|2w&iK?L zyDYFvE>u(rmxo}NbCj^@?XpG*yA0Yuh-nd}*}t?WX$ zRX#GI%i>V+`%+k3g$G4Sn`laNWK?qEio=53oo_cui3v$ALFYWldwXFE1JBzW$OIUh zns7KDBqVn!jP6>p+M_1jA5~ck_mWmWlv&WHefjdlRnZLGcw}pPJ48fyCGc{gd|l0a zJ0(|88gT`}9zM%_&(h4IO=joa)8-{?c>2AsA%z1BJ`kn1$ zhp#{!&4sdGL`Den^MV^zq0tlC74eN1%*+yOzH@u1~)|B}*{z#Ep(caZGSXR32Z~}J(!1j%0rSIbd(pJ>&x@ILJG@fdo zIy&8S);;;`+#kEBT)}(M%lyhC>0VS@$8Za0&onbeFd_D4FE~_+VBk=JnSlJ~epmkv z=W+{gbdV&Ap3|;V(YFN`@7Tb4FcsxFdZsxU|BQFr?i}uWmBDHXsgFHtRQKdjSJ2|f zE}g{lvkw(CHb!c-Uzfn2^Kb<_MleYDS(GqL{;o5m&bqjj+mDA$o$@nI5VnHI$2Q+h zGrJ7=JXeFQpGHVr@yqds60VL4=wPWJaBUL@$ql>RhKUkvc9!|rKiTSB#NOSw_X-_l zFi}a@-6@*&H96ndS{E;NZr74#w$T<`czonL$p~xh%Y&AYvmY$nI*relBYRbGD_066 zq#IrR-F`|C#d2C@hJW(f*L=x;NC_D>5?=RGrBtajr)CcA*KutJyMTQ!rT zlh)+@**%JxiPJ7gT!I^8^Wsc2xM~Nh%UqVv?pQIq!Yayc#SK>flw>*mjm^H+pM?7>sSi>$`-^ zAv`i&j!KdV)tHrZLR1!w#9y5`JeRL{1nKuQ3TK>^GVu~ds@Y_E z1G-MRwCYR_wC2d{WZ+R#cuTY(pNj_1D3$43h5gdS;a@I-CaNnv;vkR-$*r#sV*vT) z!4GD^EV}%ua!J4HBF_UY4Gh7rSpq z@)vtvCAOQ3>M!F8bP@k}P0@Yg!C1MSL-S*-q>IcqU6_8vaaVoxlI(7avWPPO zD}(;cOt#E3-(616jDVafrK`o(GZ!O4gf{Lv_wcGKK>XZ;SX<4f25a??_s&;g^vm37 z+pTnY)9uJFzTAPzsX`?m?4!0@CY2>Twu>M0BS+WTV|`;a$GQ6s>vJMtQF?yelXvrz zGnZu3;HgF7$@a#j{NB^5HkI$wjMiUWJ*)TWGU}c*+8Bz1J562lR|vx+c(M6L@{T5| zpq#AA9EL_ho8?%^pT$|oaypL{#$;7``4*-JdF!`y$*abpw%Z%0)<2rRavD^pXOo@j z*V^Cn|58`?nH~FBY@`DFWxoM2-&ej9>L$qzxp34lFGr}=mDQ*!Nsd0j2jnV}X8d#; z3PztX!Q0WnVYWfhqXT<6@`~l~uHBQ|A?_pW8mBHM_2hkL9NICjsj16#}l z^7(Rg?9`ugoz>s4ofi7o56M^Y;_hD|@h>(UA2C+40B`9Zwox&nJG5<+lHwT> z6QeIIS6ZLyXAr;N9c1zCGP0nzY~vY@#ZEy7%`nNkR0N)$$ys?!v>1Py4)ufBg5IQDrqFYz-vi!IexTZh=Kqvz5(w6U(5tO(bn$6U0HnB z6S!-_P?Su4mqB8KXUQ%*n5^AF(vnlWa|Jif1&yeVcEnXnZU?J%1Dc_f=@BDdcD{3H zV@O2cg_O%u{C;6gKHoN&rYKK|Hyn1u2%;08_~p&7qoSnPssb!*UNgYj*mdJZvWUqf3AKgyD40Ss|)#4ek@ZrE&2E!hN;!ir>Wv z+wMFSd$KA~`Ylkbd*bga^Mf=j7?x-05wsXER&7HzcE6|#w>BIdeM{z7E{+l!Ba-Z* znoWYmJ^cj=mKK8Rc|sp(@aA{=+K%3M@{j~cA)%fZ4ET6NH}?(;G|+5fe@`**u@`hG{?t|AQo55c31=XQ9ztT9`+TSWQPE*GgAb|*i@#Lbm{$4 zMeO|m-pO^b$#=uE6KoQSKWkGe-tMSvtdJEzF6!W%J=dOa0M1dF#qweijg1)?rs3yA zM|%nJ(yM?!(^)yXYpwZw+EHC6^C4Pl1H@+(kAgN{V?3C1?GNjuJY9E!hd1E`cV_!@ zGEfs@_jOPmJ1D}I?)gMLz;pxA&b%j)Hr9`~Sz?O!yQ|a%2c}D+xX>=!LMSVrW7)1R z`5yJhUss;+U0wsbc*oO|y4MNeds^IMf*o^Tl*`!hR9HdU8ttfNJ5HZei1&i(d%uDe zMg1JZ<+dhg{KfbFt`MSKP(A{iKoPQPl4Z_DtB>SO zosidTdUzhfv;4GCt`&Jgl(GSGafjpg{XNRiO2HVTtI2MTPxl+-l$>QBVV{DB)C#-o|xF9JhTkok2q;Wk~mlo-LPJ5Q2@W z1R(?IA&L4P<)jA4E%ai_Am(Ws!=Eo{(Q(P2!pCBKCg7WW6rdCZZ<<*+4?cfK(eX4D z>>sMLOc;Grmnc{6D*Q_n?FalW_wb=9c+3*n9j#EZ``ih_rtKDwLgFpVh z1lI?4?h+A%y9qg7`ib)z&R-E^{fzyshVFY2ra}g+eZOR6W5j zU?p2`p*4fWwi`WMGb^09t3Jx2>c8$REA=}2s}prNj42+P|C@NHc1{rddh*(fyq1nt zdpwDb3X*bJ*G;D6PNsM`c@JsyQo+=pc|p~~InBWse333hqy&`@*nYp?`6Qd`fABl& zA@sS!^F|l9oU?mBh-%nn)poOWf<*q=b}JeFszZq5gPpg0a6486_-sy8J@VO|CXJa1 zDn}VG6e*9dz5E@11%1sE)T9iw_B@_?gA?5g!27?<&i|6m{{LWi=W`G5Np+LVx3%#) z7*N@%si|>Y87KyNa$g?~B9X|+INdt8$yDTNvF%`)6Q&(VG{gk<_xGjw9rp0ZdKnYc&;K6Q78jM!RK_w+2 zE-n|({|Z<#9*h9lC>A9C_3L|Uy*~h+prK)LvhJ`m>7oTF2m($UV2}9GbzD?BlmdlD zfQQp4G3r|Q4t~7M)7achuZ{P>xH*2OT{<>3Rl<3wM9r1jr^ODyL{%&Wpi@O^+42A| zw_TuVlPGATg|MeR%so0f5d0{2m~t zHS<)?6AQ;%|G!X)%G%I$T*Anq`t%XfSDx|3I4mjAfQW%$XYx4~TG(_^Sp62DbTnz47`L)Yso%i4Q2Q zU`Ozzz*@7g)z``!h;q&oi|Jk!=sPo0T0+5x5b*E!zQTX20i+#(61)fQ zsGF2V=x9!%WT;5Dse4$P$O$}cpfE-%;Q)K_!v89s`CV|00f5YPo7QXEn3!^XwA_7j z%9oD~^d9XeMSxm-=$9`lc|5Y|6i0x z!r&$jpl%6Dkc~A!SXwQtGyDqVRZ`PzE4pf7L5~)KPHS@nVex(BSiRzJPeAdfzB9wy z|K^sn(cgl%Z9{ro!n&ie69$F7@$~rkb5Nj%CsjH})*&8Sl4bd5qvc1lMyRP+slM zmqN`q<`W?Emz4_aF^oGz8dTDmOJ5_ya*LN9H|B3^(Z1*&j^i}H z{BqIeAi~>KQONM0##jgH0o7%nC0f9EINm{^&ajt?UfObvBxoYZ*A?*&+^N&NjX(#o zX+y>34UFxnQ1ZT9RGeSQHW&mRa@Htkp=Uu^*jTMR3$ zV}M9W2*BH%J{4fVdqH3C5}7byIzvO!vQ6H$V+!Idkwe?rG1fS9+RG9cFW}P?5fTCr z3Bv1e19TX8*c`W8FbzH$pW4iJK!eKvKF5q%sZOe&#fYK%wElZ9av)hlD!DTf&OqAw z=_(j&#>OjdvDiGrpCtREXAM&N%7FsS4dPEl6KAjDRp3(x8VqPIEs?M^<44< zW9TvGf2pA!%h{B%+=df5N;V13OFbF0-Cs3$J~Rc9rU^NI|AG%djP?yR-kE=u=souw zr3nBT%~5l;FiF5h*mFla3G#bLN`d_ z>eg%(cJKcf5a}9+!W&MDMY}7qd|rF+Z`9dOO7fizF7N`a*6-0e*48W4+vEcF6M$+| z6&-yqft;MYzrrfY&2F58%Be);Jlq03;dwZH;V*);#*{tew|<p*jjV5wKz81^GakWf(? z6v+7>_8e`e-MD_#u@`^%mxHAWxQiq~m(SbS=3{w{Gjas)($il6*j59sy$~_&XzAn> za=N2kWxYHF} z%{~A3YaAgIy2+NdwWfeR_eBE%Tu$SEqZ#m4y;;x)t#TLN}MlA zc`~ds$OVC?f7wI@`nG)D2g#xv)k@xZ0K0>j6p-!x8q2NUs?^Jw0;R)C7SzkDE&{FP zd~#sGA==LeAQY!??K+70ol6uwTHC~A4)B!*mfffPI>eE|oW<{jPPzmKmeXLVfYle& z1b7k|$&~PK>-fz0(o&?T$i&2g=rCN$jDt`#8Gvhu=8AhlN|It@SyB0OTSK;8Q7N;sp8scFnE1vPap zlBi?|s#@>>o0Vh0Yt)d>VeyscY9r#RjV7b~zvN=TE0c6!{#c zipxK3B_G_cKi%*i>y$h{QcpPs^KPL9+Hht+lRj=QtLY8Pi31yI?d+hQI zOp22~5yhAWlqPP6C!VW5YtMkA?y9hErrmPsnd;aGzA7tpTT@}a7>wo_ZV-5J~d>mD>2Wr_dPG*!ftiGAZuIs)FK67 zx!+FsOGMn(;umU;cPTs54(rSw!6QF+#iHmWB~d^LcGC3_c|TOzSJwIo>;k#)m%7eR z5;N|$KE@#(DU@A|9oaS=>)hMJCA28hq&;6Qq%`)@^#&4q0B@ZmZ=iED`~mC=C`!SM zD7ZM9cS+Cz9yP%7rt&y4&FrGEpUfDDVHb%r)7tF=p^6_MT!elNRmTEPd6&vWiaU2y z9H@b$IJ2(SxGNBBL!Aknb^|vXf&HK%prD0b1d=Flv7HfsE3On)(~m@QBcwy`D<7Ya zzk)4qOdKu%X380kn70Ocyy?6s-;l6-3I(~p(B^OqX`krkH^+;GR!gV2jt`ROAkrMx zPCAtz^Z<$Npb1Hhp^~nMl=4fVgtpDSdAP()Vq*1>=OD^MzxgS!F1Vi@=tHgR49~wW zjh1)6L$p4oZ_QSSYg0N8>phnWZ?7_&(8>WfF45R-f+&vUikqnmvJ|# z#1og1@@@mqY-ihh`9<$%i_%9%MnpGja}8=*e|?0)&MH+8r@Y}ODTzr*r@upaDZ_%X zujhU%@S^&FZn+Gd=TVHd(MoS-ortl?gy?1%`$-Kj0MK9?Di*gp0&wwItNxCOoIsKK zP#;eKcnvk_Ix%m-}Inq%lB^QrGlvcYkJP z=HM}qD|VmqgeOh`vl@2jRwn5*z0l0i%*@+_XKS$THW=+n*Fx6+okv23lOmA0~S8F=_Ot{ zvY^2Y6@Dp=P86^rqoRt4j;{a9)X44lS-|R@Xm_7st>X6#5o5n#0Ms|~o&!6EH-d6x zY7LRWpNqe!FGZFSFw+4FeVu?oM-!>!NdE4AnOvk*RwW1fOW|kiTKT0?JFC3q7T59d z+bYa{#Tk2LNOy3WBRZf;2g>VXmC+{QdYSjQE_9wKcd(RE3A!$GR6fT3>}!%yOg zOMd87y(OGZaB_g3UDy-Q2{BB$B+lwR$u+=iwM59KFmlQ-BsjR2F4FCI5q1$1JcacB zN`Dx9lKzi3*crGXAao&1q$&*)rhqU?K>4vvu3C+wB5 zlfF>i$HjBWS_aV`8L54RF&pw5`@=@A^tQ@m2Pq$SNyRm;$=rSTJP)?8^|D58MNK_g z-f!}nJyJ8psAh$RBsb~k`1)Z%(uHM)kND!^O9xTwDFy3gUOofYk z$=X|cIrG%;!`c7?p4wlf${G;h%NG9LUPVVwF{?}9wldXBV^NA0=%aj-X7%OuKf&bA z2JiIFsevqX=|An%Zo{M_b0OG#Oo-x4VD)KtX%?I6xSb=jgTd0X9#sX>k@0| zHQ%im>(GCvf^RCV2gUl5=2prJ2}orzx~HaZ zoCh_JQ6J1ZlmiEVQl>WBL|q<8_tnPh1SdVDWM#ixHMsFUW3G&8S*f8o3z=69xtap> zzA~@@wNX?3S#nV=4Hp3?rU0N{$y6&ZyCOn*3u$bxHWGD@5mV6|dNEX>&XfjwI&R_# zXu6>~O^aQ}gXw>j9#Z&JK-R}Vcm?q{4E z^KDHtLq(dT+1jN3t3X@G!NFm*usBk_FEU$U906r}HV7#kNG`V3{N7N-`sh(3leLhb zgcwT03Nac|PG)7aIf;PI1DmRg$;kNl_}IkQ*i^R#*;1myjfcs8iNEuYZ?7mgL78a6{rQwz%Y2mgpB7B6dD=$w8?CZ^(etcvZd0!lGnfei%Kj`4rx%sTsDW(H; z?%molNLG5Z=Pp+AV1#K!@ds=Se$LKAeO<`Fh#4t|)lh(iP6I*=&_+XFgZ>~g88WVna+A6H5~ zKEA==M*qeS@OHsMvxsN-)sd8ULsfpqDELMC6<@!HAWAO3)jvAO95a`Fz1p0uE@-8O z#BSWE@Y|M}6U+3ooz*&Ms|s9T`Z!;++@g1v<;;EANuq?4&~}AXof#-nEODXQj7iEZ z&KMEcG#Ky``c_iEV*)hb2UxPR4v(?(#(*JeB?-|E3XSsRK{(DQBPYE#8tQs72`81g zfWnxzp0R7TjOW^)U|f_B9LR<#^KxsbIh6^}Qc$p<&H{-k?M8k+Wng$|*WR6mz2$KC z?(DGPu6EMo!7cy-0%-iqp&dJT)94S?|8>xv>8v=sy z`&&(IIa>TFhg(8vW9Ya7txTvV}=*Rbib-xFdZ>h(F3q4Id_t}U2sKlF|H-O+0xMMi^p zwVvpDQDn@UCSqm7qgU_Izo;asbg>=6`LIAieq3+t>^}r)IhWYCKZY)t585{8P*N92 z!NtiBtEhuS8vy2$FPZ>*4_wPWCN4Ln=%L#VpCcfTax;48TTWWruTLwVmFN&b4<~%% zm?ih~{-la~8RT6jU82825=U(5RpaeDQSF#vsic*s@={MjA(l(iV0eRzqcMf1%WQvzAHSrm z(gY*l{F6D`S7Mtu-&g*J`Kkamy;AL|8ma@R0yD?N136&eX)s~k1$}RAIIJeI6;-$1XCs5d~<^uL}6TXMO)?QK8f5NExCxf2( zu5GhjoY-W&Dj^Nf>KHNj0DO>^}8&ldioU5;|Z}x)&^f)wM^4z%$X873Z1hqeuWCt-a*hqtkQ5G8^6J54gX#KYQ z&POGo4aY-3SE~YlER}{!&pJpt%zgglRGC*nem#3WCE7Km>k&QPdKSTq(+g0Vm@REd zRcUF7ydz+<*uAop`EXwhkG%tk1gfvxCYMaRLrcr;t5_qKEfeyN5~ov2!lqmrT^o3M zm>}QKCUL%?Uiq4>8I_}yc(1eo5VFLc?D@H#+dF_~EY| zhg_))U$P=s{6!0(YR2@-5H2qeIRo25qq%msG2KE-3a>IdgcGJ0B?E7*5-w*h7YCrB{Ec6CTt8N;6PIRea82-s27p6&*KGeL+j2rJsaIbc zzsZ+_p%BTd$pYzbOw`ma^a&1@rhb4*kl=&^(rFezvQ_2f`L@BrM)&n=41M-EXNEQI zQ#X1Zuf0ICKz5||{4>vrN_Ucgo!-wGQdhB8>}+-Po^Fs5{W`ZF9GV<}f_VbERi~eC z(K<~dfw@H0Xi;R)Kg`>(q3E@t_51tI{3Z*;`Bqlzur~D6hooR3@Smx7WWd^Nj|%QH%ac%`f6}w8e`$7qA)P zEqfs=+dA_L;a#+zXj2@kE#~$7AUn=jm7MuZ-N62)!{bw}qSlf(KK#cD+*f1?tU(Qi z<&{-R{}(cY@jWrw3a^ct2+8hv_Woy<=*Xm&tAIhADT8vv&XgNF%Y8_E-7PrfvkGdA z8bP)y1~S2I+Kaj+mIcl!qOO2TC^%r1Us{tJ4(bYkMGvIq5v>)k1Ljcb%c+GmDvp{3 z3qx6Q^TgCM%nqHPtO%sRS>DOAYuK;&crp;KX1+oC@o=iwPE9noj9dP;OjD4Sw;%Hv zr3TtyYULJRcl{<+OB(q00tie^=RvWqq0{cg+d;626*>BbTNucY^42|$W|wR=7R4WC z5}yFPRxO7|g6?XB=O%Ml!KncjP<1}9^IlzdH}aYFZ1%kIW{|$&qS!snPYoXj%w;f( z=UmH;poXHqUaP;cX*ksjWEa)@^>;10LOgYe(>v1xL&}s_hpS;Nk_Sgoq4#;+ByDDY z`vsQE@7EZ)dx+cUpHPS7|>9q8#@7S3zYDGEEafMcsuvDN(& z$dHui@Zq7aeij!u=6;XNHCv6c|B+&-FZg43DrJ-OaNO zG?KG4o4F>6p7_--=sdP&N6TSx!PO(`Y;&3=s`}Q17)B;c=#w0iFrO<1#$HUa<`xzk zBc;x#-)MIxN3$H6ma|ir3tnU83m&?z3I_#*LzlXehB$>%2CchVTW=8)BW0!L+9YlI z&ML<3fK1Bc+83HVVN~UG-Z;W$;falKQ0gc^M_cQ@di8-h2fr>6QHZAA=J41gYPn>SbOB$xD_fVSwx>xi+b&-*sn zO~rVWG6wbKL@E*-ug?1~i94V#(%v98T$+{y6s2*^=L)Q^$4gVEjhJlmiuGW5yb>xM%4>NKAp3n9CKJ8|>U_ zdN$(+W4h&ja*FZ~g_aIpULycab1sqE-$3aFDE!NbdrMhAflD6bU439|VBquW6%Ka{ z%WHwlUx(qgTkg&5Ncl^Rx=*^Mz>eNoXOK>M%-;^#{z~%{H}mV#r6BU}kUkVOzx^IG zETY(8qxHJa@SimJok2BA^h+KJ@6?nVbv$?+bs0}UX>J!3e_#pwg>JLbsw*u>!xw!5 zfT_t#jyiJs88987LIXI!a7^2)F<J zQdMBV&h|D5=&~A6ZTtx~D|R3Se@G@{GzD6ZWWr{8b3t%8+?y4!2Ni1Ji++<>1#!f)!4^y~ku^JIJgHF=^D5$?p&z&pt9apcNDH$2Pk%NYyuO-j51KECNHs_+>7I7EqmO@b}ml%M1a%N~pp8DjP zF582rr$?eMFsWtxQez(liF92w@DC^~%rW_CO7?yJpEJQ=I_vW7{;-gbq1fs^l9c-ttw^4n$ zB*27KXMzSbBs$>HyH-4GfY{{|;`FMI7r#8!i~rSu;{OcRVLJEfJNW4h#@Vjde&F;^ z?hryXX22(5uYV^26b$I}zZk&F1s^m%X0BHaxcn2cAdQ(dl7BEH?}TVPS%W52KV5!) zHQ0`9D*4*+*gDDOo!9366|nR#zx&V88guZ4f}NhLf~n~OxjLRtlsqT)@EhZ$S4Q!u z@{KASo(v_RaJ;~-3Dk)=@->dGxrG4Q6`mK1s#0NfLIt23U7BO~^XNJ&OINA`sa}UL zHt-zMrO?TXLtOkdlCd_Gmwc`sDtNe?p1-ruj!+W#!3%BM$ZiPgAivEtc*1~M`w|S; zLr7>*gAdVoLSfJvX{rf+lzxq@lj#1iB7R)dLqiA;K@<38bXhV@q0Fv^IfsjtRppp% z7{}#fw=RloK|45oSXS(gV5E^c1)oI|1;4AY-1w3h;$Z{U6fOcD~dwy*4{3^x98=3 zRL>;aspltBY(^5XW_y^qj@}>32Om?WzAmy2tvu4smJIrv_wd#afx42>mhrmkHt-7K zSu$pD4_kA({6sJ&??%||T#cnEye<^%$V$!)Zqt94Awm9o(pf!^6f8BDRY3a`4Z8Wt{SVHf{emua*D%lmAKS{g=&pNpF+q;BR{BZEmqfSa)?Ma!VD@ z;5tynl~Y!z1JDW)x1~J1&zM$q5|)uy*N}`-$nFY7uh`5(;?jisL2+?A`>pW+9!`Nr z36L*w15nhti0PstJfWeX0Tc|ro0{l_7V3Go#l2wn1eSes^Yi)$tFt90I0wr!=f$G= z7Pz!_7(?SMSx0-jUW3muYpJ6;#2!+<*p(<~=Qg=NOh7?Y2QP;se0Wcrv7OTc_7u_a5NVRj(Okmj1!?}l<4wm#d_lzJa=TFYHfNAqhBsF zO^-1Ox0&pj9$t&Fab2u;Q^JAK0o9}IMl(L~?FI5N_^9rvdjx&QCA-d8f|yXhSHg&zX;lhK}Fo(3{)mRvOKyAe1vRiu*-+q3!S@ngw_|}eF%6e?H#f90MmE=Jx zK3}e80ZZ4#NyAr_Y+77cXRJ~Nk-tJco4QtOj+mQdqf{voeJA#>mH*o3u^CD3PBv^|?odkDC#>H$J&rrKC-L+EX;H z5iFDH$4No~M?eD|$RfCjx~>mYwuN~K=8C{fqw#EKv2Yh6C5T=zonnDmrN7shN#_N1 zy-nny5wITx*@ZFWq|C0aijG(|>iuF^0;>D?tS6%>A%W`Y)84O1&yi;b<9fz}W2S9Z z{eC!K^jbRwCvtbAPSkxZdB1~enPq((RRSQOpmprKlAlX-5IL~4h#h(rlWOJl=H%R$ zz)XaJ;oVls$HC+ImalP2njw}4P!u|zo(iBK1?n|eS`eLrd@1tMbO7l34gb(jijFn_KGz4dW0FUWAIA=8L|m4R zDO0r#=%s6blP1dwL76;;1ByDUQmlXuYfZf>Ukt3D=Flvp`6`|mAD?$``TcjLBzs*% zIK1T=@^?YY!X{2Jl&N+4_-X7HXKa$=N~hok+^-L6BRmu6>Qw;`e_GJ3;y<|F~>RY~ku zq3q#5tqXD0JAoc_L_zZUr%@WEP6JlPZkxF9tQ$QKdlyU6;Hp5}EONW8)6Dgx`>>Es zIX1+!tf*#hzNk@^LM!QK7#{vmf|c@dubb)ltie?ldYr?@AsEA26yIu z<$&qZ4Yj2ZsO($wZzbkAJJOiEjc2CWJQ3+{CTgvS1264YAqZ&$)IcXaal zcop!VAM-7pn3!;HsXyJCz=BQ^)HW*aVP43HAI}kiPa5q|UNUths6hjw zf3ek~b-l2J6?N37@Nu)c$@C0$zO!21lsf1)!A9jg?Ux|D8fSdzAx=v>->_AjQg;KO zxWdyaY0;1~GS4B=$T zot2RlU2ZGvrEIo`DDq7s-sojHXEitt0x6M|e02%$3W?W1h_TyW2nQ#d; zY*1hzd-GPy2g$4@r}6Zcv?6Xrx`jg-7w4yI<=wyz>XC69zs=ACk-aN+ZE1ezy0!f|E$k|D{@9h5>JpP+pWyrPJWW`JtrecQKByPpqDIAdc#fcZqIPd+si_`D$n! zAFPk%JCi9V1EH6*NHOO7hwHxBa2e zHt4;q*xHS^msrfsNyE;L5~tONBY$OHbeb(EYuw9# z+JL81Uzistdd-qU-Y)~q+vmlYv~b(6;_ogjUh1v4TOQB9TZ7A$nR)zN4q0g+WOrjn z=kFpe;zTVqE#NjEqeB-*9b1@7Jw4g?3s2ZG5OqxLuI2SsjsirgC!Ap(hi- z?+VDR3uX7mY%&*;^jW89pXJ!Z;nG=+8^M*v?G0qTJnro8mUXZ6``B-NON}_ z6Hvk?plcNyF}>}ygfDX=D0%{;@+MQW63|j^_XTe1NMy=%HGuk)O6>JSWXfcxGOxzr z74WzL4--NAf%ivSSmvkCZF*mw)JWF{I*$o-39lbSqL!$k^`$b03ZDWZ9A5= z+(ara<}mZZvqmn8O%9Aj6#QzwG-$KM>^%a)(l3oh*{~ac)Cxy4s-INiq0R? z$AA^SL!e_*PY=N)^DX*_C8@yg>fmrB9HT244uqIJaocPxG3|S%aEV1#F0RL{eKHTz zI}m1^#Z9Qc7m*^f!vlZ$&j8iW#-QUck@M=+v+j(5BQ$B7^{V>b<~^KF+RgptQ@9@e z#ZmSBR`lz>aq_d@EZq61i(?x;AH*n&1(=SiE`j5;gsg0XXYLbIk z3qPct?U~w`0AbPHCIp*272DGna7M=8gPV&W(Jyq)B6tqE5PS^APJoHi7MbRQE8U9+ z*lfSlZRcN=a{I4B?-u-WpU8*MhPQ?9c4#lTY3kqiMTmX;w^l~{?O0n^ zU3uoSoNI5nAg?O0p<$s@KFP4)4IMTuYNkObKTJ$_-u z)c6vNM$7%T&;~hn^a;mQ_wmDrTDRo_p;f!x$vBPE+#QR`hYJ9xzhHD= zmVqgb?Mksc1wBJ0VPkxRlJmBk_b3w`UWk~{`(tI7v%Lo_~GYF9Og zKW$iRbWH!=9Wm|Wgze-qR@w9(&7WJ`{R-vJq(vY@Cc!bGV{ueLvlMC@R zVNm4F{>u1dPu7}abQH}BIgYYiX>MNBezY=JsK2BR{)=#GZU8rmR&91XqAli3O2$85lf@FhL41zT`=opTwCm_(klUmDZ8 zVZQk{dcg*Jq!)J2pv?g}j^D=3(_3YxelYa02G~)}ktv(A20IGZv`oW_^59*-;zA8F zSrDjENv*Lvd2XyKm!}toE&u&thiRahPB{yLdG*_TU4Bu?I|cTJY$lxm!q7j*z&DdgkZdQ{{$DmgAbkX0+E&oU|rRJMLr-BqA2*WUKNNq~O5f zia;LZUk)f~NHSO?-DujhA}Zn`lOx#FixfOm(wm*#w+a(n& z)FF>}6QfL^U`%scT6@BYkO-rD-*`!H9Rims7_ zw2Wz}t46}fugf-6!hN9)ug8xU80l1Cew}^|K$IDnE>t-M41C$95_tUGM0oSR^>*(6 zO#X2nSCX%gn~>yGaz0a*b1BDioI`Rdw3RcXau~kSQ8~@|R8C=xn8OxE`66>z&Xp`; z4PmiqRu1=P?*HNbdDr9Gbv>@nc75Kj_w)UFrN#|Qi^Il>3)L~CExFNG%hf_FPzOox%#$wIVv%vW7DNGu9BZpag^a~5BPTQq6QVB zjt(^OOMBGt?wyweU#&uPqq{mB%svQ)Ht3Ci7H1;fFsZP}!+U z=0GrW*wb38O1d4)s)9Vq#J(z}I)U=Hwm~U>c$yZ3!wX{^t*v+?dzb*y?6=Xg;GKk_ zIq?(dz-&?YJ1%`)f$@VLfj$mBBl%f_aWIX|!6E1A@L+%B^BHwy)190r zJ{R*Z&MU!{N>+{X-8H*b4e~qRMb>z{p%*65d#(6B0(l0>{2)ku=g!H^_Yvkb@kcq* z)Ce>BV8Y*-WR*!S4IdGX|3yYL2UgQ1daJ57j65l4?*+KjD>0OWf`Iy@jx=8?UHjY- zLn9yo6*c93ujsG#@`MryVh31#xySn;6+9w(L>J>;BRLH`1w}owwv9BSHWoBGw;hel zTs;sNx;7xV0&~P%Ls`x$uAmUNGQv#YH*|;XGFRYkgxU9v(-T=ucaX~uTq~Z@{U(yO zwoJ3{HJ{TE4p@7*cOg#4WObtc80Ggy`LT!D)w?q>%DFDjRndoLgB+J%K2Jcnz_$s? zmtqvB@|j<$3QZ0Ojl)dAr_7$>Y+)U>lp;8AYVycf3(K964$x^vXfGG8GI-gd+(io@ zpU9d&&)t~0_w47%^}DSj^s+MF@$W}EO=j^nLDV0%5DJ|8uX^gbfr)d9jOQ$t{OYgQA*J~7*K-oSLh*wup89?4#q8~+Uz6PPzA-Fm7 zptv%X!ZI;1c{eV*KA^JuYsZOVY9Eg&&<#=b$pgC|Dkv zSJK}H1BzLchnfi6y`|#9ZpHM1xxbVSd?+?@yb2WfyW3cVOe?_a34h6LQrz!Tk+crb zIkmb-nX`~8Hh|j#eV`T&ULQajX2lVO-Dcl$ORGlIKF+9#3+H$PN|R7hu~Ei-^%axJ ztk(g)w;F) z0C+v>OP2V2Ij_lx8_s(xd(xZe%m7E za>e?5Z^01k^YcC_&YhlTP zhP}9=T7aa^8mMIlf~RYe29Sa6<(hh12@|iyC@JPwME!O)5DCd3W$}_yi$TbdkzvMm zWE8CPxbMrY#ZNIN@~OM(>x9;0QsObH#!7q5!EMG1wXU~sJ9X2tVgS~xWHeS!tsL@3 zNxow^9*Cbm-(Xm2+)az}d;s0(zyj&BmJn@~iKXQgcL`!)>ZRYKZ zIJ!-00H0#r>96JeC~kg<`#_Hd>R`FB!~5>;rYk|e*Ct{;~#=gw`y zkLQ8#MsemCmvvRxyu4BL@4}tw5FOZXWOV&p`p=w)Q-X`DhMi&MI z?(Y5lws*$K$^+Vs!p%K8`L$k4`OKuHk@F}i;`nEj(5yt=6&R752GR!a?NPiCtLJD0 z`tTUZ5J;Jhczo}%b;rke!TQc!bLv>1{K{1Qw^6w(3rsK>(MiOZK3rr(e7=9h&u<~1 z%v=O%YNf?5cHUl+>z7RqkaY%4koH{+Mg;51r6O`u7$WDr`3DszzbfVwsjppgJxRao zu@l-Qr1irP^2E{$^#guRF(B#YEdb%t6n24DctFDhym7eAy zVAU%qs80%VT_~yQDq&cis0P1FSFb1vP*b<3ym?J_7t0ctY@9)=43Ifp2&t5l*U|ET zz|^p0fsn`5R(0|cuLEVkuGr8m8V6dfZLjL(LZ}ab00{QUDlWBExLz14R>o$b8d|B_ zUV2l|ZX6wq{KEjwvfxGf7gSgiHzO;e!ORY@E*}`wqNV8lyf01d2$Bnv(zsq`zDXnO z)PeMe4UeZjz#+NuT74Yy>Ev2gqrX2Sz21A-GxU)G@V_{!UCA*S@Me^=!UX$ty7^; zZ7pl->HL0_)=EJJ%IB_iczCF#Wl6WZv|f05ENep!ZyDg2PZt8fFQ(+;>u6_WOMeI$-H%@b7j3;6M97>~60 z1hfeM9>4YeflceTP(c!mg9yBrIA%+4JCpVZ?(YeCFa7T+0%^-TXIhxj1mzZYq9d7e zRk@agrwNGOcwLT!WJ-zG%_8?hvHCiQ{qNZRUXjWL1)w4xRU0SO=Ga#BPyZZu z2tM5YidP*1CaF1a!lR86k5k=b6Oqbho;c{KgaMI#qTz-ZHydpDe{Dbe5)tQtR|AJw zQW69uo63fdZioqyuO3xqV-CGHTc?r0x(|H*L{@|Jn2i-B{JWsHV!ZD~ctb{r99#Z^ z4JUQU0ONsv7*=1x4>avLIMY7K&kgCNg#-uJ0TzK{2I^Y?OtA+1<@QgO@SiSEh(<%>=(kJ#5-lx|ynJ&qo(RgI7v z0{e#ZE5E-=B=6tNhyMQTGm-@XlcGZ zU{sF$V&=FPcc&L;6LAD^+6vp#Zuy6F9?M?X`Wrx}a~6s(g4f>=3_Ej^G5wj2;m=GxwMCvrgMH+Fbnq zzz`oc7-A)cvXp-}*b#-^P`*zvExzLK?+>i|uO2%5@X!`Fn~%b6sb&Y>NwW3<_Qqi* z^l{I*1GXI`;;=1H6p*?To>&qi#HL(R>v+oqTax%>I~foCH=^Gsw`xRo!bpr9Z( z(+{kv!5<&fekGE?YP-;{Cy~Nn2p0@Rb<~EZr-N2L+&YAqcd+n#_8b_!d4#==@$!0| z&?QsdyAPm*s_JF0)DE^AKK>jCgsl^GPZ+O-1=*)pNC!IxXMMDG(8lkhNBOk={!s=O zqj(}9IN03fZFg`^t-R3~u$n>jJ4Hl8pB)WFx1$=d*s;;E3K@m5QHLxs>Z*P%FG&;7A)_9WA^smoEQf#uN;Tj btDBR9HDafk7JrnzJ~*t*Y%kZD-bnmklD)yr literal 0 HcmV?d00001 diff --git a/apps/web/content/docs/dev/sso/index.mdx b/apps/web/content/docs/dev/sso/index.mdx index 94648628cc..c95b087121 100644 --- a/apps/web/content/docs/dev/sso/index.mdx +++ b/apps/web/content/docs/dev/sso/index.mdx @@ -27,9 +27,10 @@ export const vitNodeApiConfig = buildApiConfig({ }) ``` -A **Google** button automatically renders on your login and registration forms, -brand mark included - the built-in adapters ship their own icons, and a custom -one can [add its own](/docs/dev/sso/icons). +A **Continue with Google** button automatically renders on your login and +registration forms, four-color G included. The built-in adapters ship their own +marks and colors - Discord and Facebook buttons are filled with their brand +color - and a custom adapter can [add its own](/docs/dev/sso/icons). --- @@ -57,8 +58,8 @@ one can [add its own](/docs/dev/sso/icons). href="/docs/dev/sso/custom-adapter" /> diff --git a/apps/web/content/docs/ui/meta.json b/apps/web/content/docs/ui/meta.json index 66f2ec853f..0ff7684c6b 100644 --- a/apps/web/content/docs/ui/meta.json +++ b/apps/web/content/docs/ui/meta.json @@ -11,6 +11,7 @@ "spacing", "elevation", "motion", + "view-transitions", "icons", "accessibility", "---Forms---", diff --git a/apps/web/content/docs/ui/view-transitions.mdx b/apps/web/content/docs/ui/view-transitions.mdx new file mode 100644 index 0000000000..9a99757f83 --- /dev/null +++ b/apps/web/content/docs/ui/view-transitions.mdx @@ -0,0 +1,255 @@ +--- +title: View transitions +description: Animate page changes, morph the logo between layouts and animate components with React's ViewTransition in VitNode, or turn view transitions off in vitnode.config.ts. +icon: Layers2 +--- + +VitNode uses the browser's View Transition API in two places. Every navigation +to another page crossfades in 150ms, and the logo glides from the site header +to the docs header. Inside a page, React's +[``](https://react.dev/reference/react/ViewTransition) animates +a component when it appears, leaves or changes. + +## Page transitions + +Click any link in the sidebar: the old page fades out while the new one fades +in. Your router turns this on with `navigationViewTransition`: + +```tsx title="src/router.tsx" +import { navigationViewTransition } from '@vitnode/core/tanstack/view-transitions' + +import { vitNodeConfig } from './vitnode.config' + +const router = createTanStackRouter({ + defaultViewTransition: navigationViewTransition( + vitNodeConfig.viewTransitions, + ), + // ... +}) +``` + +The router starts the transition only after the next page has loaded, so the +old page never freezes while data is fetched. Changes that only touch the +query string, like paging a table or typing in a filter, skip the animation. + +The timing lives in `@vitnode/core/styles/view-transitions.css`, which your +`styles.css` imports: + +```css title="src/styles.css" +@import '@vitnode/core/styles/view-transitions.css'; +``` + +## Morph the logo between layouts + +Go to the [home page](/) and click **Docs** in the header. The logo moves and +resizes into the docs header instead of fading. Both logos carry the same +`view-transition-name`, so the browser treats them as one element: + +```tsx +import { LOGO_VIEW_TRANSITION_NAME } from '@vitnode/core/tanstack/view-transitions' + +; + + +``` + +The site header already does this for the logo you set in +[`vitnode.config.ts`](/docs/dev/configuration#logo). Add the same style to a +logo in your own layout to join in. + + + A `view-transition-name` must be unique among the visible elements on a page. + If two visible elements share a name, the browser skips the whole transition. + Put the name on one wrapper, not on a logo component that a footer renders + too. + + +## Animate a component with ViewTransition + + + +Wrap the element in `` and change state inside +`startTransition`. React animates the element with the class you pass to +`enter` or `exit`: + +```tsx +import { startTransition, ViewTransition } from 'react' + +{ + isOpen && ( + + + + ) +} + +; +``` + +The class becomes a view transition class, so you style it with the +`::view-transition-*` pseudo-elements: + +```css +@keyframes vt-slide-in { + from { + opacity: 0; + translate: 0 0.5rem; + scale: 0.97; + } +} + +@keyframes vt-slide-out { + to { + opacity: 0; + translate: 0 0.25rem; + scale: 0.98; + } +} + +::view-transition-new(.vt-slide-up) { + animation: vt-slide-in 200ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +::view-transition-old(.vt-slide-down) { + animation: vt-slide-out 150ms cubic-bezier(0.4, 0, 1, 1) both; +} +``` + +The exit is shorter and travels less than the enter: once something is +dismissed, it should get out of the way. A plain `setState` outside +`startTransition` updates instantly, with no animation. + +## Shared element transitions + + + +Give two `` boundaries the same `name`. When one unmounts and +the other mounts in the same transition, React morphs the first into the +second: + +```tsx + + + +``` + +Build each name from a stable id, so a list item and its detail view match. +The same uniqueness rule as the logo applies. + +The browser stretches both snapshots to the morphing box by default, which +squashes text when the shape changes. Keep them at their natural size and let +the box clip them instead: + +```css +::view-transition-group(.vt-morph) { + overflow: clip; + border-radius: var(--radius-xl); + animation-duration: 250ms; + animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1); +} + +::view-transition-old(.vt-morph), +::view-transition-new(.vt-morph) { + height: 100%; + object-fit: none; + object-position: left top; +} +``` + +A shared element is already on screen, so it moves with an ease-in-out curve +rather than the ease-out used for things that appear. When the icon or title +sits in a different spot in each view, give it its own `name` too +(`${name}-mark`, `${name}-title`). It then travels on its own path instead of +showing up twice while the boxes crossfade. + +## Direction with transition types + + + +Call `addTransitionType` inside `startTransition` to tag the update, then map +each type to a class. Here **Next** slides in from the right and **Back** from +the left: + +```tsx +import { addTransitionType, startTransition, ViewTransition } from 'react' + +const go = (direction: 'back' | 'forward') => { + startTransition(() => { + addTransitionType(direction) + setIndex((index) => index + (direction === 'forward' ? 1 : -1)) + }) +} + +; + + +``` + +Changing `key` makes React treat each step as a new element, so the old step +exits while the new one enters. Keep the card's border and background outside +the ``: snapshots are drawn above the page and ignore +`overflow: hidden`, so only content that stays inside the padding should move. + + + TanStack Router renders a route change as a synchronous update, and React + only runs `` animations for updates inside `startTransition`. + That is why navigations use the router's `defaultViewTransition` and + `` covers changes inside a page. + + +## Turn view transitions off + +Set `viewTransitions` to `false` in your shared config: + +```ts title="src/vitnode.config.ts" +export const vitNodeConfig = buildConfig({ + // ... + viewTransitions: false, +}) +``` + +Navigations then swap pages instantly and the logo no longer morphs. The +option is `true` by default. React `` boundaries in your own +components still animate, because they don't go through the router. + +## Reduced motion + +When the system asks for reduced motion, the router skips page transitions and +`view-transitions.css` turns off every view transition animation, including +your own `` classes. Browsers without the View Transition API +just swap the content instantly. + +## Related + + + + + + diff --git a/apps/web/src/docs/docs.css b/apps/web/src/docs/docs.css index f97f516506..ff46dfcc56 100644 --- a/apps/web/src/docs/docs.css +++ b/apps/web/src/docs/docs.css @@ -17,3 +17,87 @@ :root:not(.dark) #fd-spacious-layout .prose { --tw-prose-body: oklch(0.373 0.003 264); } + +@keyframes vt-enter { + from { + opacity: 0; + translate: var(--vt-from-x, 0) var(--vt-from-y, 0); + scale: var(--vt-from-scale, 1); + } +} + +@keyframes vt-exit { + to { + opacity: 0; + translate: var(--vt-to-x, 0) var(--vt-to-y, 0); + scale: var(--vt-to-scale, 1); + } +} + +::view-transition-new(.vt-slide-up), +::view-transition-new(.vt-fade-in), +::view-transition-new(.vt-from-left), +::view-transition-new(.vt-from-right) { + animation: vt-enter 200ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +::view-transition-old(.vt-slide-down), +::view-transition-old(.vt-fade-out) { + animation: vt-exit 150ms cubic-bezier(0.4, 0, 1, 1) both; +} + +::view-transition-old(.vt-to-left), +::view-transition-old(.vt-to-right) { + animation: vt-exit 120ms cubic-bezier(0.23, 1, 0.32, 1) both; +} + +::view-transition-new(.vt-slide-up) { + --vt-from-y: 0.5rem; + --vt-from-scale: 0.97; +} + +::view-transition-old(.vt-slide-down) { + --vt-to-y: 0.25rem; + --vt-to-scale: 0.98; +} + +::view-transition-new(.vt-from-right) { + --vt-from-x: 1rem; + animation-delay: 40ms; +} + +::view-transition-new(.vt-from-left) { + --vt-from-x: -1rem; + animation-delay: 40ms; +} + +::view-transition-old(.vt-to-left) { + --vt-to-x: -1rem; +} + +::view-transition-old(.vt-to-right) { + --vt-to-x: 1rem; +} + +::view-transition-group(.vt-morph) { + overflow: clip; + border-radius: var(--radius-xl); + animation-duration: 250ms; + animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1); +} + +::view-transition-old(.vt-morph), +::view-transition-new(.vt-morph) { + height: 100%; + object-fit: none; + object-position: left top; + animation-duration: 250ms; + animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1); +} + +::view-transition-group(.vt-morph-part), +::view-transition-old(.vt-morph-part), +::view-transition-new(.vt-morph-part) { + animation-duration: 250ms; + animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1); +} diff --git a/apps/web/src/docs/examples/view-transition-enter-exit.tsx b/apps/web/src/docs/examples/view-transition-enter-exit.tsx new file mode 100644 index 0000000000..bc5ca80773 --- /dev/null +++ b/apps/web/src/docs/examples/view-transition-enter-exit.tsx @@ -0,0 +1,34 @@ +import { Button } from '@vitnode/core/components/ui/button' +import { BellIcon } from 'lucide-react' +import React, { startTransition, ViewTransition } from 'react' + +export default function ViewTransitionEnterExit() { + const [isOpen, setIsOpen] = React.useState(true) + + return ( +
+
+ {isOpen && ( + +
+ +

+ 3 new replies to your topic +

+
+
+ )} +
+ +
+ ) +} diff --git a/apps/web/src/docs/examples/view-transition-shared.tsx b/apps/web/src/docs/examples/view-transition-shared.tsx new file mode 100644 index 0000000000..db9a4cb6fd --- /dev/null +++ b/apps/web/src/docs/examples/view-transition-shared.tsx @@ -0,0 +1,111 @@ +import { Button } from '@vitnode/core/components/ui/button' +import { ArrowLeftIcon } from 'lucide-react' +import React, { startTransition, ViewTransition } from 'react' + +const PLUGINS = [ + { + description: 'Articles, categories and a friendly editor.', + id: 'blog', + initial: 'B', + title: 'Blog', + }, + { + description: 'Send email through any SMTP server.', + id: 'nodemailer', + initial: 'N', + title: 'Nodemailer', + }, + { + description: 'Store uploads in any S3-compatible bucket.', + id: 's3', + initial: 'S', + title: 'S3', + }, +] as const + +type Plugin = (typeof PLUGINS)[number] + +const Mark = ({ name, plugin }: { name: string; plugin: Plugin }) => ( + + + {plugin.initial} + + +) + +const Title = ({ name, plugin }: { name: string; plugin: Plugin }) => ( + + {plugin.title} + +) + +export default function ViewTransitionShared() { + const groupId = React.useId() + const [selectedId, setSelectedId] = React.useState(null) + const selected = PLUGINS.find((plugin) => plugin.id === selectedId) + const nameOf = (plugin: Plugin) => `${groupId}-${plugin.id}` + + const select = (id: null | Plugin['id']) => { + startTransition(() => { + setSelectedId(id) + }) + } + + if (selected) { + return ( + +
+ +
+ +
+

+ + </h3> + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {selected.description} + </p> + </div> + </div> + </ViewTransition> + <Button + className="self-start" + onClick={() => { + select(null) + }} + variant="ghost" + > + <ArrowLeftIcon aria-hidden /> + All plugins + </Button> + </div> + </ViewTransition> + ) + } + + return ( + <ViewTransition enter="vt-fade-in" exit="vt-fade-out" key="list"> + <ul className="not-prose flex w-full flex-col gap-2"> + {PLUGINS.map((plugin) => ( + <li key={plugin.id}> + <ViewTransition name={nameOf(plugin)} share="vt-morph"> + <button + className="bg-card text-card-foreground hover:bg-accent focus-visible:ring-ring flex w-full items-center gap-3 rounded-xl border p-3 text-start text-sm font-medium shadow-xs transition-colors focus-visible:ring-2 focus-visible:outline-none" + onClick={() => { + select(plugin.id) + }} + type="button" + > + <Mark name={nameOf(plugin)} plugin={plugin} /> + <Title name={nameOf(plugin)} plugin={plugin} /> + </button> + </ViewTransition> + </li> + ))} + </ul> + </ViewTransition> + ) +} diff --git a/apps/web/src/docs/examples/view-transition-types.tsx b/apps/web/src/docs/examples/view-transition-types.tsx new file mode 100644 index 0000000000..e7cdb5f061 --- /dev/null +++ b/apps/web/src/docs/examples/view-transition-types.tsx @@ -0,0 +1,77 @@ +import { Button } from '@vitnode/core/components/ui/button' +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react' +import React, { + addTransitionType, + startTransition, + ViewTransition, +} from 'react' + +const STEPS = [ + { body: 'Pick a name and a language.', title: 'Create your community' }, + { body: 'Turn on the plugins you need.', title: 'Add plugins' }, + { body: 'Invite people and start talking.', title: 'Go live' }, +] as const + +export default function ViewTransitionTypes() { + const [index, setIndex] = React.useState(0) + const step = STEPS[index] ?? STEPS[0] + + const go = (direction: 'back' | 'forward') => { + startTransition(() => { + addTransitionType(direction) + setIndex((current) => current + (direction === 'forward' ? 1 : -1)) + }) + } + + return ( + <div className="not-prose flex w-full flex-col gap-4"> + <div className="bg-card text-card-foreground rounded-xl border p-6 shadow-xs"> + <ViewTransition + default="none" + enter={{ + back: 'vt-from-left', + default: 'none', + forward: 'vt-from-right', + }} + exit={{ + back: 'vt-to-right', + default: 'none', + forward: 'vt-to-left', + }} + key={index} + > + <div className="flex flex-col gap-1"> + <span className="text-muted-foreground text-xs font-medium"> + Step {index + 1} of {STEPS.length} + </span> + <h3 className="text-lg font-semibold text-balance">{step.title}</h3> + <p className="text-muted-foreground text-sm leading-relaxed text-pretty"> + {step.body} + </p> + </div> + </ViewTransition> + </div> + <div className="flex items-center justify-between gap-2"> + <Button + disabled={index === 0} + onClick={() => { + go('back') + }} + variant="outline" + > + <ChevronLeftIcon aria-hidden /> + Back + </Button> + <Button + disabled={index === STEPS.length - 1} + onClick={() => { + go('forward') + }} + > + Next + <ChevronRightIcon aria-hidden /> + </Button> + </div> + </div> + ) +} diff --git a/apps/web/src/docs/shell-content.tsx b/apps/web/src/docs/shell-content.tsx index eaa235e433..ad28072522 100644 --- a/apps/web/src/docs/shell-content.tsx +++ b/apps/web/src/docs/shell-content.tsx @@ -3,6 +3,7 @@ import type { Root } from 'fumadocs-core/page-tree' import { useRouterState } from '@tanstack/react-router' import { LogoVitNode } from '@vitnode/core/components/logo-vitnode' import { ThemeSwitcher } from '@vitnode/core/components/switchers/themes/theme-switcher' +import { LOGO_VIEW_TRANSITION_NAME } from '@vitnode/core/tanstack/view-transitions' import { cn } from 'cn' import { DocsLayout, useSpaciousLayout } from 'fumadocs-ui/layouts/spacious' import { RootProvider } from 'fumadocs-ui/provider/tanstack' @@ -15,7 +16,13 @@ const DocsSearchDialog = React.lazy(async () => await import('./search-dialog')) const DocsNavTitle = () => { const logoId = useId() - return <LogoVitNode className="w-30" idPrefix={logoId} /> + return ( + <LogoVitNode + className="w-30" + idPrefix={logoId} + style={{ viewTransitionName: LOGO_VIEW_TRANSITION_NAME }} + /> + ) } const DocsActions = ({ className, ...props }: React.ComponentProps<'div'>) => { diff --git a/apps/web/src/locales/@vitnode/core/pl.json b/apps/web/src/locales/@vitnode/core/pl.json index fd1043c857..5c2329ada1 100644 --- a/apps/web/src/locales/@vitnode/core/pl.json +++ b/apps/web/src/locales/@vitnode/core/pl.json @@ -2046,6 +2046,7 @@ "title": "Połącz <provider></provider> ze swoim kontem" }, "or": "Lub kontynuuj przez e-mail", + "continue_with": "Kontynuuj przez {provider}", "connection": { "pending": "Kończymy z {provider}…" } diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 74f7210ed1..b7f0dd098c 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -16,6 +16,7 @@ import { pluginRouteSpecs, withVitNodeRoutes, } from '@vitnode/core/tanstack/plugin-routes' +import { navigationViewTransition } from '@vitnode/core/tanstack/view-transitions' // Imported for its side effect: this module calls `configureIntl`, which is // what registers the locale rules every route's redirects are rewritten by. @@ -25,6 +26,7 @@ import { pluginRouteSources } from './plugin-routes.gen' import { Route as adminShellRoute } from './routes/_admin' import { Route as mainShellRoute } from './routes/_main' import { routeTree as fileRouteTree } from './routeTree.gen' +import { vitNodeConfig } from './vitnode.config' configureContentRegistry( async () => (await import('@/content-registry.gen')).contentRegistry, @@ -53,6 +55,9 @@ export function getRouter() { defaultPendingMinMs: 300, defaultPreload: 'intent', defaultPreloadStaleTime: 0, + defaultViewTransition: navigationViewTransition( + vitNodeConfig.viewTransitions, + ), defaultStaleReloadMode: 'blocking', scrollRestoration: true, dehydrate: () => dehydrateDocsPage(holder.current), diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index f61664ff42..2941fd5e02 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -3,6 +3,7 @@ @import 'tw-animate-css'; @import './theme.css'; @import '@vitnode/core/styles/tiptap.css'; +@import '@vitnode/core/styles/view-transitions.css'; @import './site/marketing/marketing.css'; @custom-variant dark (&:where(.dark, .dark *)); diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx index b634647716..2a879f03cc 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/router.tsx @@ -16,6 +16,7 @@ import { pluginRouteSpecs, withVitNodeRoutes, } from "@vitnode/core/tanstack/plugin-routes"; +import { navigationViewTransition } from "@vitnode/core/tanstack/view-transitions"; // Imported for its side effect: this module calls `configureIntl`, which is // what registers the locale rules every route's redirects are rewritten by. @@ -24,6 +25,7 @@ import { pluginRouteSources } from "./plugin-routes.gen"; import { Route as adminShellRoute } from "./routes/_admin"; import { Route as mainShellRoute } from "./routes/_main"; import { routeTree as fileRouteTree } from "./routeTree.gen"; +import { vitNodeConfig } from "./vitnode.config"; configureContentRegistry( async () => (await import("./content-registry.gen")).contentRegistry, @@ -53,6 +55,9 @@ export function getRouter() { defaultPendingMinMs: 300, defaultPreload: "intent", defaultPreloadStaleTime: 0, + defaultViewTransition: navigationViewTransition( + vitNodeConfig.viewTransitions, + ), defaultStaleReloadMode: "blocking", rewrite: createLocaleRewrite(() => holder.current), routeTree, diff --git a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/styles.css b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/styles.css index f03b228add..8d32d701ac 100644 --- a/packages/create-vitnode-app/copy-of-vitnode-app/root/src/styles.css +++ b/packages/create-vitnode-app/copy-of-vitnode-app/root/src/styles.css @@ -2,6 +2,7 @@ @import "shadcn/tailwind.css"; @import "tw-animate-css"; @import "@vitnode/core/styles/tiptap.css"; +@import "@vitnode/core/styles/view-transitions.css"; @custom-variant dark (&:where(.dark, .dark *)); @custom-variant reduced-transparency (@media (prefers-reduced-transparency: reduce)); diff --git a/packages/vitnode/.npmignore b/packages/vitnode/.npmignore index 6533c49935..e22ba95285 100644 --- a/packages/vitnode/.npmignore +++ b/packages/vitnode/.npmignore @@ -4,6 +4,7 @@ !/src/locales !/src/locales/** !/src/tiptap.css +!/src/view-transitions.css !/src/emoji-picker.css /node_modules diff --git a/packages/vitnode/package.json b/packages/vitnode/package.json index fe4aab1e0a..242dbb2822 100644 --- a/packages/vitnode/package.json +++ b/packages/vitnode/package.json @@ -204,7 +204,8 @@ "default": "./dist/src/*.js" }, "./locales/*.json": "./src/locales/*.json", - "./styles/tiptap.css": "./src/tiptap.css" + "./styles/tiptap.css": "./src/tiptap.css", + "./styles/view-transitions.css": "./src/view-transitions.css" }, "scripts": { "build:scripts": "tsup", diff --git a/packages/vitnode/src/api/adapters/sso/discord.ts b/packages/vitnode/src/api/adapters/sso/discord.ts index 617d3f16c4..043d85b34f 100644 --- a/packages/vitnode/src/api/adapters/sso/discord.ts +++ b/packages/vitnode/src/api/adapters/sso/discord.ts @@ -129,6 +129,7 @@ export const DiscordSSOApiPlugin = ({ return url.toString(); }, + brandColor: "#5865F2", icon: DISCORD_ICON, id, name: "Discord", diff --git a/packages/vitnode/src/api/adapters/sso/facebook.ts b/packages/vitnode/src/api/adapters/sso/facebook.ts index fbdc3677d8..0597552b49 100644 --- a/packages/vitnode/src/api/adapters/sso/facebook.ts +++ b/packages/vitnode/src/api/adapters/sso/facebook.ts @@ -44,6 +44,7 @@ export const FacebookSSOApiPlugin = ({ }); return { + brandColor: "#0866FF", icon: FACEBOOK_ICON, id, name: "Facebook", diff --git a/packages/vitnode/src/api/adapters/sso/icons.ts b/packages/vitnode/src/api/adapters/sso/icons.ts index 246a6f6585..cf1e1a3828 100644 --- a/packages/vitnode/src/api/adapters/sso/icons.ts +++ b/packages/vitnode/src/api/adapters/sso/icons.ts @@ -5,4 +5,4 @@ export const FACEBOOK_ICON = '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z"/></svg>'; export const GOOGLE_ICON = - '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"/></svg>'; + '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/><path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/><path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/><path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/></svg>'; diff --git a/packages/vitnode/src/api/models/sso.ts b/packages/vitnode/src/api/models/sso.ts index 45bdad7746..e8fbe090e6 100644 --- a/packages/vitnode/src/api/models/sso.ts +++ b/packages/vitnode/src/api/models/sso.ts @@ -42,6 +42,7 @@ export interface SSOProviderUser { } export interface SSOApiPlugin { + brandColor?: string; fetchToken: ( code: string, ) => Promise<{ access_token: string; token_type: string }>; diff --git a/packages/vitnode/src/api/modules/middleware/route.ts b/packages/vitnode/src/api/modules/middleware/route.ts index 3544bb77cc..8ad2995847 100644 --- a/packages/vitnode/src/api/modules/middleware/route.ts +++ b/packages/vitnode/src/api/modules/middleware/route.ts @@ -26,6 +26,7 @@ export const routeMiddlewareSchema = z.object({ id: z.string(), name: z.string(), icon: z.string().optional(), + brandColor: z.string().optional(), }), ), isEmail: z.boolean(), @@ -70,7 +71,12 @@ export const routeMiddleware = buildRoute({ password: c.get("core").authorization.password.enabled, navigation, bottomBar, - sso: sso.map(s => ({ id: s.id, name: s.name, icon: s.icon })), + sso: sso.map(s => ({ + id: s.id, + name: s.name, + icon: s.icon, + brandColor: s.brandColor, + })), captcha: c.get("core").captcha ? { siteKey: c.get("core").captcha?.siteKey ?? "", diff --git a/packages/vitnode/src/blocks/built-in/links.test.tsx b/packages/vitnode/src/blocks/built-in/links.test.tsx index 125e8be83d..99b3d5001f 100644 --- a/packages/vitnode/src/blocks/built-in/links.test.tsx +++ b/packages/vitnode/src/blocks/built-in/links.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/blocks/edit-widgets-host.test.tsx b/packages/vitnode/src/blocks/edit-widgets-host.test.tsx index 7248383fc2..2bd286fce8 100644 --- a/packages/vitnode/src/blocks/edit-widgets-host.test.tsx +++ b/packages/vitnode/src/blocks/edit-widgets-host.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, type RenderResult, screen } from "@testing-library/react"; import { act, type ReactNode, useEffect } from "react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/blocks/edit.test.tsx b/packages/vitnode/src/blocks/edit.test.tsx index 46636db4be..d86311e360 100644 --- a/packages/vitnode/src/blocks/edit.test.tsx +++ b/packages/vitnode/src/blocks/edit.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render } from "@testing-library/react"; import { act, diff --git a/packages/vitnode/src/blocks/page-save.test.tsx b/packages/vitnode/src/blocks/page-save.test.tsx index 04f2463517..67ea9a1257 100644 --- a/packages/vitnode/src/blocks/page-save.test.tsx +++ b/packages/vitnode/src/blocks/page-save.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { act, type ReactNode } from "react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/blocks/page.test.tsx b/packages/vitnode/src/blocks/page.test.tsx index 300fdc9197..8277705865 100644 --- a/packages/vitnode/src/blocks/page.test.tsx +++ b/packages/vitnode/src/blocks/page.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { act, type ReactNode } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/blocks/renderer.test.tsx b/packages/vitnode/src/blocks/renderer.test.tsx index 685b64c3d1..b996cc7556 100644 --- a/packages/vitnode/src/blocks/renderer.test.tsx +++ b/packages/vitnode/src/blocks/renderer.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ai/ai-field-assist.test.tsx b/packages/vitnode/src/components/ai/ai-field-assist.test.tsx index 5d189b3aad..9da1c8abe7 100644 --- a/packages/vitnode/src/components/ai/ai-field-assist.test.tsx +++ b/packages/vitnode/src/components/ai/ai-field-assist.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.test.tsx b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.test.tsx index e71cd260f5..1eb0876041 100644 --- a/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.test.tsx +++ b/packages/vitnode/src/components/confirm-action/confirm-action-alert-dialog.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, fireEvent, render, screen } from "@testing-library/react"; import { Trash2Icon } from "lucide-react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/form/auto-form.test.tsx b/packages/vitnode/src/components/form/auto-form.test.tsx index 7ceadd7059..c0beae776a 100644 --- a/packages/vitnode/src/components/form/auto-form.test.tsx +++ b/packages/vitnode/src/components/form/auto-form.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/form/fields/combobox.test.tsx b/packages/vitnode/src/components/form/fields/combobox.test.tsx index 194a4ea152..05e062c768 100644 --- a/packages/vitnode/src/components/form/fields/combobox.test.tsx +++ b/packages/vitnode/src/components/form/fields/combobox.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/form/fields/file-dropzone.test.tsx b/packages/vitnode/src/components/form/fields/file-dropzone.test.tsx index 17c978a329..86da5e8598 100644 --- a/packages/vitnode/src/components/form/fields/file-dropzone.test.tsx +++ b/packages/vitnode/src/components/form/fields/file-dropzone.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/form/fields/files.test.tsx b/packages/vitnode/src/components/form/fields/files.test.tsx index 958c7941c2..4c5dc95d8e 100644 --- a/packages/vitnode/src/components/form/fields/files.test.tsx +++ b/packages/vitnode/src/components/form/fields/files.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { Toaster } from "sonner"; diff --git a/packages/vitnode/src/components/form/fields/multi-lang.test.tsx b/packages/vitnode/src/components/form/fields/multi-lang.test.tsx index 92562616cd..adc6940eb5 100644 --- a/packages/vitnode/src/components/form/fields/multi-lang.test.tsx +++ b/packages/vitnode/src/components/form/fields/multi-lang.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import React from "react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/pet/pet.test.tsx b/packages/vitnode/src/components/pet/pet.test.tsx index 67c8c9d454..6e6dc8687c 100644 --- a/packages/vitnode/src/components/pet/pet.test.tsx +++ b/packages/vitnode/src/components/pet/pet.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/switchers/themes/theme-switchers.test.tsx b/packages/vitnode/src/components/switchers/themes/theme-switchers.test.tsx index 83210ef823..9166004e3f 100644 --- a/packages/vitnode/src/components/switchers/themes/theme-switchers.test.tsx +++ b/packages/vitnode/src/components/switchers/themes/theme-switchers.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen, within } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/table/content.test.tsx b/packages/vitnode/src/components/table/content.test.tsx index d0e370a689..8b11cda0d2 100644 --- a/packages/vitnode/src/components/table/content.test.tsx +++ b/packages/vitnode/src/components/table/content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen, within } from "@testing-library/react"; import React from "react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/table/expansion.test.tsx b/packages/vitnode/src/components/table/expansion.test.tsx index a7c0450b6c..1797d9cfed 100644 --- a/packages/vitnode/src/components/table/expansion.test.tsx +++ b/packages/vitnode/src/components/table/expansion.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, diff --git a/packages/vitnode/src/components/table/order-table-head.test.tsx b/packages/vitnode/src/components/table/order-table-head.test.tsx index 1086efa94b..663ced8b4b 100644 --- a/packages/vitnode/src/components/table/order-table-head.test.tsx +++ b/packages/vitnode/src/components/table/order-table-head.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, fireEvent, render, screen, within } from "@testing-library/react"; import React from "react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/table/reorder.test.tsx b/packages/vitnode/src/components/table/reorder.test.tsx index a533077214..cfbe1cd6c3 100644 --- a/packages/vitnode/src/components/table/reorder.test.tsx +++ b/packages/vitnode/src/components/table/reorder.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, fireEvent, diff --git a/packages/vitnode/src/components/tiptap/panel.test.ts b/packages/vitnode/src/components/tiptap/panel.test.ts index d2477d30f7..948f83368b 100644 --- a/packages/vitnode/src/components/tiptap/panel.test.ts +++ b/packages/vitnode/src/components/tiptap/panel.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { afterEach, describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts b/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts index f64fb89e1a..cea3527ab0 100644 --- a/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts +++ b/packages/vitnode/src/components/tiptap/quick-ask/quick-ask-editor.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { afterEach, describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/tiptap/slash-command/slash-command-list.test.tsx b/packages/vitnode/src/components/tiptap/slash-command/slash-command-list.test.tsx index 9b3c7adda9..0e293f36de 100644 --- a/packages/vitnode/src/components/tiptap/slash-command/slash-command-list.test.tsx +++ b/packages/vitnode/src/components/tiptap/slash-command/slash-command-list.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import type { SuggestionKeyDownProps } from "@tiptap/suggestion"; import { act, fireEvent, render, screen, within } from "@testing-library/react"; diff --git a/packages/vitnode/src/components/tiptap/submit-form-shortcut.test.ts b/packages/vitnode/src/components/tiptap/submit-form-shortcut.test.ts index 57b8ab559a..7a7a708bc8 100644 --- a/packages/vitnode/src/components/tiptap/submit-form-shortcut.test.ts +++ b/packages/vitnode/src/components/tiptap/submit-form-shortcut.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { Editor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx b/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx index e881314266..1a88fa19a0 100644 --- a/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx +++ b/packages/vitnode/src/components/tiptap/toolbar/actions/quick-ask-action.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/tiptap/toolbar/tiptap-toolbar.test.tsx b/packages/vitnode/src/components/tiptap/toolbar/tiptap-toolbar.test.tsx index a8c014617d..c2f90947ad 100644 --- a/packages/vitnode/src/components/tiptap/toolbar/tiptap-toolbar.test.tsx +++ b/packages/vitnode/src/components/tiptap/toolbar/tiptap-toolbar.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { cleanup, fireEvent, diff --git a/packages/vitnode/src/components/ui/alert-dialog.test.tsx b/packages/vitnode/src/components/ui/alert-dialog.test.tsx index 494390ed5e..c969860318 100644 --- a/packages/vitnode/src/components/ui/alert-dialog.test.tsx +++ b/packages/vitnode/src/components/ui/alert-dialog.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/alert.test.tsx b/packages/vitnode/src/components/ui/alert.test.tsx index 29909a0947..11b55cc07b 100644 --- a/packages/vitnode/src/components/ui/alert.test.tsx +++ b/packages/vitnode/src/components/ui/alert.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { HardDriveIcon } from "lucide-react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/button.test.tsx b/packages/vitnode/src/components/ui/button.test.tsx index 78c6ed115f..7daad518fe 100644 --- a/packages/vitnode/src/components/ui/button.test.tsx +++ b/packages/vitnode/src/components/ui/button.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/calendar.test.tsx b/packages/vitnode/src/components/ui/calendar.test.tsx index acaf70c67b..6d5d8d0553 100644 --- a/packages/vitnode/src/components/ui/calendar.test.tsx +++ b/packages/vitnode/src/components/ui/calendar.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/carousel.test.tsx b/packages/vitnode/src/components/ui/carousel.test.tsx index 02d9d9a709..5ee9394a29 100644 --- a/packages/vitnode/src/components/ui/carousel.test.tsx +++ b/packages/vitnode/src/components/ui/carousel.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/cascader.test.tsx b/packages/vitnode/src/components/ui/cascader.test.tsx index 199653f432..125c3f85c2 100644 --- a/packages/vitnode/src/components/ui/cascader.test.tsx +++ b/packages/vitnode/src/components/ui/cascader.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/chart.test.tsx b/packages/vitnode/src/components/ui/chart.test.tsx index f3fed852a3..27c18c211d 100644 --- a/packages/vitnode/src/components/ui/chart.test.tsx +++ b/packages/vitnode/src/components/ui/chart.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import type { ChartPoint } from "@tanstack/charts"; import { render, screen, within } from "@testing-library/react"; diff --git a/packages/vitnode/src/components/ui/copy-button.test.tsx b/packages/vitnode/src/components/ui/copy-button.test.tsx index f664fcc66a..a54f96f557 100644 --- a/packages/vitnode/src/components/ui/copy-button.test.tsx +++ b/packages/vitnode/src/components/ui/copy-button.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, fireEvent, render, screen } from "@testing-library/react"; import { toast } from "sonner"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/counter.test.tsx b/packages/vitnode/src/components/ui/counter.test.tsx index 11d3385966..c8d1db7111 100644 --- a/packages/vitnode/src/components/ui/counter.test.tsx +++ b/packages/vitnode/src/components/ui/counter.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/date-picker.test.tsx b/packages/vitnode/src/components/ui/date-picker.test.tsx index 4834918acf..299612b474 100644 --- a/packages/vitnode/src/components/ui/date-picker.test.tsx +++ b/packages/vitnode/src/components/ui/date-picker.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/date-range-picker.test.tsx b/packages/vitnode/src/components/ui/date-range-picker.test.tsx index 5d7ab8c66f..a7f3cfe944 100644 --- a/packages/vitnode/src/components/ui/date-range-picker.test.tsx +++ b/packages/vitnode/src/components/ui/date-range-picker.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/dialog.test.tsx b/packages/vitnode/src/components/ui/dialog.test.tsx index 016441d1e1..eae71930e9 100644 --- a/packages/vitnode/src/components/ui/dialog.test.tsx +++ b/packages/vitnode/src/components/ui/dialog.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import React from "react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/dynamic-icon.client.test.tsx b/packages/vitnode/src/components/ui/dynamic-icon.client.test.tsx index 50fb20a27d..1d5a839a59 100644 --- a/packages/vitnode/src/components/ui/dynamic-icon.client.test.tsx +++ b/packages/vitnode/src/components/ui/dynamic-icon.client.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, waitFor } from "@testing-library/react"; import React from "react"; import { expect, test } from "vitest"; diff --git a/packages/vitnode/src/components/ui/input-group.test.tsx b/packages/vitnode/src/components/ui/input-group.test.tsx index 5487d2c23e..2480609def 100644 --- a/packages/vitnode/src/components/ui/input-group.test.tsx +++ b/packages/vitnode/src/components/ui/input-group.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/number-field.test.tsx b/packages/vitnode/src/components/ui/number-field.test.tsx index 620998db8b..1bba9d8a19 100644 --- a/packages/vitnode/src/components/ui/number-field.test.tsx +++ b/packages/vitnode/src/components/ui/number-field.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/pagination.test.tsx b/packages/vitnode/src/components/ui/pagination.test.tsx index c68c052d59..6e5edff95a 100644 --- a/packages/vitnode/src/components/ui/pagination.test.tsx +++ b/packages/vitnode/src/components/ui/pagination.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/progress.test.tsx b/packages/vitnode/src/components/ui/progress.test.tsx index 955d8adbd1..bc4731c87c 100644 --- a/packages/vitnode/src/components/ui/progress.test.tsx +++ b/packages/vitnode/src/components/ui/progress.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/sidebar.test.tsx b/packages/vitnode/src/components/ui/sidebar.test.tsx index 09278be419..72b92cb98b 100644 --- a/packages/vitnode/src/components/ui/sidebar.test.tsx +++ b/packages/vitnode/src/components/ui/sidebar.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/components/ui/spinner.test.tsx b/packages/vitnode/src/components/ui/spinner.test.tsx index 3682341cbb..e1d4379d91 100644 --- a/packages/vitnode/src/components/ui/spinner.test.tsx +++ b/packages/vitnode/src/components/ui/spinner.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/toggle-group.test.tsx b/packages/vitnode/src/components/ui/toggle-group.test.tsx index 59e2f08250..1848e8cebc 100644 --- a/packages/vitnode/src/components/ui/toggle-group.test.tsx +++ b/packages/vitnode/src/components/ui/toggle-group.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/components/ui/tooltip.test.tsx b/packages/vitnode/src/components/ui/tooltip.test.tsx index 9655b27ec7..0689ebcfc2 100644 --- a/packages/vitnode/src/components/ui/tooltip.test.tsx +++ b/packages/vitnode/src/components/ui/tooltip.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/editor/block-shell/block-shell.test.tsx b/packages/vitnode/src/editor/block-shell/block-shell.test.tsx index edbf205ec1..c0bc3036f7 100644 --- a/packages/vitnode/src/editor/block-shell/block-shell.test.tsx +++ b/packages/vitnode/src/editor/block-shell/block-shell.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { type ReactElement, type ReactNode } from "react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/editor/sidebar/sidebar.test.tsx b/packages/vitnode/src/editor/sidebar/sidebar.test.tsx index 09065c9072..afaf74a84d 100644 --- a/packages/vitnode/src/editor/sidebar/sidebar.test.tsx +++ b/packages/vitnode/src/editor/sidebar/sidebar.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen, within } from "@testing-library/react"; import { act, type ReactElement, useEffect, useReducer, useState } from "react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/hooks/use-intersection-observer.test.ts b/packages/vitnode/src/hooks/use-intersection-observer.test.ts index fb789cc7e0..855c84b1d6 100644 --- a/packages/vitnode/src/hooks/use-intersection-observer.test.ts +++ b/packages/vitnode/src/hooks/use-intersection-observer.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/hooks/use-local-storage.test.ts b/packages/vitnode/src/hooks/use-local-storage.test.ts index 29c55e002e..228d08a90d 100644 --- a/packages/vitnode/src/hooks/use-local-storage.test.ts +++ b/packages/vitnode/src/hooks/use-local-storage.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/hooks/use-mobile.test.tsx b/packages/vitnode/src/hooks/use-mobile.test.tsx index c07c993d79..6dfdf58369 100644 --- a/packages/vitnode/src/hooks/use-mobile.test.tsx +++ b/packages/vitnode/src/hooks/use-mobile.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { act, render, screen } from "@testing-library/react"; import { renderToString } from "react-dom/server"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/lib/fetcher/rate-limit.test.ts b/packages/vitnode/src/lib/fetcher/rate-limit.test.ts index 33fd97cd14..cfaf6a3046 100644 --- a/packages/vitnode/src/lib/fetcher/rate-limit.test.ts +++ b/packages/vitnode/src/lib/fetcher/rate-limit.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; import type { RateLimitEventDetail } from "./rate-limit"; diff --git a/packages/vitnode/src/lib/sanitize-editor-html.test.ts b/packages/vitnode/src/lib/sanitize-editor-html.test.ts index 50f76dfe83..4d35a0750f 100644 --- a/packages/vitnode/src/lib/sanitize-editor-html.test.ts +++ b/packages/vitnode/src/lib/sanitize-editor-html.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { describe, expect, it } from "vitest"; import { sanitizeEditorHtml } from "./sanitize-editor-html"; diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json index aad263bb4f..82473aa549 100644 --- a/packages/vitnode/src/locales/en.json +++ b/packages/vitnode/src/locales/en.json @@ -758,6 +758,7 @@ "auth": { "sso": { "or": "Or continue with email", + "continue_with": "Continue with {provider}", "access_denied": "You have denied access to the application or the request has expired. Please try again.", "email_exists": { "title": "You cannot sign in with <provider></provider>", diff --git a/packages/vitnode/src/tanstack/admin/tab-presence.test.ts b/packages/vitnode/src/tanstack/admin/tab-presence.test.ts index 6c5f81b57b..e7152ddb20 100644 --- a/packages/vitnode/src/tanstack/admin/tab-presence.test.ts +++ b/packages/vitnode/src/tanstack/admin/tab-presence.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { diff --git a/packages/vitnode/src/tanstack/breadcrumb/pending.test.tsx b/packages/vitnode/src/tanstack/breadcrumb/pending.test.tsx index 2b8371ba99..ba95b22676 100644 --- a/packages/vitnode/src/tanstack/breadcrumb/pending.test.tsx +++ b/packages/vitnode/src/tanstack/breadcrumb/pending.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/tanstack/notifications/bell.test.tsx b/packages/vitnode/src/tanstack/notifications/bell.test.tsx index a9f005914a..48ea5ec4c6 100644 --- a/packages/vitnode/src/tanstack/notifications/bell.test.tsx +++ b/packages/vitnode/src/tanstack/notifications/bell.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createMemoryHistory, diff --git a/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx b/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx index 66855935f9..c9b3832d67 100644 --- a/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx +++ b/packages/vitnode/src/tanstack/notifications/state-sync.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/tanstack/pending/guard-pending.test.tsx b/packages/vitnode/src/tanstack/pending/guard-pending.test.tsx index d01eb946e7..1ae4b0056e 100644 --- a/packages/vitnode/src/tanstack/pending/guard-pending.test.tsx +++ b/packages/vitnode/src/tanstack/pending/guard-pending.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx b/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx index acfea74fa9..c58576cfc1 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx +++ b/packages/vitnode/src/tanstack/plugin-routes/components.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts b/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts index 359508f7ac..cf159d950a 100644 --- a/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts +++ b/packages/vitnode/src/tanstack/plugin-routes/mount-freshness.test.ts @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import type { AnyRoute } from "@tanstack/react-router"; import { diff --git a/packages/vitnode/src/tanstack/view-transitions/index.test.ts b/packages/vitnode/src/tanstack/view-transitions/index.test.ts new file mode 100644 index 0000000000..cc6624a784 --- /dev/null +++ b/packages/vitnode/src/tanstack/view-transitions/index.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { navigationViewTransition, PAGE_VIEW_TRANSITION_TYPE } from "."; + +type ViewTransitionParams = (() => unknown) | { update: () => unknown }; + +const startViewTransition = vi.fn((params: ViewTransitionParams) => { + const update = typeof params === "function" ? params : params.update; + + return { updateCallbackDone: Promise.resolve(update()) }; +}); + +const stubReducedMotion = (reduce: boolean) => { + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => ({ + matches: reduce && query.includes("reduce"), + })), + ); +}; + +const routerWith = async (enabled?: boolean) => { + const rootRoute = createRootRoute(); + const routeTree = rootRoute.addChildren([ + createRoute({ getParentRoute: () => rootRoute, path: "/" }), + createRoute({ getParentRoute: () => rootRoute, path: "/docs" }), + ]); + const router = createRouter({ + defaultViewTransition: navigationViewTransition(enabled), + history: createMemoryHistory({ initialEntries: ["/"] }), + routeTree, + }); + + await router.load(); + startViewTransition.mockClear(); + + return router; +}; + +describe("navigationViewTransition", () => { + beforeEach(() => { + Object.defineProperty(document, "startViewTransition", { + configurable: true, + value: startViewTransition, + }); + vi.stubGlobal("CSS", { supports: () => true }); + stubReducedMotion(false); + }); + + afterEach(() => { + Reflect.deleteProperty(document, "startViewTransition"); + vi.unstubAllGlobals(); + startViewTransition.mockClear(); + }); + + it("animates a navigation to another page", async () => { + const router = await routerWith(); + + await router.navigate({ to: "/docs" }); + + expect(startViewTransition).toHaveBeenCalledOnce(); + expect(startViewTransition.mock.calls[0]?.[0]).toMatchObject({ + types: [PAGE_VIEW_TRANSITION_TYPE], + }); + expect(router.state.location.pathname).toBe("/docs"); + }); + + it("skips a change that only touches the search params", async () => { + const router = await routerWith(); + + await router.navigate({ search: { page: 2 }, to: "/" }); + + expect(startViewTransition).not.toHaveBeenCalled(); + expect(router.state.location.search).toEqual({ page: 2 }); + }); + + it("skips every navigation when the user prefers reduced motion", async () => { + stubReducedMotion(true); + const router = await routerWith(); + + await router.navigate({ to: "/docs" }); + + expect(startViewTransition).not.toHaveBeenCalled(); + expect(router.state.location.pathname).toBe("/docs"); + }); + + it("skips every navigation when view transitions are turned off", async () => { + const router = await routerWith(false); + + await router.navigate({ to: "/docs" }); + + expect(startViewTransition).not.toHaveBeenCalled(); + expect(router.state.location.pathname).toBe("/docs"); + }); +}); diff --git a/packages/vitnode/src/tanstack/view-transitions/index.ts b/packages/vitnode/src/tanstack/view-transitions/index.ts new file mode 100644 index 0000000000..058c8a25c1 --- /dev/null +++ b/packages/vitnode/src/tanstack/view-transitions/index.ts @@ -0,0 +1,23 @@ +export const LOGO_VIEW_TRANSITION_NAME = "vitnode-logo"; + +export const PAGE_VIEW_TRANSITION_TYPE = "vitnode-page"; + +interface LocationChange { + pathChanged: boolean; +} + +const prefersReducedMotion = (): boolean => + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + +export const pageViewTransitionTypes = ({ + pathChanged, +}: LocationChange): false | string[] => { + if (!pathChanged || prefersReducedMotion()) return false; + + return [PAGE_VIEW_TRANSITION_TYPE]; +}; + +export const navigationViewTransition = (enabled = true) => + enabled ? { types: pageViewTransitionTypes } : false; diff --git a/packages/vitnode/src/view-transitions.css b/packages/vitnode/src/view-transitions.css new file mode 100644 index 0000000000..558fccdb5a --- /dev/null +++ b/packages/vitnode/src/view-transitions.css @@ -0,0 +1,26 @@ +::view-transition-group(root), +::view-transition-old(root), +::view-transition-new(root) { + animation-duration: 150ms; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); +} + +::view-transition-group(vitnode-logo), +::view-transition-old(vitnode-logo), +::view-transition-new(vitnode-logo) { + animation-duration: 200ms; + animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1); +} + +::view-transition-old(vitnode-logo), +::view-transition-new(vitnode-logo) { + height: 100%; +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none !important; + } +} diff --git a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.test.tsx b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.test.tsx index 6bb17cc98f..c58da0d039 100644 --- a/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.test.tsx +++ b/packages/vitnode/src/views/admin/layouts/sidebar/nav/item-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/views/admin/views/content/form/status-switch.test.tsx b/packages/vitnode/src/views/admin/views/content/form/status-switch.test.tsx index 1423e6cdac..353c00a6ab 100644 --- a/packages/vitnode/src/views/admin/views/content/form/status-switch.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/form/status-switch.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx b/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx index 9a10df5ae1..fde38e820f 100644 --- a/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx +++ b/packages/vitnode/src/views/admin/views/content/table/cells.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.test.tsx b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.test.tsx index a939c89aab..67d12cafb0 100644 --- a/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/advanced/cron/cron-scheduler-status.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render as renderDom, screen } from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/views/admin/views/core/ai/actions/actions-content.test.tsx b/packages/vitnode/src/views/admin/views/core/ai/actions/actions-content.test.tsx index 5f59c01197..89914b6689 100644 --- a/packages/vitnode/src/views/admin/views/core/ai/actions/actions-content.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/ai/actions/actions-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, diff --git a/packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.test.tsx b/packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.test.tsx index e9487ea66d..cd5066b184 100644 --- a/packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/ai/overview/overview-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-panel.test.tsx b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-panel.test.tsx index de82d8f0cf..0f4f2a5d3e 100644 --- a/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-panel.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/dashboard/grid/widget-panel.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -21,6 +22,14 @@ const desktopMediaQuery = (media: string): MediaQueryList => ({ beforeEach(() => { vi.stubGlobal("innerWidth", 1280); vi.stubGlobal("matchMedia", desktopMediaQuery); + vi.stubGlobal( + "ResizeObserver", + class { + disconnect() {} + observe() {} + unobserve() {} + }, + ); }); afterEach(() => { diff --git a/packages/vitnode/src/views/admin/views/core/navigation/navigation-list-content.test.tsx b/packages/vitnode/src/views/admin/views/core/navigation/navigation-list-content.test.tsx index 8c94ad0a75..8c8971d47e 100644 --- a/packages/vitnode/src/views/admin/views/core/navigation/navigation-list-content.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/navigation/navigation-list-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, diff --git a/packages/vitnode/src/views/admin/views/core/system/files/alt/file-alt-editor.test.tsx b/packages/vitnode/src/views/admin/views/core/system/files/alt/file-alt-editor.test.tsx index b044d92b47..f85e2d73d1 100644 --- a/packages/vitnode/src/views/admin/views/core/system/files/alt/file-alt-editor.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/system/files/alt/file-alt-editor.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-account-panels.test.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-panels.test.tsx index 7dc4f7733c..0491c5b8ec 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-account-panels.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-account-panels.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { IntlProvider } from "use-intl"; diff --git a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx index 7554a5b733..17dbf7e345 100644 --- a/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx +++ b/packages/vitnode/src/views/admin/views/core/users/detail/user-detail-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.test.tsx b/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.test.tsx index 951d839de2..4d7fa4ab7e 100644 --- a/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.test.tsx +++ b/packages/vitnode/src/views/auth/password-reset/form/password-reset-form-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { afterEach, describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx index fe0d08ff89..24c59fef0a 100644 --- a/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx +++ b/packages/vitnode/src/views/auth/settings/ai/ai-usage-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { fireEvent, diff --git a/packages/vitnode/src/views/auth/settings/devices/device-item.test.tsx b/packages/vitnode/src/views/auth/settings/devices/device-item.test.tsx index c695fe2e8d..91a191819e 100644 --- a/packages/vitnode/src/views/auth/settings/devices/device-item.test.tsx +++ b/packages/vitnode/src/views/auth/settings/devices/device-item.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { describe, expect, it, vi } from "vitest"; diff --git a/packages/vitnode/src/views/auth/settings/nav-content.test.tsx b/packages/vitnode/src/views/auth/settings/nav-content.test.tsx index a363013dc2..5bb7d2133f 100644 --- a/packages/vitnode/src/views/auth/settings/nav-content.test.tsx +++ b/packages/vitnode/src/views/auth/settings/nav-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/views/auth/settings/passkeys/passkeys-content.test.tsx b/packages/vitnode/src/views/auth/settings/passkeys/passkeys-content.test.tsx index 4ee98f5c1f..b4e9b8a534 100644 --- a/packages/vitnode/src/views/auth/settings/passkeys/passkeys-content.test.tsx +++ b/packages/vitnode/src/views/auth/settings/passkeys/passkeys-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { IntlProvider } from "use-intl"; import { diff --git a/packages/vitnode/src/views/auth/settings/sso/sso-connections-content.test.tsx b/packages/vitnode/src/views/auth/settings/sso/sso-connections-content.test.tsx index b898e85cdb..e48a4b3520 100644 --- a/packages/vitnode/src/views/auth/settings/sso/sso-connections-content.test.tsx +++ b/packages/vitnode/src/views/auth/settings/sso/sso-connections-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { fireEvent, render, diff --git a/packages/vitnode/src/views/auth/sso/brand.test.ts b/packages/vitnode/src/views/auth/sso/brand.test.ts new file mode 100644 index 0000000000..f1b6a663e5 --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/brand.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { ssoBrandForeground } from "./brand"; + +describe("picking the text color for a brand-filled SSO button", () => { + it("keeps white text on saturated brand colors that carry it", () => { + expect(ssoBrandForeground("#5865f2")).toBe("#ffffff"); + expect(ssoBrandForeground("#0866ff")).toBe("#ffffff"); + expect(ssoBrandForeground("#000")).toBe("#ffffff"); + }); + + it("switches to dark text on light brand colors", () => { + expect(ssoBrandForeground("#ffcc00")).toBe("#0a0a0a"); + expect(ssoBrandForeground("#fff")).toBe("#0a0a0a"); + expect(ssoBrandForeground("#00a4ef")).toBe("#0a0a0a"); + }); +}); diff --git a/packages/vitnode/src/views/auth/sso/brand.ts b/packages/vitnode/src/views/auth/sso/brand.ts new file mode 100644 index 0000000000..695008f9f9 --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/brand.ts @@ -0,0 +1,52 @@ +const HEX_COLOR = /^#(?:[\da-f]{3}|[\da-f]{6})$/i; + +const LIGHT_FOREGROUND = "#ffffff"; +const DARK_FOREGROUND = "#0a0a0a"; +const MIN_TEXT_CONTRAST = 4.5; + +export const ssoBrandColor = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined; + + const color = value.trim().toLowerCase(); + + return HEX_COLOR.test(color) ? color : undefined; +}; + +const expandHex = (hex: string) => + hex.length === 4 + ? hex + .slice(1) + .split("") + .map(digit => digit + digit) + .join("") + : hex.slice(1); + +const linearChannel = (value: number) => { + const channel = value / 255; + + return channel <= 0.040_45 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; +}; + +const relativeLuminance = (hex: string) => { + const digits = expandHex(hex); + const [red, green, blue] = [0, 2, 4].map(start => + linearChannel(Number.parseInt(digits.slice(start, start + 2), 16)), + ); + + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +}; + +const contrastRatio = (lighter: number, darker: number) => + (lighter + 0.05) / (darker + 0.05); + +export const ssoBrandForeground = (brandColor: string): string => { + const luminance = relativeLuminance(brandColor); + const onLight = contrastRatio(1, luminance); + const onDark = contrastRatio(luminance, relativeLuminance(DARK_FOREGROUND)); + + return onLight >= MIN_TEXT_CONTRAST || onLight >= onDark + ? LIGHT_FOREGROUND + : DARK_FOREGROUND; +}; diff --git a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx new file mode 100644 index 0000000000..5679ea145f --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { IntlProvider } from "use-intl"; +import { describe, expect, it, vi } from "vitest"; + +import type { SSOSelectProvider, SSOStartResult } from "./sso-buttons-content"; + +import { SSOButtonsContent } from "./sso-buttons-content"; + +const PROVIDERS = [ + { brandColor: "#5865f2", id: "discord", name: "Discord" }, + { id: "google", name: "Google" }, +]; + +const renderButtons = (onSelectProvider: SSOSelectProvider) => + render( + <IntlProvider locale="en" messages={{}} timeZone="UTC"> + <SSOButtonsContent + onSelectProvider={onSelectProvider} + providers={PROVIDERS} + /> + </IntlProvider>, + ); + +const deferred = () => { + let resolve: (value: SSOStartResult) => void = () => undefined; + const promise = new Promise<SSOStartResult>(done => { + resolve = done; + }); + + return { promise, resolve }; +}; + +describe("SSOButtonsContent", () => { + it("renders one button per provider", () => { + renderButtons(vi.fn()); + + expect( + screen.getAllByRole("button", { name: "core.auth.sso.continue_with" }), + ).toHaveLength(2); + }); + + it("starts the chosen provider and locks the row while it redirects", async () => { + const start = deferred(); + const onSelectProvider = vi.fn(async () => start.promise); + renderButtons(onSelectProvider); + + const [discord, google] = screen.getAllByRole("button"); + await act(async () => { + fireEvent.click(discord); + }); + + expect(onSelectProvider).toHaveBeenCalledWith("discord"); + expect( + screen.getByRole("button", { name: "core.global.loading" }), + ).toBeTruthy(); + expect((google as HTMLButtonElement).disabled).toBe(true); + }); + + it("unlocks the row when the provider could not be started", async () => { + const onSelectProvider = vi.fn(async () => ({ message: "failed" })); + renderButtons(onSelectProvider); + + await act(async () => { + fireEvent.click(screen.getAllByRole("button")[1]); + }); + + const buttons = screen.getAllByRole("button", { + name: "core.auth.sso.continue_with", + }); + expect(buttons).toHaveLength(2); + expect( + buttons.every(button => !(button as HTMLButtonElement).disabled), + ).toBe(true); + }); +}); diff --git a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx index 9310a95eae..cded8329f3 100644 --- a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx @@ -1,3 +1,5 @@ +import { cn } from "cn"; +import { useState } from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; @@ -6,12 +8,24 @@ import { Skeleton } from "@/components/ui/skeleton"; import type { SSOProvider } from "../providers"; +import { ssoBrandForeground } from "../brand"; import { SSOProviderIcon } from "./sso-provider-icon"; export type SSOStartResult = undefined | { message?: string }; export type SSOSelectProvider = (providerId: string) => Promise<SSOStartResult>; +const BRAND_FILL = + "border-transparent bg-(--sso-brand) text-(--sso-brand-foreground) hover:bg-(--sso-brand) hover:brightness-90 dark:hover:brightness-110"; + +const brandStyle = (brandColor: string | undefined) => + brandColor + ? ({ + "--sso-brand": brandColor, + "--sso-brand-foreground": ssoBrandForeground(brandColor), + } as React.CSSProperties) + : undefined; + export const SSOButtonsContent = ({ onSelectProvider, providers, @@ -23,31 +37,42 @@ export const SSOButtonsContent = ({ }) => { const t = useTranslations("core.auth.sso"); const tErrors = useTranslations("core.global.errors"); + const [pendingId, setPendingId] = useState<null | string>(null); if (!providers.length) { return null; } + const selectProvider = async (providerId: string) => { + setPendingId(providerId); + const result = await onSelectProvider(providerId); + + if (result?.message) { + setPendingId(null); + toast.error(tErrors("title"), { + description: tErrors("internal_server_error"), + }); + } + }; + return ( <> - <div className="flex flex-wrap items-center justify-center gap-4"> + <div className="flex flex-col gap-3"> {providers.map(provider => ( <Button - className="bg-card w-[calc(50%-0.5rem)]" + className={cn( + "h-10 w-full [&_img]:size-4.5 [&_svg]:size-4.5 [&>span]:size-4.5", + provider.brandColor && BRAND_FILL, + )} + disabled={pendingId !== null && pendingId !== provider.id} + isLoading={pendingId === provider.id} key={provider.id} - onClick={async () => { - const result = await onSelectProvider(provider.id); - - if (result?.message) { - toast.error(tErrors("title"), { - description: tErrors("internal_server_error"), - }); - } - }} - variant="outline" + onClick={async () => selectProvider(provider.id)} + style={brandStyle(provider.brandColor)} + variant={provider.brandColor ? "default" : "outline"} > <SSOProviderIcon provider={provider} /> - {provider.name} + {t("continue_with", { provider: provider.name })} </Button> ))} </div> @@ -71,8 +96,8 @@ export const SSOButtonsContent = ({ /** The row's shape while the deployment configuration is still in flight. */ export const SSOButtonsSkeleton = () => ( - <div className="flex gap-4"> - <Skeleton className="mb-6 h-8 w-full" /> - <Skeleton className="mb-6 h-8 w-full" /> + <div className="mb-6 flex flex-col gap-3"> + <Skeleton className="h-10 w-full" /> + <Skeleton className="h-10 w-full" /> </div> ); diff --git a/packages/vitnode/src/views/auth/sso/providers.test.ts b/packages/vitnode/src/views/auth/sso/providers.test.ts index a90407d7cd..c353f84ea4 100644 --- a/packages/vitnode/src/views/auth/sso/providers.test.ts +++ b/packages/vitnode/src/views/auth/sso/providers.test.ts @@ -67,6 +67,24 @@ describe("normalising the SSO provider list", () => { ]); }); + it("keeps a hex brand color and drops anything else", () => { + expect( + normalizeSSOProviders([ + { brandColor: "#5865F2", id: "discord", name: "Discord" }, + { brandColor: " #FFF ", id: "white", name: "White" }, + { brandColor: "red", id: "named", name: "Named" }, + { brandColor: "#5865F2; background: url(x)", id: "css", name: "CSS" }, + { brandColor: 42, id: "number", name: "Number" }, + ]), + ).toEqual([ + { brandColor: "#5865f2", id: "discord", name: "Discord" }, + { brandColor: "#fff", id: "white", name: "White" }, + { id: "named", name: "Named" }, + { id: "css", name: "CSS" }, + { id: "number", name: "Number" }, + ]); + }); + it("keeps the first of two providers sharing an id", () => { // React keys the row by id, so a duplicate is a warning plus a button that // cannot be told apart from the one above it. diff --git a/packages/vitnode/src/views/auth/sso/providers.ts b/packages/vitnode/src/views/auth/sso/providers.ts index 39610f2287..0b9973ad57 100644 --- a/packages/vitnode/src/views/auth/sso/providers.ts +++ b/packages/vitnode/src/views/auth/sso/providers.ts @@ -1,14 +1,17 @@ import type { SSOIconSource } from "./icon"; +import { ssoBrandColor } from "./brand"; import { ssoIconSource } from "./icon"; export interface SSOProvider { + brandColor?: string; icon?: SSOIconSource; id: string; name: string; } interface UnverifiedProvider { + brandColor?: unknown; icon?: unknown; id: string; name: string; @@ -23,22 +26,45 @@ const isProvider = (value: unknown): value is UnverifiedProvider => const warned = new Set<string>(); -const warnAboutIcon = (providerId: string) => { - if (process.env.NODE_ENV !== "development" || warned.has(providerId)) return; +const warnOnce = (key: string, message: string) => { + if (process.env.NODE_ENV !== "development" || warned.has(key)) return; - warned.add(providerId); + warned.add(key); // oxlint-disable-next-line no-console - console.warn( - `[vitnode] the SSO provider "${providerId}" sent an icon its button cannot render, so it renders without one. An icon has to be a single <svg> element carrying nothing executable - no <script>, <style>, event handlers - or a URL to an image.`, - ); + console.warn(message); }; -const toProvider = ({ icon, id, name }: UnverifiedProvider): SSOProvider => { +const isPresent = (value: unknown) => value !== null && value !== undefined; + +const toProvider = ({ + brandColor, + icon, + id, + name, +}: UnverifiedProvider): SSOProvider => { const source = ssoIconSource(icon); + const color = ssoBrandColor(brandColor); + + if (!source && isPresent(icon)) { + warnOnce( + `${id}:icon`, + `[vitnode] the SSO provider "${id}" sent an icon its button cannot render, so it renders without one. An icon has to be a single <svg> element carrying nothing executable - no <script>, <style>, event handlers - or a URL to an image.`, + ); + } - if (!source && icon !== null && icon !== undefined) warnAboutIcon(id); + if (!color && isPresent(brandColor)) { + warnOnce( + `${id}:brandColor`, + `[vitnode] the SSO provider "${id}" sent a brand color its button cannot use, so the button stays neutral. A brand color has to be a hex value such as "#5865F2".`, + ); + } - return source ? { icon: source, id, name } : { id, name }; + return { + ...(color ? { brandColor: color } : {}), + ...(source ? { icon: source } : {}), + id, + name, + }; }; export const normalizeSSOProviders = (value: unknown): SSOProvider[] => { diff --git a/packages/vitnode/src/views/auth/verify-email/confirm-email-content.test.tsx b/packages/vitnode/src/views/auth/verify-email/confirm-email-content.test.tsx index c8c709718e..2d113cd6dd 100644 --- a/packages/vitnode/src/views/auth/verify-email/confirm-email-content.test.tsx +++ b/packages/vitnode/src/views/auth/verify-email/confirm-email-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/views/error/error-content.test.tsx b/packages/vitnode/src/views/error/error-content.test.tsx index 94d614de33..15dd5a3d66 100644 --- a/packages/vitnode/src/views/error/error-content.test.tsx +++ b/packages/vitnode/src/views/error/error-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { render, screen } from "@testing-library/react"; import { describe, expect, it } from "vitest"; diff --git a/packages/vitnode/src/views/layouts/theme/header/header-content.test.tsx b/packages/vitnode/src/views/layouts/theme/header/header-content.test.tsx index 37083fd0d5..a3cf3b6548 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-content.test.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/header-content.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx index 9e2534acd4..06a7f7c31d 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-content.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/header-content.tsx @@ -1,6 +1,8 @@ import { Link } from "@tanstack/react-router"; import { cn } from "cn"; +import { LOGO_VIEW_TRANSITION_NAME } from "@/tanstack/view-transitions"; + import type { HeaderNavItem } from "./header-nav"; import { HEADER_HREF } from "./header-nav"; @@ -40,7 +42,13 @@ export const HeaderLayoutContent = ({ {...props} > <div className="dark:bg-background/75 bg-card/75 container mx-auto flex h-14 items-center border-b px-4 py-2 backdrop-blur sm:rounded-lg sm:border sm:shadow-sm"> - <Link to={HEADER_HREF.home}>{logo}</Link> + <Link + className="flex shrink-0 items-center" + style={{ viewTransitionName: LOGO_VIEW_TRANSITION_NAME }} + to={HEADER_HREF.home} + > + {logo} + </Link> {isNavigationPending ? ( <HeaderNavSkeleton className="ms-4" label={navigationLoadingLabel} /> diff --git a/packages/vitnode/src/views/layouts/theme/header/header-nav-menu.test.tsx b/packages/vitnode/src/views/layouts/theme/header/header-nav-menu.test.tsx index 3344c3bdf6..6e51b69ca5 100644 --- a/packages/vitnode/src/views/layouts/theme/header/header-nav-menu.test.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/header-nav-menu.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-bar.test.tsx b/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-bar.test.tsx index 7089f2857d..f740e30f9d 100644 --- a/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-bar.test.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-nav-bar.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-user-menu.test.tsx b/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-user-menu.test.tsx index 69037670b9..50abf4940b 100644 --- a/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-user-menu.test.tsx +++ b/packages/vitnode/src/views/layouts/theme/header/mobile-nav/mobile-user-menu.test.tsx @@ -1,3 +1,4 @@ +// @vitest-environment jsdom import { createMemoryHistory, createRootRoute, diff --git a/packages/vitnode/src/vitnode.config.ts b/packages/vitnode/src/vitnode.config.ts index d64bf91e8e..95688bb0c7 100644 --- a/packages/vitnode/src/vitnode.config.ts +++ b/packages/vitnode/src/vitnode.config.ts @@ -43,6 +43,7 @@ export interface VitNodeConfig< ThemeProviderProps, "attribute" | "children" | "disableTransitionOnChange" | "enableSystem" >; + viewTransitions?: boolean; } export interface VitNodeServerConfig< diff --git a/packages/vitnode/vitest.config.ts b/packages/vitnode/vitest.config.ts index c9e4c42b12..7d83e226a3 100644 --- a/packages/vitnode/vitest.config.ts +++ b/packages/vitnode/vitest.config.ts @@ -6,7 +6,8 @@ export default defineConfig({ plugins: [react()], test: { globals: true, - environment: "jsdom", + environment: "node", + pool: "threads", setupFiles: ["./src/tests/setup.ts"], // `*.test-d.ts` files assert types only. They run under `pnpm test:types` // (`vitest --typecheck`) and are skipped by the normal runtime suite. diff --git a/turbo.json b/turbo.json index ce3f703e40..3851477d26 100644 --- a/turbo.json +++ b/turbo.json @@ -3,7 +3,6 @@ "ui": "tui", "tasks": { "test": { - "dependsOn": ["^test"], "cache": false, "passThroughEnv": ["VITNODE_TEST_POSTGRES_URL"] }, @@ -11,14 +10,6 @@ "dependsOn": ["^test:types"], "cache": false }, - "web#test": { - "dependsOn": ["^build:plugins", "build"], - "cache": false - }, - "web#test:types": { - "dependsOn": ["^build:plugins"], - "cache": false - }, "docker:dev": { "dependsOn": ["^docker:dev"], "cache": false, From 20e65735f2b2dc00ea327c5379294af7df214585 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axen@vitnode.com> Date: Fri, 9 Oct 2026 22:25:31 +0200 Subject: [PATCH 2/3] =?UTF-8?q?refactor(sso):=20=E2=99=BB=EF=B8=8F=20Updat?= =?UTF-8?q?e=20button=20descriptions=20for=20clarity=20and=20improve=20for?= =?UTF-8?q?eground=20color=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/content/docs/dev/sso/custom-adapter.mdx | 2 +- apps/web/content/docs/dev/sso/icons.mdx | 6 +++--- packages/vitnode/src/views/auth/sso/brand.ts | 10 +++++++--- .../auth/sso/buttons/sso-buttons-content.test.tsx | 14 +++----------- 4 files changed, 14 insertions(+), 18 deletions(-) diff --git a/apps/web/content/docs/dev/sso/custom-adapter.mdx b/apps/web/content/docs/dev/sso/custom-adapter.mdx index d8b6821609..a22e1df3ad 100644 --- a/apps/web/content/docs/dev/sso/custom-adapter.mdx +++ b/apps/web/content/docs/dev/sso/custom-adapter.mdx @@ -102,7 +102,7 @@ export const vitNodeApiConfig = buildApiConfig({ }) ``` -A **GitHub** login button automatically renders on `/login` and `/register`, and `/login/sso/github` routes incoming authentication requests. It is a plain white button until you give the adapter an [icon and a brand color](/docs/dev/sso/icons). +A **GitHub** login button automatically renders on `/login` and `/register`, and `/login/sso/github` routes incoming authentication requests. It is a neutral, text-only button until you give the adapter an [icon and a brand color](/docs/dev/sso/icons). --- diff --git a/apps/web/content/docs/dev/sso/icons.mdx b/apps/web/content/docs/dev/sso/icons.mdx index aa17047400..d477a88235 100644 --- a/apps/web/content/docs/dev/sso/icons.mdx +++ b/apps/web/content/docs/dev/sso/icons.mdx @@ -13,7 +13,7 @@ import loginSsoButtons from './icons/login-sso-buttons.png' Every SSO provider gets a full-width **Continue with _Provider_** button on `/login` and `/register`. The built-in Google, Discord and Facebook adapters already carry their brand marks and colors, so those buttons look right without -any configuration. A custom adapter starts out as a plain white button with +any configuration. A custom adapter starts out as a neutral button with text only - a **GitHub** button with no octocat - and this page shows how to give it a mark and a color. @@ -42,7 +42,7 @@ Use `fill="currentColor"` and leave the width and height off: the mark then takes the button's text color in both light and dark mode - white on a [brand-colored button](#brand-color) - and is sized to match the label. A multicolor mark, such as Google's four-color G, keeps its own fills; give its -adapter no brand color so it sits on the white button it was designed for. +adapter no brand color so it sits on the neutral button it was designed for. An image works the same way: @@ -138,7 +138,7 @@ own text color: white when white text reaches a 4.5:1 contrast ratio on your color, near-black otherwise, so a yellow brand still gets a readable label. A `currentColor` icon follows that text color. -Leave `brandColor` out and the button stays white with a border. That is the +Leave `brandColor` out and the button stays neutral, with a border. That is the right choice for providers whose guidelines ask for a neutral button, such as Google and Microsoft. A value that is not a hex color is ignored the same way, and in development the console names the provider. diff --git a/packages/vitnode/src/views/auth/sso/brand.ts b/packages/vitnode/src/views/auth/sso/brand.ts index 695008f9f9..16f36a4d96 100644 --- a/packages/vitnode/src/views/auth/sso/brand.ts +++ b/packages/vitnode/src/views/auth/sso/brand.ts @@ -43,10 +43,14 @@ const contrastRatio = (lighter: number, darker: number) => export const ssoBrandForeground = (brandColor: string): string => { const luminance = relativeLuminance(brandColor); - const onLight = contrastRatio(1, luminance); - const onDark = contrastRatio(luminance, relativeLuminance(DARK_FOREGROUND)); + const whiteTextContrast = contrastRatio(1, luminance); + const darkTextContrast = contrastRatio( + luminance, + relativeLuminance(DARK_FOREGROUND), + ); - return onLight >= MIN_TEXT_CONTRAST || onLight >= onDark + return whiteTextContrast >= MIN_TEXT_CONTRAST || + whiteTextContrast >= darkTextContrast ? LIGHT_FOREGROUND : DARK_FOREGROUND; }; diff --git a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx index 5679ea145f..b418874d08 100644 --- a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx @@ -22,15 +22,6 @@ const renderButtons = (onSelectProvider: SSOSelectProvider) => </IntlProvider>, ); -const deferred = () => { - let resolve: (value: SSOStartResult) => void = () => undefined; - const promise = new Promise<SSOStartResult>(done => { - resolve = done; - }); - - return { promise, resolve }; -}; - describe("SSOButtonsContent", () => { it("renders one button per provider", () => { renderButtons(vi.fn()); @@ -41,8 +32,9 @@ describe("SSOButtonsContent", () => { }); it("starts the chosen provider and locks the row while it redirects", async () => { - const start = deferred(); - const onSelectProvider = vi.fn(async () => start.promise); + const onSelectProvider = vi.fn( + async () => new Promise<SSOStartResult>(() => undefined), + ); renderButtons(onSelectProvider); const [discord, google] = screen.getAllByRole("button"); From cd6e20803b4d0b251c093ce259dcac8b12b2cf53 Mon Sep 17 00:00:00 2001 From: aXenDeveloper <axen@vitnode.com> Date: Fri, 9 Oct 2026 22:36:10 +0200 Subject: [PATCH 3/3] =?UTF-8?q?refactor(sso):=20=E2=99=BB=EF=B8=8F=20Updat?= =?UTF-8?q?e=20SSO=20button=20color=20logic=20to=20use=20`ssoBrandColors`?= =?UTF-8?q?=20for=20improved=20styling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../vitnode/src/views/auth/sso/brand.test.ts | 21 ++++++---- packages/vitnode/src/views/auth/sso/brand.ts | 33 +++++++++++++-- .../sso/buttons/sso-buttons-content.test.tsx | 19 +++++++++ .../auth/sso/buttons/sso-buttons-content.tsx | 40 ++++++++++++------- 4 files changed, 86 insertions(+), 27 deletions(-) diff --git a/packages/vitnode/src/views/auth/sso/brand.test.ts b/packages/vitnode/src/views/auth/sso/brand.test.ts index f1b6a663e5..e43f1d1b19 100644 --- a/packages/vitnode/src/views/auth/sso/brand.test.ts +++ b/packages/vitnode/src/views/auth/sso/brand.test.ts @@ -1,17 +1,22 @@ import { describe, expect, it } from "vitest"; -import { ssoBrandForeground } from "./brand"; +import { ssoBrandColors } from "./brand"; -describe("picking the text color for a brand-filled SSO button", () => { +describe("coloring a brand-filled SSO button", () => { it("keeps white text on saturated brand colors that carry it", () => { - expect(ssoBrandForeground("#5865f2")).toBe("#ffffff"); - expect(ssoBrandForeground("#0866ff")).toBe("#ffffff"); - expect(ssoBrandForeground("#000")).toBe("#ffffff"); + expect(ssoBrandColors("#5865f2").foreground).toBe("#ffffff"); + expect(ssoBrandColors("#0866ff").foreground).toBe("#ffffff"); + expect(ssoBrandColors("#000").foreground).toBe("#ffffff"); }); it("switches to dark text on light brand colors", () => { - expect(ssoBrandForeground("#ffcc00")).toBe("#0a0a0a"); - expect(ssoBrandForeground("#fff")).toBe("#0a0a0a"); - expect(ssoBrandForeground("#00a4ef")).toBe("#0a0a0a"); + expect(ssoBrandColors("#ffcc00").foreground).toBe("#0a0a0a"); + expect(ssoBrandColors("#fff").foreground).toBe("#0a0a0a"); + expect(ssoBrandColors("#00a4ef").foreground).toBe("#0a0a0a"); + }); + + it("moves the hover color away from the text so contrast never drops", () => { + expect(ssoBrandColors("#5865f2").hover).toBe("#4d59d5"); + expect(ssoBrandColors("#ffcc00").hover).toBe("#ffd21f"); }); }); diff --git a/packages/vitnode/src/views/auth/sso/brand.ts b/packages/vitnode/src/views/auth/sso/brand.ts index 16f36a4d96..9e08360c93 100644 --- a/packages/vitnode/src/views/auth/sso/brand.ts +++ b/packages/vitnode/src/views/auth/sso/brand.ts @@ -3,6 +3,7 @@ const HEX_COLOR = /^#(?:[\da-f]{3}|[\da-f]{6})$/i; const LIGHT_FOREGROUND = "#ffffff"; const DARK_FOREGROUND = "#0a0a0a"; const MIN_TEXT_CONTRAST = 4.5; +const HOVER_SHIFT = 0.12; export const ssoBrandColor = (value: unknown): string | undefined => { if (typeof value !== "string") return undefined; @@ -29,19 +30,34 @@ const linearChannel = (value: number) => { : ((channel + 0.055) / 1.055) ** 2.4; }; -const relativeLuminance = (hex: string) => { +const channelsOf = (hex: string) => { const digits = expandHex(hex); - const [red, green, blue] = [0, 2, 4].map(start => - linearChannel(Number.parseInt(digits.slice(start, start + 2), 16)), + + return [0, 2, 4].map(start => + Number.parseInt(digits.slice(start, start + 2), 16), + ); +}; + +const toHex = (channels: number[]) => + `#${channels + .map(channel => Math.round(channel).toString(16).padStart(2, "0")) + .join("")}`; + +const shiftToward = (hex: string, target: number) => + toHex( + channelsOf(hex).map(channel => channel + (target - channel) * HOVER_SHIFT), ); +const relativeLuminance = (hex: string) => { + const [red, green, blue] = channelsOf(hex).map(linearChannel); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; }; const contrastRatio = (lighter: number, darker: number) => (lighter + 0.05) / (darker + 0.05); -export const ssoBrandForeground = (brandColor: string): string => { +const brandForeground = (brandColor: string) => { const luminance = relativeLuminance(brandColor); const whiteTextContrast = contrastRatio(1, luminance); const darkTextContrast = contrastRatio( @@ -54,3 +70,12 @@ export const ssoBrandForeground = (brandColor: string): string => { ? LIGHT_FOREGROUND : DARK_FOREGROUND; }; + +export const ssoBrandColors = (brandColor: string) => { + const foreground = brandForeground(brandColor); + + return { + foreground, + hover: shiftToward(brandColor, foreground === LIGHT_FOREGROUND ? 0 : 255), + }; +}; diff --git a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx index b418874d08..1b2ebebff2 100644 --- a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx @@ -49,6 +49,25 @@ describe("SSOButtonsContent", () => { expect((google as HTMLButtonElement).disabled).toBe(true); }); + it("unlocks the row when starting the provider throws", async () => { + const onSelectProvider = vi.fn(async () => + Promise.reject(new Error("malformed response")), + ); + renderButtons(onSelectProvider); + + await act(async () => { + fireEvent.click(screen.getAllByRole("button")[0]); + }); + + const buttons = screen.getAllByRole("button", { + name: "core.auth.sso.continue_with", + }); + expect(buttons).toHaveLength(2); + expect( + buttons.every(button => !(button as HTMLButtonElement).disabled), + ).toBe(true); + }); + it("unlocks the row when the provider could not be started", async () => { const onSelectProvider = vi.fn(async () => ({ message: "failed" })); renderButtons(onSelectProvider); diff --git a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx index cded8329f3..c6eb27c1e6 100644 --- a/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.tsx @@ -8,7 +8,7 @@ import { Skeleton } from "@/components/ui/skeleton"; import type { SSOProvider } from "../providers"; -import { ssoBrandForeground } from "../brand"; +import { ssoBrandColors } from "../brand"; import { SSOProviderIcon } from "./sso-provider-icon"; export type SSOStartResult = undefined | { message?: string }; @@ -16,15 +16,19 @@ export type SSOStartResult = undefined | { message?: string }; export type SSOSelectProvider = (providerId: string) => Promise<SSOStartResult>; const BRAND_FILL = - "border-transparent bg-(--sso-brand) text-(--sso-brand-foreground) hover:bg-(--sso-brand) hover:brightness-90 dark:hover:brightness-110"; + "border-transparent bg-(--sso-brand) text-(--sso-brand-foreground) hover:bg-(--sso-brand-hover)"; -const brandStyle = (brandColor: string | undefined) => - brandColor - ? ({ - "--sso-brand": brandColor, - "--sso-brand-foreground": ssoBrandForeground(brandColor), - } as React.CSSProperties) - : undefined; +const brandStyle = (brandColor: string | undefined) => { + if (!brandColor) return undefined; + + const { foreground, hover } = ssoBrandColors(brandColor); + + return { + "--sso-brand": brandColor, + "--sso-brand-foreground": foreground, + "--sso-brand-hover": hover, + } as React.CSSProperties; +}; export const SSOButtonsContent = ({ onSelectProvider, @@ -43,15 +47,21 @@ export const SSOButtonsContent = ({ return null; } + const failToStart = () => { + setPendingId(null); + toast.error(tErrors("title"), { + description: tErrors("internal_server_error"), + }); + }; + const selectProvider = async (providerId: string) => { setPendingId(providerId); - const result = await onSelectProvider(providerId); - if (result?.message) { - setPendingId(null); - toast.error(tErrors("title"), { - description: tErrors("internal_server_error"), - }); + try { + const result = await onSelectProvider(providerId); + if (result?.message) failToStart(); + } catch { + failToStart(); } };