From 2934d5a2a640d195cb0c8df6f3d22d373cc88799 Mon Sep 17 00:00:00 2001
From: Annabel Dunstone Gray <annabel.dunstone@gmail.com>
Date: Fri, 9 Feb 2024 18:43:18 +0000
Subject: [PATCH] Reorganize profile page layout

- Create sidebar with user info
- Group info types
- Move other content to right column
- Add border to readme and activity calendar
- Move activity directly under activity graph

Changelog: changed
---
 .../pages/users/activity_calendar.js          |  24 +-
 .../javascripts/pages/users/user_tabs.js      |  65 ++--
 .../profile/components/activity_calendar.vue  |  35 +-
 .../profile/components/overview_tab.vue       |   8 +-
 .../profile/components/profile_tabs.vue       |   2 +-
 .../profile/components/user_achievements.vue  |  13 +-
 .../stylesheets/page_bundles/profile.scss     | 175 ++++------
 .../stylesheets/page_bundles/projects.scss    |  13 +-
 app/controllers/users_controller.rb           |   3 +-
 app/views/shared/projects/_list.html.haml     |  27 +-
 app/views/shared/projects/_project.html.haml  |  90 +++---
 .../shared/projects/_project_card.html.haml   |  91 ++++++
 app/views/users/_follow_user.html.haml        |  10 +-
 app/views/users/_overview.html.haml           |  86 ++---
 .../users/_view_user_in_admin_area.html.haml  |   2 +-
 app/views/users/show.html.haml                | 303 +++++++++---------
 locale/gitlab.pot                             |  30 +-
 spec/features/calendar_spec.rb                |  36 ---
 spec/features/profiles/account_spec.rb        |   4 +-
 .../profiles/user_visits_profile_spec.rb      |   2 +-
 .../user_uploads_avatar_to_profile_spec.rb    |   2 +-
 spec/features/users/overview_spec.rb          |  28 +-
 spec/features/users/rss_spec.rb               |   4 +-
 spec/features/users/show_spec.rb              |  14 +-
 .../components/activity_calendar_spec.js      |  30 --
 spec/frontend/read_more_spec.js               |  38 +++
 .../shared/projects/_list.html.haml_spec.rb   |  14 +
 .../projects/_project_card.html.haml_spec.rb  |  34 ++
 28 files changed, 618 insertions(+), 565 deletions(-)
 create mode 100644 app/views/shared/projects/_project_card.html.haml
 create mode 100644 spec/views/shared/projects/_project_card.html.haml_spec.rb

diff --git a/app/assets/javascripts/pages/users/activity_calendar.js b/app/assets/javascripts/pages/users/activity_calendar.js
index 13bba06d425bb..abc72018c82c8 100644
--- a/app/assets/javascripts/pages/users/activity_calendar.js
+++ b/app/assets/javascripts/pages/users/activity_calendar.js
@@ -72,7 +72,7 @@ export default class ActivityCalendar {
     this.clickDay = this.clickDay.bind(this);
     this.currentSelectedDate = '';
     this.daySpace = 1;
-    this.daySize = 15;
+    this.daySize = 14;
     this.daySizeWithSpace = this.daySize + this.daySpace * 2;
     this.monthNames = [
       __('Jan'),
@@ -131,7 +131,6 @@ export default class ActivityCalendar {
     this.renderDays();
     this.renderMonths();
     this.renderDayTitles();
-    this.renderKey();
   }
 
   // Add extra padding for the last month label if it is also the last column
@@ -153,7 +152,7 @@ export default class ActivityCalendar {
       .select(container)
       .append('svg')
       .attr('width', width)
-      .attr('height', 169)
+      .attr('height', 140)
       .attr('class', 'contrib-calendar')
       .attr('data-testid', 'contrib-calendar');
   }
@@ -257,25 +256,6 @@ export default class ActivityCalendar {
       .text((date) => this.monthNames[date.month]);
   }
 
-  renderKey() {
-    this.svg
-      .append('g')
-      .attr('transform', `translate(18, ${this.daySizeWithSpace * 8 + 16})`)
-      .selectAll('rect')
-      .data(CONTRIB_LEGENDS)
-      .enter()
-      .append('rect')
-      .attr('width', this.daySize)
-      .attr('height', this.daySize)
-      .attr('x', (_, i) => this.daySizeWithSpace * i)
-      .attr('y', 0)
-      .attr('data-level', (_, i) => i)
-      .attr('class', 'user-contrib-cell has-tooltip contrib-legend')
-      .attr('title', (x) => x.title)
-      .attr('data-container', 'body')
-      .attr('data-html', true);
-  }
-
   clickDay(stamp) {
     if (this.currentSelectedDate !== stamp.date) {
       this.currentSelectedDate = stamp.date;
diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js
index f9e22808b0d9c..35d7edad96b8b 100644
--- a/app/assets/javascripts/pages/users/user_tabs.js
+++ b/app/assets/javascripts/pages/users/user_tabs.js
@@ -1,8 +1,8 @@
 // TODO: Remove this with the removal of the old navigation.
 // See https://gitlab.com/gitlab-org/gitlab/-/issues/435899.
 
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
 import $ from 'jquery';
+import initReadMore from '~/read_more';
 import Activities from '~/activities';
 import AjaxCache from '~/lib/utils/ajax_cache';
 import axios from '~/lib/utils/axios_utils';
@@ -66,18 +66,35 @@ import UserOverviewBlock from './user_overview_block';
 
 const CALENDAR_TEMPLATE = `
   <div class="calendar">
-    <div class="js-contrib-calendar"></div>
-    <div class="calendar-hint"></div>
+    <div class="js-contrib-calendar gl-overflow-x-auto"></div>
+    <div class="calendar-help gl-display-flex gl-justify-content-space-between gl-ml-auto gl-mr-auto">
+      <div class="calendar-legend">
+        <svg width="80px" height="20px">
+          <g>
+            <rect width="13" height="13" x="2" y="2" data-level="0" class="user-contrib-cell has-tooltip contrib-legend" title="${__(
+              'No contributions',
+            )}" data-container="body"></rect>
+            <rect width="13" height="13" x="17" y="2" data-level="1" class="user-contrib-cell has-tooltip contrib-legend" title="${__(
+              '1-9 contributions',
+            )}" data-container="body"></rect>
+            <rect width="13" height="13" x="32" y="2" data-level="2" class="user-contrib-cell has-tooltip contrib-legend" title="${__(
+              '10-19 contributions',
+            )}" data-container="body"></rect>
+            <rect width="13" height="13" x="47" y="2" data-level="3" class="user-contrib-cell has-tooltip contrib-legend" title="${__(
+              '20-29 contributions',
+            )}" data-container="body"></rect>
+            <rect width="13" height="13" x="62" y="2" data-level="4" class="user-contrib-cell has-tooltip contrib-legend" title="${__(
+              '30+ contributions',
+            )}" data-container="body"></rect>
+          </g>
+        </svg>
+      </div>
+      <div class="calendar-hint gl-font-sm gl-text-secondary"></div>
+    </div>
   </div>
 `;
 
-const CALENDAR_PERIOD_6_MONTHS = 6;
 const CALENDAR_PERIOD_12_MONTHS = 12;
-/* computation based on
- * width = (group + 1) * this.daySizeWithSpace + this.getExtraWidthPadding(group);
- * (see activity_calendar.js)
- */
-const OVERVIEW_CALENDAR_BREAKPOINT = 918;
 
 export default class UserTabs {
   constructor({ defaultAction, action, parentEl }) {
@@ -105,12 +122,6 @@ export default class UserTabs {
       .off('shown.bs.tab', '.nav-links a[data-toggle="tab"]')
       .on('shown.bs.tab', '.nav-links a[data-toggle="tab"]', (event) => this.tabShown(event))
       .on('click', '.gl-pagination a', (event) => this.changeProjectsPage(event));
-
-    window.addEventListener('resize', () => this.onResize());
-  }
-
-  onResize() {
-    this.loadActivityCalendar();
   }
 
   changeProjectsPage(e) {
@@ -194,19 +205,25 @@ export default class UserTabs {
       return;
     }
 
+    initReadMore();
+
     this.loadActivityCalendar();
 
     UserTabs.renderMostRecentBlocks('#js-overview .activities-block', {
       requestParams: { limit: 15 },
     });
+
     UserTabs.renderMostRecentBlocks('#js-overview .projects-block', {
-      requestParams: { limit: 10, skip_pagination: true, skip_namespace: true, compact_mode: true },
+      requestParams: { limit: 3, skip_pagination: true, skip_namespace: true, card_mode: true },
     });
 
     this.loaded.overview = true;
   }
 
   static renderMostRecentBlocks(container, options) {
+    if ($(container).length === 0) {
+      return;
+    }
     // eslint-disable-next-line no-new
     new UserOverviewBlock({
       container,
@@ -218,8 +235,6 @@ export default class UserTabs {
 
   loadActivityCalendar() {
     const $calendarWrap = this.$parentEl.find('.tab-pane.active .user-calendar');
-    if (!$calendarWrap.length || bp.getBreakpointSize() === 'xs') return;
-
     const calendarPath = $calendarWrap.data('calendarPath');
 
     AjaxCache.retrieve(calendarPath)
@@ -240,7 +255,6 @@ export default class UserTabs {
   }
 
   static renderActivityCalendar(data, $calendarWrap) {
-    const monthsAgo = UserTabs.getVisibleCalendarPeriod($calendarWrap);
     const calendarActivitiesPath = $calendarWrap.data('calendarActivitiesPath');
     const utcOffset = $calendarWrap.data('utcOffset');
     const calendarHint = __('Issues, merge requests, pushes, and comments.');
@@ -257,8 +271,12 @@ export default class UserTabs {
       calendarActivitiesPath,
       utcOffset,
       firstDayOfWeek: gon.first_day_of_week,
-      monthsAgo,
+      CALENDAR_PERIOD_12_MONTHS,
     });
+
+    // Scroll to end
+    const calendarContainer = document.querySelector('.js-contrib-calendar');
+    calendarContainer.scrollLeft = calendarContainer.scrollWidth;
   }
 
   toggleLoading(status) {
@@ -282,11 +300,4 @@ export default class UserTabs {
   getCurrentAction() {
     return this.$parentEl.find('.nav-links a.active').data('action');
   }
-
-  static getVisibleCalendarPeriod($calendarWrap) {
-    const width = $calendarWrap.width();
-    return width < OVERVIEW_CALENDAR_BREAKPOINT
-      ? CALENDAR_PERIOD_6_MONTHS
-      : CALENDAR_PERIOD_12_MONTHS;
-  }
 }
diff --git a/app/assets/javascripts/profile/components/activity_calendar.vue b/app/assets/javascripts/profile/components/activity_calendar.vue
index d359b478d35c0..b9668210f7915 100644
--- a/app/assets/javascripts/profile/components/activity_calendar.vue
+++ b/app/assets/javascripts/profile/components/activity_calendar.vue
@@ -1,12 +1,8 @@
 <script>
 import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
-import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
-import { debounce } from 'lodash';
-
 import { __ } from '~/locale';
 import AjaxCache from '~/lib/utils/ajax_cache';
 import ActivityCalendar from '~/pages/users/activity_calendar';
-import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
 import { getVisibleCalendarPeriod } from '../utils';
 
 export default {
@@ -20,26 +16,14 @@ export default {
   data() {
     return {
       isLoading: true,
-      showCalendar: true,
       hasError: false,
     };
   },
   mounted() {
     this.renderActivityCalendar();
-    window.addEventListener('resize', this.handleResize);
-  },
-  beforeDestroy() {
-    window.removeEventListener('resize', this.handleResize);
   },
   methods: {
     async renderActivityCalendar() {
-      if (bp.getBreakpointSize() === 'xs') {
-        this.showCalendar = false;
-
-        return;
-      }
-
-      this.showCalendar = true;
       this.isLoading = true;
       this.hasError = false;
 
@@ -66,9 +50,6 @@ export default {
         this.hasError = true;
       }
     },
-    handleResize: debounce(function debouncedHandleResize() {
-      this.renderActivityCalendar();
-    }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
     handleClickDay() {
       // Render activities for specific day.
       // Blocked by https://gitlab.com/gitlab-org/gitlab/-/issues/378695
@@ -78,8 +59,8 @@ export default {
 </script>
 
 <template>
-  <div v-if="showCalendar" ref="calendarContainer">
-    <gl-loading-icon v-if="isLoading" size="md" />
+  <div ref="calendarContainer" class="gl-pb-5 gl-border-b">
+    <gl-loading-icon v-if="isLoading" size="sm" />
     <gl-alert
       v-else-if="hasError"
       :title="$options.i18n.errorAlertTitle"
@@ -88,13 +69,11 @@ export default {
       :primary-button-text="$options.i18n.retry"
       @primaryAction="renderActivityCalendar"
     />
-    <div v-else class="gl-text-center">
-      <div class="gl-display-inline-block gl-relative">
-        <div ref="calendarSvgContainer"></div>
-        <p class="gl-absolute gl-right-0 gl-bottom-0 gl-mb-0 gl-font-sm">
-          {{ $options.i18n.calendarHint }}
-        </p>
-      </div>
+    <div v-else class="gl-display-inline-block gl-relative gl-w-full">
+      <div ref="calendarSvgContainer"></div>
+      <p class="gl-absolute gl-right-0 gl-bottom-0 gl-mb-0 gl-font-sm gl-text-secondary">
+        {{ $options.i18n.calendarHint }}
+      </p>
     </div>
   </div>
 </template>
diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue
index 8cfa3fb3eea18..ab8a2871a41c1 100644
--- a/app/assets/javascripts/profile/components/overview_tab.vue
+++ b/app/assets/javascripts/profile/components/overview_tab.vue
@@ -54,19 +54,19 @@ export default {
 
 <template>
   <gl-tab :title="$options.i18n.title">
-    <activity-calendar />
-    <div class="gl-mx-n5 gl-display-flex gl-flex-wrap">
-      <div class="gl-px-5 gl-w-full gl-lg-w-half" data-testid="activity-section">
+    <div class="gl-mt-5 gl-display-flex gl-flex-wrap">
+      <div class="gl-w-full" data-testid="activity-section">
         <div
           class="gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"
         >
           <h4 class="gl-flex-grow-1">{{ $options.i18n.activity }}</h4>
           <gl-link href="">{{ $options.i18n.viewAll }}</gl-link>
         </div>
+        <activity-calendar />
         <gl-loading-icon v-if="eventsLoading" class="gl-mt-5" size="md" />
         <contribution-events v-else :events="events" />
       </div>
-      <div class="gl-px-5 gl-w-full gl-lg-w-half" data-testid="personal-projects-section">
+      <div class="gl-w-full" data-testid="personal-projects-section">
         <div
           class="gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"
         >
diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue
index e24167eb4fa71..a15794ac07d51 100644
--- a/app/assets/javascripts/profile/components/profile_tabs.vue
+++ b/app/assets/javascripts/profile/components/profile_tabs.vue
@@ -81,7 +81,7 @@ export default {
   },
   async mounted() {
     try {
-      const response = await getUserProjects(this.userId, { per_page: 10 });
+      const response = await getUserProjects(this.userId, { per_page: 3 });
       this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true }).map(
         (project) => {
           // This API does not return the `visibility` key if user is signed out.
diff --git a/app/assets/javascripts/profile/components/user_achievements.vue b/app/assets/javascripts/profile/components/user_achievements.vue
index 7ce6b61c4aca7..f317f7871f83f 100644
--- a/app/assets/javascripts/profile/components/user_achievements.vue
+++ b/app/assets/javascripts/profile/components/user_achievements.vue
@@ -28,6 +28,11 @@ export default {
       },
     },
   },
+  computed: {
+    hasUserAchievements() {
+      return Boolean(this.userAchievements?.length);
+    },
+  },
   methods: {
     processNodes(nodes) {
       return Object.entries(groupBy(nodes, 'achievement.id'))
@@ -67,12 +72,16 @@ export default {
   i18n: {
     awardedBy: s__('Achievements|Awarded %{timeAgo} by %{namespace}'),
     awardedByUnknownNamespace: s__('Achievements|Awarded %{timeAgo} by a private namespace'),
+    achievementsLabel: s__('Achievements|Achievements'),
   },
 };
 </script>
 
 <template>
-  <div class="gl-mb-3">
+  <div v-if="hasUserAchievements">
+    <h2 class="gl-font-base gl-mb-2 gl-mt-4">
+      {{ $options.i18n.achievementsLabel }}
+    </h2>
     <div
       v-for="userAchievement in userAchievements"
       :key="userAchievement.id"
@@ -85,7 +94,7 @@ export default {
         :size="32"
         tabindex="0"
         shape="rect"
-        class="gl-mx-2 gl-p-1 gl-border-none"
+        class="gl-mr-2 gl-p-1 gl-border-none"
       />
       <br />
       <gl-badge v-if="showCountBadge(userAchievement.count)" variant="info" size="sm">{{
diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss
index 912f0145bf1dc..561a7d9f4cb25 100644
--- a/app/assets/stylesheets/page_bundles/profile.scss
+++ b/app/assets/stylesheets/page_bundles/profile.scss
@@ -29,97 +29,16 @@
   }
 }
 
-.calendar-block {
-  padding-left: 0;
-  padding-right: 0;
-  border-top: 0;
-
-  @media (min-width: map-get($grid-breakpoints, sm)) and (max-width: map-get($grid-breakpoints, sm)) {
-    overflow-x: auto;
-  }
-}
-
-.calendar-hint {
-  font-size: 12px;
-  direction: ltr;
-  margin-top: -23px;
-  float: right;
+.calendar-help {
+  // Match width of calendar
+  max-width: 864px;
 }
 
-.cover-block {
-  text-align: center;
-  background: var(--gray-50, $gray-light);
-  padding-top: 44px;
-  position: relative;
-
-  .avatar-holder {
-    .avatar,
-    .identicon {
-      margin: 0 auto;
-      float: none;
-    }
-
-    .identicon {
-      border-radius: 50%;
-    }
-  }
-
-  .cover-title {
-    color: var(--gl-text-color, $gl-text-color);
-    font-size: 23px;
-
-    h1 {
-      color: var(--gl-text-color, $gl-text-color);
-      margin-bottom: 6px;
-      font-size: 23px;
-    }
-
-    .visibility-icon {
-      display: inline-block;
-      margin-left: 5px;
-      font-size: 18px;
-      color: color('gray');
-    }
-
-    p {
-      padding: 0 $gl-padding;
-      color: var(--gl-text-color, $gl-text-color);
-    }
-  }
-
-  .cover-controls {
-    @include media-breakpoint-up(sm) {
-      position: absolute;
-      top: 1rem;
-      right: 1.25rem;
-    }
-
-    &.left {
-      @include media-breakpoint-up(sm) {
-        left: 1.25rem;
-        right: auto;
-      }
-    }
-  }
-
-  &.user-cover-block {
-    padding: 24px 0 0;
-
-    .nav-links {
-      width: 100%;
-      float: none;
-
-      &.scrolling-tabs {
-        float: none;
-      }
-    }
-
-    li:first-child {
-      margin-left: auto;
-    }
-
-    li:last-child {
-      margin-right: auto;
+.user-profile-image {
+  .gl-avatar {
+    @include media-breakpoint-up(md) {
+      height: 6.5rem;
+      width: 6.5rem;
     }
   }
 }
@@ -140,13 +59,10 @@
   max-width: 600px;
 }
 
-.user-calendar {
-  text-align: center;
-  min-height: 172px;
-
-  .calendar {
-    display: inline-block;
-  }
+.profile-readme-wrapper .read-more-trigger {
+  bottom: 0;
+  left: 1px;
+  right: 1px;
 }
 
 .user-calendar-activities {
@@ -158,14 +74,17 @@
 }
 
 .user-contrib-text {
-  font-size: 12px;
+  font-size: 11px;
   fill: $calendar-user-contrib-text;
 }
 
 .user-profile {
-  .profile-header {
-    .avatar-holder {
-      margin: 0 auto 10px;
+  @include media-breakpoint-up(lg) {
+    .profile-header {
+      position: sticky;
+      top: $calc-application-header-height;
+      height: $calc-application-viewport-height;
+      padding-left: $gl-spacing-scale-2;
     }
   }
 
@@ -189,23 +108,9 @@
     .gl-label-scoped {
       --label-inset-border: inset 0 0 0 1px currentColor;
     }
-
-    @include media-breakpoint-up(lg) {
-      margin-right: 5px;
-    }
-  }
-
-  .projects-block {
-    @include media-breakpoint-up(lg) {
-      margin-left: 5px;
-    }
   }
 
   @include media-breakpoint-down(xs) {
-    .cover-block {
-      padding-top: 20px;
-    }
-
     .user-profile-nav {
       a {
         margin-right: 0;
@@ -268,3 +173,45 @@
     border: 0;
   }
 }
+
+.user-profile {
+  position: relative;
+
+  @include media-breakpoint-up(lg) {
+    display: grid;
+    grid-template-columns: 1fr $right-sidebar-width;
+    gap: 2rem;
+  }
+}
+
+.user-profile-sidebar {
+  z-index: 2;
+}
+
+.user-profile-sidebar,
+.user-profile-content {
+  min-width: 1px; // grid overflow fix
+}
+
+// Home panel show profile sidebar
+// information on top
+.user-profile {
+  @include media-breakpoint-down(md) {
+    display: flex;
+    flex-direction: column;
+
+    .user-overview-page.active {
+      display: flex;
+      flex-wrap: wrap;
+
+      .user-profile-content {
+        flex-basis: 100%;
+      }
+    }
+
+    .user-profile-sidebar {
+      order: -1;
+      flex-basis: 100%;
+    }
+  }
+}
diff --git a/app/assets/stylesheets/page_bundles/projects.scss b/app/assets/stylesheets/page_bundles/projects.scss
index ebb4dffecee56..fd65485f2be82 100644
--- a/app/assets/stylesheets/page_bundles/projects.scss
+++ b/app/assets/stylesheets/page_bundles/projects.scss
@@ -325,14 +325,6 @@
       @include media-breakpoint-up(lg) {
         justify-content: flex-start;
         padding-right: $gl-spacing-scale-9;
-
-        &:not(.with-pipeline-status) {
-          .icon-wrapper:first-of-type {
-            @include media-breakpoint-up(lg) {
-              margin-left: $gl-spacing-scale-7;
-            }
-          }
-        }
       }
     }
 
@@ -569,3 +561,8 @@
     }
   }
 }
+
+.projects-list .description p {
+  @include gl-line-clamp-2;
+  margin-bottom: 0;
+}
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 83cd84c396ab4..df8ae07721a4c 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -143,13 +143,14 @@ def present_projects
     skip_pagination = Gitlab::Utils.to_boolean(params[:skip_pagination])
     skip_namespace = Gitlab::Utils.to_boolean(params[:skip_namespace])
     compact_mode = Gitlab::Utils.to_boolean(params[:compact_mode])
+    card_mode = Gitlab::Utils.to_boolean(params[:card_mode])
 
     respond_to do |format|
       format.html { render 'show' }
       format.json do
         projects = yield
 
-        pager_json("shared/projects/_list", projects.count, projects: projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode)
+        pager_json("shared/projects/_list", projects.count, projects: projects, skip_pagination: skip_pagination, skip_namespace: skip_namespace, compact_mode: compact_mode, card_mode: card_mode)
       end
     end
   end
diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml
index 74c325383a1a7..368ffc44be9f7 100644
--- a/app/views/shared/projects/_list.html.haml
+++ b/app/views/shared/projects/_list.html.haml
@@ -10,6 +10,7 @@
 - remote = false unless local_assigns[:remote] == true
 - skip_pagination = false unless local_assigns[:skip_pagination] == true
 - compact_mode = false unless local_assigns[:compact_mode] == true
+- card_mode = local_assigns[:card_mode] == true
 - css_classes = "#{'compact' if compact_mode} #{'explore' if explore_projects_tab?}"
 - contributed_projects_current_user_empty_message_header = s_('UserProfile|Explore public groups to find projects to contribute to.')
 - contributed_projects_visitor_empty_message = s_('UserProfile|This user hasn\'t contributed to any projects')
@@ -33,14 +34,24 @@
     - load_pipeline_status(projects) if pipeline_status
     - load_max_project_member_accesses(projects) # Prime cache used in shared/projects/project view rendered below
     - load_catalog_resources(projects)
-    %ul.projects-list.gl-text-secondary.gl-w-full.gl-my-2{ class: css_classes }
-      - projects.each_with_index do |project, i|
-        - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil
-        = render "shared/projects/project", project: project, skip_namespace: skip_namespace,
-          avatar: avatar, stars: stars, css_class: css_class, use_creator_avatar: use_creator_avatar,
-          forks: able_to_see_forks_count?(project, user), show_last_commit_as_description: show_last_commit_as_description,
-          user: user, merge_requests: able_to_see_merge_requests?(project, user), issues: able_to_see_issues?(project, user),
-          pipeline_status: pipeline_status, compact_mode: compact_mode
+    - if card_mode
+      .projects-list.gl-text-secondary.gl-w-full.gl-display-flex.gl-flex-direction-column.gl-lg-flex-direction-row.gl-gap-4.gl-overflow-x-auto{ class: css_classes }
+        - projects.take(projects_limit).each_with_index do |project, i| # rubocop: disable CodeReuse/ActiveRecord -- it's Enumerable#take
+
+          = render "shared/projects/project_card", project: project, skip_namespace: skip_namespace,
+            avatar: avatar, stars: stars, use_creator_avatar: use_creator_avatar,
+            forks: able_to_see_forks_count?(project, user), show_last_commit_as_description: show_last_commit_as_description,
+            user: user, merge_requests: able_to_see_merge_requests?(project, user), issues: able_to_see_issues?(project, user),
+            pipeline_status: pipeline_status, compact_mode: compact_mode
+    - else
+      %ul.projects-list.gl-text-secondary.gl-w-full.gl-my-2{ class: css_classes }
+        - projects.take(projects_limit).each_with_index do |project, i| # rubocop: disable CodeReuse/ActiveRecord -- it's Enumerable#take
+
+          = render "shared/projects/project", project: project, skip_namespace: skip_namespace,
+            avatar: avatar, stars: stars, use_creator_avatar: use_creator_avatar,
+            forks: able_to_see_forks_count?(project, user), show_last_commit_as_description: show_last_commit_as_description,
+            user: user, merge_requests: able_to_see_merge_requests?(project, user), issues: able_to_see_issues?(project, user),
+            pipeline_status: pipeline_status, compact_mode: compact_mode
     = paginate_collection(projects, remote: remote) unless skip_pagination
   - else
     - if @contributed_projects
diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml
index 5e60e66bf8445..19f9e7b90b8d9 100644
--- a/app/views/shared/projects/_project.html.haml
+++ b/app/views/shared/projects/_project.html.haml
@@ -1,31 +1,34 @@
-- avatar = true unless local_assigns[:avatar] == false
-- stars = true unless local_assigns[:stars] == false
-- forks = true unless local_assigns[:forks] == false
-- merge_requests = true unless local_assigns[:merge_requests] == false
-- issues = true unless local_assigns[:issues] == false
-- pipeline_status = true unless local_assigns[:pipeline_status] == false
-- skip_namespace = false unless local_assigns[:skip_namespace] == true
-- access = max_project_member_access(project)
-- compact_mode = false unless local_assigns[:compact_mode] == true
+- avatar = local_assigns[:avatar].nil? || local_assigns[:avatar]
+- stars = local_assigns[:stars].nil? || local_assigns[:stars]
+- forks = local_assigns[:forks].nil? || local_assigns[:forks]
+- merge_requests = local_assigns[:merge_requests].nil? || local_assigns[:merge_requests]
+- issues = local_assigns[:issues].nil? || local_assigns[:issues]
+- pipeline_status = local_assigns[:pipeline_status].nil? || local_assigns[:pipeline_status]
+- skip_namespace = local_assigns[:skip_namespace]
+- compact_mode = local_assigns[:compact_mode]
+- use_creator_avatar = local_assigns[:use_creator_avatar]
 - show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project)
+- access = max_project_member_access(project)
 - css_class = "gl-sm-display-flex gl-align-items-center gl-vertical-align-middle!" if project.description.blank? && !show_last_commit_as_description
 - updated_tooltip = time_ago_with_tooltip(project.last_activity_at || project.updated_at)
 - show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
 - last_pipeline = last_pipeline_from_status_cache(project) if show_pipeline_status_icon
 - css_controls_class = "with-pipeline-status" if show_pipeline_status_icon && last_pipeline.present?
-- css_metadata_classes = "gl-display-flex gl-align-items-center gl-ml-5 gl-reset-color! icon-wrapper has-tooltip"
+- css_metadata_classes = "gl-display-flex gl-align-items-center gl-reset-color! icon-wrapper has-tooltip"
 
 %li.project-row
   - if avatar
     .project-cell.gl-w-11
-      = link_to project_path(project), class: dom_class(project) do
-        - if project.creator && use_creator_avatar
-          = render Pajamas::AvatarComponent.new(project.creator, size: 48, alt: '', class: 'gl-mr-5')
-        - else
-          = render Pajamas::AvatarComponent.new(project, size: 48, alt: '', class: 'gl-mr-5')
+      .project-avatar-container.gl-mr-5.gl-relative.gl-pb-4
+        = link_to project_path(project), class: dom_class(project) do
+          - if project.creator && use_creator_avatar
+            = render Pajamas::AvatarComponent.new(project.creator, size: 48, alt: '')
+          - else
+            = render Pajamas::AvatarComponent.new(project, size: 48, alt: '')
+
   .project-cell{ class: css_class }
     .project-details.gl-pr-9.gl-sm-pr-0.gl-w-full.gl-display-flex.gl-flex-direction-column{ data: { testid: 'project-content', qa_project_name: project.name } }
-      .gl-display-flex.gl-align-items-center.gl-flex-wrap
+      .gl-display-flex.gl-align-items-baseline.gl-flex-wrap
         %h2.gl-font-base.gl-line-height-20.gl-my-0.gl-overflow-wrap-anywhere
           = link_to project_path(project), class: 'text-plain gl-mr-3 js-prefetch-document', title: project.name do
             %span.namespace-name.gl-font-weight-normal
@@ -83,32 +86,31 @@
             = _('Updated')
             = updated_tooltip
 
-  .project-cell{ class: "#{css_class} gl-display-none! gl-sm-display-table-cell!" }
-    .project-controls.gl-display-flex.gl-flex-direction-column.gl-align-items-flex-end.gl-w-full{ data: { testid: 'project_controls'} }
-      .controls.gl-display-flex.gl-align-items-center.gl-mb-2{ class: "#{css_controls_class} gl-pr-0!" }
-        - if show_pipeline_status_icon && last_pipeline.present?
-          - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
-          %span.icon-wrapper.pipeline-status
-            = render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
+  .project-cell.project-controls{ class: "#{css_class} gl-display-none! gl-sm-display-table-cell!", data: { testid: 'project_controls'} }
+    .controls.gl-display-flex.gl-align-items-center.gl-mb-2.gl-gap-4{ class: "#{css_controls_class} gl-pr-0! gl-justify-content-end!" }
+      - if show_pipeline_status_icon && last_pipeline.present?
+        - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
+        %span.icon-wrapper.pipeline-status
+          = render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
 
-        = render_if_exists 'shared/projects/archived', project: project
-        - if stars
-          = link_to project_starrers_path(project), class: "#{css_metadata_classes} stars", title: _('Stars'), data: { container: 'body', placement: 'top' } do
-            = sprite_icon('star-o', size: 14, css_class: 'gl-mr-2')
-            = badge_count(project.star_count)
-        - if show_count?(disabled: !forks, compact_mode: compact_mode)
-          = link_to project_forks_path(project), class: "#{css_metadata_classes} forks", title: _('Forks'), data: { container: 'body', placement: 'top' } do
-            = sprite_icon('fork', size: 14, css_class: 'gl-mr-2')
-            = badge_count(project.forks_count)
-        - if show_count?(disabled: !merge_requests, compact_mode: compact_mode)
-          = link_to project_merge_requests_path(project), class: "#{css_metadata_classes} merge-requests", title: _('Merge requests'), data: { container: 'body', placement: 'top' } do
-            = sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2')
-            = badge_count(project.open_merge_requests_count)
-        - if show_count?(disabled: !issues, compact_mode: compact_mode)
-          = link_to project_issues_path(project), class: "#{css_metadata_classes} issues", title: _('Issues'), data: { container: 'body', placement: 'top' } do
-            = sprite_icon('issues', size: 14, css_class: 'gl-mr-2')
-            = badge_count(project.open_issues_count)
-      .updated-note.gl-font-sm.gl-white-space-nowrap.gl-justify-content-end
-        %span
-          = _('Updated')
-          = updated_tooltip
+      = render_if_exists 'shared/projects/archived', project: project
+      - if stars
+        = link_to project_starrers_path(project), class: "#{css_metadata_classes} stars", title: _('Stars'), data: { container: 'body', placement: 'top' } do
+          = sprite_icon('star-o', size: 14, css_class: 'gl-mr-2')
+          = badge_count(project.star_count)
+      - if show_count?(disabled: !forks, compact_mode: compact_mode)
+        = link_to project_forks_path(project), class: "#{css_metadata_classes} forks", title: _('Forks'), data: { container: 'body', placement: 'top' } do
+          = sprite_icon('fork', size: 14, css_class: 'gl-mr-2')
+          = badge_count(project.forks_count)
+      - if show_count?(disabled: !merge_requests, compact_mode: compact_mode)
+        = link_to project_merge_requests_path(project), class: "#{css_metadata_classes} merge-requests", title: _('Merge requests'), data: { container: 'body', placement: 'top' } do
+          = sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2')
+          = badge_count(project.open_merge_requests_count)
+      - if show_count?(disabled: !issues, compact_mode: compact_mode)
+        = link_to project_issues_path(project), class: "#{css_metadata_classes} issues", title: _('Issues'), data: { container: 'body', placement: 'top' } do
+          = sprite_icon('issues', size: 14, css_class: 'gl-mr-2')
+          = badge_count(project.open_issues_count)
+    .updated-note.gl-font-sm.gl-white-space-nowrap.gl-justify-content-end
+      %span
+        = _('Updated')
+        = updated_tooltip
diff --git a/app/views/shared/projects/_project_card.html.haml b/app/views/shared/projects/_project_card.html.haml
new file mode 100644
index 0000000000000..b554d6ad6db82
--- /dev/null
+++ b/app/views/shared/projects/_project_card.html.haml
@@ -0,0 +1,91 @@
+- avatar = local_assigns[:avatar].nil? || local_assigns[:avatar]
+- stars = local_assigns[:stars].nil? || local_assigns[:stars]
+- forks = local_assigns[:forks].nil? || local_assigns[:forks]
+- merge_requests = local_assigns[:merge_requests].nil? || local_assigns[:merge_requests]
+- issues = local_assigns[:issues].nil? || local_assigns[:issues]
+- pipeline_status = local_assigns[:pipeline_status].nil? || local_assigns[:pipeline_status]
+- skip_namespace = local_assigns[:skip_namespace]
+- compact_mode = local_assigns[:compact_mode]
+- use_creator_avatar = local_assigns[:use_creator_avatar]
+- show_last_commit_as_description = false unless local_assigns[:show_last_commit_as_description] == true && can_show_last_commit_in_list?(project)
+- updated_tooltip = time_ago_with_tooltip(project.last_activity_at || project.updated_at)
+- show_pipeline_status_icon = pipeline_status && can?(current_user, :read_cross_project) && project.pipeline_status.has_status? && can?(current_user, :read_build, project)
+- last_pipeline = last_pipeline_from_status_cache(project) if show_pipeline_status_icon
+- css_controls_class = "with-pipeline-status" if show_pipeline_status_icon && last_pipeline.present?
+- css_metadata_classes = "gl-display-flex gl-align-items-center gl-ml-0! gl-reset-color! icon-wrapper has-tooltip"
+
+= render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-justify-content-space-between gl-lg-w-25p gl-flex-grow-1 gl-shrink-0 gl-mt-0' }, header_options: { class: 'gl-new-card-header gl-flex-grow-1 gl-align-items-flex-start gl-border-b-0 gl-px-4 gl-gap-5' }, body_options: { class: 'gl-new-card-body gl-px-4 gl-py-4' }) do |c|
+  - c.with_header do
+    - if avatar
+      .project-avatar-container.gl-relative.gl-pb-4
+        = link_to project_path(project), class: dom_class(project) do
+          - if project.creator && use_creator_avatar
+            = render Pajamas::AvatarComponent.new(project.creator, size: 48, alt: '')
+          - else
+            = render Pajamas::AvatarComponent.new(project, size: 48, alt: '')
+
+    .gl-w-full.gl-pt-2.gl-word-break-word
+      .gl-display-flex.gl-align-items-center.gl-flex-wrap
+        %h2.gl-font-base.gl-line-height-20.gl-my-0
+          = link_to project_path(project), class: 'text-plain gl-mr-3 js-prefetch-document', title: project.name do
+            %span.namespace-name.gl-font-weight-normal
+              - if project.namespace && !skip_namespace
+                = project.namespace.human_name
+                \/
+            %span.project-name<
+              = project.name
+
+        = visibility_level_content(project)
+
+      - if show_last_commit_as_description
+        .description.gl-display-none.gl-sm-display-block.gl-mt-2.gl-font-sm
+          = link_to_markdown(project.commit.title, project_commit_path(project, project.commit), class: "commit-row-message")
+      - elsif project.description.present?
+        .description.gl-display-none.gl-sm-display-block.gl-mt-2.gl-font-sm
+          = markdown_field(project, :description)
+
+      - if project.topics.any?
+        .gl-mt-3.gl-ml-n1
+          = render "shared/projects/topics", project: project.present(current_user: current_user)
+      - if project.catalog_resource
+        = render partial: 'shared/ci_catalog_badge', locals: { href: project_ci_catalog_resource_path(project, project.catalog_resource) }
+
+      - if explore_projects_tab? && project_license_name(project)
+        %span.gl-display-inline-flex.gl-align-items-center.gl-mr-3
+          = sprite_icon('scale', size: 14, css_class: 'gl-mr-2')
+          = project_license_name(project)
+
+      - if !explore_projects_tab?
+        = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: project, additional_classes: 'gl-ml-3!'
+  - c.with_body do
+    .project-controls{ data: { testid: 'project_controls'} }
+      .gl-display-flex.gl-align-items-center.gl-gap-2.gl-mb-2.gl-justify-content-space-between.gl-flex-wrap
+        .controls.gl-display-flex.gl-align-items-center.gl-gap-4{ class: "#{css_controls_class} gl-pr-0!" }
+          - if stars
+            = link_to project_starrers_path(project), class: "#{css_metadata_classes} stars", title: _('Stars'), data: { container: 'body', placement: 'top' } do
+              = sprite_icon('star-o', size: 14, css_class: 'gl-mr-2')
+              = badge_count(project.star_count)
+          - if show_count?(disabled: !forks, compact_mode: compact_mode)
+            = link_to project_forks_path(project), class: "#{css_metadata_classes} forks", title: _('Forks'), data: { container: 'body', placement: 'top' } do
+              = sprite_icon('fork', size: 14, css_class: 'gl-mr-2')
+              = badge_count(project.forks_count)
+          - if show_count?(disabled: !merge_requests, compact_mode: compact_mode)
+            = link_to project_merge_requests_path(project), class: "#{css_metadata_classes} merge-requests", title: _('Merge requests'), data: { container: 'body', placement: 'top' } do
+              = sprite_icon('git-merge', size: 14, css_class: 'gl-mr-2')
+              = badge_count(project.open_merge_requests_count)
+          - if show_count?(disabled: !issues, compact_mode: compact_mode)
+            = link_to project_issues_path(project), class: "#{css_metadata_classes} issues", title: _('Issues'), data: { container: 'body', placement: 'top' } do
+              = sprite_icon('issues', size: 14, css_class: 'gl-mr-2')
+              = badge_count(project.open_issues_count)
+
+        .gl-display-flex.gl-align-items-center.gl-gap-2.gl-mr-n2
+          - if show_pipeline_status_icon && last_pipeline.present?
+            - pipeline_path = pipelines_project_commit_path(project.pipeline_status.project, project.pipeline_status.sha, ref: project.pipeline_status.ref)
+            %span.icon-wrapper.pipeline-status
+              = render 'ci/status/icon', status: last_pipeline.detailed_status(current_user), tooltip_placement: 'top', path: pipeline_path
+
+          = render_if_exists 'shared/projects/archived', project: project
+      .updated-note.gl-font-sm.gl-white-space-nowrap.gl-justify-content-start
+        %span
+          = _('Updated')
+          = updated_tooltip
diff --git a/app/views/users/_follow_user.html.haml b/app/views/users/_follow_user.html.haml
index 71f8a462cbf7b..953e74c1f27c1 100644
--- a/app/views/users/_follow_user.html.haml
+++ b/app/views/users/_follow_user.html.haml
@@ -1,11 +1,9 @@
-- link_classes = "flex-grow-1 gl-display-inline-block"
-
 - if current_user&.following_users_allowed?(@user)
   - if current_user.following?(@user)
-    = form_tag user_unfollow_path(@user, :json), class: link_classes do
-      = render Pajamas::ButtonComponent.new(type: :submit, button_options: { class: 'gl-w-full', data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do
+    = form_tag user_unfollow_path(@user, :json) do
+      = render Pajamas::ButtonComponent.new(type: :submit, button_options: { data: { track_action: 'click_button', track_label: 'unfollow_from_profile' } }) do
         = _('Unfollow')
   - else
-    = form_tag user_follow_path(@user, :json), class: link_classes do
-      = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-w-full', data: { testid: 'follow-user-link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do
+    = form_tag user_follow_path(@user, :json) do
+      = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { data: { testid: 'follow-user-link', track_action: 'click_button', track_label: 'follow_from_profile' } }) do
         = _('Follow')
diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml
index 3a073436aa327..c9ccd63fae363 100644
--- a/app/views/users/_overview.html.haml
+++ b/app/views/users/_overview.html.haml
@@ -1,46 +1,46 @@
-- activity_pane_class = Feature.enabled?(:security_auto_fix) && @user.bot? ? "col-12" : "col-md-12 col-lg-6 gl-align-self-start"
+- if can?(current_user, :read_cross_project) && @user.user_readme&.rich_viewer
+  .profile-readme-wrapper.gl-relative.gl-overflow-hidden.gl-w-full.gl-pt-5
+    .profile-readme.read-more-container.gl-relative.justify-content-center.gl-border.gl-rounded-base.gl-overflow-hidden{ data: { 'read-more-height': 400 } }
+      .read-more-content.read-more-content--has-scrim.gl-py-5.gl-px-6
+        .gl-display-flex
+          = render Pajamas::BreadcrumbComponent.new(class: 'gl-mb-4') do |c|
+            - c.with_item(text: @user.username, href: project_path(@user.user_project))
+            - c.with_item(text: @user.user_readme.path, href: @user.user_project.readme_url)
 
-.row.d-none.d-sm-flex
-  .col-12.calendar-block.gl-my-3
-    .user-calendar.light{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: local_timezone_instance(@user.timezone).now.utc_offset } }
-      = gl_loading_icon(size: 'md', css_class: 'gl-my-8')
-      .user-calendar-error.invisible
-        = _('There was an error loading users activity calendar.')
-        %a.js-retry-load{ href: '#' }
-          = s_('UserProfile|Retry')
-- if @user.user_readme&.rich_viewer
-  .row.justify-content-center
-    .col-12.col-md-10.col-lg-8.gl-my-6
-      .gl-display-flex
-        = render Pajamas::BreadcrumbComponent.new(class: 'gl-mb-4') do |c|
-          - c.with_item(text: @user.username, href: project_path(@user.user_project))
-          - c.with_item(text: @user.user_readme.path, href: @user.user_project.readme_url)
+          - if current_user == @user
+            .gl-ml-auto
+              = link_to _('Edit file'), edit_blob_path(@user.user_project, @user.user_project.default_branch, @user.user_readme.path)
+        = render 'projects/blob/viewer', viewer: @user.user_readme.rich_viewer, load_async: false
+      .js-read-more-trigger.read-more-trigger.gl-h-8.gl-absolute.gl-z-index-2.gl-bg-white.gl-px-6.gl-rounded-bottom-base
+        = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'gl-mt-4 gl-ml-n1', 'aria-label': _("Expand Readme") }) do
+          = sprite_icon('chevron-down', size: 14, css_class: 'gl-mr-1 gl-mb-n1')
+          = _("Read more")
 
-        - if current_user == @user
-          .gl-ml-auto
-            = link_to _('Edit'), edit_blob_path(@user.user_project, @user.user_project.default_branch, @user.user_readme.path)
-      = render 'projects/blob/viewer', viewer: @user.user_readme.rich_viewer, load_async: false
-.row
-  .col-12.user-calendar-activities
-.row
-  %div{ class: activity_pane_class }
-    - if can?(current_user, :read_cross_project)
-      .activities-block
-        .gl-mt-5
-          .gl-display-flex.gl-align-items-center.gl-border-b-1.gl-border-b-gray-100.gl-border-b-solid
-            %h4.gl-flex-grow-1
-              = Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity')
-            = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all"
-          .overview-content-list.user-activity-content{ data: { href: user_activity_path, testid: 'user-activity-content' } }
-            = gl_loading_icon(size: 'md', css_class: 'loading')
+- if can?(current_user, :read_cross_project)
+  .gl-align-self-start.gl-overflow-hidden
+    .activities-block
+      .gl-display-flex.gl-align-items-baseline
+        %h2.gl-heading-3.gl-flex-grow-1{ class: 'gl-mt-5! gl-mb-3!' }
+          = Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity')
+        = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all"
 
-  - unless Feature.enabled?(:security_auto_fix) && @user.bot?
-    .col-md-12.col-lg-6
-      .projects-block
-        .gl-mt-5
-          .gl-display-flex.gl-align-items-center.gl-border-b-1.gl-border-b-gray-100.gl-border-b-solid
-            %h4.gl-flex-grow-1
-              = s_('UserProfile|Personal projects')
-            = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
-          .overview-content-list{ data: { href: user_projects_path } }
-            = gl_loading_icon(size: 'md', css_class: 'loading')
+      .user-calendar.gl-border.light.gl-rounded-base.gl-px-3.gl-pt-4.gl-text-center{ data: { calendar_path: user_calendar_path(@user, :json), calendar_activities_path: user_calendar_activities_path, utc_offset: local_timezone_instance(@user.timezone).now.utc_offset } }
+        = gl_loading_icon(size: 'md', css_class: 'gl-my-8')
+        .user-calendar-error.invisible
+          = _('There was an error loading users activity calendar.')
+          = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'js-retry-load' }) do
+            = s_('UserProfile|Retry')
+
+      .user-calendar-activities
+      .overview-content-list.user-activity-content.gl-mb-5{ data: { href: user_activity_path, testid: 'user-activity-content' } }
+        = gl_loading_icon(size: 'md', css_class: 'loading')
+
+- unless Feature.enabled?(:security_auto_fix) && @user.bot?
+  - if @user.personal_projects.any?
+    .projects-block.gl-w-full
+      .gl-display-flex.gl-align-items-baseline
+        %h2.gl-heading-3.gl-flex-grow-1{ class: 'gl-mt-5! gl-mb-3!' }
+          = s_('UserProfile|Personal projects')
+        = link_to s_('UserProfile|View all'), user_projects_path, class: "hide js-view-all"
+      .overview-content-list{ data: { href: user_projects_path } }
+        = gl_loading_icon(size: 'md', css_class: 'loading')
diff --git a/app/views/users/_view_user_in_admin_area.html.haml b/app/views/users/_view_user_in_admin_area.html.haml
index 36b3c33d8ab36..c8265ac9a9aea 100644
--- a/app/views/users/_view_user_in_admin_area.html.haml
+++ b/app/views/users/_view_user_in_admin_area.html.haml
@@ -1,4 +1,4 @@
 - if current_user && current_user.admin?
   = render Pajamas::ButtonComponent.new(href: [:admin, @user],
     icon: 'user',
-    button_options: { class: 'gl-flex-grow-1 has-tooltip', title: s_('UserProfile|View user in admin area'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } })
+    button_options: { class: 'has-tooltip', title: s_('UserProfile|View user in admin area'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } })
diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml
index 0f6d08dee8b9d..6bd45d165a4c4 100644
--- a/app/views/users/show.html.haml
+++ b/app/views/users/show.html.haml
@@ -12,105 +12,170 @@
 = content_for :meta_tags do
   = auto_discovery_link_tag(:atom, user_url(@user, format: :atom), title: "#{@user.name} activity")
 
-.user-profile
-  .cover-block.user-cover-block.gl-border-t.gl-border-b.gl-mt-n1
-    %div{ class: container_class }
-      .cover-controls.gl-display-flex.gl-gap-3.gl-pb-4
+%div{ class: container_class }
+  .user-profile-header.gl-display-flex.gl-justify-content-space-between.gl-flex-direction-column.gl-md-flex-direction-row-reverse.gl-mt-5.gl-mb-2
+    %div
+      .cover-controls.gl-display-flex.gl-gap-3.gl-mb-4.gl-md-justify-content-end.gl-md-flex-direction-row-reverse
+        .js-user-profile-actions{ data: user_profile_actions_data(@user) }
         = render 'users/follow_user'
         -# The following edit button is mutually exclusive to the follow user button, they won't be shown together
         - if @user == current_user
           = render Pajamas::ButtonComponent.new(href: user_settings_profile_path,
-            button_options: { class: 'gl-flex-grow-1', title: s_('UserProfile|Edit profile') }) do
+            button_options: { title: s_('UserProfile|Edit profile') }) do
             = s_("UserProfile|Edit profile")
         = render 'users/view_gpg_keys'
         = render 'users/view_user_in_admin_area'
-        .js-user-profile-actions{ data: user_profile_actions_data(@user) }
-
-      .profile-header.gl-mx-5.gl-mb-4{ class: [('gl-mb-6' if profile_tabs.empty?)] }
-        .gl-display-inline-block.gl-mx-8.gl-vertical-align-top
-          .avatar-holder
-            = link_to avatar_icon_for_user(@user, 400, current_user: current_user), target: '_blank', rel: 'noopener noreferrer' do
-              = render Pajamas::AvatarComponent.new(@user, alt: "", size: 96, avatar_options: { itemprop: "image" })
-          - if @user.achievements_enabled && Ability.allowed?(current_user, :read_user_profile, @user)
-            #js-user-achievements{ data: { root_url: root_url, user_id: @user.id } }
-        .gl-display-inline-block.gl-vertical-align-top.gl-text-left.gl-max-w-80
-          - if @user.blocked? || !@user.confirmed?
-            .user-info
-              %h1.cover-title.gl-my-0
-                = user_display_name(@user)
-            = render "users/profile_basic_info"
-          - else
-            .user-info
-              %h1.cover-title.gl-my-0{ itemprop: 'name' }
-                = @user.name
-                - if @user.pronouns.present?
-                  %span.gl-font-base.gl-text-gray-500.gl-vertical-align-middle
-                    = "(#{@user.pronouns})"
-                - if @user.status&.busy?
-                  = render Pajamas::BadgeComponent.new(s_('UserProfile|Busy'), size: 'sm', variant: 'warning', class: 'gl-vertical-align-middle')
-
-              - if @user.pronunciation.present?
-                .gl-align-items-center
-                  %p.gl-mb-4.gl-text-gray-500= s_("UserProfile|Pronounced as: %{pronunciation}") % { pronunciation: @user.pronunciation }
-
-              - if @user.status&.customized?
-                .cover-status.gl-display-inline-flex.gl-align-items-baseline.gl-mb-3
-                  = emoji_icon(@user.status.emoji, class: 'gl-mr-2')
-                  = markdown_field(@user.status, :message)
-              = render "users/profile_basic_info"
-              - user_local_time = local_time(@user.timezone)
-              - if @user.location.present? || user_local_time.present? || work_information(@user).present?
-                .gl-text-gray-900
+    .gl-display-flex.gl-flex-direction-row.gl-align-items-flex-start.gl-md-align-items-center.gl-column-gap-5.gl-mt-2.gl-sm-mt-0
+      .user-image.gl-relative.gl-py-3
+        = link_to avatar_icon_for_user(@user, 400, current_user: current_user), class: "user-profile-image", target: '_blank', rel: 'noopener noreferrer', title: s_('UserProfile|View large avatar') do
+          = render Pajamas::AvatarComponent.new(@user, alt: s_('UserProfile|User profile picture'), size: 64, avatar_options: { itemprop: "image" })
+        - if @user.status&.busy?
+          = render Pajamas::BadgeComponent.new(s_('UserProfile|Busy'), size: 'sm', variant: 'warning', class: 'gl-absolute gl-display-flex gl-justify-content-center gl-align-items-center gl-bottom-0 gl-left-50p gl-bg-gray-50 gl-border gl-border-white gl-translate-x-n50')
+      %div
+        %h1.gl-heading-1.gl-line-height-1.gl-mr-2{ class: 'gl-my-0!', itemprop: 'name' }
+          = user_display_name(@user)
+        .gl-font-size-h2.gl-text-gray-600.gl-font-weight-normal.gl-my-0
+          = @user.to_reference
+        - if !@user.blocked? && @user.confirmed? && @user.status&.customized?
+          .gl-my-2.cover-status.gl-font-sm.gl-pt-2.gl-display-flex.gl-flex-direction-column
+            .gl-display-inline-flex.gl-gap-3.gl-align-items-baseline
+              = emoji_icon(@user.status.emoji)
+              = markdown_field(@user.status, :message)
+  .user-profile
+    .user-profile-content
+      - if !profile_tabs.empty? && Feature.enabled?(:profile_tabs_vue, current_user)
+        #js-profile-tabs{ data: user_profile_tabs_app_data(@user) }
+      - unless Feature.enabled?(:profile_tabs_vue, current_user)
+        .tab-content
+          - if profile_tab?(:overview)
+            #js-overview.tab-pane.user-overview-page
+              = render "users/overview"
+
+          - if profile_tab?(:activity)
+            #activity.tab-pane
+              .flash-container
+              - if can?(current_user, :read_cross_project)
+                .content_list.user-activity-content{ data: { href: user_activity_path } }
+                .loading
+                  = gl_loading_icon(size: 'md')
+          - unless @user.bot?
+            - if profile_tab?(:groups)
+              #groups.tab-pane
+                -# This tab is always loaded via AJAX
+
+            - if profile_tab?(:contributed)
+              #contributed.tab-pane
+                -# This tab is always loaded via AJAX
+
+            - if profile_tab?(:projects)
+              #projects.tab-pane
+                -# This tab is always loaded via AJAX
+
+            - if profile_tab?(:starred)
+              #starred.tab-pane
+                -# This tab is always loaded via AJAX
+
+            - if profile_tab?(:snippets)
+              #snippets.tab-pane
+                -# This tab is always loaded via AJAX
+
+            - if profile_tab?(:followers)
+              #followers.tab-pane
+                -# This tab is always loaded via AJAX
+
+            - if profile_tab?(:following)
+              #following.tab-pane
+                -# This tab is always loaded via AJAX
+
+        .loading.hide
+          .gl-spinner.gl-spinner-md
+
+      - if profile_tabs.empty?
+        .svg-content
+          = image_tag 'illustrations/profile_private_mode.svg'
+        .text-content.text-center
+          %h4
+            - if @user.blocked?
+              = s_('UserProfile|This user is blocked')
+            - else
+              = s_('UserProfile|This user has a private profile')
+    .user-profile-sidebar
+      .profile-header.gl-pb-5.gl-pt-3.gl-overflow-y-auto.gl-sm-pr-4
+        .gl-vertical-align-top.gl-text-left.gl-max-w-80.gl-overflow-wrap-anywhere
+          .user-info
+            - if !@user.blocked? && @user.confirmed?
+              .gl-display-flex.gl-gap-4.gl-flex-direction-column
+                - if @user.pronouns.present? || @user.pronunciation.present? || @user.bio.present?
+                  %div
+                    %h2.gl-font-base.gl-mb-2.gl-mt-4= s_('UserProfile|About')
+                    .gl-display-flex.gl-gap-2.gl-flex-direction-column
+                      - if @user.pronouns.present? || @user.pronunciation.present?
+                        %div
+                          - if @user.pronunciation.present?
+                            %div= sprintf(s_("UserProfile|Pronounced as: %{div_start}%{pronunciation}%{div_end}"), { pronunciation: @user.pronunciation, div_start: '<div class="gl-font-sm gl-text-secondary gl-display-inline-flex">', div_end: '</div>' }).html_safe
+                          - if @user.pronouns.present?
+                            %div= sprintf(s_("UserProfile|Pronouns: %{div_start}%{pronouns}%{div_end}"), { pronouns: @user.pronouns, div_start: '<div class="gl-font-sm gl-text-secondary gl-display-inline-flex">', div_end: '</div>' }).html_safe
+                      - if @user.bio.present?
+                        %p.profile-user-bio.gl-mb-0
+                          = @user.bio
+
+                - if @user.achievements_enabled && Ability.allowed?(current_user, :read_user_profile, @user)
+                  #js-user-achievements{ data: { root_url: root_url, user_id: @user.id } }
+
+                - user_local_time = local_time(@user.timezone)
+                %div{ itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' }
+                  %h2.gl-font-base.gl-mb-2.gl-mt-4= s_('UserProfile|Info')
+                  - if work_information(@user).present?
+                    .gl-mb-2
+                      = sprite_icon('work', css_class: 'fgray')
+                      %span.gl-ml-1
+                        = work_information(@user, with_schema_markup: true)
                   - if @user.location.present?
-                    = render 'middle_dot_divider', stacking: true, itemprop: 'address', itemscope: true, itemtype: 'https://schema.org/PostalAddress' do
+                    .gl-mb-2
                       = sprite_icon('location', css_class: 'fgray')
-                      %span{ itemprop: 'addressLocality' }
+                      %span.gl-ml-1{ itemprop: 'addressLocality' }
                         = @user.location
                   - if user_local_time.present?
-                    = render 'middle_dot_divider', stacking: true, data: { testid: 'user-local-time' } do
+                    .gl-mb-2{ data: { testid: 'user-local-time' } }
                       = sprite_icon('clock', css_class: 'fgray')
-                      %span
+                      %span.gl-ml-1
                         = user_local_time
-                  - if work_information(@user).present?
-                    = render 'middle_dot_divider', stacking: true do
-                      = sprite_icon('work', css_class: 'fgray')
-                      %span
-                        = work_information(@user, with_schema_markup: true)
-              .gl-text-gray-900
-                - if @user.skype.present?
-                  = render 'middle_dot_divider' do
-                    = link_to "skype:#{@user.skype}", class: 'gl-hover-text-decoration-none', title: "Skype" do
-                      = sprite_icon('skype', css_class: 'skype-icon')
-                - if @user.linkedin.present?
-                  = render 'middle_dot_divider' do
-                    = link_to linkedin_url(@user), class: 'gl-hover-text-decoration-none', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow' do
-                      = sprite_icon('linkedin', css_class: 'linkedin-icon')
-                - if @user.twitter.present?
-                  = render 'middle_dot_divider', breakpoint: 'sm' do
-                    = link_to twitter_url(@user), class: 'gl-hover-text-decoration-none', title: _("X (formerly Twitter)"), target: '_blank', rel: 'noopener noreferrer nofollow' do
-                      = sprite_icon('x', css_class: 'x-icon')
-                - if @user.discord.present?
-                  = render 'middle_dot_divider', breakpoint: 'sm' do
-                    = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do
-                      = sprite_icon('discord', css_class: 'discord-icon')
-                - if @user.mastodon.present?
-                  = render 'middle_dot_divider', breakpoint: 'sm' do
-                    = link_to mastodon_url(@user), class: 'gl-hover-text-decoration-none', title: "Mastodon", target: '_blank', rel: 'noopener noreferrer nofollow' do
-                      = sprite_icon('mastodon', css_class: 'mastodon-icon')
-                - if @user.website_url.present?
-                  = render 'middle_dot_divider', stacking: true do
-                    - if Feature.enabled?(:security_auto_fix) && @user.bot?
-                      = sprite_icon('question-o', css_class: 'gl-text-blue-500')
-                    = link_to @user.short_website_url, @user.full_website_url, target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
-                - if display_public_email?(@user)
-                  = render 'middle_dot_divider', stacking: true do
-                    = link_to @user.public_email, "mailto:#{@user.public_email}", itemprop: 'email'
-
-              -# Ensure this stays indented one level less than the social links
-              -# See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/118314
-              - if @user.bio.present? && @user.confirmed? && !@user.blocked?
-                %p.profile-user-bio.gl-mb-3
-                  = @user.bio
+                  = sprite_icon('calendar', css_class: 'fgray')
+                  %span.gl-ml-1= s_('Member since %{date}') % { date: l(@user.created_at.to_date, format: :long) }
+
+                - if @user.website_url.present? || display_public_email?(@user) || @user.skype.present? || @user.linkedin.present? || @user.twitter.present? || @user.mastodon.present? || @user.discord.present?
+                  .gl-text-gray-900
+                    %h2.gl-font-base.gl-mb-2.gl-mt-4= s_('UserProfile|Contact')
+                    - if @user.website_url.present?
+                      .gl-mb-2
+                        - if Feature.enabled?(:security_auto_fix) && @user.bot?
+                          = sprite_icon('question-o', css_class: 'gl-text-blue-500')
+                        = sprite_icon('earth', css_class: 'fgray')
+                        = link_to @user.short_website_url, @user.full_website_url, class: 'gl-text-gray-900 gl-ml-1', target: '_blank', rel: 'me noopener noreferrer nofollow', itemprop: 'url'
+                    - if display_public_email?(@user)
+                      .gl-mb-2
+                        = sprite_icon('mail', css_class: 'fgray')
+                        = link_to @user.public_email, "mailto:#{@user.public_email}", class: 'gl-text-gray-900 gl-ml-1', itemprop: 'email'
+                    - if @user.skype.present?
+                      .gl-mb-2
+                        = sprite_icon('skype', css_class: 'fgray')
+                        = link_to @user.skype, "skype:#{@user.skype}", class: 'gl-text-gray-900 gl-ml-1', title: "Skype"
+                    - if @user.linkedin.present?
+                      .gl-mb-2
+                        = sprite_icon('linkedin', css_class: 'fgray')
+                        = link_to @user.linkedin, linkedin_url(@user), class: 'gl-text-gray-900 gl-ml-1', title: "LinkedIn", target: '_blank', rel: 'noopener noreferrer nofollow'
+                    - if @user.twitter.present?
+                      .gl-mb-2
+                        = sprite_icon('x', css_class: 'fgray')
+                        = link_to @user.twitter, twitter_url(@user), class: 'gl-text-gray-900 gl-ml-1', title: _("X (formerly Twitter)"), target: '_blank', rel: 'noopener noreferrer nofollow'
+                    - if @user.mastodon.present?
+                      .gl-mb-2
+                        = sprite_icon('mastodon', css_class: 'fgray')
+                        = link_to @user.mastodon, mastodon_url(@user), class: 'gl-text-gray-900 gl-ml-1', title: "Mastodon", target: '_blank', rel: 'noopener noreferrer nofollow'
+                    - if @user.discord.present?
+                      .gl-mb-2
+                        = sprite_icon('discord', css_class: 'fgray')
+                        = link_to @user.discord, discord_url(@user), class: 'gl-text-gray-900 gl-ml-1', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow'
 
       -# TODO: Remove this with the removal of the old navigation.
       -# See https://gitlab.com/gitlab-org/gitlab/-/issues/435899.
@@ -144,7 +209,7 @@
                     = s_('UserProfile|Personal projects')
               - if profile_tab?(:starred)
                 %li.js-starred-tab
-                  = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json) } do
+                  = link_to user_starred_projects_path, data: { target: 'div#starred', action: 'starred', toggle: 'tab', endpoint: user_starred_projects_path(format: :json), card_mode: true } do
                     = s_('UserProfile|Starred projects')
               - if profile_tab?(:snippets)
                 %li.js-snippets-tab
@@ -157,67 +222,7 @@
                     = gl_badge_tag @user.followers.count, size: :sm
               - if profile_tab?(:following)
                 %li.js-following-tab
-                  = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json) } do
+                  = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json), testid: 'following_tab' } do
                     = s_('UserProfile|Following')
                     = gl_badge_tag @user.followees.count, size: :sm
-    - if !profile_tabs.empty? && Feature.enabled?(:profile_tabs_vue, current_user)
-      #js-profile-tabs{ data: user_profile_tabs_app_data(@user) }
-  %div{ class: container_class }
-    - unless Feature.enabled?(:profile_tabs_vue, current_user)
-      .tab-content
-        - if profile_tab?(:overview)
-          #js-overview.tab-pane
-            = render "users/overview"
-
-        - if profile_tab?(:activity)
-          #activity.tab-pane
-            .row
-              .col-12
-                .flash-container
-                - if can?(current_user, :read_cross_project)
-                  %h4.prepend-top-20
-                    = s_('UserProfile|Most Recent Activity')
-                  .content_list.user-activity-content{ data: { href: user_activity_path } }
-                  .loading
-                    = gl_loading_icon(size: 'md')
-        - unless @user.bot?
-          - if profile_tab?(:groups)
-            #groups.tab-pane
-              -# This tab is always loaded via AJAX
-
-          - if profile_tab?(:contributed)
-            #contributed.tab-pane
-              -# This tab is always loaded via AJAX
-
-          - if profile_tab?(:projects)
-            #projects.tab-pane
-              -# This tab is always loaded via AJAX
-
-          - if profile_tab?(:starred)
-            #starred.tab-pane
-              -# This tab is always loaded via AJAX
-
-          - if profile_tab?(:snippets)
-            #snippets.tab-pane
-              -# This tab is always loaded via AJAX
-
-          - if profile_tab?(:followers)
-            #followers.tab-pane
-              -# This tab is always loaded via AJAX
-
-          - if profile_tab?(:following)
-            #following.tab-pane
-              -# This tab is always loaded via AJAX
-
-      .loading.hide
-        .gl-spinner.gl-spinner-md
-
-    - if profile_tabs.empty?
-      .svg-content
-        = image_tag 'illustrations/profile_private_mode.svg'
-      .text-content.text-center
-        %h4
-          - if @user.blocked?
-            = s_('UserProfile|This user is blocked')
-          - else
-            = s_('UserProfile|This user has a private profile')
+
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 108921089fe4e..c97bfe8e8d038 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2722,6 +2722,9 @@ msgstr ""
 msgid "Achievements|%{namespace_link} awarded you the %{bold_start}%{achievement_name}%{bold_end} achievement!"
 msgstr ""
 
+msgid "Achievements|Achievements"
+msgstr ""
+
 msgid "Achievements|Awarded %{timeAgo} by %{namespace}"
 msgstr ""
 
@@ -18594,6 +18597,9 @@ msgstr ""
 msgid "Edit environment"
 msgstr ""
 
+msgid "Edit file"
+msgstr ""
+
 msgid "Edit files in the editor and commit changes here"
 msgstr ""
 
@@ -20329,6 +20335,9 @@ msgstr ""
 msgid "Expand AI-generated summary"
 msgstr ""
 
+msgid "Expand Readme"
+msgstr ""
+
 msgid "Expand all"
 msgstr ""
 
@@ -53953,6 +53962,9 @@ msgstr ""
 msgid "UserProfile|%{id} · created %{created} by %{author}"
 msgstr ""
 
+msgid "UserProfile|About"
+msgstr ""
+
 msgid "UserProfile|Activity"
 msgstr ""
 
@@ -53977,6 +53989,9 @@ msgstr ""
 msgid "UserProfile|Busy"
 msgstr ""
 
+msgid "UserProfile|Contact"
+msgstr ""
+
 msgid "UserProfile|Contributed projects"
 msgstr ""
 
@@ -54007,10 +54022,10 @@ msgstr ""
 msgid "UserProfile|Groups are the best way to manage projects and members."
 msgstr ""
 
-msgid "UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!"
+msgid "UserProfile|Info"
 msgstr ""
 
-msgid "UserProfile|Most Recent Activity"
+msgid "UserProfile|Join or create a group to start contributing by commenting on issues or submitting merge requests!"
 msgstr ""
 
 msgid "UserProfile|No snippets found."
@@ -54022,7 +54037,10 @@ msgstr ""
 msgid "UserProfile|Personal projects"
 msgstr ""
 
-msgid "UserProfile|Pronounced as: %{pronunciation}"
+msgid "UserProfile|Pronounced as: %{div_start}%{pronunciation}%{div_end}"
+msgstr ""
+
+msgid "UserProfile|Pronouns: %{div_start}%{pronouns}%{div_end}"
 msgstr ""
 
 msgid "UserProfile|Retry"
@@ -54085,9 +54103,15 @@ msgstr ""
 msgid "UserProfile|User profile navigation"
 msgstr ""
 
+msgid "UserProfile|User profile picture"
+msgstr ""
+
 msgid "UserProfile|View all"
 msgstr ""
 
+msgid "UserProfile|View large avatar"
+msgstr ""
+
 msgid "UserProfile|View user in admin area"
 msgstr ""
 
diff --git a/spec/features/calendar_spec.rb b/spec/features/calendar_spec.rb
index 291c40f0f6b38..c750cd0a84481 100644
--- a/spec/features/calendar_spec.rb
+++ b/spec/features/calendar_spec.rb
@@ -224,24 +224,6 @@ def selected_day_activities(visible: true)
       end
     end
 
-    describe 'on smaller screens' do
-      shared_examples 'hidden activity calendar' do
-        include_context 'when user page is visited'
-
-        it 'hides the activity calender' do
-          expect(find('#js-overview')).not_to have_css('.js-contrib-calendar')
-        end
-      end
-
-      context 'when screen size is xs' do
-        before do
-          resize_screen_xs
-        end
-
-        it_behaves_like 'hidden activity calendar'
-      end
-    end
-
     describe 'first_day_of_week setting' do
       context 'when first day of the week is set to Monday' do
         before do
@@ -356,24 +338,6 @@ def selected_day_activities(visible: true)
       end
     end
 
-    describe 'on smaller screens' do
-      shared_examples 'hidden activity calendar' do
-        include_context 'when user page is visited'
-
-        it 'hides the activity calender' do
-          expect(page).not_to have_css('[data-testid="contrib-calendar"]')
-        end
-      end
-
-      context 'when screen size is xs' do
-        before do
-          resize_screen_xs
-        end
-
-        it_behaves_like 'hidden activity calendar'
-      end
-    end
-
     describe 'first_day_of_week setting' do
       context 'when first day of the week is set to Monday' do
         before do
diff --git a/spec/features/profiles/account_spec.rb b/spec/features/profiles/account_spec.rb
index 7e4308106be63..08d9ec5f3a9f3 100644
--- a/spec/features/profiles/account_spec.rb
+++ b/spec/features/profiles/account_spec.rb
@@ -51,14 +51,14 @@
       update_username(new_username)
       visit new_user_path
       expect(page).to have_current_path(new_user_path, ignore_query: true)
-      expect(find('.user-info')).to have_content(new_username)
+      expect(find('.user-profile-header')).to have_content(new_username)
     end
 
     it 'the old user path redirects to the new path' do
       update_username(new_username)
       visit old_user_path
       expect(page).to have_current_path(new_user_path, ignore_query: true)
-      expect(find('.user-info')).to have_content(new_username)
+      expect(find('.user-profile-header')).to have_content(new_username)
     end
 
     context 'with a project' do
diff --git a/spec/features/profiles/user_visits_profile_spec.rb b/spec/features/profiles/user_visits_profile_spec.rb
index 7edfb542594ff..ac5682991b31c 100644
--- a/spec/features/profiles/user_visits_profile_spec.rb
+++ b/spec/features/profiles/user_visits_profile_spec.rb
@@ -48,7 +48,7 @@
       it 'shows expected content', :js do
         visit(user_path(user))
 
-        page.within ".cover-block" do
+        page.within ".user-profile-header" do
           expect(page).to have_content user.name
           expect(page).to have_content user.username
         end
diff --git a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
index 88d2d1c46bc17..58fb8faf05e09 100644
--- a/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
+++ b/spec/features/uploads/user_uploads_avatar_to_profile_spec.rb
@@ -51,7 +51,7 @@
 
       visit user_path(user)
 
-      expect(page).to have_selector(%(img[src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=96"]))
+      expect(page).to have_selector(%(img[src$="/uploads/-/system/user/avatar/#{user.id}/dk.png?width=64"]))
 
       # Cheating here to verify something that isn't user-facing, but is important
       expect(user.reload.avatar.file).to exist
diff --git a/spec/features/users/overview_spec.rb b/spec/features/users/overview_spec.rb
index 1da61ecb86861..727b87c69dd8c 100644
--- a/spec/features/users/overview_spec.rb
+++ b/spec/features/users/overview_spec.rb
@@ -86,22 +86,6 @@ def push_code_contribution
   end
 
   describe 'projects section' do
-    describe 'user has no personal projects' do
-      include_context 'visit overview tab'
-
-      it 'shows an empty project list with an info message' do
-        page.within('.projects-block') do
-          expect(page).to have_selector('.loading', visible: false)
-          expect(page).to have_content('You haven\'t created any personal projects.')
-          expect(page).not_to have_selector('.project-row')
-        end
-      end
-
-      it 'does not show a link to the project list' do
-        expect(find('#js-overview .projects-block')).to have_selector('.js-view-all', visible: false)
-      end
-    end
-
     describe 'user has a personal project' do
       before do
         create(:project, :private, namespace: user.namespace, creator: user) { |p| p.add_maintainer(user) }
@@ -111,7 +95,7 @@ def push_code_contribution
 
       it 'shows one entry in the list of projects' do
         page.within('.projects-block') do
-          expect(page).to have_selector('.project-row', count: 1)
+          expect(page).to have_selector('.gl-card', count: 1)
         end
       end
 
@@ -119,9 +103,9 @@ def push_code_contribution
         expect(find('#js-overview .projects-block')).to have_selector('.js-view-all', visible: true)
       end
 
-      it 'shows projects in "compact mode"' do
+      it 'shows projects in "card mode"' do
         page.within('#js-overview .projects-block') do
-          expect(find('.js-projects-list-holder')).to have_selector('.compact')
+          expect(find('.js-projects-list-holder')).to have_css('.gl-card')
         end
       end
     end
@@ -135,9 +119,9 @@ def push_code_contribution
 
       include_context 'visit overview tab'
 
-      it 'shows max. ten entries in the list of projects' do
+      it 'shows max. 3 entries in the list of projects' do
         page.within('.projects-block') do
-          expect(page).to have_selector('.project-row', count: 10)
+          expect(page).to have_selector('.gl-card', count: 3)
         end
       end
 
@@ -315,7 +299,7 @@ def push_code_contribution
       end
 
       it 'shows projects panel' do
-        expect(page).to have_selector('.projects-block')
+        expect(page).not_to have_selector('.projects-block')
       end
     end
   end
diff --git a/spec/features/users/rss_spec.rb b/spec/features/users/rss_spec.rb
index 730c31df899f8..f0ca0715e145d 100644
--- a/spec/features/users/rss_spec.rb
+++ b/spec/features/users/rss_spec.rb
@@ -13,7 +13,7 @@
     end
 
     it 'shows the RSS link with overflow menu', :js do
-      page.within('.user-cover-block') do
+      page.within('.user-profile-header') do
         find_by_testid('base-dropdown-toggle').click
       end
 
@@ -27,7 +27,7 @@
     end
 
     it 'has an RSS without a feed token', :js do
-      page.within('.user-cover-block') do
+      page.within('.user-profile-header') do
         find_by_testid('base-dropdown-toggle').click
       end
 
diff --git a/spec/features/users/show_spec.rb b/spec/features/users/show_spec.rb
index c56b261fe282c..754de6ed67f28 100644
--- a/spec/features/users/show_spec.rb
+++ b/spec/features/users/show_spec.rb
@@ -12,7 +12,7 @@
   it 'shows copy user id action in the dropdown', :js do
     subject
 
-    page.within('.user-cover-block') do
+    page.within('.cover-controls') do
       find_by_testid('base-dropdown-toggle').click
     end
 
@@ -305,7 +305,7 @@
     end
 
     it 'shows user name as blocked' do
-      expect(page).to have_css(".cover-title", text: 'Blocked user')
+      expect(page).to have_css(".user-profile-header", text: 'Blocked user')
     end
 
     it 'shows no additional fields' do
@@ -343,7 +343,7 @@
       end
 
       it 'shows user name as unconfirmed' do
-        expect(page).to have_css(".cover-title", text: 'Unconfirmed user')
+        expect(page).to have_css(".user-profile-header", text: 'Unconfirmed user')
       end
 
       it 'shows no tab' do
@@ -393,7 +393,7 @@
 
     subject
 
-    expect(page).to have_content("(they/them)")
+    expect(page).to have_content("Pronouns: they/them")
   end
 
   it 'shows the pronunctiation of the user if there was one' do
@@ -435,12 +435,6 @@
       stub_feature_flags(profile_tabs_vue: false)
     end
 
-    it 'shows the most recent activity' do
-      subject
-
-      expect(page).to have_content('Most Recent Activity')
-    end
-
     context 'when external authorization is enabled' do
       before do
         enable_external_authorization_service_check
diff --git a/spec/frontend/profile/components/activity_calendar_spec.js b/spec/frontend/profile/components/activity_calendar_spec.js
index fb9dc7b22f73a..043e717538c2d 100644
--- a/spec/frontend/profile/components/activity_calendar_spec.js
+++ b/spec/frontend/profile/components/activity_calendar_spec.js
@@ -1,5 +1,4 @@
 import { GlLoadingIcon, GlAlert } from '@gitlab/ui';
-import * as GitLabUIUtils from '@gitlab/ui/dist/utils';
 
 import ActivityCalendar from '~/profile/components/activity_calendar.vue';
 import AjaxCache from '~/lib/utils/ajax_cache';
@@ -57,23 +56,6 @@ describe('ActivityCalendar', () => {
       expect(findCalendar().exists()).toBe(true);
       expect(wrapper.findByText(ActivityCalendar.i18n.calendarHint).exists()).toBe(true);
     });
-
-    describe('when window is resized', () => {
-      it('re-renders the calendar', async () => {
-        createComponent();
-
-        await waitForPromises();
-
-        mockSuccessfulApiRequest();
-        window.innerWidth = 1200;
-        window.dispatchEvent(new Event('resize'));
-
-        await waitForPromises();
-
-        expect(findCalendar().exists()).toBe(true);
-        expect(AjaxCache.retrieve).toHaveBeenCalledTimes(2);
-      });
-    });
   });
 
   describe('when API request is not successful', () => {
@@ -105,16 +87,4 @@ describe('ActivityCalendar', () => {
       });
     });
   });
-
-  describe('when screen is extra small', () => {
-    beforeEach(() => {
-      GitLabUIUtils.GlBreakpointInstance.getBreakpointSize.mockReturnValueOnce('xs');
-    });
-
-    it('does not render the calendar', () => {
-      createComponent();
-
-      expect(findCalendar().exists()).toBe(false);
-    });
-  });
 });
diff --git a/spec/frontend/read_more_spec.js b/spec/frontend/read_more_spec.js
index 6b1acfef8f553..a4104c39e04b1 100644
--- a/spec/frontend/read_more_spec.js
+++ b/spec/frontend/read_more_spec.js
@@ -85,3 +85,41 @@ describe('Read more click-to-expand functionality', () => {
     });
   });
 });
+
+describe('data-read-more-height defines when to show the read-more button', () => {
+  const findTrigger = () => document.querySelectorAll('.js-read-more-trigger');
+
+  afterEach(() => {
+    resetHTMLFixture();
+  });
+
+  it('if not set shows button all the time', () => {
+    setHTMLFixture(`
+      <div class="read-more-container">
+        <p class="read-more-content">Occaecat voluptate exercitation aliqua et duis eiusmod mollit esse ea laborum amet consectetur officia culpa anim. Fugiat laboris eu irure deserunt excepteur laboris irure quis. Occaecat nostrud irure do officia ea laborum velit sunt. Aliqua incididunt non deserunt proident magna aliqua sunt laborum laborum eiusmod ullamco. Et elit commodo irure. Labore eu nisi proident.</p>
+        <button type="button" class="js-read-more-trigger">
+          Button text
+        </button>
+      </div>
+    `);
+
+    initReadMore();
+
+    expect(findTrigger().length).toBe(1);
+  });
+
+  it('if set hides button as threshold is met', () => {
+    setHTMLFixture(`
+      <div class="read-more-container" data-read-more-height="120">
+        <p class="read-more-content">Occaecat voluptate exercitation aliqua et duis eiusmod mollit esse ea laborum amet consectetur officia culpa anim. Fugiat laboris eu irure deserunt excepteur laboris irure quis. Occaecat nostrud irure do officia ea laborum velit sunt. Aliqua incididunt non deserunt proident magna aliqua sunt laborum laborum eiusmod ullamco. Et elit commodo irure. Labore eu nisi proident.</p>
+        <button type="button" class="js-read-more-trigger">
+          Button text
+      </button>
+      </div>
+    `);
+
+    initReadMore();
+
+    expect(findTrigger().length).toBe(0);
+  });
+});
diff --git a/spec/views/shared/projects/_list.html.haml_spec.rb b/spec/views/shared/projects/_list.html.haml_spec.rb
index 1b6c4e00c97f5..ff295662e3c6e 100644
--- a/spec/views/shared/projects/_list.html.haml_spec.rb
+++ b/spec/views/shared/projects/_list.html.haml_spec.rb
@@ -31,6 +31,20 @@
       expect(rendered).not_to have_css('a.issues')
       expect(rendered).not_to have_css('a.merge-requests')
     end
+
+    it 'renders list in list view' do
+      expect(rendered).not_to have_css('.gl-new-card')
+    end
+  end
+
+  context 'with projects in card mode' do
+    let(:projects) { build_stubbed_list(:project, 1) }
+
+    it 'renders card mode when set to true' do
+      render template: 'shared/projects/_list', locals: { card_mode: true }
+
+      expect(rendered).to have_css('.gl-new-card')
+    end
   end
 
   context 'without projects' do
diff --git a/spec/views/shared/projects/_project_card.html.haml_spec.rb b/spec/views/shared/projects/_project_card.html.haml_spec.rb
new file mode 100644
index 0000000000000..4f4e31176af0a
--- /dev/null
+++ b/spec/views/shared/projects/_project_card.html.haml_spec.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'shared/projects/_project_card.html.haml', feature_category: :shared do
+  let(:project) { build(:project) }
+
+  before do
+    allow(view)
+      .to receive(:current_application_settings)
+      .and_return(Gitlab::CurrentSettings.current_application_settings)
+    allow(view).to receive(:can?).and_return(true)
+  end
+
+  it 'renders as a card component' do
+    render 'shared/projects/project_card', use_creator_avatar: true, project: project
+
+    expect(rendered).to have_selector('.gl-new-card')
+  end
+
+  it 'renders creator avatar if project has a creator' do
+    render 'shared/projects/project_card', use_creator_avatar: true, project: project
+
+    expect(rendered).to have_selector('img.gl-avatar')
+  end
+
+  it 'renders a generic avatar if project does not have a creator' do
+    project.creator = nil
+
+    render 'shared/projects/project_card', use_creator_avatar: true, project: project
+
+    expect(rendered).to have_selector('.gl-avatar-identicon')
+  end
+end
-- 
GitLab