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..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 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 neutral, text-only 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..d477a88235 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 neutral 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 neutral 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 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. ## From the browser (React component or JSX) @@ -133,6 +172,12 @@ needs a wrapper element. 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..e43f1d1b19 --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/brand.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { ssoBrandColors } from "./brand"; + +describe("coloring a brand-filled SSO button", () => { + it("keeps white text on saturated brand colors that carry it", () => { + 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(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 new file mode 100644 index 0000000000..9e08360c93 --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/brand.ts @@ -0,0 +1,81 @@ +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; + + 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 channelsOf = (hex: string) => { + const digits = expandHex(hex); + + 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); + +const brandForeground = (brandColor: string) => { + const luminance = relativeLuminance(brandColor); + const whiteTextContrast = contrastRatio(1, luminance); + const darkTextContrast = contrastRatio( + luminance, + relativeLuminance(DARK_FOREGROUND), + ); + + return whiteTextContrast >= MIN_TEXT_CONTRAST || + whiteTextContrast >= darkTextContrast + ? 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 new file mode 100644 index 0000000000..1b2ebebff2 --- /dev/null +++ b/packages/vitnode/src/views/auth/sso/buttons/sso-buttons-content.test.tsx @@ -0,0 +1,87 @@ +// @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>, + ); + +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 onSelectProvider = vi.fn( + async () => new Promise<SSOStartResult>(() => undefined), + ); + 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 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); + + 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..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 @@ -1,3 +1,5 @@ +import { cn } from "cn"; +import { useState } from "react"; import { toast } from "sonner"; import { useTranslations } from "use-intl"; @@ -6,12 +8,28 @@ import { Skeleton } from "@/components/ui/skeleton"; import type { SSOProvider } from "../providers"; +import { ssoBrandColors } 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)"; + +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, providers, @@ -23,31 +41,48 @@ 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 failToStart = () => { + setPendingId(null); + toast.error(tErrors("title"), { + description: tErrors("internal_server_error"), + }); + }; + + const selectProvider = async (providerId: string) => { + setPendingId(providerId); + + try { + const result = await onSelectProvider(providerId); + if (result?.message) failToStart(); + } catch { + failToStart(); + } + }; + 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 +106,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,