From d1d631590cbe525d84efa19568af81429798cff1 Mon Sep 17 00:00:00 2001
From: Jinjiu Liu <jili@odoo.com>
Date: Thu, 22 May 2025 12:39:25 +0200
Subject: [PATCH 01/10] [FIX] remove the unnecessary link preview loading and
 notification

Because when we change the link element in the popover and apply, we
always close the current one and open a new one, the loading at the apply
is redundant. We do the reopening to let the overlay plugin reposition popover,
and let browser handle the relative/root-relative url to add the domain.
---
 addons/html_editor/static/src/main/link/link_popover.js | 1 -
 1 file changed, 1 deletion(-)

diff --git a/addons/html_editor/static/src/main/link/link_popover.js b/addons/html_editor/static/src/main/link/link_popover.js
index d8ff9c7220457..860df0047d051 100644
--- a/addons/html_editor/static/src/main/link/link_popover.js
+++ b/addons/html_editor/static/src/main/link/link_popover.js
@@ -215,7 +215,6 @@ export class LinkPopover extends Component {
         this.state.url = deducedUrl
             ? this.correctLink(deducedUrl)
             : this.correctLink(this.state.url);
-        this.loadAsyncLinkPreview();
     }
     onClickEdit() {
         this.state.editing = true;

From 40c29506a816fd9a6075da02ac65536779ea92ee Mon Sep 17 00:00:00 2001
From: Alessandro Lupo <alup@odoo.com>
Date: Mon, 12 May 2025 14:30:16 +0200
Subject: [PATCH 02/10] [FIX] html_builder: fix space between rating stars

---
 .../static/src/core/setup_editor_plugin.js    |  3 +-
 .../builder/plugins/rating_option_plugin.js   |  8 +--
 .../builder/options/rating_option.test.js     | 71 ++++++++++---------
 3 files changed, 44 insertions(+), 38 deletions(-)

diff --git a/addons/html_builder/static/src/core/setup_editor_plugin.js b/addons/html_builder/static/src/core/setup_editor_plugin.js
index 719f4b47c3e61..33101f986362a 100644
--- a/addons/html_builder/static/src/core/setup_editor_plugin.js
+++ b/addons/html_builder/static/src/core/setup_editor_plugin.js
@@ -1,13 +1,14 @@
 import { Plugin } from "@html_editor/plugin";
 import { _t } from "@web/core/l10n/translation";
 import { registry } from "@web/core/registry";
+import { withSequence } from "@html_editor/utils/resource";
 
 export class SetupEditorPlugin extends Plugin {
     static id = "setup_editor_plugin";
     static shared = ["getEditableAreas"];
     resources = {
         clean_for_save_handlers: this.cleanForSave.bind(this),
-        normalize_handlers: this.setContenteditable.bind(this),
+        normalize_handlers: withSequence(0, this.setContenteditable.bind(this)),
     };
 
     setup() {
diff --git a/addons/website/static/src/builder/plugins/rating_option_plugin.js b/addons/website/static/src/builder/plugins/rating_option_plugin.js
index 675f899f00183..f324ea850f2af 100644
--- a/addons/website/static/src/builder/plugins/rating_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/rating_option_plugin.js
@@ -111,11 +111,9 @@ function createIcons({ editingElement, nbActiveIcons, nbTotalIcons }) {
     const iconEls = getAllIcons(editingElement);
     [...iconEls].forEach((iconEl) => iconEl.remove());
     for (let i = 0; i < nbTotalIcons; i++) {
-        if (i < nbActiveIcons) {
-            activeIconEl.appendChild(document.createElement("i"));
-        } else {
-            inactiveIconEl.append(document.createElement("i"));
-        }
+        const targetEl = i < nbActiveIcons ? activeIconEl : inactiveIconEl;
+        targetEl.appendChild(document.createElement("i"));
+        targetEl.appendChild(document.createTextNode(" "));
     }
     renderIcons(editingElement);
 }
diff --git a/addons/website/static/tests/builder/options/rating_option.test.js b/addons/website/static/tests/builder/options/rating_option.test.js
index 3f7e7cc92d6ab..c85d645ea42d0 100644
--- a/addons/website/static/tests/builder/options/rating_option.test.js
+++ b/addons/website/static/tests/builder/options/rating_option.test.js
@@ -5,23 +5,24 @@ import { contains } from "@web/../tests/web_test_helpers";
 
 defineWebsiteModels();
 
+const websiteContent = `
+    <div class="s_rating pt16 pb16" data-icon="fa-star" data-snippet="s_rating" data-name="Rating">
+        <h4 class="s_rating_title">Quality</h4>
+        <div class="s_rating_icons o_not_editable">
+            <span class="s_rating_active_icons">
+                <i class="fa fa-star"></i>
+                <i class="fa fa-star"></i>
+                <i class="fa fa-star"></i>
+            </span>
+            <span class="s_rating_inactive_icons">
+                <i class="fa fa-star-o"></i>
+                <i class="fa fa-star-o"></i>
+            </span>
+        </div>
+    </div>`;
+
 test("change rating score", async () => {
-    await setupWebsiteBuilder(
-        `<div class="s_rating pt16 pb16" data-icon="fa-star" data-snippet="s_rating" data-name="Rating">
-            <h4 class="s_rating_title">Quality</h4>
-            <div class="s_rating_icons">
-                <span class="s_rating_active_icons">
-                    <i class="fa fa-star"></i>
-                    <i class="fa fa-star"></i>
-                    <i class="fa fa-star"></i>
-                </span>
-                <span class="s_rating_inactive_icons">
-                    <i class="fa fa-star-o"></i>
-                    <i class="fa fa-star-o"></i>
-                </span>
-            </div>
-        </div>`
-    );
+    await setupWebsiteBuilder(websiteContent);
     expect(":iframe .s_rating .s_rating_active_icons i").toHaveCount(3);
     expect(":iframe .s_rating .s_rating_inactive_icons i").toHaveCount(2);
     await contains(":iframe .s_rating").click();
@@ -33,24 +34,30 @@ test("change rating score", async () => {
     await clear();
     await fill("4");
     expect(":iframe .s_rating .s_rating_inactive_icons i").toHaveCount(3);
-});
-test("Ensure order of operations when clicking very fast on two options", async () => {
-    await setupWebsiteBuilder(
-        `<div class="s_rating pt16 pb16" data-icon="fa-star" data-snippet="s_rating" data-name="Rating">
-            <h4 class="s_rating_title">Quality</h4>
-            <div class="s_rating_icons">
-                <span class="s_rating_active_icons">
-                    <i class="fa fa-star"></i>
-                    <i class="fa fa-star"></i>
-                    <i class="fa fa-star"></i>
-                </span>
-                <span class="s_rating_inactive_icons">
-                    <i class="fa fa-star-o"></i>
-                    <i class="fa fa-star-o"></i>
-                </span>
-            </div>
+    expect(":iframe .s_rating").toHaveInnerHTML(
+        `<h4 class="s_rating_title">Quality</h4>
+        <div class="s_rating_icons o_not_editable" contenteditable="false">
+            <span class="s_rating_active_icons">
+                <i class="fa fa-star" contenteditable="false">
+                    &ZeroWidthSpace;
+                </i>
+            </span>
+            <span class="s_rating_inactive_icons">
+                <i class="fa fa-star-o" contenteditable="false">
+                    &ZeroWidthSpace;
+                </i>
+                <i class="fa fa-star-o" contenteditable="false">
+                    &ZeroWidthSpace;
+                </i>
+                <i class="fa fa-star-o" contenteditable="false">
+                    &ZeroWidthSpace;
+                </i>
+            </span>
         </div>`
     );
+});
+test("Ensure order of operations when clicking very fast on two options", async () => {
+    await setupWebsiteBuilder(websiteContent);
     await contains(":iframe .s_rating").click();
     await waitFor("[data-label='Icon']");
     expect("[data-label='Icon'] .btn-primary.dropdown-toggle").toHaveText("Stars");

From a7e45286056c6db00b0741245276c4ba1b344106 Mon Sep 17 00:00:00 2001
From: Alessandro Lupo <alup@odoo.com>
Date: Fri, 9 May 2025 17:10:53 +0200
Subject: [PATCH 03/10] [FIX] html_builder: search for snippet name in add
 snippet dialog

---
 addons/html_builder/static/src/snippets/snippet_viewer.js | 7 +++++--
 1 file changed, 5 insertions(+), 2 deletions(-)

diff --git a/addons/html_builder/static/src/snippets/snippet_viewer.js b/addons/html_builder/static/src/snippets/snippet_viewer.js
index 8fed3102f79cd..ba431f3a280ba 100644
--- a/addons/html_builder/static/src/snippets/snippet_viewer.js
+++ b/addons/html_builder/static/src/snippets/snippet_viewer.js
@@ -109,9 +109,12 @@ export class SnippetViewer extends Component {
         }
         if (this.props.state.search) {
             const strMatches = (str) =>
-                str.toLowerCase().includes(this.props.state.search.toLowerCase());
+                str ? str.toLowerCase().includes(this.props.state.search.toLowerCase()) : false;
             return snippetStructures.filter(
-                (snippet) => strMatches(snippet.title) || strMatches(snippet.keyWords || "")
+                (snippet) =>
+                    strMatches(snippet.name) ||
+                    strMatches(snippet.title) ||
+                    strMatches(snippet.keyWords)
             );
         }
 

From c60bdbe0d9c11145d1befab23eec82c7360f4ddf Mon Sep 17 00:00:00 2001
From: emge-odoo <emge@odoo.com>
Date: Mon, 19 May 2025 12:48:31 +0200
Subject: [PATCH 04/10] [FIX] website: default colorpicker tab = custom in
 theme colors option

---
 .../builder/plugins/theme/theme_colors_option.xml | 15 ++++++++++-----
 1 file changed, 10 insertions(+), 5 deletions(-)

diff --git a/addons/website/static/src/builder/plugins/theme/theme_colors_option.xml b/addons/website/static/src/builder/plugins/theme/theme_colors_option.xml
index ee75d98474e38..a22b256b72b92 100644
--- a/addons/website/static/src/builder/plugins/theme/theme_colors_option.xml
+++ b/addons/website/static/src/builder/plugins/theme/theme_colors_option.xml
@@ -18,16 +18,19 @@
                                 title="Primary"
                                 action="'customizeWebsiteColor'"
                                 actionParam="'o-color-1'"
-                                enabledTabs="['solid', 'custom']"/>
+                                enabledTabs="['solid', 'custom']"
+                                selectedTab="'custom'"/>
                             <BuilderColorPicker
                                 title="Secondary"
                                 action="'customizeWebsiteColor'"
                                 actionParam="'o-color-2'"
-                                enabledTabs="['solid', 'custom']"/>
+                                enabledTabs="['solid', 'custom']"
+                                selectedTab="'custom'"/>
                             <BuilderColorPicker
                                 action="'customizeWebsiteColor'"
                                 actionParam="'o-color-3'"
-                                enabledTabs="['solid', 'custom']"/>
+                                enabledTabs="['solid', 'custom']"
+                                selectedTab="'custom'"/>
                         </div>
                     </div>
                     <div class="d-flex flex-column h-100 justify-content-between">
@@ -36,11 +39,13 @@
                             <BuilderColorPicker
                                 action="'customizeWebsiteColor'"
                                 actionParam="'o-color-4'"
-                                enabledTabs="['solid', 'custom']"/>
+                                enabledTabs="['solid', 'custom']"
+                                selectedTab="'custom'"/>
                             <BuilderColorPicker
                                 action="'customizeWebsiteColor'"
                                 actionParam="'o-color-5'"
-                                enabledTabs="['solid', 'custom']"/>
+                                enabledTabs="['solid', 'custom']"
+                                selectedTab="'custom'"/>
                         </div>
                     </div>
                     <div class="d-flex flex-column h-100">

From 29ef1a5b87cd872bdf77d41d83dd34f7979480a2 Mon Sep 17 00:00:00 2001
From: divy-odoo <divy@odoo.com>
Date: Thu, 15 May 2025 16:05:01 +0530
Subject: [PATCH 05/10] [FIX] test_website: adapt TestImageUploadProgress test

---
 .../static/tests/tours/image_upload_progress.js      | 12 ++++++------
 .../test_website/tests/test_image_upload_progress.py |  3 ---
 2 files changed, 6 insertions(+), 9 deletions(-)

diff --git a/addons/test_website/static/tests/tours/image_upload_progress.js b/addons/test_website/static/tests/tours/image_upload_progress.js
index 4a3661fcc539a..f955964fc5f0a 100644
--- a/addons/test_website/static/tests/tours/image_upload_progress.js
+++ b/addons/test_website/static/tests/tours/image_upload_progress.js
@@ -1,6 +1,6 @@
 import { insertSnippet, registerWebsitePreviewTour } from "@website/js/tours/tour_utils";
 
-import { FileSelectorControlPanel } from "@web_editor/components/media_dialog/file_selector";
+import { FileSelectorControlPanel } from "@html_editor/main/media/media_dialog/file_selector";
 import { patch } from "@web/core/utils/patch";
 
 let patchWithError = false;
@@ -72,7 +72,7 @@ registerWebsitePreviewTour('test_image_upload_progress', {
         run: "click",
     }, {
         content: "click on add images to open image dialog (in multi mode)",
-        trigger: 'we-customizeblock-option [data-add-images]',
+        trigger: "button[data-action-id='addImage']",
         run: "click",
     }, {
         content: "manually trigger input change",
@@ -123,7 +123,7 @@ registerWebsitePreviewTour('test_image_upload_progress', {
         run: "click",
     }, {
         content: "click on replace media to open image dialog",
-        trigger: 'we-customizeblock-option [data-replace-media]',
+        trigger: "button[data-action-id='replaceMedia']",
         run: "click",
     }, {
         content: "manually trigger input change",
@@ -162,7 +162,7 @@ registerWebsitePreviewTour('test_image_upload_progress', {
         run: "click",
     }, {
         content: "click on replace media to open image dialog",
-        trigger: 'we-customizeblock-option [data-replace-media]',
+        trigger: "button[data-action-id='replaceMedia']",
         run: "click",
     }, {
         content: "manually trigger input change",
@@ -206,7 +206,7 @@ registerWebsitePreviewTour('test_image_upload_progress_unsplash', {
         run: "click",
     }, {
         content: "click on replace media to open image dialog",
-        trigger: 'we-customizeblock-option [data-replace-media]',
+        trigger: "button[data-action-id='replaceMedia']",
         run: "click",
     }, {
         content: "search 'fox' images",
@@ -230,7 +230,7 @@ registerWebsitePreviewTour('test_image_upload_progress_unsplash', {
         run: "click",
     }, {
         content: "unsplash image (mocked to logo) should have been used",
-        trigger: ":iframe #wrap .s_image_gallery .img[data-original-src^='/unsplash/HQqIOc8oYro/fox']",
+        trigger: ":iframe #wrap .s_image_gallery img[src^='/unsplash/HQqIOc8oYro/fox']",
         run() {
             unpatchMediaDialog();
         },
diff --git a/addons/test_website/tests/test_image_upload_progress.py b/addons/test_website/tests/test_image_upload_progress.py
index a9d0c5d6bda18..019728c4700cb 100644
--- a/addons/test_website/tests/test_image_upload_progress.py
+++ b/addons/test_website/tests/test_image_upload_progress.py
@@ -9,9 +9,6 @@
 from odoo import http
 import unittest
 
-
-# TODO master-mysterious-egg fix error
-@unittest.skip("prepare mysterious-egg for merging")
 @odoo.tests.common.tagged('post_install', '-at_install')
 class TestImageUploadProgress(odoo.tests.HttpCase):
 

From 1a79c817f44c23a58c912208c0c9278ed7abbb48 Mon Sep 17 00:00:00 2001
From: divy-odoo <divy@odoo.com>
Date: Thu, 15 May 2025 15:18:37 +0530
Subject: [PATCH 06/10] [FIX] test_website: adapt
 test_01_multi_website_settings test

---
 addons/test_website/tests/test_settings.py | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/addons/test_website/tests/test_settings.py b/addons/test_website/tests/test_settings.py
index 032ec85890f42..7f569ac528e97 100644
--- a/addons/test_website/tests/test_settings.py
+++ b/addons/test_website/tests/test_settings.py
@@ -7,8 +7,7 @@
 
 @odoo.tests.tagged('-at_install', 'post_install')
 class TestWebsiteSettings(odoo.tests.HttpCase):
-    # TODO master-mysterious-egg fix error
-    @unittest.skip("prepare mysterious-egg for merging")
+
     def test_01_multi_website_settings(self):
         # If not enabled (like in demo data), landing on res.config will try
         # to disable module_sale_quotation_builder and raise an issue

From 05412f4a1522c7d445a62988b76ce9de0f7799c4 Mon Sep 17 00:00:00 2001
From: divy-odoo <divy@odoo.com>
Date: Fri, 9 May 2025 18:06:52 +0530
Subject: [PATCH 07/10] [FIX] website: adapt reset_view test

---
 addons/test_website/static/tests/tours/reset_views.js | 3 ++-
 addons/test_website/tests/test_reset_views.py         | 2 --
 2 files changed, 2 insertions(+), 3 deletions(-)

diff --git a/addons/test_website/static/tests/tours/reset_views.js b/addons/test_website/static/tests/tours/reset_views.js
index 9a4323b81a54e..0e242a37a7f4f 100644
--- a/addons/test_website/static/tests/tours/reset_views.js
+++ b/addons/test_website/static/tests/tours/reset_views.js
@@ -20,7 +20,8 @@ registerWebsitePreviewTour(
     () => [
         {
             content: "Drag the Intro snippet group and drop it in #oe_structure_test_website_page.",
-            trigger: '#oe_snippets .oe_snippet[name="Intro"] .oe_snippet_thumbnail:not(.o_we_ongoing_insertion)',
+            trigger:
+                "#snippet_groups .o_snippet[name='Intro'] .o_snippet_thumbnail:not(.o_we_ongoing_insertion) .o_snippet_thumbnail_area",
             // id starting by 'oe_structure..' will actually create an inherited view
             run: "drag_and_drop :iframe #oe_structure_test_website_page",
         },
diff --git a/addons/test_website/tests/test_reset_views.py b/addons/test_website/tests/test_reset_views.py
index 498e01665ecdd..aa48e4d0cda3f 100644
--- a/addons/test_website/tests/test_reset_views.py
+++ b/addons/test_website/tests/test_reset_views.py
@@ -93,8 +93,6 @@ def test_06_reset_specific_view_controller_inexisting_template(self):
         self.assertEqual(total_views + 1, self.View.search_count([('type', '=', 'qweb')]), "Missing COW view (2)")
         self.fix_it('/test_view')
 
-    # TODO master-mysterious-egg fix error
-    @unittest.skip("prepare mysterious-egg for merging")
     @mute_logger('odoo.http')
     def test_07_reset_page_view_complete_flow(self):
         self.start_tour(self.env['website'].get_client_action_url('/test_page_view'), 'test_reset_page_view_complete_flow_part1', login="admin")

From 12bd75e7e92c5727419c07ba9f119b6dabd0d35e Mon Sep 17 00:00:00 2001
From: Keval Bhatt <ksbh@odoo.com>
Date: Fri, 9 May 2025 13:25:57 +0530
Subject: [PATCH 08/10] [IMP] website_crm: adapt website_crm_pre_tour

---
 addons/website_crm/static/tests/tours/website_crm.js | 6 +++---
 addons/website_crm/tests/test_website_crm.py         | 4 ----
 2 files changed, 3 insertions(+), 7 deletions(-)

diff --git a/addons/website_crm/static/tests/tours/website_crm.js b/addons/website_crm/static/tests/tours/website_crm.js
index 36910c0f6fe99..87da2771d03e7 100644
--- a/addons/website_crm/static/tests/tours/website_crm.js
+++ b/addons/website_crm/static/tests/tours/website_crm.js
@@ -13,15 +13,15 @@ registerWebsitePreviewTour('website_crm_pre_tour', {
     run: "click",
 },
 {
-    trigger: "#oe_snippets .o_we_customize_snippet_btn.active",
+    trigger: ".o-snippets-menu .o-snippets-tabs [data-name='customize'].active",
 },
 {
     content: "Open action select",
-    trigger: "we-select:has(we-button:contains('Create an Opportunity')) we-toggler",
+    trigger: ".o-snippets-menu [data-container-title='Block'] [data-label='Action'] .dropdown-toggle",
     run: "click",
 }, {
     content: "Select 'Create an Opportunity' as form action",
-    trigger: "we-select we-button:contains('Create an Opportunity')",
+    trigger: ".o_popover [data-action-id='selectAction']:contains('Create an Opportunity')",
     run: "click",
 },
 ...clickOnSave(),
diff --git a/addons/website_crm/tests/test_website_crm.py b/addons/website_crm/tests/test_website_crm.py
index 38e21c626de92..5b1048276da8f 100644
--- a/addons/website_crm/tests/test_website_crm.py
+++ b/addons/website_crm/tests/test_website_crm.py
@@ -2,11 +2,7 @@
 # Part of Odoo. See LICENSE file for full copyright and licensing details.
 
 import odoo.tests
-import unittest
 
-
-# TODO master-mysterious-egg fix error
-@unittest.skip("prepare mysterious-egg for merging")
 @odoo.tests.tagged('post_install', '-at_install')
 class TestWebsiteCrm(odoo.tests.HttpCase):
 

From 8478118431e8f848431d728253b69e26d1edd57d Mon Sep 17 00:00:00 2001
From: Benoit Socias <bso@odoo.com>
Date: Wed, 21 May 2025 14:47:48 +0200
Subject: [PATCH 09/10] [FIX] website, html_builder: various fixes

This commit contains multiple changes squashed together, coming from
various pull requests on master-mysterious-egg. These changes includes:

- No image in colorpicker and working gradient editor for preset background
- Clean carousel related tests, remove repeated ones and merge/rename files
- Fix logo adding/removing option
- Use only "color" tabs in countdown color picker options
- Add support for compact hex colors (e.g. #fff)
- Fix color styling
- Fix setExtraStep
- Auto-optimize image upon replace media
- Adapt extra product image to also rely on openMediaDialog
- Use correct mimetype fields from dataset
- Adapt tests
- Refresh Dynamic Snippet Carousel when scrolling mode is changed

Co-authored-by: Alessandro Lupo <alup@odoo.com>
Co-authored-by: Benoit Socias <bso@odoo.com>
Co-authored-by: divy-odoo <divy@odoo.com>
Co-authored-by: emge-odoo <emge@odoo.com>
Co-authored-by: FrancoisGe <fge@odoo.com>
Co-authored-by: Jinjiu Liu <jili@odoo.com>
Co-authored-by: Keval Bhatt <ksbh@odoo.com>
Co-authored-by: Serhii Rubanskyi - seru <seru@odoo.com>
---
 .../src/core/core_builder_action_plugin.js    |  21 +++-
 .../static/src/main/font/color_plugin.js      |   8 +-
 .../src/main/media/image_crop_plugin.js       |   8 +-
 .../main/media/image_post_process_plugin.js   |   9 +-
 .../main/media/media_dialog/image_selector.js |   2 +-
 .../main/media/media_dialog/media_dialog.js   |   1 +
 .../static/src/main/media/media_plugin.js     |  22 +++-
 .../plugins/caption_plugin/caption_plugin.js  |   2 +-
 addons/web/static/src/core/utils/colors.js    |  10 ++
 .../background_image_option_plugin.js         |  23 ++--
 .../plugins/customize_website_plugin.js       |   8 +-
 .../image/image_filter_option_plugin.js       |   8 +-
 .../plugins/image/image_format_option.js      |  76 +++++++------
 .../image/image_format_option_plugin.js       |   8 +-
 .../image/image_shape_option_plugin.js        |   2 +-
 .../plugins/image/image_tool_option_plugin.js |  48 ++++++--
 .../add_element_option_plugin.js              |   3 +-
 .../plugins/options/countdown_option.xml      |   6 +-
 .../options/cover_properties_option_plugin.js |  18 +--
 .../plugins/options/header_element_option.js  |  25 +++++
 .../builder/plugins/options/header_option.xml |   5 +-
 .../plugins/options/header_option_plugin.js   |   3 +-
 .../options/image_gallery_option_plugin.js    |   5 +-
 .../options/image_snippet_option_plugin.js    |   3 +-
 .../dynamic_snippet_carousel.edit.js          |  20 ++++
 .../dynamic_snippet_carousel.js               |   6 -
 .../image_snippet_option.test.js              |   4 +-
 ...der.edit.test.js => carousel.edit.test.js} |  16 ++-
 ...arousel_bootstrap_upgrade_fix.edit.test.js |  88 ++++++++-------
 .../carousel_bootstrap_upgrade_fix.test.js    |  60 ----------
 .../carousel/carousel_slider.edit.test.js     | 106 ------------------
 .../carousel/carousel_slider.test.js          |   1 -
 .../attachment_media_dialog.js                |  10 --
 .../website_builder/checkout_page_option.xml  |   6 +-
 .../checkout_page_option_plugin.js            |  29 ++++-
 .../product_page_option_plugin.js             |   7 +-
 36 files changed, 329 insertions(+), 348 deletions(-)
 create mode 100644 addons/website/static/src/builder/plugins/options/header_element_option.js
 create mode 100644 addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.edit.js
 rename addons/website/static/tests/interactions/carousel/{carousel_section_slider.edit.test.js => carousel.edit.test.js} (92%)
 delete mode 100644 addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.test.js
 delete mode 100644 addons/website/static/tests/interactions/carousel/carousel_slider.edit.test.js
 delete mode 100644 addons/website_sale/static/src/website_builder/attachment_media_dialog.js

diff --git a/addons/html_builder/static/src/core/core_builder_action_plugin.js b/addons/html_builder/static/src/core/core_builder_action_plugin.js
index b300b9a62a758..08ed7ee6b2511 100644
--- a/addons/html_builder/static/src/core/core_builder_action_plugin.js
+++ b/addons/html_builder/static/src/core/core_builder_action_plugin.js
@@ -1,5 +1,10 @@
 import { Plugin } from "@html_editor/plugin";
-import { CSS_SHORTHANDS, applyNeededCss, areCssValuesEqual } from "@html_builder/utils/utils_css";
+import {
+    CSS_SHORTHANDS,
+    applyNeededCss,
+    areCssValuesEqual,
+    normalizeColor,
+} from "@html_builder/utils/utils_css";
 
 export function withoutTransition(editingElement, callback) {
     if (editingElement.classList.contains("o_we_force_no_transition")) {
@@ -263,10 +268,18 @@ const attributeAction = {
 };
 
 const dataAttributeAction = {
-    getValue: ({ editingElement, params: { mainParam: attributeName } = {} }) =>
-        editingElement.dataset[attributeName],
+    // if it's a color action, we have to normalize the value
+    getValue: ({ editingElement, params: { mainParam: attributeName } = {} }) => {
+        if (!/(^color|Color)($|(?=[A-Z]))/.test(attributeName)) {
+            return editingElement.dataset[attributeName];
+        }
+        const color = normalizeColor(editingElement.dataset[attributeName]);
+        return color;
+    },
     isApplied: ({ editingElement, params: { mainParam: attributeName } = {}, value }) => {
         if (value) {
+            const match = value.match(/^var\(--(.*)\)$/);
+            value = match ? match[1] : value;
             return editingElement.dataset[attributeName] === value;
         } else {
             return !(attributeName in editingElement.dataset);
@@ -274,6 +287,8 @@ const dataAttributeAction = {
     },
     apply: ({ editingElement, params: { mainParam: attributeName } = {}, value }) => {
         if (value) {
+            const match = value.match(/^var\(--(.*)\)$/);
+            value = match ? match[1] : value;
             editingElement.dataset[attributeName] = value;
         } else {
             delete editingElement.dataset[attributeName];
diff --git a/addons/html_editor/static/src/main/font/color_plugin.js b/addons/html_editor/static/src/main/font/color_plugin.js
index 0bbf47b67ca7d..42552f24bfa8b 100644
--- a/addons/html_editor/static/src/main/font/color_plugin.js
+++ b/addons/html_editor/static/src/main/font/color_plugin.js
@@ -23,6 +23,7 @@ import {
     rgbaToHex,
     COLOR_COMBINATION_CLASSES_REGEX,
 } from "@web/core/utils/colors";
+import { backgroundImageCssToParts } from "@html_editor/utils/image";
 import { ColorSelector } from "./color_selector";
 
 const RGBA_OPACITY = 0.6;
@@ -136,7 +137,8 @@ export class ColorPlugin extends Plugin {
     getElementColors(el) {
         const elStyle = getComputedStyle(el);
         const backgroundImage = elStyle.backgroundImage;
-        const hasGradient = isColorGradient(backgroundImage);
+        const gradient = backgroundImageCssToParts(backgroundImage).gradient;
+        const hasGradient = isColorGradient(gradient);
         const hasTextGradientClass = el.classList.contains("text-gradient");
 
         let backgroundColor = elStyle.backgroundColor;
@@ -155,9 +157,9 @@ export class ColorPlugin extends Plugin {
         }
 
         return {
-            color: hasGradient && hasTextGradientClass ? backgroundImage : rgbaToHex(elStyle.color),
+            color: hasGradient && hasTextGradientClass ? gradient : rgbaToHex(elStyle.color),
             backgroundColor:
-                hasGradient && !hasTextGradientClass ? backgroundImage : rgbaToHex(backgroundColor),
+                hasGradient && !hasTextGradientClass ? gradient : rgbaToHex(backgroundColor),
         };
     }
 
diff --git a/addons/html_editor/static/src/main/media/image_crop_plugin.js b/addons/html_editor/static/src/main/media/image_crop_plugin.js
index 3ce206db760e3..3be9b5910fc0b 100644
--- a/addons/html_editor/static/src/main/media/image_crop_plugin.js
+++ b/addons/html_editor/static/src/main/media/image_crop_plugin.js
@@ -42,10 +42,10 @@ export class ImageCropPlugin extends Plugin {
                 onSave: async (newDataset) => {
                     // todo: should use the mutex if there is one?
                     const updateImageAttributes =
-                        await this.dependencies.imagePostProcess.processImage(
-                            selectedImg,
-                            newDataset
-                        );
+                        await this.dependencies.imagePostProcess.processImage({
+                            img: selectedImg,
+                            newDataset,
+                        });
                     updateImageAttributes();
                     this.dependencies.history.addStep();
                 },
diff --git a/addons/html_editor/static/src/main/media/image_post_process_plugin.js b/addons/html_editor/static/src/main/media/image_post_process_plugin.js
index c048042f26b58..d39cb1fe4f776 100644
--- a/addons/html_editor/static/src/main/media/image_post_process_plugin.js
+++ b/addons/html_editor/static/src/main/media/image_post_process_plugin.js
@@ -24,14 +24,21 @@ export class ImagePostProcessPlugin extends Plugin {
      *
      * @param {HTMLImageElement} img the image to which modifications are applied
      * @param {Object} newDataset an object containing the modifications to apply
+     * @param {Function} [onImageInfoLoaded] can be used to fill
+     * newDataset after having access to image info, return true to cancel call
      * @returns {Function} callback that sets dataURL of the image with the
      * applied modifications to `img` element
      */
-    async processImage(img, newDataset = {}) {
+    async processImage({ img, newDataset = {}, onImageInfoLoaded }) {
         const processContext = {};
         if (!newDataset.originalSrc || !newDataset.mimetypeBeforeConversion) {
             Object.assign(newDataset, await loadImageInfo(img));
         }
+        if (onImageInfoLoaded) {
+            if (await onImageInfoLoaded(newDataset)) {
+                return () => {};
+            }
+        }
         for (const cb of this.getResource("process_image_warmup_handlers")) {
             const addedContext = await cb(img, newDataset);
             if (addedContext) {
diff --git a/addons/html_editor/static/src/main/media/media_dialog/image_selector.js b/addons/html_editor/static/src/main/media/media_dialog/image_selector.js
index 5ce3121d28135..f369d2f0aa3e3 100644
--- a/addons/html_editor/static/src/main/media/media_dialog/image_selector.js
+++ b/addons/html_editor/static/src/main/media/media_dialog/image_selector.js
@@ -94,7 +94,7 @@ export class ImageSelector extends FileSelector {
 
         this.fileMimetypes = IMAGE_MIMETYPES.join(",");
         this.isImageField =
-            !!this.props.media?.closest("[data-oe-type=image]") || !!this.env.addFieldImage;
+            !!this.props.media?.closest("[data-oe-type=image]") || !!this.props.addFieldImage;
     }
 
     get canLoadMore() {
diff --git a/addons/html_editor/static/src/main/media/media_dialog/media_dialog.js b/addons/html_editor/static/src/main/media/media_dialog/media_dialog.js
index 72cef1ab5924a..f85430413082b 100644
--- a/addons/html_editor/static/src/main/media/media_dialog/media_dialog.js
+++ b/addons/html_editor/static/src/main/media/media_dialog/media_dialog.js
@@ -133,6 +133,7 @@ export class MediaDialog extends Component {
             this.addTab(TABS.IMAGES, {
                 useMediaLibrary: this.props.useMediaLibrary,
                 multiSelect: this.props.multiImages,
+                addFieldImage: this.props.addFieldImage,
             });
         }
         if (!noIcons) {
diff --git a/addons/html_editor/static/src/main/media/media_plugin.js b/addons/html_editor/static/src/main/media/media_plugin.js
index 1a8732d9e2dae..444ac91bce01b 100644
--- a/addons/html_editor/static/src/main/media/media_plugin.js
+++ b/addons/html_editor/static/src/main/media/media_plugin.js
@@ -126,13 +126,12 @@ export class MediaPlugin extends Plugin {
         }
     }
 
-    onSaveMediaDialog(element, { node }) {
+    async onSaveMediaDialog(element, { node }) {
         if (!element) {
             // @todo @phoenix to remove
             throw new Error("Element is required: onSaveMediaDialog");
             // return;
         }
-
         if (node) {
             const changedIcon = isIconElement(node) && isIconElement(element);
             if (changedIcon) {
@@ -151,11 +150,25 @@ export class MediaPlugin extends Plugin {
         // Collapse selection after the inserted/replaced element.
         const [anchorNode, anchorOffset] = rightPos(element);
         this.dependencies.selection.setSelection({ anchorNode, anchorOffset });
-        this.delegateTo("afer_save_media_dialog_handlers", element);
+        this.delegateTo("after_save_media_dialog_handlers", element);
         this.dependencies.history.addStep();
     }
 
     openMediaDialog(params = {}, editableEl = null) {
+        const oldSave =
+            params.save || ((element) => this.onSaveMediaDialog(element, { node: params.node }));
+        params.save = async (...args) => {
+            const selection = args[0];
+            const elements = selection
+                ? selection[Symbol.iterator]
+                    ? selection
+                    : [selection]
+                : [];
+            for (const onMediaDialogSaved of this.getResource("on_media_dialog_saved_handlers")) {
+                await onMediaDialogSaved(elements, { node: params.node });
+            }
+            return oldSave(...args);
+        };
         const { resModel, resId, field, type } = this.getRecordInfo(editableEl);
         const mediaDialogClosedPromise = this.dependencies.dialog.addDialog(MediaDialog, {
             resModel,
@@ -165,9 +178,6 @@ export class MediaPlugin extends Plugin {
                 ((resModel === "ir.ui.view" && field === "arch") || type === "html")
             ), // @todo @phoenix: should be removed and moved to config.mediaModalParams
             media: params.node,
-            save: (element) => {
-                this.onSaveMediaDialog(element, { node: params.node });
-            },
             onAttachmentChange: this.config.onAttachmentChange || (() => {}),
             noVideos: !this.config.allowMediaDialogVideo,
             noImages: !this.config.allowImage,
diff --git a/addons/html_editor/static/src/others/embedded_components/plugins/caption_plugin/caption_plugin.js b/addons/html_editor/static/src/others/embedded_components/plugins/caption_plugin/caption_plugin.js
index 4278d51a27fda..6ade00ab4f982 100644
--- a/addons/html_editor/static/src/others/embedded_components/plugins/caption_plugin/caption_plugin.js
+++ b/addons/html_editor/static/src/others/embedded_components/plugins/caption_plugin/caption_plugin.js
@@ -37,7 +37,7 @@ export class CaptionPlugin extends Plugin {
         clean_for_save_handlers: this.cleanForSave.bind(this),
         mount_component_handlers: this.setupNewCaption.bind(this),
         delete_image_handlers: this.handleDeleteImage.bind(this),
-        afer_save_media_dialog_handlers: this.onImageReplaced.bind(this),
+        after_save_media_dialog_handlers: this.onImageReplaced.bind(this),
         hints: [{ selector: "FIGCAPTION", text: _t("Write a caption...") }],
         unsplittable_node_predicates: [
             (node) => ["FIGURE", "FIGCAPTION"].includes(node.nodeName), // avoid merge
diff --git a/addons/web/static/src/core/utils/colors.js b/addons/web/static/src/core/utils/colors.js
index e18306eabbc3e..6dca83f3d6c15 100644
--- a/addons/web/static/src/core/utils/colors.js
+++ b/addons/web/static/src/core/utils/colors.js
@@ -217,6 +217,16 @@ export function convertCSSColorToRgba(cssColor) {
     }
 
     // Otherwise, check if cssColor is an hexadecimal code color
+    // first check if it's in its compact form (e.g. #FFF)
+    if (/^#([0-9a-f]{3})$/i.test(cssColor)) {
+        return {
+            red: parseInt(cssColor[1] + cssColor[1], 16),
+            green: parseInt(cssColor[2] + cssColor[2], 16),
+            blue: parseInt(cssColor[3] + cssColor[3], 16),
+            opacity: 100,
+        };
+    }
+
     if (/^#([0-9A-F]{6}|[0-9A-F]{8})$/i.test(cssColor)) {
         return {
             red: parseInt(cssColor.substr(1, 2), 16),
diff --git a/addons/website/static/src/builder/plugins/background_option/background_image_option_plugin.js b/addons/website/static/src/builder/plugins/background_option/background_image_option_plugin.js
index dd13d966310f7..3615d3a030f16 100644
--- a/addons/website/static/src/builder/plugins/background_option/background_image_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/background_option/background_image_option_plugin.js
@@ -77,7 +77,7 @@ export class BackgroundImageOptionPlugin extends Plugin {
                     editingElement.querySelector(".o_we_bg_filter")?.remove();
                     this.applyReplaceBackgroundImage.bind(this)({
                         editingElement: editingElement,
-                        loadResult: "",
+                        loadResult: undefined,
                         params: { forceClean: true },
                     });
                     this.dispatchTo("on_bg_image_hide_handlers", editingElement);
@@ -134,12 +134,13 @@ export class BackgroundImageOptionPlugin extends Plugin {
             newEditingEl.classList.toggle("o_modified_image_to_save", isModifiedImage);
         }
     }
-    loadReplaceBackgroundImage() {
+    loadReplaceBackgroundImage({ editingElement }) {
         return new Promise((resolve) => {
             const onClose = this.dependencies.media.openMediaDialog({
                 onlyImages: true,
-                save: (imageEl) => {
-                    resolve(imageEl.getAttribute("src"));
+                node: editingElement,
+                save: async (imageEl) => {
+                    resolve(imageEl);
                 },
             });
             onClose.then(resolve);
@@ -147,18 +148,24 @@ export class BackgroundImageOptionPlugin extends Plugin {
     }
     applyReplaceBackgroundImage({
         editingElement,
-        loadResult: imageSrc,
+        loadResult: imageEl,
         params: { forceClean = false },
     }) {
-        if (!forceClean && !imageSrc) {
+        if (!forceClean && !imageEl) {
             // Do nothing: no images has been selected on the media dialog
             return;
         }
-        this.setImageBackground(editingElement, imageSrc);
+        const src = imageEl?.src || "";
+        this.setImageBackground(editingElement, src);
         for (const attr of removeOnImageChangeAttrs) {
             delete editingElement.dataset[attr];
         }
-        // TODO: call _autoOptimizeImage of the ImageHandlersOption
+        if (imageEl) {
+            if (src.startsWith("data:")) {
+                editingElement.classList.add("o_modified_image_to_save");
+            }
+            Object.assign(editingElement.dataset, imageEl.dataset);
+        }
     }
     /**
      *
diff --git a/addons/website/static/src/builder/plugins/customize_website_plugin.js b/addons/website/static/src/builder/plugins/customize_website_plugin.js
index be35f7febc928..169ae96807f8a 100644
--- a/addons/website/static/src/builder/plugins/customize_website_plugin.js
+++ b/addons/website/static/src/builder/plugins/customize_website_plugin.js
@@ -87,7 +87,13 @@ export class CustomizeWebsitePlugin extends Plugin {
                     if (gradientColor) {
                         const gradientValue = this.getWebsiteVariableValue(gradientColor);
                         if (gradientValue) {
-                            return gradientValue;
+                            // Pass through style to restore rgb/a which might
+                            // have been lost during SCSS generation process.
+                            // TODO Remove this once colorpicker will be able
+                            // to cope with #rrggbb gradient color elements.
+                            const el = document.createElement("div");
+                            el.style.setProperty("background-image", gradientValue);
+                            return el.style.getPropertyValue("background-image");
                         }
                     }
                     return getCSSVariableValue(color, style);
diff --git a/addons/website/static/src/builder/plugins/image/image_filter_option_plugin.js b/addons/website/static/src/builder/plugins/image/image_filter_option_plugin.js
index ea9684bbddc65..b17c409dc9ad4 100644
--- a/addons/website/static/src/builder/plugins/image/image_filter_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/image/image_filter_option_plugin.js
@@ -20,9 +20,9 @@ class ImageFilterOptionPlugin extends Plugin {
                     }
                 },
                 load: async ({ editingElement: img, params: { mainParam: glFilterName } }) =>
-                    await this.dependencies.imagePostProcess.processImage(img, {
+                    await this.dependencies.imagePostProcess.processImage({ img, newDataset: {
                         glFilter: glFilterName,
-                    }),
+                    }}),
                 apply: ({ loadResult: updateImageAttributes }) => {
                     updateImageAttributes();
                 },
@@ -53,9 +53,9 @@ class ImageFilterOptionPlugin extends Plugin {
                     const filterOptions = JSON.parse(img.dataset.filterOptions || "{}");
                     filterOptions[filterProperty] =
                         filterProperty === "filterColor" ? normalizeColor(value) : value;
-                    return this.dependencies.imagePostProcess.processImage(img, {
+                    return this.dependencies.imagePostProcess.processImage({ img, newDataset: {
                         filterOptions: JSON.stringify(filterOptions),
-                    });
+                    }});
                 },
                 apply: ({ loadResult: updateImageAttributes }) => {
                     updateImageAttributes();
diff --git a/addons/website/static/src/builder/plugins/image/image_format_option.js b/addons/website/static/src/builder/plugins/image/image_format_option.js
index c13f2b6e1e5ac..e2d10d4ca4efa 100644
--- a/addons/website/static/src/builder/plugins/image/image_format_option.js
+++ b/addons/website/static/src/builder/plugins/image/image_format_option.js
@@ -38,42 +38,46 @@ export class ImageFormatOption extends BaseOptionComponent {
         if (this.props.computeMaxDisplayWidth) {
             return this.props.computeMaxDisplayWidth(img);
         }
-        const window = img.ownerDocument.defaultView;
-        if (!window) {
-            return;
-        }
-        const computedStyles = window.getComputedStyle(img);
-        const displayWidth = parseFloat(computedStyles.getPropertyValue("width"));
-        const gutterWidth =
-            parseFloat(computedStyles.getPropertyValue("--o-grid-gutter-width")) || 30;
+        return computeMaxDisplayWidth(img, this.MAX_SUGGESTED_WIDTH);
+    }
+}
 
-        // For the logos we don't want to suggest a width too small.
-        if (img.closest("nav")) {
-            return Math.round(Math.min(displayWidth * 3, this.MAX_SUGGESTED_WIDTH));
-            // If the image is in a container(-small), it might get bigger on
-            // smaller screens. So we suggest the width of the current image unless
-            // it is smaller than the size of the container on the md breapoint
-            // (which is where our bootstrap columns fallback to full container
-            // width since we only use col-lg-* in Odoo).
-        } else if (img.closest(".container, .o_container_small")) {
-            const mdContainerMaxWidth =
-                parseFloat(computedStyles.getPropertyValue("--o-md-container-max-width")) || 720;
-            const mdContainerInnerWidth = mdContainerMaxWidth - gutterWidth;
-            return Math.round(clamp(displayWidth, mdContainerInnerWidth, this.MAX_SUGGESTED_WIDTH));
-            // If the image is displayed in a container-fluid, it might also get
-            // bigger on smaller screens. The same way, we suggest the width of the
-            // current image unless it is smaller than the max size of the container
-            // on the md breakpoint (which is the LG breakpoint since the container
-            // fluid is full-width).
-        } else if (img.closest(".container-fluid")) {
-            const lgBp = parseFloat(computedStyles.getPropertyValue("--breakpoint-lg")) || 992;
-            const mdContainerFluidMaxInnerWidth = lgBp - gutterWidth;
-            return Math.round(
-                clamp(displayWidth, mdContainerFluidMaxInnerWidth, this.MAX_SUGGESTED_WIDTH)
-            );
-        }
-        // If it's not in a container, it's probably not going to change size
-        // depending on breakpoints. We still keep a margin safety.
-        return Math.round(Math.min(displayWidth * 1.5, this.MAX_SUGGESTED_WIDTH));
+export function computeMaxDisplayWidth(img, MAX_SUGGESTED_WIDTH = 1920) {
+    const window = img.ownerDocument.defaultView;
+    if (!window) {
+        return;
+    }
+    const computedStyles = window.getComputedStyle(img);
+    const displayWidth = parseFloat(computedStyles.getPropertyValue("width"));
+    const gutterWidth =
+        parseFloat(computedStyles.getPropertyValue("--o-grid-gutter-width")) || 30;
+
+    // For the logos we don't want to suggest a width too small.
+    if (img.closest("nav")) {
+        return Math.round(Math.min(displayWidth * 3, MAX_SUGGESTED_WIDTH));
+        // If the image is in a container(-small), it might get bigger on
+        // smaller screens. So we suggest the width of the current image unless
+        // it is smaller than the size of the container on the md breapoint
+        // (which is where our bootstrap columns fallback to full container
+        // width since we only use col-lg-* in Odoo).
+    } else if (img.closest(".container, .o_container_small")) {
+        const mdContainerMaxWidth =
+            parseFloat(computedStyles.getPropertyValue("--o-md-container-max-width")) || 720;
+        const mdContainerInnerWidth = mdContainerMaxWidth - gutterWidth;
+        return Math.round(clamp(displayWidth, mdContainerInnerWidth, MAX_SUGGESTED_WIDTH));
+        // If the image is displayed in a container-fluid, it might also get
+        // bigger on smaller screens. The same way, we suggest the width of the
+        // current image unless it is smaller than the max size of the container
+        // on the md breakpoint (which is the LG breakpoint since the container
+        // fluid is full-width).
+    } else if (img.closest(".container-fluid")) {
+        const lgBp = parseFloat(computedStyles.getPropertyValue("--breakpoint-lg")) || 992;
+        const mdContainerFluidMaxInnerWidth = lgBp - gutterWidth;
+        return Math.round(
+            clamp(displayWidth, mdContainerFluidMaxInnerWidth, MAX_SUGGESTED_WIDTH)
+        );
     }
+    // If it's not in a container, it's probably not going to change size
+    // depending on breakpoints. We still keep a margin safety.
+    return Math.round(Math.min(displayWidth * 1.5, MAX_SUGGESTED_WIDTH));
 }
diff --git a/addons/website/static/src/builder/plugins/image/image_format_option_plugin.js b/addons/website/static/src/builder/plugins/image/image_format_option_plugin.js
index b73a3e16ba021..00d2a4bd51740 100644
--- a/addons/website/static/src/builder/plugins/image/image_format_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/image/image_format_option_plugin.js
@@ -29,10 +29,10 @@ class ImageFormatOptionPlugin extends Plugin {
                     );
                 },
                 load: async ({ editingElement: img, params: { width, mimetype } }) =>
-                    this.dependencies.imagePostProcess.processImage(img, {
+                    this.dependencies.imagePostProcess.processImage({ img, newDataset: {
                         resizeWidth: width,
                         formatMimetype: mimetype,
-                    }),
+                    }}),
                 apply: ({ loadResult: updateImageAttributes }) => {
                     updateImageAttributes();
                 },
@@ -41,9 +41,9 @@ class ImageFormatOptionPlugin extends Plugin {
                 getValue: ({ editingElement: img }) =>
                     ("quality" in img.dataset && img.dataset.quality) || DEFAULT_IMAGE_QUALITY,
                 load: async ({ editingElement: img, value: quality }) =>
-                    this.dependencies.imagePostProcess.processImage(img, {
+                    this.dependencies.imagePostProcess.processImage({ img, newDataset: {
                         quality,
-                    }),
+                    }}),
                 apply: ({ loadResult: updateImageAttributes }) => {
                     updateImageAttributes();
                 },
diff --git a/addons/website/static/src/builder/plugins/image/image_shape_option_plugin.js b/addons/website/static/src/builder/plugins/image/image_shape_option_plugin.js
index ad3fb968f2d87..00097c1cecd19 100644
--- a/addons/website/static/src/builder/plugins/image/image_shape_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/image/image_shape_option_plugin.js
@@ -143,7 +143,7 @@ class ImageShapeOptionPlugin extends Plugin {
         return shapeSvgText;
     }
     async loadShape(img, newData = {}) {
-        return this.dependencies.imagePostProcess.processImage(img, newData);
+        return this.dependencies.imagePostProcess.processImage({ img, newDataset: newData });
         //todo: handle hover effect before
         // todo: is it still needed?
         // await loadImage(shapeDataURL, img);
diff --git a/addons/website/static/src/builder/plugins/image/image_tool_option_plugin.js b/addons/website/static/src/builder/plugins/image/image_tool_option_plugin.js
index d70aa6a889987..9a04043531593 100644
--- a/addons/website/static/src/builder/plugins/image/image_tool_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/image/image_tool_option_plugin.js
@@ -1,4 +1,4 @@
-import { cropperDataFieldsWithAspectRatio, isGif } from "@html_editor/utils/image_processing";
+import { cropperDataFieldsWithAspectRatio, isGif, loadImage } from "@html_editor/utils/image_processing";
 import { registry } from "@web/core/registry";
 import { Plugin } from "@html_editor/plugin";
 import { ImageToolOption } from "./image_tool_option";
@@ -10,6 +10,7 @@ import {
     ALIGNMENT_STYLE_PADDING,
 } from "@html_builder/utils/option_sequence";
 import { ReplaceMediaOption, searchSupportedParentLinkEl } from "./replace_media_option";
+import { computeMaxDisplayWidth } from "./image_format_option";
 
 export const REPLACE_MEDIA_SELECTOR = "img, .media_iframe_video, span.fa, i.fa";
 export const REPLACE_MEDIA_EXCLUDE =
@@ -45,6 +46,39 @@ class ImageToolOptionPlugin extends Plugin {
             }),
         ],
         builder_actions: this.getActions(),
+        on_media_dialog_saved_handlers: async (elements, { node }) => {
+            for (const image of elements) {
+                if (image && image.tagName === "IMG") {
+                    const updateImageAttributes = await this.dependencies.imagePostProcess.processImage({
+                        img: image,
+                        newDataset: {
+                            formatMimetype: "image/webp",
+                        },
+                        // TODO Using a callback is currently needed to avoid
+                        // the extra RPC that would occur if loadImageInfo was
+                        // called before processImage as well. This flow can be
+                        // simplified if image infos are somehow cached.
+                        onImageInfoLoaded: async (dataset) => {
+                            if (!dataset.originalSrc || !dataset.originalId) {
+                                return true;
+                            }
+                            const original = await loadImage(dataset.originalSrc);
+                            const maxWidth = dataset.width ? image.naturalWidth : original.naturalWidth;
+                            const optimizedWidth = Math.min(maxWidth, computeMaxDisplayWidth(node || this.editable));
+                            if (!["image/gif", "image/svg+xml"].includes(dataset.mimetypeBeforeConversion)) {
+                                // Convert to recommended format and width.
+                                dataset.resizeWidth = optimizedWidth;
+                            } else if (dataset.shape && dataset.mimetypeBeforeConversion !== "image/gif") {
+                                dataset.resizeWidth = optimizedWidth;
+                            } else {
+                                return true;
+                            }
+                        },
+                    });
+                    updateImageAttributes();
+                }
+            }
+        },
     };
     getActions() {
         return {
@@ -57,7 +91,7 @@ class ImageToolOptionPlugin extends Plugin {
                             onClose: resolve,
                             onSave: async (newDataset) => {
                                 resolve(
-                                    this.dependencies.imagePostProcess.processImage(img, newDataset)
+                                    this.dependencies.imagePostProcess.processImage({ img, newDataset })
                                 );
                             },
                         });
@@ -71,7 +105,7 @@ class ImageToolOptionPlugin extends Plugin {
                     const newDataset = Object.fromEntries(
                         cropperDataFieldsWithAspectRatio.map((field) => [field, undefined])
                     );
-                    return this.dependencies.imagePostProcess.processImage(img, newDataset);
+                    return this.dependencies.imagePostProcess.processImage({ img, newDataset });
                 },
                 apply: ({ loadResult: updateImageAttributes }) => {
                     updateImageAttributes();
@@ -96,14 +130,14 @@ class ImageToolOptionPlugin extends Plugin {
             },
             replaceMedia: {
                 load: async ({ editingElement }) => {
-                    let icon;
+                    let image;
                     await this.dependencies.media.openMediaDialog({
                         node: editingElement,
-                        save: (newIcon) => {
-                            icon = newIcon;
+                        save: (newImage) => {
+                            image = newImage;
                         },
                     });
-                    return icon;
+                    return image;
                 },
                 apply: ({ editingElement, loadResult: newImage }) => {
                     if (!newImage) {
diff --git a/addons/website/static/src/builder/plugins/layout_option/add_element_option_plugin.js b/addons/website/static/src/builder/plugins/layout_option/add_element_option_plugin.js
index 8a8069fcd7074..c0cce21b0d4c9 100644
--- a/addons/website/static/src/builder/plugins/layout_option/add_element_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/layout_option/add_element_option_plugin.js
@@ -28,11 +28,12 @@ export class AddElementOptionPlugin extends Plugin {
                 },
             },
             addElImage: {
-                load: async () => {
+                load: async ({ editingElement }) => {
                     let selectedImage;
                     await new Promise((resolve) => {
                         const onClose = this.dependencies.media.openMediaDialog({
                             onlyImages: true,
+                            node: editingElement,
                             save: (images) => {
                                 selectedImage = images;
                                 resolve();
diff --git a/addons/website/static/src/builder/plugins/options/countdown_option.xml b/addons/website/static/src/builder/plugins/options/countdown_option.xml
index 5132fd4806935..6660b90cf77b4 100644
--- a/addons/website/static/src/builder/plugins/options/countdown_option.xml
+++ b/addons/website/static/src/builder/plugins/options/countdown_option.xml
@@ -41,7 +41,7 @@
     </BuilderRow>
 
     <BuilderRow label.translate="Text Color">
-        <BuilderColorPicker dataAttributeAction="'textColor'"/>
+        <BuilderColorPicker dataAttributeAction="'textColor'" enabledTabs="['solid', 'custom']"/>
     </BuilderRow>
 
     <BuilderRow label.translate="Layout">
@@ -62,7 +62,7 @@
         </BuilderRow>
         <BuilderRow label.translate="Layout Background Color"
                     t-if="!this.isActiveItem('no_background_layout_opt')">
-            <BuilderColorPicker dataAttributeAction="'layoutBackgroundColor'"/>
+            <BuilderColorPicker dataAttributeAction="'layoutBackgroundColor'" enabledTabs="['solid', 'custom']"/>
         </BuilderRow>
 
         <BuilderRow label.translate="Progress Bar Style">
@@ -82,7 +82,7 @@
             </BuilderRow>
             <BuilderRow label.translate="Progress Bar Color"
                         t-if="!this.isActiveItem('no_progressbar_style_opt')">
-                <BuilderColorPicker dataAttributeAction="'progressBarColor'"/>
+                <BuilderColorPicker dataAttributeAction="'progressBarColor'" enabledTabs="['solid', 'custom']"/>
             </BuilderRow>
         </t>
     </t>
diff --git a/addons/website/static/src/builder/plugins/options/cover_properties_option_plugin.js b/addons/website/static/src/builder/plugins/options/cover_properties_option_plugin.js
index 4d56af26652a6..47e055191a6b2 100644
--- a/addons/website/static/src/builder/plugins/options/cover_properties_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/options/cover_properties_option_plugin.js
@@ -2,7 +2,6 @@ import { Plugin } from "@html_editor/plugin";
 import { registry } from "@web/core/registry";
 import { CoverPropertiesOption } from "@website/builder/plugins/options/cover_properties_option";
 import { classAction } from "@html_builder/core/core_builder_action_plugin";
-import { loadImageInfo } from "@html_editor/utils/image_processing";
 import { rpc } from "@web/core/network/rpc";
 import { withSequence } from "@html_editor/utils/resource";
 import { COVER_PROPERTIES } from "@website/builder/option_sequence";
@@ -43,22 +42,7 @@ class CoverPropertiesOptionPlugin extends Plugin {
                 onlyImages: true,
                 save: (imageEl) => {
                     resultPromise = (async () => {
-                        Object.assign(imageEl.dataset, await loadImageInfo(imageEl));
-                        let b64ToSave = false;
-                        if (
-                            imageEl.dataset.mimetypeBeforeConversion &&
-                            !["image/gif", "image/svg+xml", "image/webp"].includes(
-                                imageEl.dataset.mimetypeBeforeConversion
-                            )
-                        ) {
-                            // Convert to webp but keep original width.
-                            const updateImgAttributes =
-                                await this.dependencies.imagePostProcess.processImage(imageEl, {
-                                    formatMimetype: "image/webp",
-                                });
-                            updateImgAttributes();
-                            b64ToSave = true;
-                        }
+                        const b64ToSave = imageEl.getAttribute("src").startsWith("data:");
                         return { imageSrc: imageEl.getAttribute("src"), b64ToSave };
                     })();
                 },
diff --git a/addons/website/static/src/builder/plugins/options/header_element_option.js b/addons/website/static/src/builder/plugins/options/header_element_option.js
new file mode 100644
index 0000000000000..f37b86b90cb07
--- /dev/null
+++ b/addons/website/static/src/builder/plugins/options/header_element_option.js
@@ -0,0 +1,25 @@
+import { BaseOptionComponent } from "@html_builder/core/utils";
+
+export class HeaderElementOption extends BaseOptionComponent {
+    static template = "website.headerElementOption";
+
+    setup(){
+        super.setup();
+        this.customizeWebsite = this.env.editor.shared.customizeWebsite;
+        const views = [
+            "website.option_header_brand_logo", 
+            "website.option_header_brand_name"
+        ];
+        this.customizeWebsite.loadConfigKey({ views });
+    }
+    
+    get websiteLogoParams(){
+        const views = this.customizeWebsite.getConfigKey("website.option_header_brand_name") 
+                        ? ["website.option_header_brand_name"] 
+                        : ["website.option_header_brand_logo"];
+        return {
+            views,
+            resetViewArch: true,
+        }
+    }
+}
diff --git a/addons/website/static/src/builder/plugins/options/header_option.xml b/addons/website/static/src/builder/plugins/options/header_option.xml
index f7ff1f6d533f1..4eeea5adf335e 100644
--- a/addons/website/static/src/builder/plugins/options/header_option.xml
+++ b/addons/website/static/src/builder/plugins/options/header_option.xml
@@ -383,10 +383,7 @@
                 <BuilderButton
                     title.translate="Show/hide logo"
                     className="'flex-grow-1'"
-                    actionParam="{
-                        views: ['website.option_header_brand_name', 'website.option_header_brand_logo'],
-                        resetViewArch: true,
-                    }"
+                    actionParam="websiteLogoParams"
                 >
                     <Img src="'/website/static/src/img/snippets_options/header_extra_element_logo.svg'"/>
                 </BuilderButton>
diff --git a/addons/website/static/src/builder/plugins/options/header_option_plugin.js b/addons/website/static/src/builder/plugins/options/header_option_plugin.js
index b7320628e318b..922cfd9cfb4bc 100644
--- a/addons/website/static/src/builder/plugins/options/header_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/options/header_option_plugin.js
@@ -12,6 +12,7 @@ import { Plugin } from "@html_editor/plugin";
 import { withSequence } from "@html_editor/utils/resource";
 import { registry } from "@web/core/registry";
 import { HeaderBorderOption } from "./header_border_option";
+import { HeaderElementOption } from "./header_element_option";
 
 const [
     HEADER_TEMPLATE,
@@ -74,7 +75,7 @@ class HeaderOptionPlugin extends Plugin {
             }),
             withSequence(HEADER_ELEMENT, {
                 editableOnly: false,
-                template: "website.headerElementOption",
+                OptionComponent: HeaderElementOption,
                 selector: "#wrapwrap > header",
                 groups: ["website.group_website_designer"],
             }),
diff --git a/addons/website/static/src/builder/plugins/options/image_gallery_option_plugin.js b/addons/website/static/src/builder/plugins/options/image_gallery_option_plugin.js
index c5408cde1063c..b85c4cc67806b 100644
--- a/addons/website/static/src/builder/plugins/options/image_gallery_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/options/image_gallery_option_plugin.js
@@ -164,6 +164,7 @@ class ImageGalleryOption extends Plugin {
                     const onClose = this.dependencies.media.openMediaDialog({
                         onlyImages: true,
                         multiImages: true,
+                        node: editingElement,
                         save: (images) => {
                             selectedImages = images;
                             resolve();
@@ -364,10 +365,10 @@ class ImageGalleryOption extends Plugin {
                 !["image/gif", "image/svg+xml", "image/webp"].includes(mimetypeBeforeConversion)
             ) {
                 // Convert to webp but keep original width.
-                const update = await this.dependencies.imagePostProcess.processImage(img, {
+                const update = await this.dependencies.imagePostProcess.processImage({ img, newDataset: {
                     formatMimetype: "image/webp",
                     ...newDataset,
-                });
+                }});
                 update();
             }
         };
diff --git a/addons/website/static/src/builder/plugins/options/image_snippet_option_plugin.js b/addons/website/static/src/builder/plugins/options/image_snippet_option_plugin.js
index a10bbc4e4bf77..283fc0ec33b02 100644
--- a/addons/website/static/src/builder/plugins/options/image_snippet_option_plugin.js
+++ b/addons/website/static/src/builder/plugins/options/image_snippet_option_plugin.js
@@ -20,7 +20,8 @@ class ImageSnippetOptionPlugin extends Plugin {
         await new Promise((resolve) => {
             const onClose = this.dependencies.media.openMediaDialog({
                 onlyImages: true,
-                save: (selectedImageEl) => {
+                node: snippetEl,
+                save: async (selectedImageEl) => {
                     isImageSelected = true;
                     snippetEl.replaceWith(selectedImageEl);
                 },
diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.edit.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.edit.js
new file mode 100644
index 0000000000000..6f148369bf1d4
--- /dev/null
+++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.edit.js
@@ -0,0 +1,20 @@
+import { DynamicSnippetCarousel } from "@website/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel";
+import { registry } from "@web/core/registry";
+
+const DynamicSnippetCarouselEdit = (I) =>
+    class extends I {
+        getConfigurationSnapshot() {
+            let snapshot = super.getConfigurationSnapshot();
+            if (this.el.classList.contains("o_carousel_multi_items")) {
+                snapshot = JSON.parse(snapshot || "{}");
+                snapshot.multi_items = true;
+                snapshot = JSON.stringify(snapshot);
+            }
+            return snapshot;
+        }
+    };
+
+registry.category("public.interactions.edit").add("website.dynamic_snippet_carousel", {
+    Interaction: DynamicSnippetCarousel,
+    mixin: DynamicSnippetCarouselEdit,
+});
diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.js b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.js
index 19299e3620b95..e7ddd76d21e5c 100644
--- a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.js
+++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/dynamic_snippet_carousel.js
@@ -28,9 +28,3 @@ export class DynamicSnippetCarousel extends DynamicSnippet {
 registry
     .category("public.interactions")
     .add("website.dynamic_snippet_carousel", DynamicSnippetCarousel);
-
-registry
-    .category("public.interactions.edit")
-    .add("website.dynamic_snippet_carousel", {
-        Interaction: DynamicSnippetCarousel,
-    });
diff --git a/addons/website/static/tests/builder/website_builder/image_snippet_option.test.js b/addons/website/static/tests/builder/website_builder/image_snippet_option.test.js
index 052e3b61e4df1..d9eb54a241185 100644
--- a/addons/website/static/tests/builder/website_builder/image_snippet_option.test.js
+++ b/addons/website/static/tests/builder/website_builder/image_snippet_option.test.js
@@ -42,10 +42,10 @@ test("Drag & drop an 'Image' snippet opens the dialog to select an image", async
     expect(".o-website-builder_sidebar .fa-undo").not.toBeEnabled();
 
     await contains(".o_select_media_dialog img[title='logo']").click();
-    expect(".o_select_media_dialog").toHaveCount(0);
     await waitForEndOfOperation();
+    expect(".o_select_media_dialog").toHaveCount(0);
 
-    expect(":iframe div img[src='/web/static/img/logo2.png']").toHaveCount(1);
+    expect(":iframe div img[src^='data:image/webp;base64,']").toHaveCount(1);
     expect(":iframe img").toHaveCount(1);
     expect(".o-website-builder_sidebar .fa-undo").toBeEnabled();
 });
diff --git a/addons/website/static/tests/interactions/carousel/carousel_section_slider.edit.test.js b/addons/website/static/tests/interactions/carousel/carousel.edit.test.js
similarity index 92%
rename from addons/website/static/tests/interactions/carousel/carousel_section_slider.edit.test.js
rename to addons/website/static/tests/interactions/carousel/carousel.edit.test.js
index 56bfd2160ff89..3253a9c792bbd 100644
--- a/addons/website/static/tests/interactions/carousel/carousel_section_slider.edit.test.js
+++ b/addons/website/static/tests/interactions/carousel/carousel.edit.test.js
@@ -1,10 +1,5 @@
-import {
-    startInteractions,
-    setupInteractionWhiteList,
-} from "@web/../tests/public/helpers";
-
+import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers";
 import { describe, expect, test } from "@odoo/hoot";
-
 import { switchToEditMode } from "../../helpers";
 import { queryAll } from "@odoo/hoot-dom";
 
@@ -12,8 +7,9 @@ setupInteractionWhiteList("website.carousel_edit");
 
 describe.current.tags("interaction_dev");
 
-test("carousel_section_slider resets slide to attributes", async () => {
-    const { core } = await startInteractions(`
+test("[EDIT] carousel_edit resets slide to attributes", async () => {
+    const { core } = await startInteractions(
+        `
         <section>
             <div id="slideshow_sample" class="carousel carousel-dark slide" data-bs-ride="ride" data-bs-interval="0">
                 <div class="carousel-inner">
@@ -44,7 +40,9 @@ test("carousel_section_slider resets slide to attributes", async () => {
                 </div>
             </div>
         </section>
-    `, { waitForStart: true, editMode: true });
+    `,
+        { waitForStart: true, editMode: true }
+    );
     await switchToEditMode(core);
 
     expect(core.interactions).toHaveLength(1);
diff --git a/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.test.js b/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.test.js
index 154c33e7661e3..9f5ac679db935 100644
--- a/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.test.js
+++ b/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.edit.test.js
@@ -1,52 +1,49 @@
-import {
-    startInteractions,
-    setupInteractionWhiteList,
-} from "@web/../tests/public/helpers";
-
+import { startInteractions, setupInteractionWhiteList } from "@web/../tests/public/helpers";
 import { describe, expect, test } from "@odoo/hoot";
-import { queryOne } from "@odoo/hoot-dom";
-
+import { click, queryOne } from "@odoo/hoot-dom";
+import { advanceTime } from "@odoo/hoot-mock";
 import { switchToEditMode } from "../../helpers";
 
 setupInteractionWhiteList("website.carousel_bootstrap_upgrade_fix");
 
 describe.current.tags("interaction_dev");
 
-test("[EDIT] carousel_bootstrap_upgrade_fix prevents ride", async () => {
-    const { core } = await startInteractions(`
-        <section class="s_image_gallery o_slideshow pt24 pb24 s_image_gallery_controllers_outside s_image_gallery_controllers_outside_arrows_right s_image_gallery_indicators_dots s_image_gallery_arrows_default" data-snippet="s_image_gallery" data-vcss="002" data-columns="3">
-            <div class="o_container_small overflow-hidden">
-                <div id="slideshow_sample" class="carousel carousel-dark slide" data-bs-interval="5000">
-                    <div class="carousel-inner">
-                        <div class="carousel-item active">
-                            <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_08" data-name="Image" data-index="0" alt=""/>
-                        </div>
-                        <div class="carousel-item">
-                            <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_03" data-name="Image" data-index="1" alt=""/>
-                        </div>
-                        <div class="carousel-item">
-                            <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_02" data-name="Image" data-index="2" alt=""/>
-                        </div>
+const imageGalleryCarouselStyleSnippet = `
+    <section class="s_image_gallery o_slideshow pt24 pb24 s_image_gallery_controllers_outside s_image_gallery_controllers_outside_arrows_right s_image_gallery_indicators_dots s_image_gallery_arrows_default" data-snippet="s_image_gallery" data-vcss="002" data-columns="3">
+        <div class="o_container_small overflow-hidden">
+            <div id="slideshow_sample" class="carousel carousel-dark slide" data-bs-interval="5000">
+                <div class="carousel-inner">
+                    <div class="carousel-item active">
+                        <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_08" data-name="Image" data-index="0" alt=""/>
+                    </div>
+                    <div class="carousel-item">
+                        <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_03" data-name="Image" data-index="1" alt=""/>
                     </div>
-                    <div class="o_carousel_controllers">
-                        <button class="carousel-control-prev o_not_editable" contenteditable="false" data-bs-target="#slideshow_sample" data-bs-slide="prev" aria-label="Previous" title="Previous">
-                            <span class="carousel-control-prev-icon" aria-hidden="true"/>
-                            <span class="visually-hidden">Previous</span>
-                        </button>
-                        <div class="carousel-indicators">
-                            <button type="button" data-bs-target="#slideshow_sample" data-bs-slide-to="0" style="background-image: url(/web/image/website.library_image_08)" class="active" aria-label="Carousel indicator"/>
-                            <button type="button" style="background-image: url(/web/image/website.library_image_03)" data-bs-target="#slideshow_sample" data-bs-slide-to="1" aria-label="Carousel indicator"/>
-                            <button type="button" style="background-image: url(/web/image/website.library_image_02)" data-bs-target="#slideshow_sample" data-bs-slide-to="2" aria-label="Carousel indicator"/>
-                        </div>
-                        <button class="carousel-control-next o_not_editable" contenteditable="false" data-bs-target="#slideshow_sample" data-bs-slide="next" aria-label="Next" title="Next">
-                            <span class="carousel-control-next-icon" aria-hidden="true"/>
-                            <span class="visually-hidden">Next</span>
-                        </button>
+                    <div class="carousel-item">
+                        <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_02" data-name="Image" data-index="2" alt=""/>
                     </div>
                 </div>
+                <div class="o_carousel_controllers">
+                    <button class="carousel-control-prev o_not_editable" contenteditable="false" data-bs-target="#slideshow_sample" data-bs-slide="prev" aria-label="Previous" title="Previous">
+                        <span class="carousel-control-prev-icon" aria-hidden="true"/>
+                        <span class="visually-hidden">Previous</span>
+                    </button>
+                    <div class="carousel-indicators">
+                        <button type="button" data-bs-target="#slideshow_sample" data-bs-slide-to="0" style="background-image: url(/web/image/website.library_image_08)" class="active" aria-label="Carousel indicator"/>
+                        <button type="button" style="background-image: url(/web/image/website.library_image_03)" data-bs-target="#slideshow_sample" data-bs-slide-to="1" aria-label="Carousel indicator"/>
+                        <button type="button" style="background-image: url(/web/image/website.library_image_02)" data-bs-target="#slideshow_sample" data-bs-slide-to="2" aria-label="Carousel indicator"/>
+                    </div>
+                    <button class="carousel-control-next o_not_editable" contenteditable="false" data-bs-target="#slideshow_sample" data-bs-slide="next" aria-label="Next" title="Next">
+                        <span class="carousel-control-next-icon" aria-hidden="true"/>
+                        <span class="visually-hidden">Next</span>
+                    </button>
+                </div>
             </div>
-        </section>
-    `);
+        </div>
+    </section>`;
+
+test("[EDIT] carousel_bootstrap_upgrade_fix prevents ride", async () => {
+    const { core } = await startInteractions(imageGalleryCarouselStyleSnippet);
     expect(core.interactions).toHaveLength(1);
     await switchToEditMode(core);
     const carouselEl = queryOne(".carousel");
@@ -54,3 +51,18 @@ test("[EDIT] carousel_bootstrap_upgrade_fix prevents ride", async () => {
     expect(carouselBS._config.ride).toBe(false);
     expect(carouselBS._config.pause).toBe(true);
 });
+
+test("carousel_bootstrap_upgrade_fix is tagged while sliding", async () => {
+    const { core } = await startInteractions(imageGalleryCarouselStyleSnippet);
+    expect(core.interactions).toHaveLength(1);
+
+    const carouselEl = queryOne(".carousel");
+    expect(carouselEl).toHaveAttribute("data-bs-interval", "5000");
+    expect(carouselEl).not.toHaveClass("o_carousel_sliding");
+
+    await click(carouselEl.querySelector(".carousel-control-next"));
+
+    expect(carouselEl).toHaveClass("o_carousel_sliding");
+    await advanceTime(750);
+    expect(carouselEl).not.toHaveClass("o_carousel_sliding");
+});
diff --git a/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.test.js b/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.test.js
deleted file mode 100644
index 9f89ce958b04d..0000000000000
--- a/addons/website/static/tests/interactions/carousel/carousel_bootstrap_upgrade_fix.test.js
+++ /dev/null
@@ -1,60 +0,0 @@
-import {
-    startInteractions,
-    setupInteractionWhiteList,
-} from "@web/../tests/public/helpers";
-
-import { describe, expect, test } from "@odoo/hoot";
-import { click, queryOne } from "@odoo/hoot-dom";
-import { advanceTime } from "@odoo/hoot-mock";
-
-setupInteractionWhiteList("website.carousel_bootstrap_upgrade_fix");
-
-describe.current.tags("interaction_dev");
-
-test("carousel_bootstrap_upgrade_fix is tagged while sliding", async () => {
-    const { core } = await startInteractions(`
-        <section class="s_image_gallery o_slideshow pt24 pb24 s_image_gallery_controllers_outside s_image_gallery_controllers_outside_arrows_right s_image_gallery_indicators_dots s_image_gallery_arrows_default" data-snippet="s_image_gallery" data-vcss="002" data-columns="3">
-            <div class="o_container_small overflow-hidden">
-                <div id="slideshow_sample" class="carousel carousel-dark slide" data-bs-interval="5000">
-                    <div class="carousel-inner">
-                        <div class="carousel-item active">
-                            <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_08" data-name="Image" data-index="0" alt=""/>
-                        </div>
-                        <div class="carousel-item">
-                            <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_03" data-name="Image" data-index="1" alt=""/>
-                        </div>
-                        <div class="carousel-item">
-                            <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_02" data-name="Image" data-index="2" alt=""/>
-                        </div>
-                    </div>
-                    <div class="o_carousel_controllers">
-                        <button class="carousel-control-prev o_not_editable" contenteditable="false" data-bs-target="#slideshow_sample" data-bs-slide="prev" aria-label="Previous" title="Previous">
-                            <span class="carousel-control-prev-icon" aria-hidden="true"/>
-                            <span class="visually-hidden">Previous</span>
-                        </button>
-                        <div class="carousel-indicators">
-                            <button type="button" data-bs-target="#slideshow_sample" data-bs-slide-to="0" style="background-image: url(/web/image/website.library_image_08)" class="active" aria-label="Carousel indicator"/>
-                            <button type="button" style="background-image: url(/web/image/website.library_image_03)" data-bs-target="#slideshow_sample" data-bs-slide-to="1" aria-label="Carousel indicator"/>
-                            <button type="button" style="background-image: url(/web/image/website.library_image_02)" data-bs-target="#slideshow_sample" data-bs-slide-to="2" aria-label="Carousel indicator"/>
-                        </div>
-                        <button class="carousel-control-next o_not_editable" contenteditable="false" data-bs-target="#slideshow_sample" data-bs-slide="next" aria-label="Next" title="Next">
-                            <span class="carousel-control-next-icon" aria-hidden="true"/>
-                            <span class="visually-hidden">Next</span>
-                        </button>
-                    </div>
-                </div>
-            </div>
-        </section>
-    `);
-    expect(core.interactions).toHaveLength(1);
-
-    const carouselEl = queryOne(".carousel");
-    expect(carouselEl).toHaveAttribute("data-bs-interval", "5000");
-    expect(carouselEl).not.toHaveClass("o_carousel_sliding");
-
-    await click(carouselEl.querySelector(".carousel-control-next"));
-
-    expect(carouselEl).toHaveClass("o_carousel_sliding");
-    await advanceTime(750);
-    expect(carouselEl).not.toHaveClass("o_carousel_sliding");
-});
diff --git a/addons/website/static/tests/interactions/carousel/carousel_slider.edit.test.js b/addons/website/static/tests/interactions/carousel/carousel_slider.edit.test.js
deleted file mode 100644
index 4e3b80aeba641..0000000000000
--- a/addons/website/static/tests/interactions/carousel/carousel_slider.edit.test.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import { setupInteractionWhiteList, startInteractions } from "@web/../tests/public/helpers";
-
-import { describe, expect, test } from "@odoo/hoot";
-import { manuallyDispatchProgrammaticEvent, queryFirst, queryOne } from "@odoo/hoot-dom";
-
-import { switchToEditMode } from "../../helpers";
-
-setupInteractionWhiteList("website.carousel_edit");
-
-describe.current.tags("interaction_dev");
-
-// TODO: @mysterious-egg
-test.todo("[EDIT] carousel_slider prevents ride", async () => {
-    const { core } = await startInteractions(`
-        <section>
-            <div id="slideshow_sample" class="carousel carousel-dark slide" data-bs-ride="ride" data-bs-interval="0">
-                <div class="carousel-inner">
-                    <div class="carousel-item active">
-                        <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_08" data-name="Image" data-index="0" alt=""/>
-                    </div>
-                    <div class="carousel-item">
-                        <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_03" data-name="Image" data-index="1" alt=""/>
-                    </div>
-                    <div class="carousel-item">
-                        <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_02" data-name="Image" data-index="2" alt=""/>
-                    </div>
-                </div>
-                <div class="o_carousel_controllers">
-                    <button class="carousel-control-prev o_not_editable" contenteditable="false" t-attf-data-bs-target="#slideshow_sample" data-bs-slide="prev" aria-label="Previous" title="Previous">
-                        <span class="carousel-control-prev-icon" aria-hidden="true"/>
-                        <span class="visually-hidden">Previous</span>
-                    </button>
-                    <div class="carousel-indicators">
-                        <button type="button" data-bs-target="#slideshow_sample" data-bs-slide-to="0" style="background-image: url(/web/image/website.library_image_08)" class="active" aria-label="Carousel indicator"/>
-                        <button type="button" style="background-image: url(/web/image/website.library_image_03)" data-bs-target="#slideshow_sample" data-bs-slide-to="1" aria-label="Carousel indicator"/>
-                        <button type="button" style="background-image: url(/web/image/website.library_image_02)" data-bs-target="#slideshow_sample" data-bs-slide-to="2" aria-label="Carousel indicator"/>
-                    </div>
-                    <button class="carousel-control-next o_not_editable" contenteditable="false" t-attf-data-bs-target="#slideshow_sample" data-bs-slide="next" aria-label="Next" title="Next">
-                        <span class="carousel-control-next-icon" aria-hidden="true"/>
-                        <span class="visually-hidden">Next</span>
-                    </button>
-                </div>
-            </div>
-        </section>
-    `,
-        { editMode: true }
-    );
-    await switchToEditMode(core);
-
-    expect(core.interactions).toHaveLength(1);
-    const carouselEl = queryOne(".carousel");
-    const carouselBS = window.Carousel.getInstance(carouselEl);
-    expect(carouselBS._config.ride).toBe(false);
-    expect(carouselBS._config.pause).toBe(true);
-
-    core.stopInteractions();
-
-    expect(core.interactions).toHaveLength(0);
-    expect(carouselEl).toHaveAttribute("data-bs-ride", "noAutoSlide");
-});
-
-// TODO: @mysterious-egg
-test.todo("[EDIT] carousel_slider updates min height on content_changed", async () => {
-    const { core } = await startInteractions(`
-        <div id="slideshow_sample" class="carousel carousel-dark slide" data-bs-ride="ride" data-bs-interval="0">
-            <div class="carousel-inner">
-                <div class="carousel-item active">
-                    <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_08" data-name="Image" data-index="0" alt=""/>
-                </div>
-                <div class="carousel-item">
-                    <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_03" data-name="Image" data-index="1" alt=""/>
-                </div>
-                <div class="carousel-item">
-                    <img class="img img-fluid d-block mh-100 mw-100 mx-auto rounded object-fit-cover" src="/web/image/website.library_image_02" data-name="Image" data-index="2" alt=""/>
-                </div>
-            </div>
-            <div class="o_carousel_controllers">
-                <button class="carousel-control-prev o_not_editable" contenteditable="false" t-attf-data-bs-target="#slideshow_sample" data-bs-slide="prev" aria-label="Previous" title="Previous">
-                    <span class="carousel-control-prev-icon" aria-hidden="true"/>
-                    <span class="visually-hidden">Previous</span>
-                </button>
-                <div class="carousel-indicators">
-                    <button type="button" data-bs-target="#slideshow_sample" data-bs-slide-to="0" style="background-image: url(/web/image/website.library_image_08)" class="active" aria-label="Carousel indicator"/>
-                    <button type="button" style="background-image: url(/web/image/website.library_image_03)" data-bs-target="#slideshow_sample" data-bs-slide-to="1" aria-label="Carousel indicator"/>
-                    <button type="button" style="background-image: url(/web/image/website.library_image_02)" data-bs-target="#slideshow_sample" data-bs-slide-to="2" aria-label="Carousel indicator"/>
-                </div>
-                <button class="carousel-control-next o_not_editable" contenteditable="false" t-attf-data-bs-target="#slideshow_sample" data-bs-slide="next" aria-label="Next" title="Next">
-                    <span class="carousel-control-next-icon" aria-hidden="true"/>
-                    <span class="visually-hidden">Next</span>
-                </button>
-            </div>
-        </div>
-    `,
-        { editMode: true }
-    );
-    await switchToEditMode(core);
-
-    expect(core.interactions).toHaveLength(1);
-    const carouselEl = queryOne(".carousel");
-    const itemEl = queryFirst(".carousel-item");
-    const maxHeight = itemEl.style.minHeight;
-    itemEl.style.minHeight = "";
-    expect(itemEl).not.toHaveStyle({ minHeight: maxHeight });
-    await manuallyDispatchProgrammaticEvent(carouselEl, "content_changed");
-    expect(itemEl).toHaveStyle({ minHeight: maxHeight });
-});
diff --git a/addons/website/static/tests/interactions/carousel/carousel_slider.test.js b/addons/website/static/tests/interactions/carousel/carousel_slider.test.js
index 06a240ee01b78..1606a8deb3221 100644
--- a/addons/website/static/tests/interactions/carousel/carousel_slider.test.js
+++ b/addons/website/static/tests/interactions/carousel/carousel_slider.test.js
@@ -1,5 +1,4 @@
 import { setupInteractionWhiteList, startInteractions } from "@web/../tests/public/helpers";
-
 import { beforeEach, describe, expect, test } from "@odoo/hoot";
 import { queryAll } from "@odoo/hoot-dom";
 import { enableTransitions } from "@odoo/hoot-mock";
diff --git a/addons/website_sale/static/src/website_builder/attachment_media_dialog.js b/addons/website_sale/static/src/website_builder/attachment_media_dialog.js
deleted file mode 100644
index 769782d642185..0000000000000
--- a/addons/website_sale/static/src/website_builder/attachment_media_dialog.js
+++ /dev/null
@@ -1,10 +0,0 @@
-import { MediaDialog } from "@html_editor/main/media/media_dialog/media_dialog";
-import { useChildSubEnv } from "@odoo/owl";
-
-// Small override of the MediaDialog to retrieve the attachment ids instead of img elements
-export class AttachmentMediaDialog extends MediaDialog {
-    setup() {
-        super.setup();
-        useChildSubEnv({ addFieldImage: true });
-    }
-}
diff --git a/addons/website_sale/static/src/website_builder/checkout_page_option.xml b/addons/website_sale/static/src/website_builder/checkout_page_option.xml
index 2d9954067ecd4..0bc648f590748 100644
--- a/addons/website_sale/static/src/website_builder/checkout_page_option.xml
+++ b/addons/website_sale/static/src/website_builder/checkout_page_option.xml
@@ -2,10 +2,10 @@
 <templates xml:space="preserve">
 
 <t t-name="website_sale.checkoutPageOption">
+    <BuilderRow label.translate="Extra Step">
+        <BuilderCheckbox action="'setExtraStep'" actionParam="{views: ['website_sale.extra_info']}"/>
+    </BuilderRow>
     <BuilderContext action="'websiteConfig'">
-        <BuilderRow label.translate="Extra Step">
-            <BuilderCheckbox actionParam="{views: ['website_sale.extra_info']}"/>
-        </BuilderRow>
         <BuilderRow label.translate="Suggested Accessories">
             <BuilderCheckbox actionParam="{views: ['website_sale.suggested_products_list']}"/>
         </BuilderRow>
diff --git a/addons/website_sale/static/src/website_builder/checkout_page_option_plugin.js b/addons/website_sale/static/src/website_builder/checkout_page_option_plugin.js
index 8fbd352e5259d..34833fc188e42 100644
--- a/addons/website_sale/static/src/website_builder/checkout_page_option_plugin.js
+++ b/addons/website_sale/static/src/website_builder/checkout_page_option_plugin.js
@@ -1,9 +1,11 @@
 import { Plugin } from "@html_editor/plugin";
-import { registry } from "@web/core/registry";
 import { _t } from "@web/core/l10n/translation";
+import { rpc } from "@web/core/network/rpc";
+import { registry } from "@web/core/registry";
 
 class CheckoutPageOptionPlugin extends Plugin {
     static id = "checkoutPageOption";
+    static dependencies = ["builderActions"];
     resources = {
         builder_options: [
             {
@@ -14,7 +16,32 @@ class CheckoutPageOptionPlugin extends Plugin {
                 groups: ["website.group_website_designer"],
             },
         ],
+        builder_actions: this.getActions(),
     };
+    getActions() {
+        const plugin = this;
+        return {
+            get setExtraStep() {
+                const websiteConfigAction =
+                    plugin.dependencies.builderActions.getAction("websiteConfig");
+                return {
+                    ...websiteConfigAction,
+                    apply: async (...args) => {
+                        await Promise.all([
+                            websiteConfigAction.apply(...args),
+                            rpc("/shop/config/website", { extra_step: "true" }),
+                        ]);
+                    },
+                    clean: async (...args) => {
+                        await Promise.all([
+                            websiteConfigAction.clean(...args),
+                            rpc("/shop/config/website", { extra_step: "false" }),
+                        ]);
+                    },
+                };
+            },
+        };
+    }
 }
 
 registry.category("website-plugins").add(CheckoutPageOptionPlugin.id, CheckoutPageOptionPlugin);
diff --git a/addons/website_sale/static/src/website_builder/product_page_option_plugin.js b/addons/website_sale/static/src/website_builder/product_page_option_plugin.js
index 5c7b8ba0bcf26..da4ddbe5cd29d 100644
--- a/addons/website_sale/static/src/website_builder/product_page_option_plugin.js
+++ b/addons/website_sale/static/src/website_builder/product_page_option_plugin.js
@@ -2,7 +2,6 @@ import { Plugin } from "@html_editor/plugin";
 import { registry } from "@web/core/registry";
 import { _t } from "@web/core/l10n/translation";
 import { ProductPageOption } from "./product_page_option";
-import { AttachmentMediaDialog } from "./attachment_media_dialog";
 import { rpc } from "@web/core/network/rpc";
 import { isImageCorsProtected } from "@html_editor/utils/image";
 import { TABS } from "@html_editor/main/media/media_dialog/media_dialog";
@@ -10,7 +9,7 @@ import { TABS } from "@html_editor/main/media/media_dialog/media_dialog";
 export const productPageSelector = "main:has(.o_wsale_product_page)";
 class ProductPageOptionPlugin extends Plugin {
     static id = "productPageOption";
-    static dependencies = ["builderActions", "dialog", "customizeWebsite"];
+    static dependencies = ["builderActions", "media", "customizeWebsite"];
     resources = {
         builder_options: {
             OptionComponent: ProductPageOption,
@@ -183,10 +182,12 @@ class ProductPageOptionPlugin extends Plugin {
                         );
                     }
                     await new Promise((resolve) => {
-                        const onClose = this.dependencies.dialog.addDialog(AttachmentMediaDialog, {
+                        const onClose = this.dependencies.media.openMediaDialog({
+                            addFieldImage: true,
                             multiImages: true,
                             noDocuments: true,
                             noIcons: true,
+                            node: el,
                             // Kinda hack-ish but the regular save does not get the information we need
                             save: async (imgEls, selectedMedia, activeTab) => {
                                 if (selectedMedia.length) {

From 433d69478e059b6025f6edb6b5f5e51ad80d5666 Mon Sep 17 00:00:00 2001
From: emge-odoo <emge@odoo.com>
Date: Wed, 21 May 2025 17:17:11 +0200
Subject: [PATCH 10/10] fix tour

---
 .../static/tests/tours/link_to_document.js    | 58 ++++++++++++++-----
 addons/website/tests/test_attachment.py       |  1 -
 2 files changed, 45 insertions(+), 14 deletions(-)

diff --git a/addons/website/static/tests/tours/link_to_document.js b/addons/website/static/tests/tours/link_to_document.js
index ea2562a094547..93aee814fe532 100644
--- a/addons/website/static/tests/tours/link_to_document.js
+++ b/addons/website/static/tests/tours/link_to_document.js
@@ -22,6 +22,17 @@ const unpatchStep = {
     run: () => unpatch(),
 };
 
+function showLinkPopover(linkEl) {
+    const doc = linkEl.ownerDocument;
+    const selection = doc.getSelection();
+    const range = doc.createRange();
+    selection.removeAllRanges();
+    range.setStart(linkEl.childNodes[0], 0);
+    range.setEnd(linkEl.childNodes[0], 0);
+    doc.dispatchEvent(new Event("focus"));
+    selection.addRange(range);
+}
+
 /**
  * The purpose of this tour is to check the Linktools to create a link to an
  * uploaded document.
@@ -41,31 +52,52 @@ registerWebsitePreviewTour(
         {
             content: "Click on button Start Now",
             trigger: ":iframe #wrap .s_banner a:nth-child(1)",
-            run: "click",
+            run: ({ anchor }) => {
+                showLinkPopover(anchor);
+            },
         },
-        patchStep,
         {
-            content: "Click on link to an uploaded document",
-            trigger: ".o_url_input .o_we_user_value_widget.fa.fa-upload",
+            content: "Click on edit link",
+            trigger: ".o-we-linkpopover .o_we_edit_link",
             run: "click",
         },
         {
-            content: "Check if a document link is created",
-            trigger: ":iframe #wrap .s_banner .oe_edited_link[href^='/web/content']",
+            content: "Make upload button appear by emptying the link input",
+            trigger: ".o_we_href_input_link",
+            run: ({ anchor }) => {
+                anchor.value = "";
+                anchor.dispatchEvent(new Event("input"));
+            },
         },
-        unpatchStep,
+        patchStep,
         {
-            content: "Check if by default the option auto-download is enabled",
-            trigger: ":iframe #wrap .s_banner .oe_edited_link[href$='download=true']",
+            content: "Click on upload button",
+            trigger: ".o_we_href_input_link + span button",
+            run: "click",
         },
         {
-            content: "Deactivate direct download",
-            trigger: ".o_switch > we-checkbox[name='direct_download']",
+            content: "Click on apply",
+            trigger: ".o_we_apply_link",
             run: "click",
         },
         {
-            content: "Check if auto-download is disabled",
-            trigger: ":iframe #wrap .s_banner .oe_edited_link:not([href$='download=true'])",
+            content: "Check if a document link is created",
+            trigger: ":iframe #wrap .s_banner a[href^='/web/content']",
         },
+        unpatchStep,
+        // TODO: find where this option is, if it is still present
+        // {
+        //     content: "Check if by default the option auto-download is enabled",
+        //     trigger: ":iframe #wrap .s_banner .oe_edited_link[href$='download=true']",
+        // },
+        // {
+        //     content: "Deactivate direct download",
+        //     trigger: ".o_switch > we-checkbox[name='direct_download']",
+        //     run: "click",
+        // },
+        // {
+        //     content: "Check if auto-download is disabled",
+        //     trigger: ":iframe #wrap .s_banner .oe_edited_link:not([href$='download=true'])",
+        // },
     ]
 );
diff --git a/addons/website/tests/test_attachment.py b/addons/website/tests/test_attachment.py
index cb6b580b6a3ed..21cf1230501cc 100644
--- a/addons/website/tests/test_attachment.py
+++ b/addons/website/tests/test_attachment.py
@@ -41,7 +41,6 @@ def test_01_type_url_301_image(self):
     def test_02_image_quality(self):
         self.start_tour(self.env['website'].get_client_action_url('/'), 'website_image_quality', login="admin")
 
-    @unittest.skip
     def test_03_link_to_document(self):
         text = b'Lorem Ipsum'
         self.env['ir.attachment'].create({