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 774d4adf31c22832ed9cfec63c8dd9bfa193ee78..c35defe9f09f8adfe149fe9792a803518f7437ce 100644
--- a/composer.json
+++ b/composer.json
@@ -11,25 +11,29 @@
         "drupal/core": "^9.2",
         "drupal/twig_field_value": "^2.0",
         "openeuropa/composer-artifacts": "^1.0.0-alpha1",
-        "openeuropa/oe_bootstrap_theme": "1.0.0-beta1"
+        "openeuropa/oe_bootstrap_theme": "0.1.202206071025"
     },
     "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/extra_field": "^2.1",
         "drupal/facets": "1.8.0 as 2.0.1",
-        "drupal/file_link": "^2.0.6",
         "drupal/facets_form": "1.0.0-alpha2",
+        "drupal/field_group": "^3.2",
+        "drupal/file_link": "^2.0.6",
         "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",
@@ -39,6 +43,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_list_pages": "^0.16",
         "openeuropa/oe_media": "^1.14",
@@ -57,6 +62,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": {
@@ -81,6 +90,14 @@
                 }
             }
         },
+        "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"
+            },
+            "openeuropa/oe_bootstrap_theme": {
+                "latest": "https://github.com/openeuropa/oe_bootstrap_theme/compare/0.1.202206071025..1.x.diff"
+            }
+        },
         "drupal-scaffold": {
             "locations": {
                 "web-root": "./build"
diff --git a/config/optional/block.block.oe_whitelabel_search_form.yml b/config/optional/block.block.oe_whitelabel_search_form.yml
index aad75c899b2f4383323c453297cb61e44c0a94eb..1bc6559652417cf1cd2197149c5267c69d00bfa3 100644
--- a/config/optional/block.block.oe_whitelabel_search_form.yml
+++ b/config/optional/block.block.oe_whitelabel_search_form.yml
@@ -18,13 +18,13 @@ settings:
   provider: oe_whitelabel_search
   form:
     action: '#'
+    region: navigation_right
   input:
     name: text
     label: Search
-    classes: ''
     placeholder: Search
   button:
-    classes: ''
+    label: ''
   view_options:
     enable_autocomplete: false
     id: null
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_contact_forms/oe_whitelabel_contact_forms.module b/modules/oe_whitelabel_contact_forms/oe_whitelabel_contact_forms.module
index d979b6c28b3fa0885a72b2fd2482422d7ef83be4..29046b9be94a951451c15621f1bf8f14cd2e95a3 100644
--- a/modules/oe_whitelabel_contact_forms/oe_whitelabel_contact_forms.module
+++ b/modules/oe_whitelabel_contact_forms/oe_whitelabel_contact_forms.module
@@ -74,13 +74,19 @@ function oe_whitelabel_contact_forms_preprocess_status_messages(&$variables) {
       if ($field->isEmpty() || !$field->access()) {
         continue;
       }
-      $value = 'value';
-      if ($field->getFieldDefinition()->getType() == 'skos_concept_entity_reference') {
-        $value = 'target_id';
+
+      if ($field->getFieldDefinition()->getType() === 'skos_concept_entity_reference') {
+        /** @var \Drupal\rdf_skos\Entity\Concept[] $concept_entities */
+        $concept_entities = $field->referencedEntities();
+        $definition = $concept_entities[0]->label();
+      }
+      else {
+        $definition = $field->first()->getValue()['value'];
       }
+
       $items[] = [
         'term' => $field->getFieldDefinition()->getLabel(),
-        'definition' => $field->first()->getValue()[$value],
+        'definition' => $definition,
       ];
     }
     $variables['message_list']['status'][$key] = [
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/modules/oe_whitelabel_search/config/schema/oe_whitelabel_search.schema.yml b/modules/oe_whitelabel_search/config/schema/oe_whitelabel_search.schema.yml
index 51f1f67af13d303b97af15ccebf07abae58a12fb..bd15d2761ca79622fb4a94cf739eedef7c9c61f5 100644
--- a/modules/oe_whitelabel_search/config/schema/oe_whitelabel_search.schema.yml
+++ b/modules/oe_whitelabel_search/config/schema/oe_whitelabel_search.schema.yml
@@ -9,6 +9,9 @@ block.settings.whitelabel_search_block:
         action:
           type: string
           label: Action
+        region:
+          type: string
+          label: Region
     input:
       type: mapping
       label: Input
@@ -19,9 +22,6 @@ block.settings.whitelabel_search_block:
         label:
           type: string
           label: Label
-        classes:
-          type: string
-          label: Classes
         placeholder:
           type: string
           label: Placeholder
@@ -29,9 +29,9 @@ block.settings.whitelabel_search_block:
       type: mapping
       label: Button
       mapping:
-        classes:
+        label:
           type: string
-          label: Classes
+          label: Button label
     view_options:
       type: mapping
       label: View Options
diff --git a/modules/oe_whitelabel_search/oe_whitelabel_search.module b/modules/oe_whitelabel_search/oe_whitelabel_search.module
index abd7c78c35396960d3ab9d3225bf74f6d0b69156..1baeb6311243a2a1d8f95b2409c0e5dd339bc97c 100644
--- a/modules/oe_whitelabel_search/oe_whitelabel_search.module
+++ b/modules/oe_whitelabel_search/oe_whitelabel_search.module
@@ -2,7 +2,7 @@
 
 /**
  * @file
- * OE Whitelabel Search Module.
+ * Module file used for theming the search feature.
  */
 
 declare(strict_types =  1);
diff --git a/modules/oe_whitelabel_search/oe_whitelabel_search.post_update.php b/modules/oe_whitelabel_search/oe_whitelabel_search.post_update.php
new file mode 100644
index 0000000000000000000000000000000000000000..2277931db8b927ca82277e69511722be8aabad6c
--- /dev/null
+++ b/modules/oe_whitelabel_search/oe_whitelabel_search.post_update.php
@@ -0,0 +1,21 @@
+<?php
+
+/**
+ * @file
+ * OpenEuropa Whitelabel Search post updates.
+ */
+
+declare(strict_types=1);
+
+use Drupal\block\Entity\Block;
+
+/**
+ * Add region in form settings for template suggestions.
+ */
+function oe_whitelabel_search_post_update_00001(&$sandbox) {
+  $block = Block::load('oe_whitelabel_search_form');
+  $settings = $block->get('settings');
+  $settings['form']['region'] = $block->getRegion();
+  $block->set('settings', $settings);
+  $block->save();
+}
diff --git a/modules/oe_whitelabel_search/src/Form/SearchForm.php b/modules/oe_whitelabel_search/src/Form/SearchForm.php
index 4dd36f652d4dc64349931b23156c8952eac77e83..ea0883d9ebfd8b99957b1fdc405184f24a870014 100644
--- a/modules/oe_whitelabel_search/src/Form/SearchForm.php
+++ b/modules/oe_whitelabel_search/src/Form/SearchForm.php
@@ -45,64 +45,46 @@ class SearchForm extends FormBase {
    * {@inheritdoc}
    */
   public function buildForm(array $form, FormStateInterface $form_state, array $config = NULL): array {
+    if (empty($config['input']['name'])) {
+      return [];
+    }
+
     $form_state->set('oe_whitelabel_search_config', $config);
-    $input_value = '';
 
-    if (!empty($config['input']['name'])) {
-      $input_value = $this->getRequest()->get($config['input']['name']);
+    $theme_hook_suffix = $this->getFormId();
+    if (isset($config['form']['region'])) {
+      $theme_hook_suffix = $config['form']['region'] . '__' . $theme_hook_suffix;
     }
 
+    $form['#theme_wrappers'] = ['form__' . $theme_hook_suffix];
+
     $form['search_input'] = [
-      '#prefix' => '<div class="bcl-search-form__group w-100">',
-      '#suffix' => '</div>',
+      /* @see \Drupal\Core\Render\Element\Textfield */
       '#type' => 'textfield',
+      '#theme' => 'input__textfield__' . $theme_hook_suffix,
+      '#theme_wrappers' => ['form_element__' . $this->getFormId()],
       '#title' => $config['input']['label'],
       '#title_display' => 'invisible',
       '#size' => 20,
-      '#margin_class' => 'mb-0',
+      '#default_value' => $this->getRequest()->get($config['input']['name']),
+      '#required' => TRUE,
       '#attributes' => [
         'placeholder' => $config['input']['placeholder'],
-        'class' => [
-          $config['input']['classes'],
-          'rounded-0',
-          'rounded-start',
-        ],
       ],
-      '#default_value' => $input_value,
-      '#required' => TRUE,
     ];
 
     $form['submit'] = [
-      '#input' => TRUE,
-      '#is_button' => TRUE,
-      '#executes_submit_callback' => TRUE,
-      '#type' => 'pattern',
-      '#id' => 'button',
-      '#variant' => 'light',
-      '#fields' => [
-        'icon' => 'search',
-        'settings' => [
-          'type' => 'submit',
-        ],
-        'attributes' => [
-          'id' => 'submit',
-          'class' => [
-            'border-start-0',
-            'rounded-0 rounded-end',
-            'd-flex',
-            'btn btn-light',
-            'btn-md',
-            'py-2',
-            $config['button']['classes'],
-          ],
-        ],
-      ],
+      /* @see \Drupal\Core\Render\Element\Submit */
+      '#type' => 'submit',
+      '#theme_wrappers' => ['input__submit__' . $theme_hook_suffix],
+      '#value' => $config['button']['label'],
     ];
 
     if (!$config['view_options']['enable_autocomplete']) {
       return $form;
     }
 
+    /* @see \Drupal\search_api_autocomplete\Element\SearchApiAutocomplete */
     $form['search_input']['#type'] = 'search_api_autocomplete';
     // The view id.
     $form['search_input']['#search_id'] = $config['view_options']['id'];
diff --git a/modules/oe_whitelabel_search/src/Plugin/Block/SearchBlock.php b/modules/oe_whitelabel_search/src/Plugin/Block/SearchBlock.php
index ba8e470d0ea5abdcf4b539fcc73d4179df9c7cb7..e8bcb19cdd2865c4725d56b3fd919d1ecd1549fc 100644
--- a/modules/oe_whitelabel_search/src/Plugin/Block/SearchBlock.php
+++ b/modules/oe_whitelabel_search/src/Plugin/Block/SearchBlock.php
@@ -4,7 +4,6 @@ declare(strict_types = 1);
 
 namespace Drupal\oe_whitelabel_search\Plugin\Block;
 
-use Drupal\Component\Utility\Html;
 use Drupal\Core\Block\BlockBase;
 use Drupal\Core\Cache\CacheableMetadata;
 use Drupal\Core\Config\ConfigFactoryInterface;
@@ -94,15 +93,15 @@ class SearchBlock extends BlockBase implements ContainerFactoryPluginInterface {
     return [
       'form' => [
         'action' => '',
+        'region' => '',
       ],
       'input' => [
         'name' => '',
         'label' => '',
-        'classes' => '',
         'placeholder' => $this->t('Search'),
       ],
       'button' => [
-        'classes' => '',
+        'label' => '',
       ],
       'view_options' => [
         'enable_autocomplete' => FALSE,
@@ -145,13 +144,6 @@ class SearchBlock extends BlockBase implements ContainerFactoryPluginInterface {
       '#title' => $this->t('Input Label'),
       '#description' => $this->t('A label text for the search input.'),
       '#default_value' => $config['input']['label'],
-      '#required' => TRUE,
-    ];
-    $form['input']['input_classes'] = [
-      '#type' => 'textfield',
-      '#title' => $this->t('Input classes'),
-      '#description' => $this->t('Add space-separated classes that will be added to the input.'),
-      '#default_value' => $config['input']['classes'],
     ];
     $form['input']['input_placeholder'] = [
       '#type' => 'textfield',
@@ -166,11 +158,11 @@ class SearchBlock extends BlockBase implements ContainerFactoryPluginInterface {
       '#tree' => TRUE,
       '#description' => $this->t('Fill in the settings of the Button field.'),
     ];
-    $form['button']['button_classes'] = [
+    $form['button']['button_label'] = [
       '#type' => 'textfield',
-      '#title' => $this->t('Button classes'),
-      '#description' => $this->t('Add space-separated classes that will be added to the button.'),
-      '#default_value' => $config['button']['classes'],
+      '#title' => $this->t('Button label'),
+      '#description' => $this->t('A label text for the button input.'),
+      '#default_value' => $config['button']['label'],
     ];
     $form['enable_autocomplete'] = [
       '#type' => 'checkbox',
@@ -224,17 +216,17 @@ class SearchBlock extends BlockBase implements ContainerFactoryPluginInterface {
     $values = $form_state->getValues();
     $this->setConfigurationValue('form', [
       'action' => $form_state->getValue('form_action'),
+      'region' => $form_state->getCompleteFormState()->getValue('region'),
     ]);
     $input = $values['input'];
     $this->setConfigurationValue('input', [
       'name' => $input['input_name'],
       'label' => $input['input_label'],
-      'classes' => $input['input_classes'],
       'placeholder' => $input['input_placeholder'],
     ]);
     $button = $values['button'];
     $this->setConfigurationValue('button', [
-      'classes' => $button['button_classes'],
+      'label' => $button['button_label'],
     ]);
     $this->setConfigurationValue('view_options', [
       'id' => $form_state->getValue('view_id'),
@@ -249,18 +241,6 @@ class SearchBlock extends BlockBase implements ContainerFactoryPluginInterface {
   public function blockValidate($form, FormStateInterface $form_state): void {
     $values = $form_state->getValues();
 
-    if ($values['input']['input_classes'] !== Html::cleanCssIdentifier($values['input']['input_classes'])) {
-      $form_state->setErrorByName('input][input_classes', $this->t('Field "@field_name" does not contain a valid css class.', [
-        '@field_name' => $form['input']['input_classes']['#title'],
-      ]));
-    }
-
-    if ($values['button']['button_classes'] !== Html::cleanCssIdentifier($values['button']['button_classes'])) {
-      $form_state->setErrorByName('button][button_classes', $this->t('Field "@field_name" does not contain a valid css class.', [
-        '@field_name' => $form['button']['button_classes']['#title'],
-      ]));
-    }
-
     if (!$this->moduleHandler->moduleExists('views') || !$this->moduleHandler->moduleExists('search_api_autocomplete')) {
       return;
     }
diff --git a/modules/oe_whitelabel_search/tests/src/Kernel/SearchBlockTest.php b/modules/oe_whitelabel_search/tests/src/Kernel/SearchBlockTest.php
index 4fca8c00b7b0e849890b4ca3dc01c6dbbd32de7b..8a8dd84a2e6be546258ead8b51d7cf48317ae011 100644
--- a/modules/oe_whitelabel_search/tests/src/Kernel/SearchBlockTest.php
+++ b/modules/oe_whitelabel_search/tests/src/Kernel/SearchBlockTest.php
@@ -82,9 +82,46 @@ class SearchBlockTest extends KernelTestBase {
   }
 
   /**
-   * Tests the rendering of the whitelabel search block.
+   * Tests the rendering of the whitelabel search block navigation_right region.
    */
-  public function testBlockRendering(): void {
+  public function testNavigationRightSearchBlockRendering(): void {
+    $block_entity_storage = $this->container
+      ->get('entity_type.manager')
+      ->getStorage('block');
+    $entity = $block_entity_storage->load('oe_whitelabel_search_form');
+    $builder = \Drupal::entityTypeManager()->getViewBuilder('block');
+    $build = $builder->view($entity, 'block');
+    $render = $this->container->get('renderer')->renderRoot($build);
+    $crawler = new Crawler($render->__toString());
+
+    // Assert the form rendering.
+    $form = $crawler->filter('form');
+    $this->assertCount(1, $form);
+    $this->assertSame('oe-whitelabel-search-form', $form->attr('id'));
+    $this->assertStringContainsString('d-flex', $form->attr('class'));
+    // Assert search text box.
+    $input = $crawler->filter('input[name="search_input"]');
+    $this->assertCount(1, $input);
+    $classes = 'required form-control border-start-0 rounded-0 rounded-start';
+    $this->assertSame($classes, $input->attr('class'));
+    $this->assertSame('Search', $input->attr('placeholder'));
+    // Assert the button and icon rendering.
+    $button = $form->filter('button');
+    $this->assertCount(1, $button);
+    $this->assertStringContainsString('rounded-end', $button->attr('class'));
+    $this->assertStringContainsString('rounded-0', $button->attr('class'));
+    $this->assertStringContainsString('border-start-0', $button->attr('class'));
+    $this->assertStringContainsString('btn', $button->attr('class'));
+    $this->assertStringContainsString('btn-md', $button->attr('class'));
+    $this->assertStringContainsString('btn-light', $button->attr('class'));
+    $icon = $button->filter('.bi.icon--fluid');
+    $this->assertCount(1, $icon);
+  }
+
+  /**
+   * Tests the rendering of the whitelabel search block header region.
+   */
+  public function testHeaderSearchBlockRendering(): void {
     $block_entity_storage = $this->container
       ->get('entity_type.manager')
       ->getStorage('block');
@@ -93,20 +130,20 @@ class SearchBlockTest extends KernelTestBase {
       'theme' => 'oe_whitelabel',
       'plugin' => 'whitelabel_search_block',
       'settings' => [
-        'id' => 'search_block',
-        'label' => 'Search block',
+        'id' => 'whitelabel_search_block',
+        'label' => 'Header Search block',
         'provider' => 'oe_whitelabel_search',
         'form' => [
-          'action' => '/search',
+          'action' => 'search',
+          'region' => 'header',
         ],
         'input' => [
-          'name' => 'text',
+          'name' => 'search_api_fulltext',
           'label' => 'Search',
           'placeholder' => 'Search',
-          'classes' => 'input-test-class',
         ],
         'button' => [
-          'classes' => 'button-test-class',
+          'label' => 'Search',
         ],
         'view_options' => [
           'enable_autocomplete' => TRUE,
@@ -122,33 +159,37 @@ class SearchBlockTest extends KernelTestBase {
     $render = $this->container->get('renderer')->renderRoot($build);
     $crawler = new Crawler($render->__toString());
 
+    // Assert header form wrappers.
+    $wrapper = $crawler->filter(
+      'div.bg-lighter > div.container > div.row > div.col-12.col-lg-6.offset-lg-3'
+    );
+    $this->assertCount(1, $wrapper);
     // Assert the form rendering.
-    $block = $crawler->filter('#block-whitelabel-search-block');
-    $this->assertCount(1, $block);
-    $form = $block->filter('#oe-whitelabel-search-form');
+    $form = $wrapper->filter('form');
     $this->assertCount(1, $form);
-    $this->assertSame('d-flex mt-3 mt-lg-0', $form->attr('class'));
+    $this->assertSame('oe-whitelabel-search-form', $form->attr('id'));
+    $this->assertSame('bcl-search-form submittable', $form->attr('class'));
     // Assert the field wrapper rendering.
     $wrapper = $form->filter('.bcl-search-form__group');
     $this->assertCount(1, $wrapper);
     // Assert search text box.
-    $input = $crawler->filter('.input-test-class');
+    $input = $crawler->filter('input[name="search_input"]');
     $this->assertCount(1, $input);
-    $classes = 'input-test-class rounded-0 rounded-start form-autocomplete required form-control';
+    $classes = 'form-autocomplete required form-control bcl-search-form__input';
     $this->assertSame($classes, $input->attr('class'));
     $this->assertSame('Search', $input->attr('placeholder'));
-    // Assert the hidden label.
-    $label = $wrapper->filter('label');
-    $this->assertSame('Search', $label->text());
-    $classes = 'visually-hidden js-form-required form-required form-label';
-    $this->assertSame($classes, $label->attr('class'));
     // Assert the button and icon rendering.
-    $button = $crawler->filter('.button-test-class');
+    $button = $form->filter('button');
     $this->assertCount(1, $button);
-    $classes = 'border-start-0 rounded-0 rounded-end d-flex btn btn-light btn-md py-2 button-test-class btn btn-light';
-    $this->assertSame($classes, $button->attr('class'));
+    $this->assertStringContainsString('bcl-search-form__submit', $button->attr('class'));
+    $this->assertStringContainsString('btn', $button->attr('class'));
+    $this->assertStringContainsString('btn-primary', $button->attr('class'));
+    $this->assertStringContainsString('btn-md', $button->attr('class'));
     $icon = $button->filter('.bi.icon--fluid');
     $this->assertCount(1, $icon);
+    $label = $button->filter('span.d-none.d-lg-inline-block');
+    $this->assertCount(1, $label);
+    $this->assertEquals('Search', $label->text());
   }
 
 }
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 f33dffc54ae9eb351cef818fc3b4a8ab0f9994c6..1f2f14298052a273e6c1de961c858114b90d7000 100644
--- a/runner.yml.dist
+++ b/runner.yml.dist
@@ -18,14 +18,15 @@ drupal:
     - "./vendor/bin/drush en config_devel -y"
     - "./vendor/bin/drush en field_ui -y"
     - "./vendor/bin/drush en oe_authentication -y"
-    - "./vendor/bin/drush en oe_whitelabel_helper -y"
-    - "./vendor/bin/drush en oe_whitelabel_contact_forms -y"
     - "./vendor/bin/drush en oe_whitelabel_multilingual -y"
-    - "./vendor/bin/drush en oe_whitelabel_paragraphs -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_list_pages -y"
-    - "./vendor/bin/drush en oe_whitelabel_starter_event -y"
     - "./vendor/bin/drush en oe_whitelabel_starter_news -y"
+    - "./vendor/bin/drush en oe_whitelabel_starter_event -y"
+    - "./vendor/bin/drush en oe_whitelabel_paragraphs -y"
     - "./vendor/bin/drush en toolbar -y"
     - "./vendor/bin/drush theme:enable oe_whitelabel -y"
     - "./vendor/bin/drush theme:enable seven -y"
@@ -50,6 +51,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" }
@@ -58,6 +65,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/templates/content/node--oe-sc-event--full.html.twig b/templates/content/node--oe-sc-event--full.html.twig
index f776617687811f1833abbfdeaefb91cac6bdfe51..76eebb32b298f9c24dbdb213a437cd534e7fbf95 100755
--- a/templates/content/node--oe-sc-event--full.html.twig
+++ b/templates/content/node--oe-sc-event--full.html.twig
@@ -45,10 +45,14 @@
 {% endset %}
 
 <article{{attributes}}>
-  {{ pattern('inpage_navigation', {
-    title: 'Page content',
-    links: inpage_navigation_links,
-    content: inpage_navigation_fields,
-    full_layout: true,
-  }) }}
+  {% if inpage_navigation_links is empty %}
+    {{ inpage_navigation_fields }}
+  {% else %}
+    {{ pattern('inpage_navigation', {
+      title: 'Page content',
+      links: inpage_navigation_links,
+      content: inpage_navigation_fields,
+      full_layout: true,
+    }) }}
+  {% endif %}
 </article>
diff --git a/templates/overrides/navigation/block--language-block--language-interface.html.twig b/templates/overrides/navigation/block--language-block--language-interface.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..4bcb997360c9c480f2aca282cc98dd446afbfa25
--- /dev/null
+++ b/templates/overrides/navigation/block--language-block--language-interface.html.twig
@@ -0,0 +1,13 @@
+{#
+/**
+ * @file
+ * Override for blocks with plugin id = 'language_block:language_interface'.
+ *
+ * @see \Drupal\language\Plugin\Block\LanguageBlock
+ */
+#}
+{% block content %}
+  <div class="language-switcher-block nav-link">
+    {{ content }}
+  </div>
+{% endblock %}
diff --git a/templates/overrides/navigation/block--languageswitcherinterfacetext.html.twig b/templates/overrides/navigation/block--languageswitcherinterfacetext.html.twig
deleted file mode 100644
index a59ef48ca620cdd4316ab1b00f2f080c8927590f..0000000000000000000000000000000000000000
--- a/templates/overrides/navigation/block--languageswitcherinterfacetext.html.twig
+++ /dev/null
@@ -1,32 +0,0 @@
-{#
-/**
- * @file
- * Default theme implementation to display a block.
- *
- * Available variables:
- * - plugin_id: The ID of the block implementation.
- * - label: The configured label of the block if visible.
- * - configuration: A list of the block's configuration values.
- *   - label: The configured label for the block.
- *   - label_display: The display settings for the label.
- *   - provider: The module or other provider that provided this block plugin.
- *   - Block plugin specific settings will also be stored here.
- * - content: The content of this block.
- * - attributes: array of HTML attributes populated by modules, intended to
- *   be added to the main container tag of this template.
- *   - id: A valid HTML ID and guaranteed unique.
- * - title_attributes: Same as attributes, except applied to the main title
- *   tag that appears in the template.
- * - title_prefix: Additional output populated by modules, intended to be
- *   displayed in front of the main title tag that appears in the template.
- * - title_suffix: Additional output populated by modules, intended to be
- *   displayed after the main title tag that appears in the template.
- *
- * @see template_preprocess_block()
- *
- * @ingroup themeable
- */
-#}
-{% block content %}
-  {{ content }}
-{% endblock %}
diff --git a/templates/overrides/navigation/block--oe-authentication-login_block.html.twig b/templates/overrides/navigation/block--oe-authentication-login-block.html.twig
similarity index 80%
rename from templates/overrides/navigation/block--oe-authentication-login_block.html.twig
rename to templates/overrides/navigation/block--oe-authentication-login-block.html.twig
index 8cd37ebe6c4bca1b8d5ae1959f5ff3af57ddbf03..d96994c6c73eaa96e0400f638a0cf7f1d7500bd6 100644
--- a/templates/overrides/navigation/block--oe-authentication-login_block.html.twig
+++ b/templates/overrides/navigation/block--oe-authentication-login-block.html.twig
@@ -1,7 +1,9 @@
 {#
 /**
  * @file
- * icon.
+ * Override for blocks with plugin id = 'oe_authentication_login_block'.
+ *
+ * @see \Drupal\oe_authentication\Plugin\Block\LoginBlock
  */
 #}
 {% set extra_attributes = create_attribute() %}
diff --git a/templates/overrides/navigation/block--oe-whitelabel-language-switcher.html.twig b/templates/overrides/navigation/block--oe-whitelabel-language-switcher.html.twig
deleted file mode 100644
index 9a3ac86dbf65d9e5bea9fcd5b79d604d1ec9e21f..0000000000000000000000000000000000000000
--- a/templates/overrides/navigation/block--oe-whitelabel-language-switcher.html.twig
+++ /dev/null
@@ -1,5 +0,0 @@
-{% block content %}
-  <div class="language-switcher-block nav-link">
-    {{ content }}
-  </div>
-{% endblock %}
diff --git a/templates/overrides/navigation/block--system-branding-block.html.twig b/templates/overrides/navigation/block--system-branding-block.html.twig
index 31f8491a0f9e433a1d8b580671d1b7918443999c..4aa368ab01df4b1161deee57355958de17df34a5 100644
--- a/templates/overrides/navigation/block--system-branding-block.html.twig
+++ b/templates/overrides/navigation/block--system-branding-block.html.twig
@@ -1,15 +1,10 @@
 {#
 /**
  * @file
- * Theme override for a branding block.
+ * Override for blocks with plugin id = 'system_branding_block'.
  *
- * Each branding element variable (logo, name, slogan) is only available if
- * enabled in the block configuration.
- *
- * Available variables:
- * - site_logo: Logo for site as defined in Appearance or theme settings.
- * - site_name: Name for site as defined in Site information settings.
- * - site_slogan: Slogan for site as defined in Site information settings.
+ * @see build/core/modules/system/templates/block--system-branding-block.html.twig
+ * @see \Drupal\system\Plugin\Block\SystemBrandingBlock
  */
 #}
 {% set light = bcl_header_style == 'light' %}
diff --git a/templates/overrides/page/page.html.twig b/templates/overrides/page/page.html.twig
index a6f0c6e8572d5833f83c1e70f4b8e16d5876df3a..904ff33139189e0df63e050a4bb6d078fbb9b0f5 100644
--- a/templates/overrides/page/page.html.twig
+++ b/templates/overrides/page/page.html.twig
@@ -109,7 +109,7 @@
   {% endblock %}
   <main>
     {{ page.hero }}
-    <div class="container {{ page.header ? 'mt-2': 'mt-5' }}">
+    <div class="container mt-md-4-75 mt-4">
       <div class="row">
         {% if page.highlighted %}
           {{ page.highlighted }}
diff --git a/templates/overrides/search/block--facets-form.html.twig b/templates/overrides/search/block--facets-form.html.twig
index e9028b594e4602ecfbdb3414197fe25d652462ed..37d1516e3bf52037ab6fb4d435a09fc1c32e186a 100644
--- a/templates/overrides/search/block--facets-form.html.twig
+++ b/templates/overrides/search/block--facets-form.html.twig
@@ -1,12 +1,11 @@
 {#
 /**
  * @file
- * Theme block implementation to display facet form.
+ * Override for blocks with plugin id = 'facets_form'.
  *
- * @see ./core/themes/stable/templates/block/block.html.twig
+ * @see \Drupal\facets_form\Plugin\Block\FacetsFormBlock
  */
 #}
-
 {% if content.actions.reset['#type'] == 'link' %}
   {% set extra_attributes = create_attribute() %}
   {% set reset = pattern('link', {
diff --git a/templates/overrides/search/block--facets-summary-block.html.twig b/templates/overrides/search/block--facets-summary-block.html.twig
index d777df73c277dd4a862927a372f52930823996a0..d0f6d0bead5cee5e65cc15307e8dc4d010999ea4 100644
--- a/templates/overrides/search/block--facets-summary-block.html.twig
+++ b/templates/overrides/search/block--facets-summary-block.html.twig
@@ -1,9 +1,9 @@
 {#
 /**
  * @file
- * Theme block implementation to display facets summary.
+ * Override for blocks with plugin id = 'facets_summary_block'.
  *
- * @see ./core/themes/stable/templates/block/block.html.twig
+ * @see \Drupal\facets_summary\Plugin\Block\FacetsSummaryBlock
  */
 #}
 {% if label and content['#items'][0]['#theme'] is defined and content['#items'][0]['#theme'] == 'facets_summary_count' %}
diff --git a/templates/overrides/search/form--header--oe-whitelabel-search-form.html.twig b/templates/overrides/search/form--header--oe-whitelabel-search-form.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..2628c4097896c6ecc598b27a19a6275907776bb6
--- /dev/null
+++ b/templates/overrides/search/form--header--oe-whitelabel-search-form.html.twig
@@ -0,0 +1,21 @@
+{#
+/**
+ * @file
+ * Search form template for the header region.
+ *
+ * @see ./core/modules/system/templates/form.html.twig
+ */
+#}
+<div class="bg-lighter py-4 py-lg-3">
+  <div class="container">
+    <div class="row">
+      <div class="col-12 col-lg-6 offset-lg-3">
+        <form {{ attributes.addClass('bcl-search-form', 'submittable') }}>
+          <div class="bcl-search-form__group">
+            {{ children }}
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+</div>
diff --git a/templates/overrides/search/form--navigation-right--oe-whitelabel-search-form.html.twig b/templates/overrides/search/form--navigation-right--oe-whitelabel-search-form.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..c4edf6c952cd1d14d29231b5b40ffd99eeeee721
--- /dev/null
+++ b/templates/overrides/search/form--navigation-right--oe-whitelabel-search-form.html.twig
@@ -0,0 +1,8 @@
+{#
+/**
+ * @file
+ * Search form template for the navigation right region.
+ */
+#}
+{% extends "form.html.twig" %}
+{% set attributes = attributes.addClass('d-flex', 'mt-3', 'mt-lg-0') %}
diff --git a/templates/overrides/search/form-element--oe-whitelabel-search-form.html.twig b/templates/overrides/search/form-element--oe-whitelabel-search-form.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..05e1de1c2dbb96363bad19b9873a7fabd55ea125
--- /dev/null
+++ b/templates/overrides/search/form-element--oe-whitelabel-search-form.html.twig
@@ -0,0 +1,17 @@
+{#
+/**
+ * @file
+ * Theme implementation for the search form element.
+ *
+ * Display search form without wrappers or other elements like label, prefix, suffix or description.
+ *
+ * @see ./core/modules/system/templates/form-element.html.twig
+ */
+#}
+{{ children }}
+
+{% if errors %}
+  <div class="form-item--error-message invalid-feedback d-block">
+    {{ errors }}
+  </div>
+{% endif %}
diff --git a/templates/overrides/search/form-element--search-api-autocomplete.html.twig b/templates/overrides/search/form-element--search-api-autocomplete.html.twig
deleted file mode 100644
index 046ac772e59ef8ddbf2d85f0afa332ba6715dd2d..0000000000000000000000000000000000000000
--- a/templates/overrides/search/form-element--search-api-autocomplete.html.twig
+++ /dev/null
@@ -1,98 +0,0 @@
-{#
-/**
- * @file
- * Default theme implementation for a form element.
- *
- * Available variables:
- * - attributes: HTML attributes for the containing element.
- * - errors: (optional) Any errors for this form element, may not be set.
- * - prefix: (optional) The form element prefix, may not be set.
- * - suffix: (optional) The form element suffix, may not be set.
- * - required: The required marker, or empty if the associated form element is
- *   not required.
- * - type: The type of the element.
- * - name: The name of the element.
- * - label: A rendered label element.
- * - label_display: Label display setting. It can have these values:
- *   - before: The label is output before the element. This is the default.
- *     The label includes the #title and the required marker, if #required.
- *   - after: The label is output after the element. For example, this is used
- *     for radio and checkbox #type elements. If the #title is empty but the
- *     field is #required, the label will contain only the required marker.
- *   - invisible: Labels are critical for screen readers to enable them to
- *     properly navigate through forms but can be visually distracting. This
- *     property hides the label for everyone except screen readers.
- *   - attribute: Set the title attribute on the element to create a tooltip but
- *     output no label element. This is supported only for checkboxes and radios
- *     in \Drupal\Core\Render\Element\CompositeFormElementTrait::preRenderCompositeFormElement().
- *     It is used where a visual label is not needed, such as a table of
- *     checkboxes where the row and column provide the context. The tooltip will
- *     include the title and required marker.
- * - description: (optional) A list of description properties containing:
- *    - content: A description of the form element, may not be set.
- *    - attributes: (optional) A list of HTML attributes to apply to the
- *      description content wrapper. Will only be set when description is set.
- * - description_display: Description display setting. It can have these values:
- *   - before: The description is output before the element.
- *   - after: The description is output after the element. This is the default
- *     value.
- *   - invisible: The description is output after the element, hidden visually
- *     but available to screen readers.
- * - disabled: True if the element is disabled.
- * - title_display: Title display setting.
- *
- * @see template_preprocess_form_element()
- *
- * @ingroup themeable
- */
-#}
-{%
-  set classes = [
-  'js-form-item',
-  'form-item',
-  'js-form-type-' ~ type|clean_class,
-  'form-item-' ~ name|clean_class,
-  'js-form-item-' ~ name|clean_class,
-  title_display not in ['after', 'before'] ? 'form-no-label',
-  disabled == 'disabled' ? 'form-disabled',
-  errors ? 'form-item--error',
-]
-%}
-{%
-  set description_classes = [
-  'description',
-  'form-text',
-  'text-muted',
-  description_display == 'invisible' ? 'visually-hidden',
-]
-%}
-<div{{ attributes.addClass(classes) }}>
-  {% if label_display in ['before', 'invisible'] %}
-    {{ label }}
-  {% endif %}
-  {% if prefix is not empty %}
-    <span class="field-prefix">{{ prefix }}</span>
-  {% endif %}
-  {% if description_display == 'before' and description.content %}
-    <small{{ description.attributes }}>
-      {{ description.content }}
-    </small>
-  {% endif %}
-  {{ children }}
-  {% if suffix is not empty %}
-    <span class="field-suffix">{{ suffix }}</span>
-  {% endif %}
-  {% if label_display == 'after' %}
-    {{ label }}
-  {% endif %}
-  {% if errors %}
-    <div class="form-item--error-message invalid-feedback d-block">
-      {{ errors }}
-    </div>
-  {% endif %}
-  {% if description_display in ['after', 'invisible'] and description.content %}
-    <small{{ description.attributes.addClass(description_classes) }}>
-      {{ description.content }}
-    </small>
-  {% endif %}
-</div>
diff --git a/templates/overrides/search/input--submit--header--oe-whitelabel-search-form.html.twig b/templates/overrides/search/input--submit--header--oe-whitelabel-search-form.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..f2e9de362da5a4062cc6bbf4f9d9ff6caf19afc0
--- /dev/null
+++ b/templates/overrides/search/input--submit--header--oe-whitelabel-search-form.html.twig
@@ -0,0 +1,26 @@
+{#
+/**
+ * @file
+ * Theme for the header search input submit element.
+ *
+ * @see ./core/modules/system/templates/input.html.twig
+ */
+#}
+{% if element['#value'] is not empty %}
+  {% set label %}
+    <span class="d-none d-lg-inline-block">{{ element['#value']|t }}</span>
+  {% endset %}
+{% endif %}
+{{ pattern('button', {
+  'variant': 'primary',
+  'icon': 'search',
+  'label': label,
+  'type': 'submit',
+  'icon_position': 'before',
+  'attributes': attributes
+    .addClass([
+      'bcl-search-form__submit',
+      'px-3'
+    ])
+}) }}
+{{ children }}
diff --git a/templates/overrides/search/input--submit--navigation-right--oe-whitelabel-search-form.html.twig b/templates/overrides/search/input--submit--navigation-right--oe-whitelabel-search-form.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..67c8a22d7b0308df714d6fca84f34651abf473fd
--- /dev/null
+++ b/templates/overrides/search/input--submit--navigation-right--oe-whitelabel-search-form.html.twig
@@ -0,0 +1,28 @@
+{#
+/**
+ * @file
+ * Theme for the navigation_right search input submit element.
+ *
+ * @see ./core/modules/system/templates/input.html.twig
+ */
+#}
+{% if element['#value'] is not empty %}
+  {% set label %}
+    <span class="d-none d-lg-inline-block">{{ element['#value']|t }}</span>
+  {% endset %}
+{% endif %}
+{{ pattern('button', {
+  'variant': 'light',
+  'label': label,
+  'icon': 'search',
+  'type': 'submit',
+  'icon_position': 'before',
+  'attributes': attributes
+    .addClass([
+      'border-start-0',
+      'rounded-0',
+      'rounded-end',
+      'px-3',
+    ])
+}) }}
+{{ children }}
diff --git a/templates/overrides/search/input--textfield--header--oe-whitelabel-search-form.html.twig b/templates/overrides/search/input--textfield--header--oe-whitelabel-search-form.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..216db5dee1f03b20b39a9e249181df3c4f3c3b10
--- /dev/null
+++ b/templates/overrides/search/input--textfield--header--oe-whitelabel-search-form.html.twig
@@ -0,0 +1,14 @@
+{#
+/**
+ * @file
+ * Theme for the header search input textfield element.
+ */
+#}
+{% extends "input--textfield.html.twig" %}
+{% set attributes = attributes
+  .addClass([
+    'form-control',
+    'bcl-search-form__input',
+    attributes.hasClass('error') ? 'is-invalid',
+  ])
+  .removeClass('form-text') %}
diff --git a/templates/overrides/search/input--textfield--navigation-right--oe-whitelabel-search-form.html.twig b/templates/overrides/search/input--textfield--navigation-right--oe-whitelabel-search-form.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..614953b68e4ebec5d77a0adf1edd894c02f741ef
--- /dev/null
+++ b/templates/overrides/search/input--textfield--navigation-right--oe-whitelabel-search-form.html.twig
@@ -0,0 +1,16 @@
+{#
+/**
+ * @file
+ * Theme for the navigation right search input textfield element.
+ */
+#}
+{% extends "input--textfield.html.twig" %}
+{% set attributes = attributes
+  .addClass([
+    'form-control',
+    'border-start-0',
+    'rounded-0',
+    'rounded-start',
+    attributes.hasClass('error') ? 'is-invalid',
+  ])
+  .removeClass('form-text') %}
diff --git a/templates/search/block--oe-whitelabel-search-form.html.twig b/templates/search/block--oe-whitelabel-search-form.html.twig
deleted file mode 100644
index fde06a889b444923970c32066c416d4a754c6127..0000000000000000000000000000000000000000
--- a/templates/search/block--oe-whitelabel-search-form.html.twig
+++ /dev/null
@@ -1,11 +0,0 @@
-{#
-/**
- * @file
- * Theme override for the search box block.
- *
- * @see ./core/modules/system/templates/block.html.twig
- */
-#}
-{% block content %}
-  {{ content }}
-{% endblock %}
diff --git a/templates/search/block--whitelabel-search-block.html.twig b/templates/search/block--whitelabel-search-block.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..5a34679c794fb55999aa7a6683f7a84266cbe058
--- /dev/null
+++ b/templates/search/block--whitelabel-search-block.html.twig
@@ -0,0 +1,13 @@
+{#
+/**
+ * @file
+ * Override for blocks with plugin id = 'whitelabel_search_block'.
+ *
+ * Removes outer wrapper div and block label.
+ *
+ * @see \Drupal\oe_whitelabel_search\Plugin\Block\SearchBlock
+ */
+#}
+{% block content %}
+  {{ content }}
+{% endblock %}
diff --git a/templates/search/form--oe-whitelabel-search-form.html.twig b/templates/search/form--oe-whitelabel-search-form.html.twig
deleted file mode 100644
index f11d99ca398d7e5bcddb7670cfa7e913f00df5d9..0000000000000000000000000000000000000000
--- a/templates/search/form--oe-whitelabel-search-form.html.twig
+++ /dev/null
@@ -1,10 +0,0 @@
-{#
-/**
- * @file
- * Theme override for a 'form' element.
- * @see ./core/themes/stable/templates/form/form.html.twig
- */
-#}
-<form{{ attributes.addClass('d-flex', 'mt-3', 'mt-lg-0') }}>
-  {{ children }}
-</form>
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/Functional/ContentEventRenderTest.php b/tests/src/Functional/ContentEventRenderTest.php
index 9163263d20ff8dc3c578e0edcb59f0d21c75e468..7f7d10b299d892400f3bd6e6546ce9a5661437e7 100644
--- a/tests/src/Functional/ContentEventRenderTest.php
+++ b/tests/src/Functional/ContentEventRenderTest.php
@@ -153,6 +153,15 @@ class ContentEventRenderTest extends WhitelabelBrowserTestBase {
       'Event body',
       $crawler->filter('#oe-content-body p')->text()
     );
+
+    // Assert inpage_navigation not loaded if there is no body and documents.
+    $node->set('oe_documents', NULL);
+    $node->set('body', NULL);
+    $node->save();
+
+    $this->drupalGet('node/' . $node->id());
+
+    $this->assertSession()->elementNotExists('css', 'nav.bcl-inpage-navigation');
   }
 
   /**
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;
+
+}