diff --git a/.drone.yml b/.drone.yml
index 105c3b958c695645890ff97ee38b08af0636e7ec..bc123a89bc7e27047df3a0db29f288cd24dfe97d 100644
--- a/.drone.yml
+++ b/.drone.yml
@@ -29,6 +29,16 @@ services:
     environment:
       - SPARQL_UPDATE=true
       - DBA_PASSWORD=dba
+  selenium:
+    image: registry.fpfis.eu/fpfis/selenium:standalone-chrome-3.141.59-oxygen
+    environment:
+      - DISPLAY=:99
+      - SE_OPTS=-debug
+      - DISPLAY=:99
+      - SCREEN_WIDTH=1280
+      - SCREEN_HEIGHT=800
+      - NODE_MAX_INSTANCES=5
+      - NODE_MAX_SESSION=5
 
 pipeline:
   npm-build:
diff --git a/composer.json b/composer.json
index 8940139bca1506795a97c917603b84ab73daf69e..f429aed09a4ad13a1a5f957d69f15a4427453dde 100644
--- a/composer.json
+++ b/composer.json
@@ -16,18 +16,22 @@
     "require-dev": {
         "composer/installers": "^1.11",
         "drupal/better_exposed_filters": "^5.0",
+        "drupal/composite_reference": "^2.1",
         "drupal/config_devel": "~1.2",
         "drupal/core-composer-scaffold": "^9.2",
         "drupal/core-dev": "^9.2",
         "drupal/ctools": "^3.7",
         "drupal/description_list_field": "^1.0@alpha",
         "drupal/drupal-extension": "~4.1",
+        "drupal/entity_reference_revisions": "^1.9",
+        "drupal/field_group": "^3.2",
         "drupal/file_link": "^2.0.6",
         "drupal/facets_form": "1.0.0-alpha2",
         "drupal/pathauto": "^1.8",
         "drupal/search_api": "^1.21",
         "drupal/search_api_autocomplete": "^1.5",
         "drupal/token": "^1.10",
+        "drupal/typed_link": "^2.0",
         "drush/drush": "^10.3",
         "easyrdf/easyrdf": "1.0.0 as 0.9.1",
         "egulias/email-validator": "^2.1.22 || ^3.0",
@@ -37,6 +41,7 @@
         "openeuropa/oe_authentication": "^1.4",
         "openeuropa/oe_contact_forms": "~1.1",
         "openeuropa/oe_content": "^2.8.0",
+        "openeuropa/oe_content_extra": "1.x-dev",
         "openeuropa/oe_corporate_blocks": "^4.4",
         "openeuropa/oe_media": "^1.14",
         "openeuropa/oe_multilingual": "^1.9",
@@ -54,6 +59,10 @@
         "drupal":{
             "type": "composer",
             "url": "https://packages.drupal.org/8"
+        },
+        "openeuropa/oe_content_extra": {
+            "type": "git",
+            "url": "https://github.com/openeuropa/oe_content_extra"
         }
     },
     "autoload": {
@@ -78,6 +87,11 @@
                 }
             }
         },
+        "patches": {
+            "drupal/entity_reference_revisions": {
+                "https://www.drupal.org/project/entity_reference_revisions/issues/2937835": "https://www.drupal.org/files/issues/2021-03-26/entity_reference_revisions-field_formatter_label-2937835-36.patch"
+            }
+        },
         "drupal-scaffold": {
             "locations": {
                 "web-root": "./build"
diff --git a/docker-compose.yml b/docker-compose.yml
index 6eb4b18ebbe41fd004a78c696c1209515c778836..1b7cab0b7423b20bbfd58bc4387e2296661369f3 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -23,6 +23,26 @@ services:
       MYSQL_ALLOW_EMPTY_PASSWORD: "yes"
     # ports:
     #   - 3306:3306
+
+  # If you would like to see what is going on you can run the following on your host:
+  # docker run --rm -p 4444:4444 -p 5900:5900 --network="host" selenium/standalone-chrome-debug:latest
+  # Newer version of this image might run into this issue:
+  # @link https://github.com/elgalu/docker-selenium/issues/20
+  selenium:
+    image: selenium/standalone-chrome-debug:3.141.59-oxygen
+    environment:
+      - DISPLAY=:99
+      - SE_OPTS=-debug
+      - SCREEN_WIDTH=1280
+      - SCREEN_HEIGHT=800
+      - VNC_NO_PASSWORD=1
+    ports:
+      - '4444:4444'
+      - '5900:5900'
+    expose:
+      - '4444'
+    shm_size: 2g
+
   node:
     image: node:14.17.3
     user: "node"
diff --git a/modules/oe_whitelabel_extra_project/README.md b/modules/oe_whitelabel_extra_project/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..6e5341582be9d16d11938f3ce2db1b3f2c81aa17
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/README.md
@@ -0,0 +1 @@
+# OpenEuropa Whitelabel Content Extra Project
diff --git a/modules/oe_whitelabel_extra_project/config/install/core.date_format.oe_whitelabel_project_date.yml b/modules/oe_whitelabel_extra_project/config/install/core.date_format.oe_whitelabel_project_date.yml
new file mode 100644
index 0000000000000000000000000000000000000000..42fc99eea373224fafd6f626f3ff5c4f216f58b0
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/config/install/core.date_format.oe_whitelabel_project_date.yml
@@ -0,0 +1,7 @@
+langcode: en
+status: true
+dependencies: {  }
+id: oe_whitelabel_project_date
+label: 'OE Whitelabel Project date'
+locked: false
+pattern: 'd F Y'
diff --git a/modules/oe_whitelabel_extra_project/config/install/core.entity_view_display.node.oe_project.full.yml b/modules/oe_whitelabel_extra_project/config/install/core.entity_view_display.node.oe_project.full.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8088900d97dc568ca29f492036d81a5cd0431f46
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/config/install/core.entity_view_display.node.oe_project.full.yml
@@ -0,0 +1,239 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.node.full
+    - field.field.node.oe_project.body
+    - field.field.node.oe_project.oe_cx_achievements_and_milestone
+    - field.field.node.oe_project.oe_cx_gallery
+    - field.field.node.oe_project.oe_cx_impacts
+    - field.field.node.oe_project.oe_cx_lead_contributors
+    - field.field.node.oe_project.oe_cx_objective
+    - field.field.node.oe_project.oe_departments
+    - field.field.node.oe_project.oe_documents
+    - field.field.node.oe_project.oe_featured_media
+    - field.field.node.oe_project.oe_project_budget
+    - field.field.node.oe_project.oe_project_budget_eu
+    - field.field.node.oe_project.oe_project_calls
+    - field.field.node.oe_project.oe_project_contact
+    - field.field.node.oe_project.oe_project_coordinators
+    - field.field.node.oe_project.oe_project_dates
+    - field.field.node.oe_project.oe_project_funding_programme
+    - field.field.node.oe_project.oe_project_locations
+    - field.field.node.oe_project.oe_project_participants
+    - field.field.node.oe_project.oe_project_result_files
+    - field.field.node.oe_project.oe_project_results
+    - field.field.node.oe_project.oe_project_website
+    - field.field.node.oe_project.oe_reference_code
+    - field.field.node.oe_project.oe_subject
+    - field.field.node.oe_project.oe_summary
+    - field.field.node.oe_project.oe_teaser
+    - node.type.oe_project
+  module:
+    - datetime_range
+    - entity_reference_revisions
+    - field_group
+    - link
+    - rdf_skos
+    - text
+    - user
+third_party_settings:
+  field_group:
+    group_project_details:
+      children:
+        - group_website
+        - group_coordinators
+      label: 'Project details'
+      parent_name: ''
+      region: content
+      weight: 2
+      format_type: html_element
+      format_settings:
+        classes: ''
+        show_empty_fields: false
+        id: ''
+        element: div
+        show_label: false
+        label_element: h3
+        label_element_classes: 'fw-bold mb-4'
+        attributes: ''
+        effect: none
+        speed: fast
+    group_coordinators:
+      children:
+        - oe_project_coordinators
+      label: Coordinators
+      parent_name: group_project_details
+      region: content
+      weight: 3
+      format_type: oe_whitelabel_helper_description_list_pattern
+      format_settings: {  }
+    group_budget:
+      children:
+        - oe_project_budget
+        - oe_project_budget_eu
+      label: Budget
+      parent_name: ''
+      region: content
+      weight: 1
+      format_type: html_element
+      format_settings:
+        classes: ''
+        show_empty_fields: false
+        id: ''
+        element: div
+        show_label: false
+        label_element: h3
+        label_element_classes: ''
+        attributes: ''
+        effect: none
+        speed: fast
+    group_website:
+      children:
+        - oe_project_website
+        - oe_project_funding_programme
+        - oe_reference_code
+      label: Website
+      parent_name: group_project_details
+      region: content
+      weight: 2
+      format_type: oe_whitelabel_helper_description_list_pattern
+      format_settings: {  }
+id: node.oe_project.full
+targetEntityType: node
+bundle: oe_project
+mode: full
+content:
+  oe_cx_achievements_and_milestone:
+    type: text_default
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 8
+    region: content
+  oe_cx_impacts:
+    type: text_default
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 5
+    region: content
+  oe_cx_lead_contributors:
+    type: entity_reference_revisions_entity_view
+    label: hidden
+    settings:
+      view_mode: default
+      link: ''
+    third_party_settings: {  }
+    weight: 6
+    region: content
+  oe_cx_objective:
+    type: text_default
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 4
+    region: content
+  oe_project_budget:
+    type: number_decimal
+    label: hidden
+    settings:
+      thousand_separator: .
+      decimal_separator: ','
+      scale: 2
+      prefix_suffix: true
+    third_party_settings: {  }
+    weight: 2
+    region: content
+  oe_project_budget_eu:
+    type: number_decimal
+    label: hidden
+    settings:
+      thousand_separator: .
+      decimal_separator: ','
+      scale: 2
+      prefix_suffix: true
+    third_party_settings: {  }
+    weight: 3
+    region: content
+  oe_project_coordinators:
+    type: entity_reference_revisions_label
+    label: hidden
+    settings:
+      link: false
+    third_party_settings: {  }
+    weight: 4
+    region: content
+  oe_project_dates:
+    type: daterange_default
+    label: hidden
+    settings:
+      timezone_override: ''
+      format_type: oe_whitelabel_project_date
+      separator: '-'
+    third_party_settings: {  }
+    weight: 0
+    region: content
+  oe_project_funding_programme:
+    type: skos_concept_entity_reference_label
+    label: hidden
+    settings:
+      link: false
+    third_party_settings: {  }
+    weight: 4
+    region: content
+  oe_project_participants:
+    type: entity_reference_revisions_entity_view
+    label: hidden
+    settings:
+      view_mode: default
+      link: ''
+    third_party_settings: {  }
+    weight: 7
+    region: content
+  oe_project_website:
+    type: link
+    label: hidden
+    settings:
+      trim_length: 80
+      url_only: false
+      url_plain: false
+      rel: ''
+      target: ''
+    third_party_settings: {  }
+    weight: 3
+    region: content
+  oe_reference_code:
+    type: string
+    label: hidden
+    settings:
+      link_to_entity: false
+    third_party_settings: {  }
+    weight: 5
+    region: content
+  oe_summary:
+    type: text_default
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 3
+    region: content
+hidden:
+  body: true
+  langcode: true
+  links: true
+  oe_content_content_owner: true
+  oe_content_legacy_link: true
+  oe_content_navigation_title: true
+  oe_content_short_title: true
+  oe_cx_gallery: true
+  oe_departments: true
+  oe_documents: true
+  oe_featured_media: true
+  oe_project_calls: true
+  oe_project_contact: true
+  oe_project_locations: true
+  oe_project_result_files: true
+  oe_project_results: true
+  oe_subject: true
+  oe_teaser: true
diff --git a/modules/oe_whitelabel_extra_project/config/install/core.entity_view_display.node.oe_project.oe_w_content_banner.yml b/modules/oe_whitelabel_extra_project/config/install/core.entity_view_display.node.oe_project.oe_w_content_banner.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d553241623ac26a3c6e2ec7136062b1c251ffe4d
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/config/install/core.entity_view_display.node.oe_project.oe_w_content_banner.yml
@@ -0,0 +1,85 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.node.oe_w_content_banner
+    - field.field.node.oe_project.body
+    - field.field.node.oe_project.oe_cx_achievements_and_milestone
+    - field.field.node.oe_project.oe_cx_gallery
+    - field.field.node.oe_project.oe_cx_impacts
+    - field.field.node.oe_project.oe_cx_lead_contributors
+    - field.field.node.oe_project.oe_cx_objective
+    - field.field.node.oe_project.oe_departments
+    - field.field.node.oe_project.oe_documents
+    - field.field.node.oe_project.oe_featured_media
+    - field.field.node.oe_project.oe_project_budget
+    - field.field.node.oe_project.oe_project_budget_eu
+    - field.field.node.oe_project.oe_project_calls
+    - field.field.node.oe_project.oe_project_contact
+    - field.field.node.oe_project.oe_project_coordinators
+    - field.field.node.oe_project.oe_project_dates
+    - field.field.node.oe_project.oe_project_funding_programme
+    - field.field.node.oe_project.oe_project_locations
+    - field.field.node.oe_project.oe_project_participants
+    - field.field.node.oe_project.oe_project_result_files
+    - field.field.node.oe_project.oe_project_results
+    - field.field.node.oe_project.oe_project_website
+    - field.field.node.oe_project.oe_reference_code
+    - field.field.node.oe_project.oe_subject
+    - field.field.node.oe_project.oe_summary
+    - field.field.node.oe_project.oe_teaser
+    - node.type.oe_project
+  module:
+    - rdf_skos
+    - text
+    - user
+id: node.oe_project.oe_w_content_banner
+targetEntityType: node
+bundle: oe_project
+mode: oe_w_content_banner
+content:
+  oe_subject:
+    type: skos_concept_entity_reference_label
+    label: hidden
+    settings:
+      link: false
+    third_party_settings: {  }
+    weight: 1
+    region: content
+  oe_teaser:
+    type: text_default
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 0
+    region: content
+hidden:
+  body: true
+  langcode: true
+  links: true
+  oe_content_content_owner: true
+  oe_content_legacy_link: true
+  oe_content_navigation_title: true
+  oe_content_short_title: true
+  oe_cx_achievements_and_milestone: true
+  oe_cx_gallery: true
+  oe_cx_impacts: true
+  oe_cx_lead_contributors: true
+  oe_cx_objective: true
+  oe_departments: true
+  oe_documents: true
+  oe_featured_media: true
+  oe_project_budget: true
+  oe_project_budget_eu: true
+  oe_project_calls: true
+  oe_project_contact: true
+  oe_project_coordinators: true
+  oe_project_dates: true
+  oe_project_funding_programme: true
+  oe_project_locations: true
+  oe_project_participants: true
+  oe_project_result_files: true
+  oe_project_results: true
+  oe_project_website: true
+  oe_reference_code: true
+  oe_summary: true
diff --git a/modules/oe_whitelabel_extra_project/config/install/core.entity_view_display.node.oe_project.teaser.yml b/modules/oe_whitelabel_extra_project/config/install/core.entity_view_display.node.oe_project.teaser.yml
new file mode 100644
index 0000000000000000000000000000000000000000..acf603362a50b8e93542177dc9aebf588f60330a
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/config/install/core.entity_view_display.node.oe_project.teaser.yml
@@ -0,0 +1,124 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - core.entity_view_mode.node.teaser
+    - field.field.node.oe_project.body
+    - field.field.node.oe_project.oe_cx_achievements_and_milestone
+    - field.field.node.oe_project.oe_cx_gallery
+    - field.field.node.oe_project.oe_cx_impacts
+    - field.field.node.oe_project.oe_cx_lead_contributors
+    - field.field.node.oe_project.oe_cx_objective
+    - field.field.node.oe_project.oe_departments
+    - field.field.node.oe_project.oe_documents
+    - field.field.node.oe_project.oe_featured_media
+    - field.field.node.oe_project.oe_project_budget
+    - field.field.node.oe_project.oe_project_budget_eu
+    - field.field.node.oe_project.oe_project_calls
+    - field.field.node.oe_project.oe_project_contact
+    - field.field.node.oe_project.oe_project_coordinators
+    - field.field.node.oe_project.oe_project_dates
+    - field.field.node.oe_project.oe_project_funding_programme
+    - field.field.node.oe_project.oe_project_locations
+    - field.field.node.oe_project.oe_project_participants
+    - field.field.node.oe_project.oe_project_result_files
+    - field.field.node.oe_project.oe_project_results
+    - field.field.node.oe_project.oe_project_website
+    - field.field.node.oe_project.oe_reference_code
+    - field.field.node.oe_project.oe_subject
+    - field.field.node.oe_project.oe_summary
+    - field.field.node.oe_project.oe_teaser
+    - node.type.oe_project
+  module:
+    - datetime_range
+    - field_group
+    - oe_content_featured_media_field
+    - rdf_skos
+    - text
+    - user
+third_party_settings:
+  field_group:
+    group_stakeholders:
+      children:
+        - oe_project_participants
+      label: Stakeholders
+      parent_name: ''
+      region: hidden
+      weight: 30
+      format_type: html_element
+      format_settings:
+        classes: ''
+        id: ''
+        element: div
+        show_label: false
+        label_element: h3
+        label_element_classes: ''
+        attributes: ''
+        effect: none
+        speed: fast
+id: node.oe_project.teaser
+targetEntityType: node
+bundle: oe_project
+mode: teaser
+content:
+  oe_featured_media:
+    type: oe_featured_media_label
+    label: hidden
+    settings:
+      link: true
+    third_party_settings: {  }
+    weight: 0
+    region: content
+  oe_project_dates:
+    type: daterange_default
+    label: hidden
+    settings:
+      timezone_override: ''
+      format_type: oe_whitelabel_project_date
+      separator: '-'
+    third_party_settings: {  }
+    weight: 3
+    region: content
+  oe_subject:
+    type: skos_concept_entity_reference_label
+    label: hidden
+    settings:
+      link: false
+    third_party_settings: {  }
+    weight: 2
+    region: content
+  oe_teaser:
+    type: text_default
+    label: hidden
+    settings: {  }
+    third_party_settings: {  }
+    weight: 1
+    region: content
+hidden:
+  body: true
+  langcode: true
+  links: true
+  oe_content_content_owner: true
+  oe_content_legacy_link: true
+  oe_content_navigation_title: true
+  oe_content_short_title: true
+  oe_cx_achievements_and_milestone: true
+  oe_cx_gallery: true
+  oe_cx_impacts: true
+  oe_cx_lead_contributors: true
+  oe_cx_objective: true
+  oe_departments: true
+  oe_documents: true
+  oe_project_budget: true
+  oe_project_budget_eu: true
+  oe_project_calls: true
+  oe_project_contact: true
+  oe_project_coordinators: true
+  oe_project_funding_programme: true
+  oe_project_locations: true
+  oe_project_participants: true
+  oe_project_result_files: true
+  oe_project_results: true
+  oe_project_website: true
+  oe_reference_code: true
+  oe_summary: true
diff --git a/modules/oe_whitelabel_extra_project/config/overrides/core.entity_form_display.node.oe_project.default.yml b/modules/oe_whitelabel_extra_project/config/overrides/core.entity_form_display.node.oe_project.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..cf518277a60ebcf6288ea70723e165a31b5ed7d6
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/config/overrides/core.entity_form_display.node.oe_project.default.yml
@@ -0,0 +1,337 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.node.oe_project.body
+    - field.field.node.oe_project.oe_cx_achievements_and_milestone
+    - field.field.node.oe_project.oe_cx_gallery
+    - field.field.node.oe_project.oe_cx_impacts
+    - field.field.node.oe_project.oe_cx_lead_contributors
+    - field.field.node.oe_project.oe_cx_objective
+    - field.field.node.oe_project.oe_departments
+    - field.field.node.oe_project.oe_documents
+    - field.field.node.oe_project.oe_featured_media
+    - field.field.node.oe_project.oe_project_budget
+    - field.field.node.oe_project.oe_project_budget_eu
+    - field.field.node.oe_project.oe_project_calls
+    - field.field.node.oe_project.oe_project_contact
+    - field.field.node.oe_project.oe_project_coordinators
+    - field.field.node.oe_project.oe_project_dates
+    - field.field.node.oe_project.oe_project_funding_programme
+    - field.field.node.oe_project.oe_project_locations
+    - field.field.node.oe_project.oe_project_participants
+    - field.field.node.oe_project.oe_project_result_files
+    - field.field.node.oe_project.oe_project_results
+    - field.field.node.oe_project.oe_project_website
+    - field.field.node.oe_project.oe_reference_code
+    - field.field.node.oe_project.oe_subject
+    - field.field.node.oe_project.oe_summary
+    - field.field.node.oe_project.oe_teaser
+    - node.type.oe_project
+  module:
+    - datetime_range
+    - field_group
+    - inline_entity_form
+    - link
+    - oe_content_featured_media_field
+    - rdf_skos
+    - text
+third_party_settings:
+  field_group:
+    group_result:
+      children:
+        - oe_project_results
+        - oe_project_result_files
+      label: Result
+      region: content
+      parent_name: ''
+      weight: 13
+      format_type: details
+      format_settings:
+        classes: ''
+        id: ''
+        open: true
+        description: ''
+        required_fields: true
+    group_budget:
+      children:
+        - oe_project_budget
+        - oe_project_budget_eu
+      label: Budget
+      region: content
+      parent_name: group_details
+      weight: 6
+      format_type: tab
+      format_settings:
+        classes: ''
+        id: ''
+        formatter: open
+        description: ''
+        required_fields: true
+    group_details:
+      children:
+        - oe_project_dates
+        - group_budget
+        - oe_project_website
+        - oe_project_funding_programme
+        - oe_reference_code
+        - oe_project_coordinators
+      label: 'Project details'
+      region: content
+      parent_name: ''
+      weight: 4
+      format_type: html_element
+      format_settings:
+        classes: ''
+        show_empty_fields: false
+        id: ''
+        element: div
+        show_label: true
+        label_element: h3
+        label_element_classes: ''
+        attributes: ''
+        effect: none
+        speed: fast
+        required_fields: true
+id: node.oe_project.default
+targetEntityType: node
+bundle: oe_project
+mode: default
+content:
+  oe_cx_achievements_and_milestone:
+    type: text_textarea
+    weight: 10
+    region: content
+    settings:
+      rows: 5
+      placeholder: ''
+    third_party_settings: {  }
+  oe_cx_gallery:
+    type: entity_reference_autocomplete
+    weight: 11
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  oe_cx_impacts:
+    type: text_textarea
+    weight: 7
+    region: content
+    settings:
+      rows: 5
+      placeholder: ''
+    third_party_settings: {  }
+  oe_cx_lead_contributors:
+    type: inline_entity_form_complex
+    weight: 9
+    region: content
+    settings:
+      form_mode: default
+      override_labels: false
+      label_singular: ''
+      label_plural: ''
+      allow_new: true
+      allow_existing: false
+      match_operator: CONTAINS
+      allow_duplicate: false
+      collapsible: false
+      collapsed: false
+      revision: false
+      removed_reference: optional
+    third_party_settings: {  }
+  oe_cx_objective:
+    type: text_textarea
+    weight: 6
+    region: content
+    settings:
+      rows: 5
+      placeholder: ''
+    third_party_settings: {  }
+  oe_documents:
+    type: entity_reference_autocomplete
+    weight: 12
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  oe_featured_media:
+    type: oe_featured_media_autocomplete
+    weight: 1
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  oe_project_budget:
+    type: number
+    weight: 9
+    region: content
+    settings:
+      placeholder: ''
+    third_party_settings: {  }
+  oe_project_budget_eu:
+    type: number
+    weight: 10
+    region: content
+    settings:
+      placeholder: ''
+    third_party_settings: {  }
+  oe_project_coordinators:
+    type: inline_entity_form_complex
+    weight: 10
+    region: content
+    settings:
+      form_mode: default
+      override_labels: false
+      label_singular: ''
+      label_plural: ''
+      allow_new: true
+      allow_existing: false
+      match_operator: CONTAINS
+      allow_duplicate: false
+      collapsible: false
+      collapsed: false
+      revision: false
+      removed_reference: optional
+    third_party_settings: {  }
+  oe_project_dates:
+    type: daterange_datelist
+    weight: 5
+    region: content
+    settings:
+      increment: 15
+      date_order: DMY
+      time_type: none
+    third_party_settings: {  }
+  oe_project_funding_programme:
+    type: skos_concept_entity_reference_autocomplete
+    weight: 8
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  oe_project_participants:
+    type: inline_entity_form_complex
+    weight: 8
+    region: content
+    settings:
+      form_mode: default
+      override_labels: true
+      label_singular: participant
+      label_plural: participants
+      allow_new: true
+      allow_existing: false
+      match_operator: CONTAINS
+      allow_duplicate: false
+      collapsible: true
+      collapsed: false
+      revision: true
+      removed_reference: keep
+    third_party_settings: {  }
+  oe_project_result_files:
+    type: entity_reference_autocomplete
+    weight: 0
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  oe_project_results:
+    type: text_textarea
+    weight: 0
+    region: content
+    settings:
+      rows: 5
+      placeholder: ''
+    third_party_settings: {  }
+  oe_project_website:
+    type: link_default
+    weight: 7
+    region: content
+    settings:
+      placeholder_url: ''
+      placeholder_title: ''
+    third_party_settings: {  }
+  oe_reference_code:
+    type: string_textfield
+    weight: 9
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  oe_subject:
+    type: skos_concept_entity_reference_autocomplete
+    weight: 3
+    region: content
+    settings:
+      match_operator: CONTAINS
+      match_limit: 10
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  oe_summary:
+    type: text_textarea
+    weight: 5
+    region: content
+    settings:
+      rows: 5
+      placeholder: ''
+    third_party_settings: {  }
+  oe_teaser:
+    type: text_textarea
+    weight: 2
+    region: content
+    settings:
+      rows: 5
+      placeholder: ''
+    third_party_settings: {  }
+  status:
+    type: boolean_checkbox
+    weight: 14
+    region: content
+    settings:
+      display_label: true
+    third_party_settings: {  }
+  title:
+    type: string_textfield
+    weight: 0
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  translation:
+    weight: 10
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+hidden:
+  body: true
+  created: true
+  langcode: true
+  oe_content_content_owner: true
+  oe_content_legacy_link: true
+  oe_content_navigation_title: true
+  oe_content_short_title: true
+  oe_departments: true
+  oe_project_calls: true
+  oe_project_contact: true
+  oe_project_locations: true
+  path: true
+  promote: true
+  sticky: true
+  uid: true
diff --git a/modules/oe_whitelabel_extra_project/config/overrides/core.entity_form_display.oe_organisation.oe_cx_project_stakeholder.default.yml b/modules/oe_whitelabel_extra_project/config/overrides/core.entity_form_display.oe_organisation.oe_cx_project_stakeholder.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..db59f56bf43ac5cfcbd01d5a32a184b7b40733aa
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/config/overrides/core.entity_form_display.oe_organisation.oe_cx_project_stakeholder.default.yml
@@ -0,0 +1,54 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_acronym
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_address
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_contact_url
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_cx_contribution_budget
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_logo
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_website
+    - oe_content_entity_organisation.oe_organisation_type.oe_cx_project_stakeholder
+  module:
+    - address
+id: oe_organisation.oe_cx_project_stakeholder.default
+targetEntityType: oe_organisation
+bundle: oe_cx_project_stakeholder
+mode: default
+content:
+  name:
+    type: string_textfield
+    weight: 0
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  oe_acronym:
+    type: string_textfield
+    weight: 1
+    region: content
+    settings:
+      size: 60
+      placeholder: ''
+    third_party_settings: {  }
+  oe_address:
+    type: address_default
+    weight: 2
+    region: content
+    settings: {  }
+    third_party_settings: {  }
+  oe_cx_contribution_budget:
+    type: number
+    weight: 3
+    region: content
+    settings:
+      placeholder: ''
+    third_party_settings: {  }
+hidden:
+  created: true
+  langcode: true
+  oe_contact_url: true
+  oe_logo: true
+  oe_website: true
+  status: true
diff --git a/modules/oe_whitelabel_extra_project/config/overrides/core.entity_view_display.oe_organisation.oe_cx_project_stakeholder.default.yml b/modules/oe_whitelabel_extra_project/config/overrides/core.entity_view_display.oe_organisation.oe_cx_project_stakeholder.default.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f1faedb5a95ea9d2c224118877ba2d9ab3ed7d8d
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/config/overrides/core.entity_view_display.oe_organisation.oe_cx_project_stakeholder.default.yml
@@ -0,0 +1,74 @@
+langcode: en
+status: true
+dependencies:
+  config:
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_acronym
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_address
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_contact_url
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_cx_contribution_budget
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_logo
+    - field.field.oe_organisation.oe_cx_project_stakeholder.oe_website
+    - oe_content_entity_organisation.oe_organisation_type.oe_cx_project_stakeholder
+  module:
+    - field_group
+    - oe_whitelabel_helper
+third_party_settings:
+  field_group:
+    group_info:
+      children:
+        - name
+        - oe_address
+        - oe_cx_contribution_budget
+      label: Info
+      parent_name: ''
+      region: content
+      weight: 1
+      format_type: oe_whitelabel_helper_description_list_pattern
+      format_settings: {  }
+id: oe_organisation.oe_cx_project_stakeholder.default
+targetEntityType: oe_organisation
+bundle: oe_cx_project_stakeholder
+mode: default
+content:
+  name:
+    type: string
+    label: hidden
+    settings:
+      link_to_entity: false
+    third_party_settings: {  }
+    weight: 1
+    region: content
+  oe_acronym:
+    type: string
+    label: hidden
+    settings:
+      link_to_entity: false
+    third_party_settings: {  }
+    weight: 0
+    region: content
+  oe_address:
+    type: oe_whitelabel_helper_address_inline
+    label: hidden
+    settings:
+      delimiter: ', '
+    third_party_settings: {  }
+    weight: 2
+    region: content
+  oe_cx_contribution_budget:
+    type: number_decimal
+    label: hidden
+    settings:
+      thousand_separator: .
+      decimal_separator: ','
+      scale: 2
+      prefix_suffix: true
+    third_party_settings: {  }
+    weight: 4
+    region: content
+hidden:
+  created: true
+  langcode: true
+  oe_contact_url: true
+  oe_logo: true
+  oe_website: true
+  status: true
diff --git a/modules/oe_whitelabel_extra_project/oe_whitelabel_extra_project.info.yml b/modules/oe_whitelabel_extra_project/oe_whitelabel_extra_project.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0ec08ee9175241b1bd4026ca9c5c04b13e4ab8a9
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/oe_whitelabel_extra_project.info.yml
@@ -0,0 +1,15 @@
+name: OpenEuropa Whitelabel Content Extra Project
+type: module
+description: Module to override content extra project.
+package: OpenEuropa Whitelabel Theme
+core_version_requirement: ^9.2
+dependencies:
+  - oe_content_extra:oe_content_extra_project
+  - oe_whitelabel_helper:oe_whitelabel_helper
+
+config_devel:
+  install:
+    - core.date_format.oe_whitelabel_project_date
+    - core.entity_view_display.node.oe_project.full
+    - core.entity_view_display.node.oe_project.oe_w_content_banner
+    - core.entity_view_display.node.oe_project.teaser
diff --git a/modules/oe_whitelabel_extra_project/oe_whitelabel_extra_project.install b/modules/oe_whitelabel_extra_project/oe_whitelabel_extra_project.install
new file mode 100644
index 0000000000000000000000000000000000000000..782c6ebd1c84569d254b203811d49468767210b0
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/oe_whitelabel_extra_project.install
@@ -0,0 +1,30 @@
+<?php
+
+/**
+ * @file
+ * Install and update functions for the whitelabel Extra Project module.
+ */
+
+declare(strict_types = 1);
+
+use Drupal\oe_bootstrap_theme\ConfigImporter;
+
+/**
+ * Implements hook_install().
+ *
+ * Customise fields for content project.
+ */
+function oe_whitelabel_extra_project_install($is_syncing): void {
+  // If we are installing from config, we bail out.
+  if ($is_syncing) {
+    return;
+  }
+
+  $configs = [
+    'core.entity_form_display.node.oe_project.default',
+    'core.entity_form_display.oe_organisation.oe_cx_project_stakeholder.default',
+    'core.entity_view_display.oe_organisation.oe_cx_project_stakeholder.default',
+  ];
+
+  ConfigImporter::importMultiple('module', 'oe_whitelabel_extra_project', '/config/overrides/', $configs);
+}
diff --git a/modules/oe_whitelabel_extra_project/oe_whitelabel_extra_project.module b/modules/oe_whitelabel_extra_project/oe_whitelabel_extra_project.module
new file mode 100644
index 0000000000000000000000000000000000000000..2300a0dcd13f54458d92c55f718e023ed04082a0
--- /dev/null
+++ b/modules/oe_whitelabel_extra_project/oe_whitelabel_extra_project.module
@@ -0,0 +1,319 @@
+<?php
+
+/**
+ * @file
+ * OE Whitelabel theme extra project.
+ */
+
+declare(strict_types =  1);
+
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Cache\CacheableMetadata;
+use Drupal\Core\Datetime\DrupalDateTime;
+use Drupal\Core\Template\Attribute;
+use Drupal\media\MediaInterface;
+use Drupal\media\Plugin\media\Source\Image;
+use Drupal\media\Plugin\media\Source\OEmbed;
+use Drupal\oe_bootstrap_theme\ValueObject\ImageValueObject;
+
+/**
+ * Implements hook_preprocess_HOOK() for "pattern_description_list".
+ *
+ * Adds a bottom border for some instances of this pattern that are used in
+ * field groups on the project detail page.
+ */
+function oe_whitelabel_extra_project_preprocess_pattern_description_list(array &$variables): void {
+  /** @var \Drupal\ui_patterns\Element\PatternContext $context */
+  $context = $variables['context'];
+  if ($context->getType() === 'field_group') {
+    $id = $context->getProperty('entity_type')
+      . '.' . $context->getProperty('bundle')
+      . '.' . $context->getProperty('view_mode')
+      . '.' . $context->getProperty('group_name');
+    switch ($id) {
+      case 'oe_organisation.oe_cx_project_stakeholder.default.group_info':
+      case 'node.oe_project.full.group_project_details':
+      case 'node.oe_project.full.group_coordinators':
+      case 'node.oe_project.full.group_period':
+      case 'node.oe_project.full.group_budget':
+      case 'node.oe_project.full.group_website':
+        /** @var \Drupal\Core\Template\Attribute $attributes */
+        $attributes = $variables['attributes'];
+        $attributes->addClass('border-bottom');
+        $attributes->addClass('pb-3');
+        break;
+    }
+  }
+}
+
+/**
+ * Implements hook_preprocess_node() for the project full view mode.
+ */
+function oe_whitelabel_extra_project_preprocess_node__oe_project__full(array &$variables): void {
+  _oe_whitelabel_extra_project_preprocess_inpage_nav($variables);
+  _oe_whitelabel_extra_project_preprocess_status_and_progress($variables);
+  _oe_whitelabel_extra_project_preprocess_contributions($variables);
+}
+
+/**
+ * Implements hook_preprocess_node() for the project content banner view mode.
+ */
+function oe_whitelabel_extra_project_preprocess_node__oe_project__oe_w_content_banner(array &$variables): void {
+  _oe_whitelabel_extra_project_preprocess_featured_media($variables);
+}
+
+/**
+ * Implements hook_preprocess_node() for the project teaser.
+ */
+function oe_whitelabel_extra_project_preprocess_node__oe_project__teaser(array &$variables): void {
+  _oe_whitelabel_extra_project_preprocess_featured_media($variables);
+}
+
+/**
+ * Creates an image value object in $variables['image'].
+ *
+ * @param array $variables
+ *   Variables from hook_preprocess_node().
+ */
+function _oe_whitelabel_extra_project_preprocess_featured_media(array &$variables): void {
+  /** @var \Drupal\node\NodeInterface $node */
+  $node = $variables['node'];
+
+  // Bail out if there is no media present.
+  if ($node->get('oe_featured_media')->isEmpty()) {
+    return;
+  }
+
+  /** @var \Drupal\media\Entity\Media $media */
+  $media = $node->get('oe_featured_media')->entity;
+  if (!$media instanceof MediaInterface) {
+    // The media entity is not available anymore, bail out.
+    return;
+  }
+
+  // Retrieve the correct media translation.
+  /** @var \Drupal\media\Entity\Media $media */
+  $media = \Drupal::service('entity.repository')->getTranslationFromContext($media, $node->language()->getId());
+
+  // Caches are handled by the formatter usually. Since we are not rendering
+  // the original render arrays, we need to propagate our caches to the
+  // paragraph template.
+  $cacheability = CacheableMetadata::createFromRenderArray($variables);
+  $cacheability->addCacheableDependency($media);
+
+  // Run access checks on the media entity.
+  $access = $media->access('view', $variables['user'], TRUE);
+  $cacheability->addCacheableDependency($access);
+  if (!$access->isAllowed()) {
+    $cacheability->applyTo($variables);
+    return;
+  }
+
+  // Get the media source.
+  $source = $media->getSource();
+
+  if (!$source instanceof OEmbed && !$source instanceof Image) {
+    // Media is not a video or image, no thumbnail will be shown.
+    $cacheability->applyTo($variables);
+    return;
+  }
+
+  /** @var \Drupal\image\Plugin\Field\FieldType\ImageItem $thumbnail */
+  $thumbnail = $media->get('thumbnail')->first();
+  $variables['image'] = ImageValueObject::fromImageItem($thumbnail);
+
+  $cacheability->applyTo($variables);
+}
+
+/**
+ * Helper function to preprocess the inpage navigation pattern fields.
+ *
+ * @param array $variables
+ *   Variables from hook_preprocess_node().
+ */
+function _oe_whitelabel_extra_project_preprocess_inpage_nav(array &$variables): void {
+  /** @var \Drupal\node\NodeInterface $node */
+  $node = $variables['node'];
+  $variables['inpage_navigation_links'] = [];
+
+  $fields = [
+    'oe_summary',
+    'oe_cx_objective',
+    'oe_cx_impacts',
+    'oe_cx_lead_contributors',
+    'oe_project_participants',
+    'oe_cx_achievements_and_milestone',
+  ];
+  foreach ($variables['content'] as &$item) {
+    if (!array_key_exists('#field_name', $item)) {
+      continue;
+    }
+
+    if (!in_array($item['#field_name'], $fields)) {
+      continue;
+    }
+
+    $unique_id = Html::getUniqueId('oe-project--' . $item['#field_name']);
+    $item['#inpage_nav_id'] = $unique_id;
+    $variables['inpage_navigation_links'][] = [
+      'path' => '#' . $unique_id,
+      'label' => $node->{$item['#field_name']}->getFieldDefinition()->getLabel(),
+    ];
+  }
+}
+
+/**
+ * Adds variables for the project status.
+ *
+ * @param array $variables
+ *   Variables from hook_preprocess_node().
+ */
+function _oe_whitelabel_extra_project_preprocess_status_and_progress(array &$variables): void {
+  /** @var \Drupal\node\NodeInterface $node */
+  $node = $variables['node'];
+  /** @var \Drupal\datetime_range\Plugin\Field\FieldType\DateRangeItem|null $date_range_item */
+  $date_range_item = $node->get('oe_project_dates')->first();
+  if ($date_range_item === NULL || !$date_range_item->value || !$date_range_item->end_value) {
+    // One of the fields is empty.
+    return;
+  }
+
+  // Dates only store the date, not the time.
+  // Use the site-wide configured timezone, not a user-specific timezone.
+  /* @see \Drupal\system\TimeZoneResolver::getTimeZone() */
+  $system_date_config = \Drupal::config('system.date');
+  /** @var string $timezone */
+  $timezone = $system_date_config->get('timezone.default') ?? 'UTC';
+
+  // Invalidate cache when site timezone is changed.
+  CacheableMetadata::createFromRenderArray($variables)
+    ->addCacheableDependency($system_date_config)
+    ->applyTo($variables);
+
+  $get_timestamp = static function (string $date_string) use ($timezone): int {
+    return (new DrupalDateTime($date_string, $timezone))->getTimestamp();
+  };
+  // Project starts at the beginning of the first day at 00:00.
+  $t_start = $get_timestamp($date_range_item->value);
+  // Project ends at the end of the last day at 24:00.
+  $t_end = $get_timestamp($date_range_item->end_value . ' +1 day');
+
+  if ($t_start >= $t_end) {
+    // Invalid date range. No progress or status can be shown.
+    return;
+  }
+
+  // Use the formatted field values for start / end date.
+  $element = $variables['elements']['oe_project_dates'][0] ?? [];
+  if ($element['#theme'] ?? NULL === 'time') {
+    // Project lasts a single day.
+    $start_date_element = $element;
+    $end_date_element = $element;
+  }
+  elseif (isset($element['start_date']['#theme'], $element['end_date']['#theme'])) {
+    // Project lasts multiple days.
+    $start_date_element = $element['start_date'];
+    $end_date_element = $element['end_date'];
+  }
+  else {
+    // Empty or incomplete date range. No progress or status can be shown.
+    return;
+  }
+
+  $status_labels = [t('Planned'), t('Ongoing'), t('Closed')];
+
+  // Values for the 'bcl-project-status' component.
+  // Some values contain placeholders that will be updated with javascript.
+  // This makes sure that tests will fail if js does not run.
+  $variables['project_status_args'] = [
+    // Placeholder value.
+    'status' => 'planned',
+    'start_date' => $start_date_element,
+    'start_label' => t('Start'),
+    'end_date' => $end_date_element,
+    'end_label' => t('End'),
+    'label' => t('Status'),
+    // Placeholder value.
+    'badge' => '&ellipsis;',
+    // Placeholder value, identical to 'planned'.
+    'progress' => 0,
+    'attributes' => new Attribute([
+      'data-start-timestamp' => $t_start,
+      'data-end-timestamp' => $t_end,
+      'data-status-labels' => implode('|', $status_labels),
+      // Hide for non-js users, to avoid showing wrong/outdated information.
+      'class' => ['d-none'],
+    ]),
+  ];
+}
+
+/**
+ * Adds variables for the project contributions chart.
+ *
+ * @param array $variables
+ *   Variables from hook_preprocess_node().
+ */
+function _oe_whitelabel_extra_project_preprocess_contributions(array &$variables): void {
+  $field_bg_classes = [
+    'oe_project_budget' => 'bg-gray-400',
+    'oe_project_budget_eu' => 'bg-primary',
+  ];
+  $legend_items = [];
+  foreach ($field_bg_classes as $field_name => $bg_class) {
+    $field_element = $variables['elements'][$field_name] ?? NULL;
+    if (!isset($field_element[0])) {
+      continue;
+    }
+    $legend_items[] = [
+      'term' => [
+        [
+          'label' => $field_element['#title'],
+          'color' => $bg_class,
+        ],
+      ],
+      'definition' => [
+        [
+          // Render only the field value, without field wrappers.
+          'label' => $field_element[0],
+        ],
+      ],
+    ];
+  }
+
+  if (!$legend_items) {
+    return;
+  }
+
+  $variables['contributions_args'] = [
+    'corporate_contributions' => NULL,
+    'chart' => FALSE,
+    'legend' => [
+      'variant' => 'horizontal',
+      'items' => $legend_items,
+    ],
+  ];
+
+  /** @var \Drupal\node\NodeInterface $node */
+  $node = $variables['node'];
+  $overall_budget = $node->get('oe_project_budget')->value;
+  $eu_budget = $node->get('oe_project_budget_eu')->value;
+
+  if ($overall_budget === NULL
+    || $eu_budget === NULL
+    || $overall_budget <= 0
+    || $eu_budget < 0
+    || $overall_budget < $eu_budget
+  ) {
+    // No pie chart can be drawn with these values.
+    return;
+  }
+
+  // The ratio will be in the range of 0..1, thanks to the checks above.
+  $ratio_01 = $eu_budget / $overall_budget;
+  // Convert to percent.
+  // The pie chart only supports multiples of 10%.
+  $percent = (int) round($ratio_01 * 10) * 10;
+
+  $variables['contributions_args']['corporate_contributions'] = $percent;
+  $variables['contributions_args']['chart'] = TRUE;
+}
diff --git a/modules/oe_whitelabel_helper/config/schema/oe_whitelabel_helper.schema.yml b/modules/oe_whitelabel_helper/config/schema/oe_whitelabel_helper.schema.yml
index df4af465acd511f0073361be579047d19cb767d9..2f171640dc7aa4fc5a609b8ef57abdcf1b7fd092 100644
--- a/modules/oe_whitelabel_helper/config/schema/oe_whitelabel_helper.schema.yml
+++ b/modules/oe_whitelabel_helper/config/schema/oe_whitelabel_helper.schema.yml
@@ -10,3 +10,16 @@ condition.plugin.oe_whitelabel_helper_current_component_library:
   mapping:
     component_library:
       type: string
+field_group.field_group_formatter_plugin.oe_whitelabel_helper_pattern_base:
+  type: field_group.field_group_formatter_plugin.base
+  label: 'Mapping for the base pattern formatter settings'
+  mapping:
+    label:
+      type: label
+      label: 'Field group label'
+    variant:
+      type: string
+      label: 'Pattern variant'
+field_group.field_group_formatter_plugin.oe_whitelabel_helper_description_list_pattern:
+  type: field_group.field_group_formatter_plugin.oe_whitelabel_helper_pattern_base
+  label: 'Mapping for the description list pattern formatter settings'
diff --git a/modules/oe_whitelabel_helper/src/Plugin/field_group/FieldGroupFormatter/DescriptionListPattern.php b/modules/oe_whitelabel_helper/src/Plugin/field_group/FieldGroupFormatter/DescriptionListPattern.php
new file mode 100644
index 0000000000000000000000000000000000000000..85c9194676624f1d229411a588889ab21250d7f1
--- /dev/null
+++ b/modules/oe_whitelabel_helper/src/Plugin/field_group/FieldGroupFormatter/DescriptionListPattern.php
@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\oe_whitelabel_helper\Plugin\field_group\FieldGroupFormatter;
+
+use Drupal\Core\Entity\EntityTypeManagerInterface;
+use Drupal\Core\Render\Element;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\ui_patterns\UiPatternsManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Format a field group using the description list pattern.
+ *
+ * @FieldGroupFormatter(
+ *   id = "oe_whitelabel_helper_description_list_pattern",
+ *   label = @Translation("Description list pattern"),
+ *   description = @Translation("Format a field group using the description list pattern."),
+ *   supported_contexts = {
+ *     "view"
+ *   }
+ * )
+ */
+class DescriptionListPattern extends PatternFormatterBase {
+  /**
+   * The entity type manager.
+   *
+   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
+   */
+  protected $entityTypeManager;
+
+  /**
+   * The renderer service.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * Constructs a DescriptionList object.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin ID for the plugin instance.
+   * @param array $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
+   *   The entity type manager.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   * @param \Drupal\ui_patterns\UiPatternsManager $patterns_manager
+   *   The pattern manager.
+   */
+  public function __construct(array $configuration, string $plugin_id, array $plugin_definition, EntityTypeManagerInterface $entity_type_manager, RendererInterface $renderer, UiPatternsManager $patterns_manager) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition, $patterns_manager);
+    $this->entityTypeManager = $entity_type_manager;
+    $this->renderer = $renderer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('entity_type.manager'),
+      $container->get('renderer'),
+      $container->get('plugin.manager.ui_patterns')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPatternId(): string {
+    return 'description_list';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function preRender(&$element, $rendering_object) {
+    parent::preRender($element, $rendering_object);
+    // Only support horizontal mode in this field group formatter.
+    $element['pattern']['#settings']['orientation'] = 'horizontal';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getFields(array &$element, $rendering_object): ?array {
+    $fields = [];
+
+    foreach (Element::children($element) as $field_name) {
+      $field_element = $element[$field_name];
+      $field_element['#label_display'] = 'hidden';
+      $field_markup = $this->renderer->render($field_element);
+      if (trim((string) $field_markup) === '') {
+        continue;
+      }
+      // Assign field label and content to the pattern's fields.
+      $fields['items'][] = [
+        'term' => $field_element['#title'] ?? '',
+        'definition' => $field_markup,
+      ];
+    }
+
+    if (empty($fields['items'])) {
+      return NULL;
+    }
+
+    return $fields;
+  }
+
+}
diff --git a/modules/oe_whitelabel_helper/src/Plugin/field_group/FieldGroupFormatter/PatternFormatterBase.php b/modules/oe_whitelabel_helper/src/Plugin/field_group/FieldGroupFormatter/PatternFormatterBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..da15223c754f0c61a8818ddc940f29952c3b17a0
--- /dev/null
+++ b/modules/oe_whitelabel_helper/src/Plugin/field_group/FieldGroupFormatter/PatternFormatterBase.php
@@ -0,0 +1,172 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\oe_whitelabel_helper\Plugin\field_group\FieldGroupFormatter;
+
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Render\Element;
+use Drupal\field_group\FieldGroupFormatterBase;
+use Drupal\ui_patterns\UiPatternsManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Base class for field group formatters that use a pattern for rendering.
+ *
+ * Field group formatters extending this class generate a pattern context that
+ * is compatible with the one generated by the ui_patterns_field_group module.
+ *
+ * If you need to override pattern templates based on node, bundle or view mode
+ * just enable the ui_patterns_field_group module.
+ *
+ * @see https://ui-patterns.readthedocs.io/en/8.x-1.x/content/developer-documentation.html#working-with-pattern-suggestions
+ */
+abstract class PatternFormatterBase extends FieldGroupFormatterBase implements ContainerFactoryPluginInterface {
+
+  /**
+   * UI Patterns manager.
+   *
+   * @var \Drupal\ui_patterns\UiPatternsManager
+   */
+  protected $patternsManager;
+
+  /**
+   * PatternFormatterBase constructor.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin_id for the plugin instance.
+   * @param array $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\ui_patterns\UiPatternsManager $patterns_manager
+   *   UI Patterns manager.
+   */
+  public function __construct(array $configuration, string $plugin_id, array $plugin_definition, UiPatternsManager $patterns_manager) {
+    parent::__construct($plugin_id, $plugin_definition, $configuration['group'], $configuration['settings'], $configuration['label']);
+    $this->configuration = $configuration;
+    $this->patternsManager = $patterns_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('plugin.manager.ui_patterns')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function defaultSettings() {
+    return [
+      'label' => '',
+      'variant' => '',
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function settingsForm() {
+    $pattern = $this->patternsManager->getDefinition($this->getPatternId());
+
+    $form['label'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Field group label'),
+      '#default_value' => $this->label,
+    ];
+
+    if ($pattern->hasVariants()) {
+      $form['variant'] = [
+        '#title' => $this->t('Variant'),
+        '#type' => 'select',
+        '#options' => $pattern->getVariantsAsOptions(),
+        '#default_value' => $this->getSetting('variant'),
+      ];
+    }
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function settingsSummary() {
+    $summary = [];
+
+    if ($this->getSetting('label')) {
+      $summary[] = $this->t('Label: @label', ['@label' => $this->getSetting('label')]);
+    }
+
+    if ($this->getSetting('variant')) {
+      $summary[] = $this->t('Variant: @variant', ['@variant' => $this->getSetting('variant')]);
+    }
+
+    return $summary;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function preRender(&$element, $rendering_object) {
+    parent::preRender($element, $rendering_object);
+
+    $fields = $this->getFields($element, $rendering_object);
+    if ($fields === NULL) {
+      // Don't render the pattern.
+      return;
+    }
+    // Instantiate the pattern render array.
+    $pattern = [
+      '#type' => 'pattern',
+      '#id' => $this->getPatternId(),
+      '#variant' => $this->getSetting('variant'),
+      '#fields' => $fields,
+      '#context' => [
+        'type' => 'field_group',
+        'group_name' => $element['#group_name'],
+        'entity_type' => $element['#entity_type'],
+        'bundle' => $element['#bundle'],
+        'view_mode' => $this->group->mode,
+      ],
+    ];
+
+    // Remove all renderable elements, while keeping render metadata as that can
+    // be used to further manipulate the render array.
+    foreach (Element::children($element) as $key) {
+      unset($element[$key]);
+    }
+    $element += [
+      'pattern' => $pattern,
+    ];
+  }
+
+  /**
+   * Return pattern ID for the current formatter plugin.
+   *
+   * @return string
+   *   Pattern ID.
+   */
+  abstract protected function getPatternId(): string;
+
+  /**
+   * Return list of fields for the current pattern.
+   *
+   * @param array $element
+   *   Field group render element.
+   * @param object $rendering_object
+   *   Field group rendering object.
+   *
+   * @return array|null
+   *   Pattern fields to be rendered, or NULL if the field group
+   *   should not be displayed at all.
+   */
+  abstract protected function getFields(array &$element, $rendering_object): ?array;
+
+}
diff --git a/modules/oe_whitelabel_helper/tests/src/Functional/Plugin/field_group/PatternFormatterTest.php b/modules/oe_whitelabel_helper/tests/src/Functional/Plugin/field_group/PatternFormatterTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d00675ce3230351cdf58906cba8df38fc03b38ba
--- /dev/null
+++ b/modules/oe_whitelabel_helper/tests/src/Functional/Plugin/field_group/PatternFormatterTest.php
@@ -0,0 +1,119 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel_helper\Functional\Plugin\field_group;
+
+use Drupal\field\Entity\FieldConfig;
+use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\field_group\Functional\FieldGroupTestTrait;
+
+/**
+ * Test the pattern field group formatter.
+ */
+class PatternFormatterTest extends BrowserTestBase {
+
+  use FieldGroupTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'node',
+    'text',
+    'field_group',
+    'oe_whitelabel_helper',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'oe_whitelabel';
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setUp(): void {
+    parent::setUp();
+
+    // Create content type.
+    $this->drupalCreateContentType([
+      'name' => 'Test',
+      'type' => 'test',
+    ]);
+
+    /** @var \Drupal\Core\Entity\Display\EntityViewDisplayInterface $display */
+    $display = \Drupal::entityTypeManager()
+      ->getStorage('entity_view_display')
+      ->load('node.test.default');
+
+    // Create test fields.
+    $fields = [
+      'field_test_1' => 'Field 1',
+      'field_test_2' => 'Field 2',
+    ];
+    foreach ($fields as $field_name => $field_label) {
+      $field_storage = FieldStorageConfig::create([
+        'field_name' => $field_name,
+        'entity_type' => 'node',
+        'type' => 'text',
+      ]);
+      $field_storage->save();
+
+      $instance = FieldConfig::create([
+        'field_storage' => $field_storage,
+        'bundle' => 'test',
+        'label' => $field_label,
+      ]);
+      $instance->save();
+
+      // Set the field visible on the display object.
+      $display->setComponent($field_name, [
+        'label' => 'above',
+        'type' => 'text_default',
+      ]);
+    }
+
+    // Save display + create node.
+    $display->save();
+  }
+
+  /**
+   * Test description list pattern formatter.
+   */
+  public function testDescriptionListPatternFormatter() {
+    $assert_session = $this->assertSession();
+
+    $data = [
+      'weight' => '1',
+      'children' => [
+        0 => 'field_test_1',
+        1 => 'field_test_2',
+      ],
+      'label' => 'Test label',
+      'format_type' => 'oe_whitelabel_helper_description_list_pattern',
+    ];
+    $group = $this->createGroup('node', 'test', 'view', 'default', $data);
+    field_group_group_save($group);
+
+    $this->drupalCreateNode([
+      'type' => 'test',
+      'field_test_1' => [
+        ['value' => 'Content test 1'],
+      ],
+      'field_test_2' => [
+        ['value' => 'Content test 2'],
+      ],
+    ]);
+
+    // Assert that fields are rendered using the field list horizontal pattern.
+    $this->drupalGet('node/1');
+    $assert_session->elementsCount('css', 'dl', 1);
+    $assert_session->elementTextContains('css', 'dl div dt', 'Field 1');
+    $assert_session->elementTextContains('css', 'dl dd:nth-child(2)', 'Content test 1');
+    $assert_session->elementTextContains('css', 'dl div:nth-child(3) dt', 'Field 2');
+    $assert_session->elementTextContains('css', 'dl dd:nth-child(4)', 'Content test 2');
+  }
+
+}
diff --git a/oe_whitelabel.libraries.yml b/oe_whitelabel.libraries.yml
index 3243b9da6a25cdb4be55fbd11dc74a34b2f24094..1a029ba94e6e8adcef99505402f3181bf7eef4ee 100644
--- a/oe_whitelabel.libraries.yml
+++ b/oe_whitelabel.libraries.yml
@@ -3,3 +3,10 @@ style:
   css:
     theme:
       assets/css/oe_whitelabel.style.min.css: {}
+
+project_status:
+  version: VERSION
+  js:
+    resources/js/oe_whitelabel.project_status.js: {}
+  dependencies:
+    - core/jquery.once
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index f5e726173dff22a539b59673b05767fa8a6a974b..efe4305824175852543ad606200c8e8b4d7b504a 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -7,6 +7,7 @@
     <env name="SIMPLETEST_BASE_URL" value="${drupal.base_url}"/>
     <env name="SIMPLETEST_SPARQL_DB" value="sparql://${drupal.sparql.host}:${drupal.sparql.port}/?module=sparql_entity_storage"/>
     <env name="SIMPLETEST_DB" value="mysql://${drupal.database.user}:${drupal.database.password}@${drupal.database.host}:${drupal.database.port}/${drupal.database.name}"/>
+    <env name="MINK_DRIVER_ARGS_WEBDRIVER" value='["${selenium.browser}", null, "${selenium.host}:${selenium.port}/wd/hub"]'/>
   </php>
   <testsuites>
     <testsuite name="OpenEuropa Whitelabel Theme">
diff --git a/resources/js/oe_whitelabel.project_status.js b/resources/js/oe_whitelabel.project_status.js
new file mode 100644
index 0000000000000000000000000000000000000000..22b701e2f2be9279a4367503a925aae5b9923f89
--- /dev/null
+++ b/resources/js/oe_whitelabel.project_status.js
@@ -0,0 +1,61 @@
+/**
+ * @file
+ * Attaches behaviors for the project status element.
+ */
+(function (bootstrap, Drupal, $) {
+
+  const colorClasses = [
+    'bg-secondary',
+    'bg-info',
+    'bg-dark',
+  ];
+
+  /**
+   * Animates the project status badge and progress bar.
+   *
+   * @type {Drupal~behavior}
+   *
+   * @prop {Drupal~behaviorAttach} attach
+   *   Initialises the behavior.
+   */
+  Drupal.behaviors.projectStatus = {
+    attach: function (context) {
+      $('.bcl-project-status', context).once('bcl-project-status').each(function () {
+        const $element = $(this);
+        const msBegin = $element.data('start-timestamp') * 1000;
+        const msEnd = $element.data('end-timestamp') * 1000;
+        const statusLabels = $element.data('status-labels').split('|');
+        const msNow = Date.now();
+        // Calculate a status id: planned = 0, ongoing = 1, closed = 2.
+        const status = (msNow >= msBegin) + (msNow > msEnd);
+        // Calculate a progress: planned = 0, ongoing = 0..1, closed = 1.
+        const progress01 = Math.max(0, Math.min(1, (msNow - msBegin) / (msEnd - msBegin)));
+        // Convert to percent: planned = 0%, ongoing = 0%..100%, closed = 100%.
+        // Round to 1%, to avoid overwhelming float digits in aria attributes.
+        const percent = Math.round(progress01 * 100);
+
+        // Process the status label.
+        $('.badge', this).each(function () {
+          const $element = $(this);
+          $element.removeClass(colorClasses);
+          $element.addClass(colorClasses[status]);
+          $element.html(statusLabels[status]);
+        });
+
+        // Process the progress bar.
+        $('.progress-bar', this).each(function () {
+          const $element = $(this);
+          $element.removeClass(colorClasses);
+          $element.addClass(colorClasses[status]);
+          $element.css('width', percent + '%');
+          $element.attr('aria-valuenow', percent);
+          $element.attr('aria-label', percent);
+        });
+
+        // Reveal the entire section.
+        $element.removeClass('d-none');
+      });
+    }
+  };
+
+})(bootstrap, Drupal, jQuery);
diff --git a/runner.yml.dist b/runner.yml.dist
index 8d2203ba9a5587c57da232ccfe5818ddfbb03e95..4727a705c3cccce9957a21b404b4e2dabbd2f07a 100644
--- a/runner.yml.dist
+++ b/runner.yml.dist
@@ -20,6 +20,7 @@ drupal:
     - "./vendor/bin/drush en oe_authentication -y"
     - "./vendor/bin/drush en oe_whitelabel_multilingual -y"
     - "./vendor/bin/drush en oe_whitelabel_contact_forms -y"
+    - "./vendor/bin/drush en oe_whitelabel_extra_project -y"
     - "./vendor/bin/drush en oe_whitelabel_helper -y"
     - "./vendor/bin/drush en oe_whitelabel_search -y"
     - "./vendor/bin/drush en oe_whitelabel_starter_news -y"
@@ -49,6 +50,12 @@ drupal:
           port: ${drupal.sparql.port}
           namespace: 'Drupal\sparql_entity_storage\Driver\Database\sparql'
           driver: 'sparql'
+
+selenium:
+  host: "http://selenium"
+  port: "4444"
+  browser: "chrome"
+
 commands:
   drupal:site-setup:
     - { task: "run", command: "drupal:symlink-project" }
@@ -57,6 +64,8 @@ commands:
     - { task: "run", command: "setup:phpunit" }
   setup:phpunit:
     - { task: "process", source: "phpunit.xml.dist", destination: "phpunit.xml" }
+    # Generate settings.testing.php, it will be used when running functional tests.
+    - { task: "process-php", type: "write", config: "drupal.settings", source: "${drupal.root}/sites/default/default.settings.php", destination: "${drupal.root}/sites/default/settings.testing.php", override: true }
 
 release:
   tasks:
diff --git a/templates/content/field--oe-organisation--oe-acronym--oe-cx-project-stakeholder.html.twig b/templates/content/field--oe-organisation--oe-acronym--oe-cx-project-stakeholder.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..5e623ee2abf9cd12100233d77162abafc3da11c7
--- /dev/null
+++ b/templates/content/field--oe-organisation--oe-acronym--oe-cx-project-stakeholder.html.twig
@@ -0,0 +1,11 @@
+{#
+/**
+ * @file
+ * Theme override for the Acronym field template.
+ *
+ * @see ./core/themes/stable/templates/field/field.html.twig
+ */
+#}
+<h4 class="fw-bold mb-3">
+  {{ element }}
+</h4>
diff --git a/templates/content/node--oe-project--full.html.twig b/templates/content/node--oe-project--full.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..5a9999f9a40e511f6e40dc8dd6e7f71362bb2e34
--- /dev/null
+++ b/templates/content/node--oe-project--full.html.twig
@@ -0,0 +1,71 @@
+{#
+/**
+ * @file
+ * Project full display.
+ */
+#}
+{% apply spaceless %}
+
+{% macro header(title, id) %}
+  {% set attributes = id ? create_attribute({'id': id}) %}
+  <h3 class="fw-bold mb-4 mt-4-5"{{ attributes }}>{{ title }}</h3>
+{% endmacro %}
+
+{% macro field_with_header(field) %}
+  {% set _content %}{{ field|field_value }}{% endset %}
+  {% if _content|trim %}
+    {{ _self.header(field|field_label, field['#inpage_nav_id'] ?? null) }}
+    {{ _content }}
+  {% endif %}
+{% endmacro %}
+
+{% set _project_details %}
+  {% if project_status_args is not empty %}
+    {{ attach_library('oe_whitelabel/project_status') }}
+    {% include '@oe-bcl/project-status' with project_status_args only %}
+  {% endif %}
+  {% if contributions_args is defined %}
+    {% include '@oe-bcl/project-status/project-contributions' with contributions_args only %}
+  {% endif %}
+  {{ content.group_project_details|filter((subgroup, key) => key|first != '#') }}
+{% endset %}
+
+{% if _project_details|trim is not empty %}
+  {% set _project_details_title = 'Project details'|t %}
+  {% set _project_details %}
+    {{ _self.header(_project_details_title, 'project-details') }}
+    {{ _project_details }}
+  {% endset %}
+  {% set inpage_navigation_links = [{
+    'path': '#project-details',
+    'label': _project_details_title,
+  }]|merge(inpage_navigation_links) %}
+{% endif %}
+
+{% set inpage_navigation_content %}
+  {#
+    Reliably absorb top margin of first element.
+    @todo Add a utility class 'eat-margin-top' with :before style.
+  #}
+  <div class="h-0 invisible mt-n5 mb-5"></div>
+  {% block inpage_navigation_content %}
+    {{ _project_details }}
+    {{ _self.field_with_header(content.oe_summary) }}
+    {{ _self.field_with_header(content.oe_cx_objective) }}
+    {{ _self.field_with_header(content.oe_cx_impacts) }}
+    {{ _self.field_with_header(content.oe_cx_lead_contributors) }}
+    {{ _self.field_with_header(content.oe_project_participants) }}
+    {{ _self.field_with_header(content.oe_cx_achievements_and_milestone) }}
+  {% endblock %}
+{% endset %}
+
+<article{{ attributes }}>
+  {{ pattern('inpage_navigation', {
+    'title': 'Page content'|t,
+    'links': inpage_navigation_links,
+    'content': inpage_navigation_content,
+    'full_layout': true,
+  }) }}
+</article>
+
+{% endapply %}
diff --git a/templates/content/node--oe-project--oe-w-content-banner.html.twig b/templates/content/node--oe-project--oe-w-content-banner.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..f5c8aeb9ed6f268f0b7bdbeb66df0edf835a8165
--- /dev/null
+++ b/templates/content/node--oe-project--oe-w-content-banner.html.twig
@@ -0,0 +1,20 @@
+{#
+/**
+ * @file
+ * Project content banner display.
+ */
+#}
+{{ pattern('content_banner', {
+  'background': 'gray',
+  'title': label,
+  'content': content.oe_teaser,
+  'image': image,
+  'attributes': create_attribute().addClass(['ps-0']),
+  'badges': content.oe_subject
+    |field_value|default([])
+    |filter(_item => _item|drupal_escape|striptags|trim is not empty)
+    |map(_item => {
+      'label': _item,
+      'rounded_pill': true,
+    }),
+}) }}
diff --git a/templates/content/node--oe-project--teaser.html.twig b/templates/content/node--oe-project--teaser.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..dc13a4979593bb2f2388e0a1bc72c20da027fc8d
--- /dev/null
+++ b/templates/content/node--oe-project--teaser.html.twig
@@ -0,0 +1,35 @@
+{#
+/**
+ * @file
+ * Template for Teaser view display of Project content type.
+ */
+#}
+{% set _title %}
+  <a class="standalone" href="{{ url }}">{{ label }}</a>
+{% endset %}
+{% set _badges = [] %}
+{% set _meta = [] %}
+{% for _item in content.oe_subject|field_value %}
+  {% set _badges = _badges|merge([{
+    label: _item,
+  }]) %}
+{% endfor %}
+{% if content.oe_project_dates|field_value is not empty %}
+  {% set _meta = _meta|merge([
+    content.oe_project_dates|field_value,
+  ]) %}
+{% endif %}
+{% block content %}
+  <article{{attributes}}>
+    {{ pattern('card', {
+      variant: 'search',
+      title: _title,
+      text: content.oe_teaser|field_value,
+      image: (image is not empty) ? image|merge({
+        path: image.src,
+      }) : {},
+      meta: _meta,
+      badges: _badges,
+    }) }}
+  </article>
+{% endblock %}
diff --git a/tests/fixtures/example_1.jpeg b/tests/fixtures/example_1.jpeg
new file mode 100644
index 0000000000000000000000000000000000000000..e04cde60ca8fa8d23a3668010a40f976abb78e8b
Binary files /dev/null and b/tests/fixtures/example_1.jpeg differ
diff --git a/tests/src/FunctionalJavascript/ContentProjectRenderTest.php b/tests/src/FunctionalJavascript/ContentProjectRenderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7e429f8d43ceb564f43959264bb201a11fdc5620
--- /dev/null
+++ b/tests/src/FunctionalJavascript/ContentProjectRenderTest.php
@@ -0,0 +1,418 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel\FunctionalJavascript;
+
+use Drupal\Core\Datetime\DrupalDateTime;
+use Drupal\Core\Entity\EntityStorageInterface;
+use Drupal\file\Entity\File;
+use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+use Drupal\media\Entity\Media;
+use Drupal\node\NodeInterface;
+use Drupal\oe_content_entity\Entity\CorporateEntityInterface;
+use Drupal\oe_content_entity_organisation\Entity\OrganisationInterface;
+use Drupal\Tests\oe_whitelabel\PatternAssertions\ContentBannerAssert;
+use Drupal\Tests\oe_whitelabel\PatternAssertions\DescriptionListAssert;
+use Drupal\Tests\oe_whitelabel\PatternAssertions\InPageNavigationAssert;
+use Drupal\Tests\sparql_entity_storage\Traits\SparqlConnectionTrait;
+use Drupal\Tests\TestFileCreationTrait;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+
+/**
+ * Tests that our Project content type renders correctly.
+ */
+class ContentProjectRenderTest extends WebDriverTestBase {
+
+  use SparqlConnectionTrait;
+  use TestFileCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'oe_whitelabel';
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'block',
+    'oe_whitelabel_extra_project',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->setUpSparql();
+
+    Role::load(RoleInterface::ANONYMOUS_ID)
+      ->grantPermission('view published skos concept entities')
+      ->grantPermission('view media')
+      ->grantPermission('view published oe_organisation')
+      ->save();
+
+    $this->config('system.date')
+      ->set('timezone.default', 'Europe/Brussels')
+      ->save();
+  }
+
+  /**
+   * Tests that the Project page renders correctly.
+   */
+  public function testProjectRendering(): void {
+    $assert_session = $this->assertSession();
+
+    // Create a media entity.
+    // Create file and media.
+    $file = File::create([
+      'uri' => $this->getTestFiles('image')[0]->uri,
+    ]);
+    $file->save();
+    $media = Media::create([
+      'bundle' => 'image',
+      'name' => 'Image test',
+      'oe_media_image' => [
+        [
+          'target_id' => $file->id(),
+          'alt' => 'Image test alt',
+          'title' => 'Image test title',
+        ],
+      ],
+    ]);
+    $media->save();
+    // Create organisations for Coordinators and Participants fields.
+    // Unpublished entity should not be shown.
+    $coordinator_organisation = $this->createStakeholderOrganisationEntity('coordinator', CorporateEntityInterface::PUBLISHED, 'oe_stakeholder');
+    $participant_organisation = $this->createStakeholderOrganisationEntity('participant', CorporateEntityInterface::PUBLISHED, 'oe_cx_project_stakeholder');
+
+    // Create a Project node.
+    /** @var \Drupal\node\Entity\Node $node */
+    $node = $this->getStorage('node')->create([
+      'type' => 'oe_project',
+      'title' => 'Test project node',
+      'oe_teaser' => 'Test project node',
+      'oe_summary' => 'Summary',
+      'oe_featured_media' => [
+        'target_id' => (int) $media->id(),
+        'caption' => 'Caption project_featured_media',
+      ],
+      'oe_project_dates' => [
+        'value' => '2020-05-10',
+        'end_value' => '2025-05-15',
+      ],
+      'oe_project_budget' => '200',
+      'oe_project_budget_eu' => '70',
+      'oe_project_website' => [
+        [
+          'uri' => 'http://example.com',
+          'title' => 'Example website',
+        ],
+      ],
+      'oe_reference_code' => 'Project reference',
+      'oe_subject' => 'http://data.europa.eu/uxp/1386',
+      'oe_project_funding_programme' => 'http://publications.europa.eu/resource/authority/eu-programme/AFIS2020',
+      'oe_project_coordinators' => [$coordinator_organisation],
+      'oe_project_participants' => [$participant_organisation],
+      'oe_cx_objective' => 'Objective',
+      'oe_cx_impacts' => 'Impacts',
+      'oe_cx_achievements_and_milestone' => 'Achievements and milestone',
+      'uid' => 0,
+      'status' => 1,
+    ]);
+    $node->save();
+    $this->drupalGet($node->toUrl());
+
+    // Assert content banner.
+    $content_banner = $assert_session->elementExists('css', '.bcl-content-banner');
+    $assert = new ContentBannerAssert();
+    $assert->assertPattern([
+      'image' => [
+        'alt' => 'Image test alt',
+        'src' => 'image-test.png',
+      ],
+      'badges' => ['wood industry'],
+      'title' => 'Test project node',
+      'description' => 'Test project node',
+    ], $content_banner->getOuterHtml());
+
+    // Assert in-page navigation.
+    $inpage_nav = $this->assertSession()->elementExists('css', 'nav.bcl-inpage-navigation');
+    $inpage_nav_assert = new InPageNavigationAssert();
+    $inpage_nav_assert->assertPattern([
+      'title' => 'Page content',
+      'links' => [
+        [
+          'label' => 'Project details',
+          'href' => '#project-details',
+        ],
+        [
+          'label' => 'Summary',
+          'href' => '#oe-project-oe-summary',
+        ],
+        [
+          'label' => 'Objective',
+          'href' => '#oe-project-oe-cx-objective',
+        ],
+        [
+          'label' => 'Impacts',
+          'href' => '#oe-project-oe-cx-impacts',
+        ],
+        [
+          'label' => 'Participants',
+          'href' => '#oe-project-oe-project-participants',
+        ],
+        [
+          'label' => 'Achievements and milestones',
+          'href' => '#oe-project-oe-cx-achievements-and-milestone',
+        ],
+      ],
+    ], $inpage_nav->getOuterHtml());
+
+    // Select the content column next to the in-page navigation.
+    $project_content = $assert_session->elementExists('css', '.col-md-9');
+
+    $this->assertProjectStatusTimestampsAsDateStrings('2020-05-10 00:00:00', '2025-05-16 00:00:00');
+    $this->assertProjectStatusVisible();
+
+    $contributions_chart = $assert_session->elementExists('css', '.bcl-project-contributions .circular-progress');
+    // The correct value would be 35, but it is rounded up to 40, because no
+    // utility classes are available for smaller increments.
+    $this->assertSame('40', $contributions_chart->getAttribute('data-percentage'));
+
+    // Select the description blocks inside the Project details.
+    $description_lists = $project_content->findAll('css', '.grid-3-9');
+    $this->assertCount(4, $description_lists);
+
+    $description_list_assert = new DescriptionListAssert();
+
+    // Assert budget list group.
+    $description_list_assert->assertPattern([
+      'items' => [
+        [
+          'term' => 'Overall budget',
+          'definition' => '€200,00',
+        ],
+        [
+          'term' => 'EU contribution',
+          'definition' => '€70,00',
+        ],
+      ],
+    ], $description_lists[0]->getHtml());
+
+    // Assert details list group.
+    $description_list_assert->assertPattern([
+      'items' => [
+        [
+          'term' => 'Website',
+          'definition' => 'Example website',
+        ],
+        [
+          'term' => 'Funding programme',
+          'definition' => 'Anti Fraud Information System (AFIS)',
+        ],
+        [
+          'term' => 'Reference',
+          'definition' => 'Project reference',
+        ],
+      ],
+    ], $description_lists[1]->getHtml());
+
+    // Assert coordinators list group.
+    $description_list_assert->assertPattern([
+      'items' => [
+        [
+          'term' => 'Coordinators',
+          'definition' => 'coordinator',
+        ],
+      ],
+    ], $description_lists[2]->getHtml());
+
+    // Assert participants list group.
+    $description_list_assert->assertPattern([
+      'items' => [
+        [
+          'term' => 'Name',
+          'definition' => 'participant',
+        ],
+        [
+          'term' => 'Address',
+          'definition' => 'Belgium',
+        ],
+        [
+          'term' => 'Contribution to the budget',
+          'definition' => '€22,30',
+        ],
+      ],
+    ], $description_lists[3]->getHtml());
+
+    // Set a project period that is fully in the past.
+    $this->setProjectDateRange($node, '2019-03-07', '2019-03-21');
+    $node->save();
+    $this->drupalGet($node->toUrl());
+
+    $this->assertProjectStatusTimestampsAsDateStrings('2019-03-07 00:00:00', '2019-03-22 00:00:00');
+    $this->assertProjectStatusVisible();
+    $this->assertProjectStatus('bg-dark', 'Closed');
+    $this->assertProjectProgress(100);
+
+    // Set a project period that is ongoing.
+    $this->setProjectDateRange($node, '-5 day', '+15 days');
+    $node->save();
+    $this->drupalGet($node->toUrl());
+
+    $this->assertProjectStatusVisible();
+    $this->assertProjectStatus('bg-info', 'Ongoing');
+    $this->assertProjectProgress(15, 35);
+
+    // Set a project period that is fully in the future.
+    $this->setProjectDateRange($node, '+5 days', '+12 days');
+    $node->save();
+    $this->drupalGet($node->toUrl());
+
+    $this->assertProjectStatusVisible();
+    $this->assertProjectStatus('bg-secondary', 'Planned');
+    $this->assertProjectProgress(0);
+  }
+
+  /**
+   * Creates a stakeholder organisation entity.
+   *
+   * @param string $name
+   *   Name of the entity. Is used as a parameter for test data.
+   * @param int $status
+   *   Entity status. 1 - published, 0 - unpublished.
+   * @param string $bundle
+   *   Bundle name used in the entity.
+   *
+   * @return \Drupal\oe_content_entity_organisation\Entity\OrganisationInterface
+   *   Organisation entity.
+   */
+  protected function createStakeholderOrganisationEntity(string $name, int $status, string $bundle): OrganisationInterface {
+    $organisation = $this->getStorage('oe_organisation')->create([
+      'bundle' => $bundle,
+      'name' => $name,
+      'oe_acronym' => "Acronym $name",
+      'oe_address' => [
+        'country_code' => 'BE',
+      ],
+      'oe_cx_contribution_budget' => '22.3',
+      'status' => $status,
+    ]);
+    $organisation->save();
+
+    return $organisation;
+  }
+
+  /**
+   * Gets the entity type's storage.
+   *
+   * @param string $entity_type_id
+   *   The entity type ID to get a storage for.
+   *
+   * @return \Drupal\Core\Entity\EntityStorageInterface
+   *   The entity type's storage.
+   */
+  protected function getStorage(string $entity_type_id): EntityStorageInterface {
+    return \Drupal::entityTypeManager()->getStorage($entity_type_id);
+  }
+
+  /**
+   * Updates the project date, saves the node, and refreshes the page.
+   *
+   * @param \Drupal\node\NodeInterface $node
+   *   Node to update.
+   * @param string $begin
+   *   Start date string.
+   * @param string $end
+   *   End date string.
+   */
+  protected function setProjectDateRange(NodeInterface $node, string $begin, string $end): void {
+    $node->oe_project_dates = [
+      'value' => (new DrupalDateTime($begin, 'Europe/Brussels'))->format('Y-m-d'),
+      'end_value' => (new DrupalDateTime($end, 'Europe/Brussels'))->format('Y-m-d'),
+    ];
+  }
+
+  /**
+   * Asserts that the d-none class has been removed from project status.
+   */
+  protected function assertProjectStatusVisible(): void {
+    $status_section = $this->assertSession()->elementExists('css', '.bcl-project-status');
+    $this->assertFalse($status_section->hasClass('d-none'));
+  }
+
+  /**
+   * Asserts 'start' and 'end' timestamp in project status section.
+   *
+   * @param string $begin
+   *   Expected start date string as 'Y-m-d H:i:s'.
+   * @param string $end
+   *   Expected end date string as 'Y-m-d H:i:s'.
+   */
+  protected function assertProjectStatusTimestampsAsDateStrings(string $begin, string $end): void {
+    $status_section = $this->assertSession()->elementExists('css', '.bcl-project-status');
+    $t_begin = (int) $status_section->getAttribute('data-start-timestamp');
+    $t_end = (int) $status_section->getAttribute('data-end-timestamp');
+    $this->assertTimestampAsDateString($begin, $t_begin, 'Europe/Brussels');
+    $this->assertTimestampAsDateString($end, $t_end, 'Europe/Brussels');
+  }
+
+  /**
+   * Asserts a timestamp matches a date string in a given timezone.
+   *
+   * @param string $expected
+   *   Expected date string as 'Y-m-d H:i:s'.
+   * @param int $timestamp
+   *   Actual timestamp.
+   * @param string $timezone
+   *   Timezone for the conversion.
+   */
+  protected function assertTimestampAsDateString(string $expected, int $timestamp, string $timezone): void {
+    $this->assertSame(
+      $expected,
+      DrupalDateTime::createFromTimestamp($timestamp, $timezone)
+        ->format('Y-m-d H:i:s'),
+    );
+  }
+
+  /**
+   * Asserts the state of the status badge and the color of the progress bar.
+   *
+   * @param string $color_class
+   *   Expected color class.
+   * @param string $status_text
+   *   Expected status text.
+   */
+  protected function assertProjectStatus(string $color_class, string $status_text): void {
+    $status_badge = $this->assertSession()->elementExists('css', '.bcl-project-status .badge');
+    $this->assertTrue($status_badge->hasClass($color_class));
+    $this->assertSame($status_text, $status_badge->getHtml());
+    $progress_bar = $this->assertSession()->elementExists('css', '.bcl-project-status .progress-bar');
+    $this->assertTrue($progress_bar->hasClass($color_class));
+  }
+
+  /**
+   * Asserts the value of the progress bar.
+   *
+   * @param int $min
+   *   Minimum progress in percent.
+   * @param int|null $max
+   *   Maximum progress in percent.
+   */
+  protected function assertProjectProgress(int $min, int $max = NULL): void {
+    $progress_bar = $this->assertSession()->elementExists('css', '.bcl-project-status .progress-bar');
+    $progress_string = $progress_bar->getAttribute('aria-valuenow');
+    $this->assertStringContainsString("width: $progress_string%", $progress_bar->getAttribute('style'));
+    if ($max === NULL) {
+      $this->assertSame((string) $min, $progress_string);
+    }
+    else {
+      $this->assertGreaterThanOrEqual($min, (float) $progress_string);
+      $this->assertLessThanOrEqual($max, (float) $progress_string);
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/AbstractKernelTestBase.php b/tests/src/Kernel/AbstractKernelTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..25b39ba842e0c5d51e557d01050f3ba7c5b5e3b2
--- /dev/null
+++ b/tests/src/Kernel/AbstractKernelTestBase.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel\Kernel;
+
+use Drupal\Tests\oe_bootstrap_theme\Kernel\AbstractKernelTestBase as BootstrapKernelTestBase;
+use Drupal\Tests\oe_bootstrap_theme\Kernel\Traits\RenderTrait;
+
+/**
+ * Base class for theme's kernel tests.
+ */
+abstract class AbstractKernelTestBase extends BootstrapKernelTestBase {
+
+  use RenderTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'oe_whitelabel_helper',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->installConfig([
+      'oe_whitelabel_helper',
+    ]);
+
+    $this->container->get('theme_installer')->install(['oe_whitelabel']);
+    $this->config('system.theme')->set('default', 'oe_whitelabel')->save();
+    $this->container->set('theme.registry', NULL);
+  }
+
+}
diff --git a/tests/src/Kernel/ContentRenderTestBase.php b/tests/src/Kernel/ContentRenderTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..bee23bdea495b92d213abcae1e3934d25ca43ede
--- /dev/null
+++ b/tests/src/Kernel/ContentRenderTestBase.php
@@ -0,0 +1,95 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel\Kernel;
+
+use Drupal\Tests\sparql_entity_storage\Traits\SparqlConnectionTrait;
+use Drupal\user\Entity\Role;
+use Drupal\user\RoleInterface;
+
+/**
+ * Base class for testing the content being rendered.
+ */
+abstract class ContentRenderTestBase extends AbstractKernelTestBase {
+
+  use SparqlConnectionTrait;
+
+  /**
+   * The node view builder.
+   *
+   * @var \Drupal\Core\Entity\EntityViewBuilderInterface
+   */
+  protected $nodeViewBuilder;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'address',
+    'composite_reference',
+    'datetime',
+    'entity_reference',
+    'entity_reference_revisions',
+    'field',
+    'field_group',
+    'file',
+    'file_link',
+    'filter',
+    'inline_entity_form',
+    'link',
+    'media',
+    'node',
+    'oe_content',
+    'oe_content_departments_field',
+    'oe_content_documents_field',
+    'oe_content_entity',
+    'oe_content_entity_contact',
+    'oe_content_entity_organisation',
+    'oe_content_reference_code_field',
+    'oe_media',
+    'options',
+    'rdf_skos',
+    'sparql_entity_storage',
+    'text',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->setUpSparql();
+
+    $this->installEntitySchema('node');
+    $this->installSchema('file', 'file_usage');
+    $this->installEntitySchema('media');
+    $this->installEntitySchema('file');
+
+    $this->container->get('module_handler')->loadInclude('oe_content_documents_field', 'install');
+    oe_content_documents_field_install(FALSE);
+
+    $this->installConfig([
+      'node',
+      'filter',
+      'oe_media',
+      'oe_content',
+      'oe_content_entity',
+      'oe_content_entity_organisation',
+      'oe_content_departments_field',
+      'oe_content_reference_code_field',
+    ]);
+
+    Role::load(RoleInterface::ANONYMOUS_ID)
+      ->grantPermission('view published skos concept entities')
+      ->grantPermission('view media')
+      ->save();
+
+    module_load_include('install', 'oe_content');
+    oe_content_install(FALSE);
+
+    $this->nodeViewBuilder = $this->container->get('entity_type.manager')->getViewBuilder('node');
+  }
+
+}
diff --git a/tests/src/Kernel/ProjectRenderTest.php b/tests/src/Kernel/ProjectRenderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..17d06e3b93750c356f43fd6df6fd261abba86d3b
--- /dev/null
+++ b/tests/src/Kernel/ProjectRenderTest.php
@@ -0,0 +1,117 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel\Kernel;
+
+use Drupal\media\Entity\Media;
+use Drupal\node\Entity\Node;
+use Drupal\Tests\oe_whitelabel\PatternAssertions\CardAssert;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+
+/**
+ * Tests the rendering of the teaser view mode of Project content type.
+ */
+class ProjectRenderTest extends ContentRenderTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = [
+    'datetime_range',
+    'image',
+    'oe_content_featured_media_field',
+    'oe_content_project',
+    'oe_content_extra',
+    'oe_content_extra_project',
+    'oe_whitelabel_extra_project',
+    'system',
+    'twig_field_value',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->installConfig([
+      'oe_content_featured_media_field',
+      'oe_content_project',
+      'oe_content_extra',
+      'oe_content_extra_project',
+      'oe_whitelabel_extra_project',
+    ]);
+
+    module_load_include('install', 'oe_whitelabel_extra_project');
+    oe_whitelabel_extra_project_install(FALSE);
+  }
+
+  /**
+   * Test a project being rendered as a teaser.
+   */
+  public function testProjectTeaser(): void {
+    $file = file_save_data(file_get_contents(drupal_get_path('theme', 'oe_whitelabel') . '/tests/fixtures/example_1.jpeg'), 'public://example_1.jpeg');
+    $file->setPermanent();
+    $file->save();
+
+    $media = Media::create([
+      'bundle' => 'image',
+      'name' => 'test image',
+      'oe_media_image' => [
+        'target_id' => $file->id(),
+        'alt' => 'Alternative text',
+        'caption' => 'Caption',
+      ],
+    ]);
+    $media->save();
+
+    $values = [
+      'type' => 'oe_project',
+      'title' => 'Project 1',
+      'oe_subject' => 'http://data.europa.eu/uxp/1005',
+      'oe_teaser' => 'The teaser text',
+      'oe_featured_media' => [
+        [
+          'target_id' => $media->id(),
+          'caption' => 'Caption text',
+          'alt' => 'Alternative text',
+        ],
+      ],
+      'oe_project_dates' => [
+        'value' => '2020-05-10',
+        'end_value' => '2025-05-15',
+      ],
+      'status' => 1,
+    ];
+
+    $node = Node::create($values);
+    $node->save();
+
+    $build = $this->nodeViewBuilder->view($node, 'teaser');
+    $html = $this->renderRoot($build);
+
+    $assert = new CardAssert();
+
+    $expected_values = [
+      'title' => 'Project 1',
+      'url' => '/node/1',
+      'description' => 'The teaser text',
+      'badges' => ['EU financing'],
+      'image' => [
+        'src' => 'example_1.jpeg',
+        'alt' => 'Alternative text',
+      ],
+      'content' => [
+        '10 May 2020',
+        '15 May 2025',
+      ],
+    ];
+    $assert->assertPattern($expected_values, $html);
+    $assert->assertVariant('search', $html);
+  }
+
+}
diff --git a/tests/src/PatternAssertions/BasePatternAssert.php b/tests/src/PatternAssertions/BasePatternAssert.php
new file mode 100644
index 0000000000000000000000000000000000000000..4bedfa7b1d144e42ad8aada691bef352b0367a2d
--- /dev/null
+++ b/tests/src/PatternAssertions/BasePatternAssert.php
@@ -0,0 +1,221 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel\PatternAssertions;
+
+use PHPUnit\Framework\Assert;
+use PHPUnit\Framework\Exception;
+use Symfony\Component\DomCrawler\Crawler;
+
+/**
+ * Base class for asserting patterns.
+ */
+abstract class BasePatternAssert extends Assert implements PatternAssertInterface {
+
+  /**
+   * Method that returns the assertions to be run by a particular pattern.
+   *
+   * Assertions extending this class need to return an array containing the
+   * assertions to be run for every possible value that can be expected.
+   *
+   * @param string $variant
+   *   The variant name that is being checked.
+   *
+   * @return array
+   *   An array containing the assertions to be run.
+   */
+  abstract protected function getAssertions(string $variant): array;
+
+  /**
+   * Method that asserts the base elements of a rendered pattern.
+   *
+   * @param string $html
+   *   The rendered pattern.
+   * @param string $variant
+   *   The variant being asserted.
+   */
+  abstract protected function assertBaseElements(string $html, string $variant): void;
+
+  /**
+   * Returns the variant of the provided rendered pattern.
+   *
+   * @param string $html
+   *   The rendered pattern.
+   *
+   * @return string
+   *   The variant of the rendered pattern.
+   */
+  protected function getPatternVariant(string $html): string {
+    return 'default';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function assertPattern(array $expected, string $html): void {
+    $variant = $this->getPatternVariant($html);
+    $this->assertBaseElements($html, $variant);
+    $assertion_map = $this->getAssertions($variant);
+    $crawler = new Crawler($html);
+    foreach ($expected as $name => $expected_value) {
+      if (!array_key_exists($name, $assertion_map)) {
+        $reflection = new \ReflectionClass($this);
+        throw new Exception(sprintf('"%s" does not provide any assertion for "%s".', $reflection->getName(), $name));
+      }
+      if (is_array($assertion_map[$name]) && is_callable($assertion_map[$name][0])) {
+        $callback = array_shift($assertion_map[$name]);
+        array_unshift($assertion_map[$name], $expected_value);
+        $assertion_map[$name][] = $crawler;
+        call_user_func_array($callback, $assertion_map[$name]);
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function assertVariant(string $variant, string $html): void {
+    self::assertEquals($variant, $this->getPatternVariant($html));
+  }
+
+  /**
+   * Asserts the value of an attribute of a particular element.
+   *
+   * @param string|null $expected
+   *   The expected value.
+   * @param string $selector
+   *   The CSS selector to find the element.
+   * @param string $attribute
+   *   The name of the attribute to check.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertElementAttribute($expected, string $selector, string $attribute, Crawler $crawler): void {
+    if (is_null($expected)) {
+      $this->assertElementNotExists($selector, $crawler);
+      return;
+    }
+    $this->assertElementExists($selector, $crawler);
+    $element = $crawler->filter($selector);
+    self::assertEquals($expected, $element->attr($attribute));
+  }
+
+  /**
+   * Asserts the text of a particular element.
+   *
+   * @param string|null $expected
+   *   The expected value.
+   * @param string $selector
+   *   The CSS selector to find the element.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertElementText($expected, string $selector, Crawler $crawler): void {
+    if (is_null($expected)) {
+      $this->assertElementNotExists($selector, $crawler);
+      return;
+    }
+    $this->assertElementExists($selector, $crawler);
+    $element = $crawler->filter($selector);
+    $actual = trim($element->text());
+    self::assertEquals($expected, $actual, \sprintf(
+      'Expected text value "%s" is not equal to the actual value "%s" found in the selector "%s".',
+      $expected, $actual, $selector
+    ));
+  }
+
+  /**
+   * Asserts the rendered html of a particular element.
+   *
+   * @param string|null $expected
+   *   The expected value.
+   * @param string $selector
+   *   The CSS selector to find the element.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertElementHtml($expected, string $selector, Crawler $crawler): void {
+    if (is_null($expected)) {
+      $this->assertElementNotExists($selector, $crawler);
+      return;
+    }
+    $this->assertElementExists($selector, $crawler);
+    $element = $crawler->filter($selector);
+    self::assertEquals($expected, $element->html());
+  }
+
+  /**
+   * Asserts that an element is present.
+   *
+   * @param string $selector
+   *   The CSS selector to find the element.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertElementExists(string $selector, Crawler $crawler): void {
+    $element = $crawler->filter($selector);
+    self::assertCount(1, $element, \sprintf(
+      'Element with selector "%s" not found in the provided html.',
+      $selector
+    ));
+  }
+
+  /**
+   * Asserts that an element is not present.
+   *
+   * @param string $selector
+   *   The CSS selector to find the element.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertElementNotExists(string $selector, Crawler $crawler): void {
+    $element = $crawler->filter($selector);
+    self::assertCount(0, $element, \sprintf(
+      'Element with selector "%s" was found in the provided html.',
+      $selector
+    ));
+  }
+
+  /**
+   * Asserts the image of the pattern.
+   *
+   * @param array|null $expected_image
+   *   The expected image.
+   * @param string $selector
+   *   The CSS selector to find the element.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertImage(?array $expected_image, string $selector, Crawler $crawler): void {
+    if (is_null($expected_image)) {
+      $this->assertElementNotExists($selector, $crawler);
+      return;
+    }
+    $this->assertElementExists($selector, $crawler);
+    $element = $crawler->filter($selector);
+    self::assertEquals($expected_image['alt'], $element->attr('alt'));
+    self::assertStringContainsString($expected_image['src'], $element->attr('src'));
+  }
+
+  /**
+   * Asserts the badges items of the pattern.
+   *
+   * @param array $badges
+   *   The expected badges item values.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertBadgesElements(array $badges, Crawler $crawler): void {
+    if (empty($badges)) {
+      $this->assertElementNotExists('.badge', $crawler);
+      return;
+    }
+    $badges_items = $crawler->filter('.mt-2-5');
+    self::assertCount(count($badges), $badges_items);
+    foreach ($badges as $index => $badge) {
+      self::assertEquals($badge, trim($badges_items->eq($index)->text()));
+    }
+  }
+
+}
diff --git a/tests/src/PatternAssertions/CardAssert.php b/tests/src/PatternAssertions/CardAssert.php
new file mode 100644
index 0000000000000000000000000000000000000000..00afdb6cb0f3052d70289b6f7ac7660195f140a0
--- /dev/null
+++ b/tests/src/PatternAssertions/CardAssert.php
@@ -0,0 +1,133 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel\PatternAssertions;
+
+use Symfony\Component\DomCrawler\Crawler;
+
+/**
+ * Assertions for the card pattern.
+ *
+ * @see ./templates/patterns/card/card.ui_patterns.yml
+ */
+class CardAssert extends BasePatternAssert {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getAssertions($variant): array {
+    return [
+      'title' => [
+        [$this, 'assertElementText'],
+        '.card-title a span',
+      ],
+      'url' => [
+        [$this, 'assertElementAttribute'],
+        '.card-title a',
+        'href',
+      ],
+      'image' => [
+        [$this, 'assertCardImage'],
+        $variant,
+      ],
+      'description' => [
+        [$this, 'assertElementText'],
+        '.card-text',
+      ],
+      'badges' => [
+        [$this, 'assertBadgesElements'],
+      ],
+      'content' => [
+        [$this, 'assertContent'],
+        $variant,
+      ],
+    ];
+  }
+
+  /**
+   * Asserts the image of a card.
+   *
+   * @param array|null $expected_image
+   *   The expected image values.
+   * @param string $variant
+   *   The variant of the pattern being checked.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertCardImage($expected_image, string $variant, Crawler $crawler): void {
+    if ($variant == 'search') {
+      $image_div = $crawler->filter('.row .col-md-3.mw-listing-img img.card-img-top');
+      self::assertEquals($expected_image['alt'], $image_div->attr('alt'));
+      self::assertStringContainsString($expected_image['src'], $image_div->attr('src'));
+    }
+    else {
+      $image_div = $crawler->filter('div.card img');
+      self::assertEquals($expected_image['alt'], $image_div->attr('alt'));
+      self::assertStringContainsString($expected_image['src'], $image_div->attr('src'));
+    }
+  }
+
+  /**
+   * Asserts the content of a card.
+   *
+   * @param array $expected_items
+   *   The expected item values.
+   * @param string $variant
+   *   The variant of the pattern being checked.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertContent(array $expected_items, string $variant, Crawler $crawler): void {
+    // There's no wrapping element in content that can be targeted,
+    // so we are checking that the expected items are present.
+    foreach ($expected_items as $expected_item) {
+      if ($variant == 'search') {
+        self::assertStringContainsString($expected_item, $crawler->filter('.row .col-md-9')->html());
+      }
+      else {
+        self::assertStringContainsString($expected_item, $crawler->html());
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertBaseElements(string $html, string $variant): void {
+    $crawler = new Crawler($html);
+    $card = $crawler->filter($this->getBaseItemClass($variant));
+    self::assertCount(1, $card);
+  }
+
+  /**
+   * Returns the base CSS selector for a list item depending on the variant.
+   *
+   * @param string $variant
+   *   The variant being checked.
+   *
+   * @return string
+   *   The base selector for the variant.
+   */
+  protected function getBaseItemClass(string $variant): string {
+    switch ($variant) {
+      case 'search':
+        return 'div.listing-item.card';
+
+      default:
+        return 'div.card';
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPatternVariant(string $html): string {
+    $crawler = new Crawler($html);
+    if ($crawler->filter('div.listing-item')->count() > 0) {
+      return 'search';
+    }
+    return 'default';
+  }
+
+}
diff --git a/tests/src/PatternAssertions/ContentBannerAssert.php b/tests/src/PatternAssertions/ContentBannerAssert.php
new file mode 100644
index 0000000000000000000000000000000000000000..af68fe0e2639c9b34f0abb9845e5ce50fff2f480
--- /dev/null
+++ b/tests/src/PatternAssertions/ContentBannerAssert.php
@@ -0,0 +1,46 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel\PatternAssertions;
+
+use Symfony\Component\DomCrawler\Crawler;
+
+/**
+ * Assertions for the page header pattern.
+ */
+class ContentBannerAssert extends BasePatternAssert {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getAssertions(string $variant): array {
+    return [
+      'image' => [
+        [$this, 'assertImage'],
+        '.card-img-top',
+      ],
+      'badges' => [
+        [$this, 'assertBadgesElements'],
+      ],
+      'title' => [
+        [$this, 'assertElementText'],
+        '.card-title',
+      ],
+      'description' => [
+        [$this, 'assertElementText'],
+        '.card-body .mt-4',
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertBaseElements(string $html, string $variant): void {
+    $crawler = new Crawler($html);
+    $page_header = $crawler->filter('.bcl-content-banner');
+    self::assertCount(1, $page_header);
+  }
+
+}
diff --git a/tests/src/PatternAssertions/DescriptionListAssert.php b/tests/src/PatternAssertions/DescriptionListAssert.php
new file mode 100644
index 0000000000000000000000000000000000000000..32b228797b8a3bf0c2728ddc053baed7b3b237d0
--- /dev/null
+++ b/tests/src/PatternAssertions/DescriptionListAssert.php
@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel\PatternAssertions;
+
+use Symfony\Component\DomCrawler\Crawler;
+
+/**
+ * Assertions for the field list pattern.
+ *
+ * @see ./templates/patterns/field_list/field_list.ui_patterns.yml
+ */
+class DescriptionListAssert extends BasePatternAssert {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getAssertions(string $variant): array {
+    return [
+      'items' => [
+        [$this, 'assertItems'],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertBaseElements(string $html, string $variant): void {
+    $crawler = new Crawler($html);
+    $field_list_container = $crawler->filter('body');
+    self::assertCount(1, $field_list_container);
+  }
+
+  /**
+   * Asserts the items of the pattern.
+   *
+   * @param array $expected_items
+   *   The expected item values.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertItems(array $expected_items, Crawler $crawler): void {
+    // Assert all labels are correct.
+    $expected_labels = array_column($expected_items, 'term');
+    $label_items = $crawler->filter('dt');
+    self::assertCount(count($expected_labels), $label_items);
+    foreach ($expected_labels as $index => $expected_label) {
+      self::assertEquals($expected_label, trim($label_items->eq($index)->text()));
+    }
+
+    // Assert all values are correct.
+    $expected_values = array_column($expected_items, 'definition');
+    $value_items = $crawler->filter('dd');
+    self::assertCount(count($expected_labels), $value_items);
+    foreach ($expected_values as $index => $expected_value) {
+      self::assertEquals($expected_value, trim($value_items->eq($index)->text()));
+    }
+  }
+
+}
diff --git a/tests/src/PatternAssertions/InPageNavigationAssert.php b/tests/src/PatternAssertions/InPageNavigationAssert.php
new file mode 100644
index 0000000000000000000000000000000000000000..b2d9bdf9be26042549046f5412dc692e210eb391
--- /dev/null
+++ b/tests/src/PatternAssertions/InPageNavigationAssert.php
@@ -0,0 +1,60 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel\PatternAssertions;
+
+use Symfony\Component\DomCrawler\Crawler;
+
+/**
+ * Assertions for in-page-navigation.
+ */
+class InPageNavigationAssert extends BasePatternAssert {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getAssertions(string $variant): array {
+    return [
+      'title' => [
+        [$this, 'assertElementText'],
+        'h5',
+      ],
+      'links' => [
+        [$this, 'assertList'],
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertBaseElements(string $html, string $variant): void {
+    $crawler = new Crawler($html);
+    $page_header = $crawler->filter('body');
+    self::assertCount(1, $page_header);
+  }
+
+  /**
+   * Asserts the in-page-navigation links list.
+   *
+   * @param array|null $expected
+   *   The expected description values.
+   * @param \Symfony\Component\DomCrawler\Crawler $crawler
+   *   The DomCrawler where to check the element.
+   */
+  protected function assertList($expected, Crawler $crawler): void {
+    $this->assertElementExists('ul.nav-pills', $crawler);
+
+    $actual = [];
+    $crawler->filter('ul.nav-pills  a.nav-link')->each(function (Crawler $node) use (&$actual) {
+      $actual[] = [
+        'label' => $node->text(),
+        'href' => $node->attr('href'),
+      ];
+    });
+
+    self::assertEquals($expected, $actual);
+  }
+
+}
diff --git a/tests/src/PatternAssertions/PatternAssertInterface.php b/tests/src/PatternAssertions/PatternAssertInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..293eb8ffe66089edbef53a7fcf9d13817e4849ce
--- /dev/null
+++ b/tests/src/PatternAssertions/PatternAssertInterface.php
@@ -0,0 +1,32 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\Tests\oe_whitelabel\PatternAssertions;
+
+/**
+ * Interface implemented by all pattern assertion objects.
+ */
+interface PatternAssertInterface {
+
+  /**
+   * Asserts that a rendered pattern is correct.
+   *
+   * @param array $expected
+   *   An array of expected values, keyed by field name.
+   * @param string $html
+   *   The rendered pattern.
+   */
+  public function assertPattern(array $expected, string $html): void;
+
+  /**
+   * Asserts that a rendered pattern uses a variant.
+   *
+   * @param string $variant
+   *   The variant to check for.
+   * @param string $html
+   *   The rendered pattern.
+   */
+  public function assertVariant(string $variant, string $html): void;
+
+}