Repository: symfony/ux
Pull request #1532 (2024-02-22 03:31:48)

[LiveComponent] New placeholder macro to generate defer/lazy skeleton
| Q             | A
| ------------- | ---
| Bug fix?      |no
| New feature?  | yes
| License       | MIT

When you use defer/lazy component, you can customize the "loading" content in the calling template. But for components that you reuse on multiple pages, it can lead to code duplication.

Moreover, you cannot adapt the size / content depending on the props.

So... introducing the "`placeholder`" macro.

If you define such macro in your lazy component template, it will be called to generate this temporary content.

```twig
{# templates/components/FooBar.html.twig #}

<div {{ attributes }}>

    {# ... #}

    {# your live component code #}

    {# ... #}

</div>


{% macro placeholder(props) %}
    {# This code will only be visible in the "loading" content #}
    <span class="loading-row"></span>
{% endmacro %}
```

Bonus, you'll get the props of your component as parameters.

So let's say you have a live component which load N comments behind an article.

When you set this prop to your live component, it will be passed to the placeholder macro, allowing you to fill the good amount of space / loading blocs in the HTML

```twig
<twig:Comments n="5" />
```


```twig
{# templates/components/Comments.html.twig #}
<div {{ attributes }}
...
</div>

{% macro placeholder(props) %}
    {% for i in 1..props.n %}
        <span class="loading-block"></span>
     {% endfor %}
{% endmacro %}
```


What do you think ? 


smnandre: I tried things with blocks (but you'd have to manually "not render it" when not in lazy), with includes (but i wanted to keep code in the same template), so it feel a good balance.
kbond: This is nice! Does rendering loading content traditionally take priority over the macro?
smnandre: > This is nice! Does rendering loading content traditionally take priority over the macro?

Yes, if you pass a loadingTemplate attribute it takes precedence. And the loadingTag is "around" for both cases.
kbond: So the primary benefits are:
- not requiring injecting the same loading template for every use
- allows accessing the passed component props
smnandre: Exactly and the last one: 
* "styling" the block in a single place

It's 100% optional and has no cost obviously :) 

Patch:
diff --git a/src/Autocomplete/translations/AutocompleteBundle.ar.php b/src/Autocomplete/translations/AutocompleteBundle.ar.php
index b8ced4cb..9d787cab 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.ar.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.ar.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'تحميل المزيد من النتائج...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'لم يتم العثور على أي نتائج',
     'No more results' => 'لا توجد نتائج أٌخرى',
 ];
diff --git a/src/Chartjs/assets/package.json b/src/Chartjs/assets/package.json
index d154b862..b121bce5 100644
--- a/src/Chartjs/assets/package.json
+++ b/src/Chartjs/assets/package.json
@@ -26,7 +26,7 @@
     "devDependencies": {
         "@hotwired/stimulus": "^3.0.0",
         "@types/chart.js": "^2.9.34",
-        "chart.js": "^3.4.1 || ^4.0",
+        "chart.js": "^3.4.1 <3.9 || ^4.0",
         "resize-observer-polyfill": "^1.5.1",
         "vitest-canvas-mock": "^0.3.3"
     }
diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md
index 233ed1ba..6a55dae2 100644
--- a/src/LiveComponent/CHANGELOG.md
+++ b/src/LiveComponent/CHANGELOG.md
@@ -1,51 +1,5 @@
 # CHANGELOG
 
-## 2.16.0
-
--   [BC BREAK] The `data-action-name` attribute behavior was removed in favor of
-    using Stimulus "action parameters" and `data-live-action-param`. This is a
-    breaking change if you were using the `data-action-name` attribute directly
-    in your templates.
-
-    To upgrade your application, follow these changes:
-
-    ```diff
-    <button
-        data-action="live#action"
-    -    data-action-name="debounce(300)|save"
-    +    data-live-action-param="debounce(300)|save"
-    >Save</button>
-    ```
-
-    To pass arguments to an action, also use the Stimulus "action parameters" syntax:
-
-    ```diff
-    <button
-        data-action="live#action"
-    -     data-action-name="addItem(id={{ item.id }}, itemName=CustomItem)"
-    +     data-live-action-param="addItem"
-    +     data-live-id-param="{{ item.id }}"
-    +     data-live-item-name-param="CustomItem"
-    >Add Item</button>
-    ```
-
-    Additionally, the `prevent` modifier (e.g. `prevent|save`) was removed. Replace
-    this with the standard Stimulus `:prevent` action option:
-
-    ```diff
-    <button
-    -    data-action="live#action
-    +    data-action="live#action:prevent"
-    -    data-action-name="prevent|save"
-    +    data-live-action-param="save"
-     >Save</button>
-    ```
-
--   [BC BREAK] The `data-event` attribute was removed in favor of using Stimulus
-    "action parameters": rename `data-event` to `data-live-event-param`. Additionally,
-    if you were passing arguments to the event name, use action parameter attributes
-    for those as well - e.g. `data-live-foo-param="bar"`.
-
 ## 2.15.0
 
 -   [BC BREAK] The `data-live-id` attribute was changed to `id` #1484
diff --git a/src/LiveComponent/assets/dist/Directive/directives_parser.d.ts b/src/LiveComponent/assets/dist/Directive/directives_parser.d.ts
index 7cdef915..cd08322f 100644
--- a/src/LiveComponent/assets/dist/Directive/directives_parser.d.ts
+++ b/src/LiveComponent/assets/dist/Directive/directives_parser.d.ts
@@ -5,6 +5,7 @@ export interface DirectiveModifier {
 export interface Directive {
     action: string;
     args: string[];
+    named: any;
     modifiers: DirectiveModifier[];
     getString: {
         (): string;
diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts
index e80e322f..fa533b60 100644
--- a/src/LiveComponent/assets/dist/live_controller.d.ts
+++ b/src/LiveComponent/assets/dist/live_controller.d.ts
@@ -95,10 +95,10 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
     disconnect(): void;
     update(event: any): void;
     action(event: any): void;
+    emit(event: Event): void;
+    emitUp(event: Event): void;
+    emitSelf(event: Event): void;
     $render(): Promise<import("./Backend/BackendResponse").default>;
-    emit(event: any): void;
-    emitUp(event: any): void;
-    emitSelf(event: any): void;
     $updateModel(model: string, value: any, shouldRender?: boolean, debounce?: number | boolean): Promise<import("./Backend/BackendResponse").default>;
     propsUpdatedFromParentValueChanged(): void;
     fingerprintValueChanged(): void;
diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js
index fedc04fb..7e5e4b78 100644
--- a/src/LiveComponent/assets/dist/live_controller.js
+++ b/src/LiveComponent/assets/dist/live_controller.js
@@ -6,8 +6,10 @@ function parseDirectives(content) {
         return directives;
     }
     let currentActionName = '';
+    let currentArgumentName = '';
     let currentArgumentValue = '';
     let currentArguments = [];
+    let currentNamedArguments = {};
     let currentModifiers = [];
     let state = 'action';
     const getLastActionName = function () {
@@ -23,30 +25,52 @@ function parseDirectives(content) {
         directives.push({
             action: currentActionName,
             args: currentArguments,
+            named: currentNamedArguments,
             modifiers: currentModifiers,
             getString: () => {
                 return content;
             }
         });
         currentActionName = '';
+        currentArgumentName = '';
         currentArgumentValue = '';
         currentArguments = [];
+        currentNamedArguments = {};
         currentModifiers = [];
         state = 'action';
     };
     const pushArgument = function () {
-        currentArguments.push(currentArgumentValue.trim());
+        const mixedArgTypesError = () => {
+            throw new Error(`Normal and named arguments cannot be mixed inside "${currentActionName}()"`);
+        };
+        if (currentArgumentName) {
+            if (currentArguments.length > 0) {
+                mixedArgTypesError();
+            }
+            currentNamedArguments[currentArgumentName.trim()] = currentArgumentValue;
+        }
+        else {
+            if (Object.keys(currentNamedArguments).length > 0) {
+                mixedArgTypesError();
+            }
+            currentArguments.push(currentArgumentValue.trim());
+        }
+        currentArgumentName = '';
         currentArgumentValue = '';
     };
     const pushModifier = function () {
         if (currentArguments.length > 1) {
             throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`);
         }
+        if (Object.keys(currentNamedArguments).length > 0) {
+            throw new Error(`The modifier "${currentActionName}()" does not support named arguments.`);
+        }
         currentModifiers.push({
             name: currentActionName,
             value: currentArguments.length > 0 ? currentArguments[0] : null,
         });
         currentActionName = '';
+        currentArgumentName = '';
         currentArguments = [];
         state = 'action';
     };
@@ -80,6 +104,11 @@ function parseDirectives(content) {
                     pushArgument();
                     break;
                 }
+                if (char === '=') {
+                    currentArgumentName = currentArgumentValue;
+                    currentArgumentValue = '';
+                    break;
+                }
                 currentArgumentValue += char;
                 break;
             case 'after_arguments':
@@ -296,7 +325,7 @@ function getAllModelDirectiveFromElements(element) {
     }
     const directives = parseDirectives(element.dataset.model);
     directives.forEach((directive) => {
-        if (directive.args.length > 0) {
+        if (directive.args.length > 0 || directive.named.length > 0) {
             throw new Error(`The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.`);
         }
         directive.action = normalizeModelName(directive.action);
@@ -313,7 +342,7 @@ function getModelDirectiveFromElement(element, throwOnMissing = true) {
         if (formElement && 'model' in formElement.dataset) {
             const directives = parseDirectives(formElement.dataset.model || '*');
             const directive = directives[0];
-            if (directive.args.length > 0) {
+            if (directive.args.length > 0 || directive.named.length > 0) {
                 throw new Error(`The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`);
             }
             directive.action = normalizeModelName(element.getAttribute('name'));
@@ -2913,18 +2942,15 @@ class LiveControllerDefault extends Controller {
         this.updateModelFromElementEvent(event.currentTarget, null);
     }
     action(event) {
-        const params = event.params;
-        if (!params.action) {
-            throw new Error(`No action name provided on element: ${getElementAsTagText(event.currentTarget)}. Did you forget to add the "data-live-action-param" attribute?`);
-        }
-        const rawAction = params.action;
-        const actionArgs = Object.assign({}, params);
-        delete actionArgs.action;
+        const rawAction = event.currentTarget.dataset.actionName;
         const directives = parseDirectives(rawAction);
         let debounce = false;
         directives.forEach((directive) => {
             let pendingFiles = {};
             const validModifiers = new Map();
+            validModifiers.set('prevent', () => {
+                event.preventDefault();
+            });
             validModifiers.set('stop', () => {
                 event.stopPropagation();
             });
@@ -2959,15 +2985,12 @@ class LiveControllerDefault extends Controller {
                 }
                 delete this.pendingFiles[key];
             }
-            this.component.action(directive.action, actionArgs, debounce);
+            this.component.action(directive.action, directive.named, debounce);
             if (getModelDirectiveFromElement(event.currentTarget, false)) {
                 this.pendingActionTriggerModelElement = event.currentTarget;
             }
         });
     }
-    $render() {
-        return this.component.render();
-    }
     emit(event) {
         this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => {
             this.component.emit(name, data, nameMatch);
@@ -2983,6 +3006,9 @@ class LiveControllerDefault extends Controller {
             this.component.emitSelf(name, data);
         });
     }
+    $render() {
+        return this.component.render();
+    }
     $updateModel(model, value, shouldRender = true, debounce = true) {
         return this.component.set(model, value, shouldRender, debounce);
     }
@@ -2993,13 +3019,11 @@ class LiveControllerDefault extends Controller {
         this.component.fingerprint = this.fingerprintValue;
     }
     getEmitDirectives(event) {
-        const params = event.params;
-        if (!params.event) {
-            throw new Error(`No event name provided on element: ${getElementAsTagText(event.currentTarget)}. Did you forget to add the "data-live-event-param" attribute?`);
+        const element = event.currentTarget;
+        if (!element.dataset.event) {
+            throw new Error(`No data-event attribute found on element: ${getElementAsTagText(element)}`);
         }
-        const eventInfo = params.event;
-        const eventArgs = Object.assign({}, params);
-        delete eventArgs.event;
+        const eventInfo = element.dataset.event;
         const directives = parseDirectives(eventInfo);
         const emits = [];
         directives.forEach((directive) => {
@@ -3015,7 +3039,7 @@ class LiveControllerDefault extends Controller {
             });
             emits.push({
                 name: directive.action,
-                data: eventArgs,
+                data: directive.named,
                 nameMatch,
             });
         });
diff --git a/src/LiveComponent/assets/src/Directive/directives_parser.ts b/src/LiveComponent/assets/src/Directive/directives_parser.ts
index 0b5f1c9d..2e2bc85d 100644
--- a/src/LiveComponent/assets/src/Directive/directives_parser.ts
+++ b/src/LiveComponent/assets/src/Directive/directives_parser.ts
@@ -25,6 +25,10 @@ export interface Directive {
      * An array of unnamed arguments passed to the action
      */
     args: string[];
+    /**
+     * An object of named arguments
+     */
+    named: any;
     /**
      * Any modifiers applied to the action
      */
@@ -37,8 +41,17 @@ export interface Directive {
  * into an array of directives, with this format:
  *
  *      [
- *          { action: 'addClass', args: ['foo'], modifiers: [] },
- *          { action: 'removeAttribute', args: ['bar'], modifiers: [] }
+ *          { action: 'addClass', args: ['foo'], named: {}, modifiers: [] },
+ *          { action: 'removeAttribute', args: ['bar'], named: {}, modifiers: [] }
+ *      ]
+ *
+ * This also handles named arguments
+ *
+ *      save(foo=bar, baz=bazzles)
+ *
+ * Which would return:
+ *      [
+ *          { action: 'save', args: [], named: { foo: 'bar', baz: 'bazzles }, modifiers: [] }
  *      ]
  *
  * @param {string} content The value of the attribute
@@ -51,8 +64,10 @@ export function parseDirectives(content: string|null): Directive[] {
     }
 
     let currentActionName = '';
+    let currentArgumentName = '';
     let currentArgumentValue = '';
     let currentArguments: string[] = [];
+    let currentNamedArguments: any = {};
     let currentModifiers: { name: string, value: string | null }[] = [];
     let state = 'action';
 
@@ -71,6 +86,7 @@ export function parseDirectives(content: string|null): Directive[] {
         directives.push({
             action: currentActionName,
             args: currentArguments,
+            named: currentNamedArguments,
             modifiers: currentModifiers,
             getString: () => {
                 // TODO - make a string representation of JUST this directive
@@ -79,15 +95,36 @@ export function parseDirectives(content: string|null): Directive[] {
             }
         });
         currentActionName = '';
+        currentArgumentName = '';
         currentArgumentValue = '';
         currentArguments = [];
+        currentNamedArguments = {};
         currentModifiers = [];
         state = 'action';
     }
     const pushArgument = function() {
-        // value is trimmed to avoid space after ","
-        // "foo, bar"
-        currentArguments.push(currentArgumentValue.trim());
+        const mixedArgTypesError = () => {
+            throw new Error(`Normal and named arguments cannot be mixed inside "${currentActionName}()"`)
+        }
+
+        if (currentArgumentName) {
+            if (currentArguments.length > 0) {
+                mixedArgTypesError();
+            }
+
+            // argument names are also trimmed to avoid space after ","
+            // "foo=bar, baz=bazzles"
+            currentNamedArguments[currentArgumentName.trim()] = currentArgumentValue;
+        } else {
+            if (Object.keys(currentNamedArguments).length > 0) {
+                mixedArgTypesError();
+            }
+
+            // value is trimmed to avoid space after ","
+            // "foo, bar"
+            currentArguments.push(currentArgumentValue.trim());
+        }
+        currentArgumentName = '';
         currentArgumentValue = '';
     }
 
@@ -96,11 +133,16 @@ export function parseDirectives(content: string|null): Directive[] {
             throw new Error(`The modifier "${currentActionName}()" does not support multiple arguments.`)
         }
 
+        if (Object.keys(currentNamedArguments).length > 0) {
+            throw new Error(`The modifier "${currentActionName}()" does not support named arguments.`)
+        }
+
         currentModifiers.push({
             name: currentActionName,
             value: currentArguments.length > 0 ? currentArguments[0] : null,
         });
         currentActionName = '';
+        currentArgumentName = '';
         currentArguments = [];
         state = 'action';
     }
@@ -154,6 +196,14 @@ export function parseDirectives(content: string|null): Directive[] {
                     break;
                 }
 
+                if (char === '=') {
+                    // this is a named argument!
+                    currentArgumentName = currentArgumentValue;
+                    currentArgumentValue = '';
+
+                    break;
+                }
+
                 // add next character to argument
                 currentArgumentValue += char;
 
diff --git a/src/LiveComponent/assets/src/dom_utils.ts b/src/LiveComponent/assets/src/dom_utils.ts
index 4970012f..b756f3de 100644
--- a/src/LiveComponent/assets/src/dom_utils.ts
+++ b/src/LiveComponent/assets/src/dom_utils.ts
@@ -141,7 +141,7 @@ export function getAllModelDirectiveFromElements(element: HTMLElement): Directiv
     const directives = parseDirectives(element.dataset.model);
 
     directives.forEach((directive) => {
-        if (directive.args.length > 0) {
+        if (directive.args.length > 0 || directive.named.length > 0) {
             throw new Error(
                 `The data-model="${element.dataset.model}" format is invalid: it does not support passing arguments to the model.`
             );
@@ -167,7 +167,7 @@ export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissin
             const directives = parseDirectives(formElement.dataset.model || '*');
             const directive = directives[0];
 
-            if (directive.args.length > 0) {
+            if (directive.args.length > 0 || directive.named.length > 0) {
                 throw new Error(
                     `The data-model="${formElement.dataset.model}" format is invalid: it does not support passing arguments to the model.`
                 );
diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts
index 5198a33e..25025c55 100644
--- a/src/LiveComponent/assets/src/live_controller.ts
+++ b/src/LiveComponent/assets/src/live_controller.ts
@@ -118,26 +118,23 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
     }
 
     action(event: any) {
-        const params = event.params;
-        if (!params.action) {
-            throw new Error(
-                `No action name provided on element: ${getElementAsTagText(
-                    event.currentTarget
-                )}. Did you forget to add the "data-live-action-param" attribute?`
-            );
-        }
-        const rawAction = params.action;
-        // all other params are considered action arguments
-        const actionArgs = { ...params };
-        delete actionArgs.action;
-
-        // data-live-action-param="debounce(1000)|save"
+        // using currentTarget means that the data-action and data-action-name
+        // must live on the same element: you can't add
+        // data-action="click->live#action" on a parent element and
+        // expect it to use the data-action-name from the child element
+        // that actually received the click
+        const rawAction = event.currentTarget.dataset.actionName;
+
+        // data-action-name="prevent|debounce(1000)|save"
         const directives = parseDirectives(rawAction);
         let debounce: number | boolean = false;
 
         directives.forEach((directive) => {
             let pendingFiles: { [key: string]: HTMLInputElement } = {};
             const validModifiers: Map<string, (modifier: DirectiveModifier) => void> = new Map();
+            validModifiers.set('prevent', () => {
+                event.preventDefault();
+            });
             validModifiers.set('stop', () => {
                 event.stopPropagation();
             });
@@ -179,7 +176,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
                 }
                 delete this.pendingFiles[key];
             }
-            this.component.action(directive.action, actionArgs, debounce);
+            this.component.action(directive.action, directive.named, debounce);
 
             // possible case where this element is also a "model" element
             // if so, to be safe, slightly delay the action so that the
@@ -191,28 +188,28 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
         });
     }
 
-    $render() {
-        return this.component.render();
-    }
-
-    emit(event: any) {
+    emit(event: Event) {
         this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => {
             this.component.emit(name, data, nameMatch);
         });
     }
 
-    emitUp(event: any) {
+    emitUp(event: Event) {
         this.getEmitDirectives(event).forEach(({ name, data, nameMatch }) => {
             this.component.emitUp(name, data, nameMatch);
         });
     }
 
-    emitSelf(event: any) {
+    emitSelf(event: Event) {
         this.getEmitDirectives(event).forEach(({ name, data }) => {
             this.component.emitSelf(name, data);
         });
     }
 
+    $render() {
+        return this.component.render();
+    }
+
     /**
      * Update a model value.
      *
@@ -233,19 +230,13 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
         this.component.fingerprint = this.fingerprintValue;
     }
 
-    private getEmitDirectives(event: any): Array<{ name: string; data: any; nameMatch: string | null }> {
-        const params = event.params;
-        if (!params.event) {
-            throw new Error(
-                `No event name provided on element: ${getElementAsTagText(
-                    event.currentTarget
-                )}. Did you forget to add the "data-live-event-param" attribute?`
-            );
+    private getEmitDirectives(event: Event): Array<{ name: string; data: any; nameMatch: string | null }> {
+        const element = event.currentTarget as HTMLElement;
+        if (!element.dataset.event) {
+            throw new Error(`No data-event attribute found on element: ${getElementAsTagText(element)}`);
         }
-        const eventInfo = params.event;
-        // all other params are considered event arguments
-        const eventArgs = { ...params };
-        delete eventArgs.event;
+
+        const eventInfo = element.dataset.event;
 
         // data-event="name(product_list)|some_event"
         const directives = parseDirectives(eventInfo);
@@ -264,7 +255,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
 
             emits.push({
                 name: directive.action,
-                data: eventArgs,
+                data: directive.named,
                 nameMatch,
             });
         });
diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst
index 3c2d503c..5f3c5cb8 100644
--- a/src/LiveComponent/doc/index.rst
+++ b/src/LiveComponent/doc/index.rst
@@ -96,9 +96,9 @@ needed if you're using AssetMapper):
     $ yarn install --force
     $ yarn watch
 
-If your project is localized in different languages (either via the `_locale route parameter`_
-or by `setting the locale in the request`_) add the ``{ _locale}`` attribute to
-the UX Live Components route definition to keep the locale between re-renders:
+In case your project `localizes its URLs`_ by adding the special
+``{_locale}`` parameter to the paths of its route definitions,
+you need to do the same with the UX Live Components route definition:
 
 .. code-block:: diff
 
@@ -1036,21 +1036,14 @@ the work::
         // ...
     }
 
-.. versionadded:: 2.16
-
-    The ``data-live-action-param`` attribute way of specifying the action
-    was added in Live Components 2.16. Previously, this was done with
-    ``data-live-action-name``.
-
-To call this, trigger the ``action`` method on the ``live`` Stimulus
-controller and pass ``resetMax`` as a `Stimulus action parameter`_ called
-``action``:
+To call this, add ``data-action="live#action"`` and ``data-action-name``
+to an element (e.g. a button or form):
 
 .. code-block:: html+twig
 
     <button
         data-action="live#action"
-        data-live-action-param="resetMax"
+        data-action-name="resetMax"
     >Reset Min/Max</button>
 
 Done! When the user clicks this button, a POST request will be sent that
@@ -1065,12 +1058,14 @@ You can also add several "modifiers" to the action:
     <form>
         <button
             data-action="live#action"
-            data-live-action-param="debounce(300)|save"
+            data-action-name="prevent|debounce(300)|save"
         >Save</button>
     </form>
 
-The ``debounce(300)`` adds 300ms of "debouncing" before the action is executed.
-In other words, if you click really fast 5 times, only one Ajax request will be made!
+The ``prevent`` modifier would prevent the form from submitting
+(``event.preventDefault()``). The ``debounce(300)`` modifier will add
+300ms of "debouncing" before the action is executed. In other words, if
+you click really fast 5 times, only one Ajax request will be made!
 
 Actions & Services
 ~~~~~~~~~~~~~~~~~~
@@ -1104,28 +1099,22 @@ This means that, for example, you can use action autowiring::
 Actions & Arguments
 ~~~~~~~~~~~~~~~~~~~
 
-.. versionadded:: 2.16
+.. versionadded:: 2.1
 
-    The ``data-live-{NAME}-param`` attribute way of specifying action
-    arguments was added in Live Components 2.16. Previously, this was done
-    inside the ``data-live-action-name`` attribute.
+    The ability to pass arguments to actions was added in version 2.1.
 
-You can also pass arguments to your action by adding each as a
-`Stimulus action parameter`_:
+You can also provide custom arguments to your action:
 
 .. code-block:: html+twig
 
     <form>
         <button
             data-action="live#action"
-            data-live-action-param="addItem"
-
-            data-live-id-param="{{ item.id }}"
-            data-live-item-name-param="CustomItem"
+            data-action-name="addItem(id={{ item.id }}, itemName=CustomItem)"
         >Add Item</button>
     </form>
 
-In your component, to allow each argument to be passed, add
+In your component, to allow each argument to be passed, we need to add
 the ``#[LiveArg()]`` attribute::
 
     // src/Components/ItemList.php
@@ -1146,6 +1135,10 @@ the ``#[LiveArg()]`` attribute::
         }
     }
 
+Normally, the argument name in PHP - e.g. ``$id`` - should match the
+argument name used in Twig ``id={{ item.id }}``. But if they don't
+match, you can pass an argument to ``LiveArg``, like we did with ``itemName``.
+
 Actions and CSRF Protection
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -1223,11 +1216,10 @@ to handle the files and tell the component when the file should be sent:
 
 .. code-block:: html+twig
 
-    <input type="file" name="my_file" />
-    <button
-        data-action="live#action"
-        data-live-action-param="files|my_action"
-    />
+    <p>
+        <input type="file" name="my_file" />
+        <button data-action="live#action" data-action-name="files|my_action" />
+    </p>
 
 To send a file (or files) with an action use ``files`` modifier.
 Without an argument it will send all pending files to your action.
@@ -1241,11 +1233,11 @@ You can also specify a modifier parameter to choose which files should be upload
         <input type="file" name="multiple[]" multiple />
 
         {# Send only file from first input #}
-        <button data-action="live#action" data-live-action-param="files(my_file)|myAction" />
+        <button data-action="live#action" data-action-name="files(my_file)|myAction" />
         {# You can chain modifiers to send multiple files #}
-        <button data-action="live#action" data-live-action-param="files(my_file)|files(multiple[])|myAction" />
+        <button data-action="live#action" data-action-name="files(my_file)|files(multiple[])|myAction" />
         {# Or send all pending files #}
-        <button data-action="live#action" data-live-action-param="files|myAction" />
+        <button data-action="live#action" data-action-name="files|myAction" />
     </p>
 
 The files will be available in a regular ``$request->files`` files bag::
@@ -1464,8 +1456,8 @@ Next, tell the ``form`` element to use this action:
 
     {{ form_start(form, {
         attr: {
-            'data-action': 'live#action:prevent',
-            'data-live-action-param': 'save'
+            'data-action': 'live#action',
+            'data-action-name': 'prevent|save'
         }
     }) }}
 
@@ -1765,8 +1757,7 @@ and ``removeComment()`` actions:
             {% for key, commentForm in form.comments %}
                 <button
                     data-action="live#action"
-                    data-live-action-param="removeComment"
-                    data-live-index-param="{{ key }}"
+                    data-action-name="removeComment(index={{ key }})"
                     type="button"
                 >X</button>
 
@@ -1778,7 +1769,7 @@ and ``removeComment()`` actions:
 
             <button
                 data-action="live#action"
-                data-live-action-param="addComment"
+                data-action-name="addComment"
                 type="button"
             >+ Add Comment</button>
 
@@ -2181,8 +2172,8 @@ re-rendered. In your template, render errors using an ``_errors`` variable:
 
     <button
         type="submit"
-        data-action="live#action:prevent"
-        data-live-action-param="save"
+        data-action="live#action"
+        data-action-name="prevent|save"
     >Save</button>
 
 Once a component has been validated, the component will "remember" that
@@ -2456,18 +2447,13 @@ Emitting an Event
 
 There are three ways to emit an event:
 
-.. versionchanged:: 2.16
-
-    The ``data-live-event-param`` attribute was added in Live Components 2.16.
-    Previously, it was called ``data-event``.
-
 1. From Twig:
 
    .. code-block:: html+twig
 
        <button
            data-action="live#emit"
-           data-live-event-param="productAdded"
+           data-event="productAdded"
        >
 
 2. From your PHP component via ``ComponentToolsTrait``::
@@ -2564,7 +2550,7 @@ If you want to emit an event to only the parent components, use the
 
     <button
         data-action="live#emitUp"
-        data-live-event-param="productAdded"
+        data-event="productAdded"
     >
 
 Or, in PHP::
@@ -2581,7 +2567,7 @@ use the ``name()`` modifier:
 
     <button
         data-action="live#emit"
-        data-live-event-param="name(ProductList)|productAdded"
+        data-event="name(ProductList)|productAdded"
     >
 
 Or, in PHP::
@@ -2597,7 +2583,7 @@ To emit an event to only yourself, use the ``emitSelf()`` method:
 
     <button
         data-action="live#emitSelf"
-        data-live-event-param="productAdded"
+        data-event="productAdded"
     >
 
 Or, in PHP::
@@ -2780,7 +2766,7 @@ suppose your child component has:
 
 .. code-block:: html
 
-    <button data-action="live#action" data-live-action-param="save">Save</button>
+    <button data-action="live#action" data-action-name="save">Save</button>
 
 When the user clicks that button, it will attempt to call the ``save``
 action in the *child* component only, even if the ``save`` action
@@ -2944,7 +2930,7 @@ In the ``EditPost`` template, you render the
 
             <button
                 data-action="live#action"
-                data-live-action-param="save"
+                data-action-name="save"
             >Save</button>
         </form>
     </div>
@@ -3470,6 +3456,7 @@ bound to Symfony's BC policy for the moment.
 .. _`experimental`: https://symfony.com/doc/current/contributing/code/experimental.html
 .. _`dependent form fields`: https://ux.symfony.com/live-component/demos/dependent-form-fields
 .. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html
+.. _`localizes its URLs`: https://symfony.com/doc/current/translation/locale.html#translation-locale-url
 .. _`attributes variable`: https://symfony.com/bundles/ux-twig-component/current/index.html#component-attributes
 .. _`CollectionType`: https://symfony.com/doc/current/form/form_collections.html
 .. _`the traditional collection type`: https://symfony.com/doc/current/form/form_themes.html#fragment-naming-for-collections
@@ -3480,6 +3467,3 @@ bound to Symfony's BC policy for the moment.
 .. _`PostMount hook`: https://symfony.com/bundles/ux-twig-component/current/index.html#postmount-hook
 .. _`validation groups`: https://symfony.com/doc/current/form/validation_groups.html
 .. _morphing library: https://github.com/bigskysoftware/idiomorph
-.. _`_locale route parameter`: https://symfony.com/doc/current/translation.html#the-locale-and-the-url
-.. _`setting the locale in the request`: https://symfony.com/doc/current/translation.html#translation-locale
-.. _`Stimulus action parameter`: https://stimulus.hotwired.dev/reference/actions#action-parameters
diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
index 06e61ea9..8683214a 100644
--- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
+++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
@@ -253,7 +253,7 @@ final class LiveComponentExtension extends Extension implements PrependExtension
         ;
 
         $container->register('ux.live_component.twig.template_mapper', TemplateMap::class)
-            ->setArguments(['%kernel.build_dir%/'.self::TEMPLATES_MAP_FILENAME]);
+            ->setArguments(['%kernel.cache_dir%/'.self::TEMPLATES_MAP_FILENAME]);
 
         $container->register('ux.live_component.twig.cache_warmer', TemplateCacheWarmer::class)
             ->setArguments([
diff --git a/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php
index 19928e8b..d7e598cf 100644
--- a/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php
+++ b/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php
@@ -52,6 +52,7 @@ final class DeferLiveComponentSubscriber implements EventSubscriberInterface
             return;
         }
 
+        $componentTemplate = $event->getMetadata()->getTemplate();
         $event->setTemplate('@LiveComponent/deferred.html.twig');
 
         $variables = $event->getVariables();
@@ -66,6 +67,8 @@ final class DeferLiveComponentSubscriber implements EventSubscriberInterface
             $variables['loadingTag'] = $mountedComponent->getExtraMetadata('loading-tag');
         }
 
+        $variables['componentTemplate'] = $componentTemplate;
+
         $event->setVariables($variables);
     }
 
diff --git a/src/LiveComponent/src/Form/Type/LiveCollectionType.php b/src/LiveComponent/src/Form/Type/LiveCollectionType.php
index 5da11a16..9ac5c71b 100644
--- a/src/LiveComponent/src/Form/Type/LiveCollectionType.php
+++ b/src/LiveComponent/src/Form/Type/LiveCollectionType.php
@@ -47,8 +47,7 @@ final class LiveCollectionType extends AbstractType
 
             $attr = $view->vars['button_add']->vars['attr'];
             $attr['data-action'] ??= 'live#action';
-            $attr['data-live-action-param'] ??= 'addCollectionItem';
-            $attr['data-live-name-param'] ??= $view->vars['full_name'];
+            $attr['data-action-name'] ??= sprintf('addCollectionItem(name=%s)', $view->vars['full_name']);
             $view->vars['button_add']->vars['attr'] = $attr;
 
             array_splice($view->vars['button_add']->vars['block_prefixes'], 1, 0, 'live_collection_button_add');
@@ -86,9 +85,7 @@ final class LiveCollectionType extends AbstractType
 
                 $attr = $entryView->vars['button_delete']->vars['attr'];
                 $attr['data-action'] ??= 'live#action';
-                $attr['data-live-action-param'] ??= 'removeCollectionItem';
-                $attr['data-live-name-param'] ??= $view->vars['full_name'];
-                $attr['data-live-index-param'] ??= $k;
+                $attr['data-action-name'] ??= sprintf('removeCollectionItem(name=%s, index=%s)', $view->vars['full_name'], $k);
                 $entryView->vars['button_delete']->vars['attr'] = $attr;
 
                 array_splice($entryView->vars['button_delete']->vars['block_prefixes'], 1, 0, 'live_collection_button_delete');
diff --git a/src/LiveComponent/src/Twig/TemplateCacheWarmer.php b/src/LiveComponent/src/Twig/TemplateCacheWarmer.php
index f465e94a..3c9d18ac 100644
--- a/src/LiveComponent/src/Twig/TemplateCacheWarmer.php
+++ b/src/LiveComponent/src/Twig/TemplateCacheWarmer.php
@@ -36,8 +36,7 @@ final class TemplateCacheWarmer implements CacheWarmerInterface
             $map[hash('xxh128', $item.$this->secret)] = $item;
         }
 
-        $cacheFile = sprintf('%s%s%s', $buildDir ?? $cacheDir, DIRECTORY_SEPARATOR, $this->cacheFilename);
-        PhpArrayAdapter::create($cacheFile, new NullAdapter())->warmUp(['map' => $map]);
+        (new PhpArrayAdapter($cacheDir.'/'.$this->cacheFilename, new NullAdapter()))->warmUp(['map' => $map]);
 
         return [];
     }
diff --git a/src/LiveComponent/src/Twig/TemplateMap.php b/src/LiveComponent/src/Twig/TemplateMap.php
index 142e58cd..03dd2ffd 100644
--- a/src/LiveComponent/src/Twig/TemplateMap.php
+++ b/src/LiveComponent/src/Twig/TemplateMap.php
@@ -21,14 +21,11 @@ use Symfony\Component\Cache\Adapter\PhpArrayAdapter;
  */
 final class TemplateMap
 {
-    /**
-     * @var array<string, string> Map of <obscured name> => <template name>
-     */
     private readonly array $map;
 
     public function __construct(string $cacheFile)
     {
-        $this->map = PhpArrayAdapter::create($cacheFile, new NullAdapter())->getItem('map')->get();
+        $this->map = (new PhpArrayAdapter($cacheFile, new NullAdapter()))->getItem('map')->get();
     }
 
     public function resolve(string $obscuredName): string
@@ -38,7 +35,8 @@ final class TemplateMap
 
     public function obscuredName(string $templateName): string
     {
-        if (false === $obscuredName = array_search($templateName, $this->map, true)) {
+        $obscuredName = array_search($templateName, $this->map, true);
+        if (false === $obscuredName) {
             throw new \RuntimeException(sprintf('Cannot find a match for template "%s". Cache may be corrupt.', $templateName));
         }
 
diff --git a/src/LiveComponent/templates/deferred.html.twig b/src/LiveComponent/templates/deferred.html.twig
index 0705be8e..b55ec440 100644
--- a/src/LiveComponent/templates/deferred.html.twig
+++ b/src/LiveComponent/templates/deferred.html.twig
@@ -2,6 +2,11 @@
     {% block loadingContent %}
         {% if loadingTemplate != null %}
             {{ include(loadingTemplate) }}
+        {% else %}
+            {% from componentTemplate import placeholder %}
+            {% if placeholder is defined %}
+                {{ placeholder(__props|default) }}
+            {% endif %}
         {% endif %}
     {% endblock %}
 </{{ loadingTag }}>
diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst
index 45e3d00a..72e93491 100644
--- a/src/TwigComponent/doc/index.rst
+++ b/src/TwigComponent/doc/index.rst
@@ -306,7 +306,7 @@ want to render on the root element:
 
     <twig:Alert message="hello!" id="custom-alert-id" />
 
-To pass an array of attributes, use ``{{...}}`` spread operator syntax.
+To pass an array of attributes, use `{{...}}` spread operator syntax.
 This requires Twig 3.7.0 or higher:
 
 .. code-block:: html+twig
diff --git a/ux.symfony.com/assets/styles/demos/_live-memory.scss b/ux.symfony.com/assets/styles/demos/_live-memory.scss
new file mode 100644
index 00000000..ba03970c
--- /dev/null
+++ b/ux.symfony.com/assets/styles/demos/_live-memory.scss
@@ -0,0 +1,25 @@
+// -----------------------------------------------------------------
+// LiveMemory demo
+// -----------------------------------------------------------------
+
+@import "live-memory/app/reset";
+@import "live-memory/app/root";
+@import "live-memory/app/html";
+
+@import "live-memory/base/header";
+
+@import "../components/Icon";
+@import "live-memory/components/Board";
+@import "live-memory/components/Card";
+@import "live-memory/components/Debug";
+@import "live-memory/components/Logo";
+@import "live-memory/components/Metric";
+@import "live-memory/components/Overlay";
+@import "live-memory/components/Score";
+@import "live-memory/components/ThemeSwitch";
+@import "live-memory/components/Timer";
+
+@import "../utilities/background";
+@import "../utilities/shadow";
+
+@import "live-memory/app/themes";
diff --git a/ux.symfony.com/assets/styles/demos/live-memory.min.css b/ux.symfony.com/assets/styles/demos/live-memory.min.css
index db51ecfd..747561dd 100644
--- a/ux.symfony.com/assets/styles/demos/live-memory.min.css
+++ b/ux.symfony.com/assets/styles/demos/live-memory.min.css
@@ -1,26 +1,992 @@
-/** -----------------------------------------------------------------
- * LiveMemory demo
- * --------------------------------------------------------------- */
+*, *::before, *::after {
+  box-sizing: border-box;
+}
 
-@import "live-memory/app/reset";
-@import "live-memory/app/root";
-@import "live-memory/app/html";
+* {
+  margin: 0;
+  padding: 0;
+}
 
-@import "live-memory/base/header";
+body {
+  line-height: 1.5;
+  -webkit-font-smoothing: antialiased;
+}
 
-@import "../components/Icon";
-@import "live-memory/components/Board";
-@import "live-memory/components/Card";
+img, picture, video, canvas, svg {
+  display: block;
+  max-width: 100%;
+}
 
-@import "live-memory/components/Debug";
-@import "live-memory/components/Logo";
-@import "live-memory/components/Metric";
-@import "live-memory/components/Overlay";
-@import "live-memory/components/Score";
-@import "live-memory/components/ThemeSwitch";
-@import "live-memory/components/Timer";
+input, button, textarea, select {
+  font: inherit;
+  background: none;
+  color: inherit;
+  border: 0;
+  padding: 0;
+}
 
-@import "../utilities/background";
-@import "../utilities/shadow";
+#root, #__next {
+  isolation: isolate;
+}
 
-@import "live-memory/app/themes";
+:root {
+  --radius: 1rem;
+  --space: 1rem;
+  --space-large: 1.5rem;
+  --font-title: system-ui, sans-serif;
+  --font-text: system-ui, sans-serif;
+  --font-code: ui-monospace, "SF Mono", SFMono-Regular, "Source Code Pro", Consolas, monospace;
+  --container-max-width: 1280px;
+}
+
+:root,
+[data-theme=light],
+[data-bs-theme=light] {
+  --color-text: #212529;
+  --color-text-rgb: 33 37 41;
+  --color-background: #fff;
+  --color-background-rgb: 255 255 255;
+  --color-border: #DEE2E6;
+}
+
+[data-theme=dark],
+[data-bs-theme=dark] {
+  --color-text: #DEE2E6;
+  --color-text-rgb: 222 226 230;
+  --color-background: #212529;
+  --color-background-rgb: 33 37 41;
+  --color-border: #1a1d20;
+}
+
+:root {
+  font-size: 100%;
+}
+
+body {
+  font-family: var(--font-text), sans-serif;
+  font-weight: 400;
+  min-height: 100vh;
+  min-width: 320px;
+  overflow-x: clip;
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  background: var(--color-background);
+  color: var(--color-text);
+  transition: background 500ms linear, color 500ms ease-in-out;
+}
+
+::selection {
+  background: var(--color-text);
+  color: var(--color-background);
+  -webkit-text-fill-color: currentColor;
+}
+
+a {
+  text-decoration: none;
+  text-underline-offset: 0.25ex;
+  color: inherit;
+}
+
+main {
+  flex: 1 1 auto;
+  width: 100%;
+  display: grid;
+}
+
+.wrapper {
+  padding: 0 1rem;
+  max-inline-size: var(--container-max-width);
+  margin-inline: auto;
+}
+
+.wrapper--full {
+  max-inline-size: 100%;
+}
+
+.Header {
+  padding: 1rem;
+  width: 100%;
+  max-inline-size: var(--container-max-width);
+  align-items: center;
+  margin-inline: auto;
+  display: grid;
+  grid-template-areas: "a logo b";
+  grid-template-columns: 1fr auto 1fr;
+}
+
+.HeaderLogo {
+  width: min(25vw, 220px);
+  mix-blend-mode: hard-light;
+  order: 2;
+}
+
+.HeaderLinks {
+  grid-area: b;
+  display: flex;
+  justify-content: flex-end;
+  flex-direction: row;
+  align-items: center;
+  gap: 1rem;
+}
+
+.HeaderButton {
+  width: 2.25rem;
+  height: 2.25rem;
+  border-radius: 12%;
+  opacity: 0.75;
+  display: grid;
+  place-content: center;
+  align-items: center;
+  background: transparent;
+  border: 1px solid transparent;
+  transition: 250ms all ease-in-out;
+}
+
+.HeaderButton:hover {
+  opacity: 0.85;
+  background: var(--color-background);
+  border: 1px solid var(--color-border);
+}
+
+.Icon {
+  display: inline-flex;
+  justify-content: center;
+  align-items: center;
+  height: 1em;
+  width: 1em;
+  color: var(--icon-color);
+  vertical-align: -0.125em;
+}
+.Icon svg {
+  fill: currentColor;
+  height: 100%;
+  width: 100%;
+}
+
+body::before {
+  position: fixed;
+  inset: 0;
+  content: "";
+  background: var(--background-cover-url);
+  background-attachment: fixed;
+  background-size: cover;
+  background-blend-mode: luminosity;
+  filter: blur(10px);
+  transform: scale(1.1);
+  opacity: 0.125;
+  transition: background 1s ease-in-out;
+}
+
+.LiveMemory {
+  padding: 1rem;
+  border: 1px solid var(--color-border);
+  border-radius: 1rem;
+  background: rgb(var(--color-background-rgb)/75%);
+  align-self: center;
+}
+
+.LiveMemory-Board {
+  display: flex;
+  flex-direction: column;
+  gap: 1rem;
+  position: relative;
+}
+
+.LiveMemory-Main {
+  display: grid;
+  align-items: center;
+  position: relative;
+  overflow: hidden;
+  border-radius: 1vmin;
+  background: rgb(var(--color-background-rgb)/50%);
+  border: 1px solid var(--color-border);
+  width: 100%;
+  aspect-ratio: 1.6 auto;
+}
+
+.LiveMemory-Background {
+  position: absolute;
+  inset: 0;
+}
+
+.LiveMemory-Tableau {
+  position: relative;
+  justify-self: normal;
+  align-self: normal;
+  display: grid;
+  padding: 1vmin;
+}
+
+.LiveMemory-Cards {
+  --cards-x: max(var(--deck-x), var(--deck-y));
+  --cards-y: min(var(--deck-x), var(--deck-y));
+  grid-template-columns: repeat(var(--cards-x), 1fr);
+  grid-template-rows: repeat(var(--cards-y), 1fr);
+  display: grid;
+  justify-self: center;
+  aspect-ratio: var(--cards-x)/var(--cards-y);
+  --card-space: calc(max(1vmin));
+}
+
+.LiveMemory-CardSlot {
+  padding: max(0.75rem, 0.5 * var(--card-space));
+  position: relative;
+  display: grid;
+  align-content: center;
+}
+
+@media (orientation: landscape) {
+  .LiveMemory-Cards {
+    height: calc(100% - 2 * var(--card-space));
+    margin-block: var(--card-space);
+    max-width: 100%;
+  }
+}
+@media (orientation: portrait) {
+  .LiveMemory-Main {
+    aspect-ratio: 1/1.6 auto;
+  }
+  .LiveMemory-Cards {
+    width: calc(100% - 2 * var(--card-space));
+    padding-inline: var(--card-space);
+    align-self: center;
+    --cards-x: min(var(--deck-x), var(--deck-y));
+    --cards-y: max(var(--deck-x), var(--deck-y));
+  }
+}
+.LiveMemory-Scores {
+  position: absolute;
+  inset: 0;
+}
+
+.LiveMemory-Footer {
+  display: grid;
+  grid-template-columns: 1fr 1fr 1fr;
+  gap: 1rem;
+  align-items: center;
+  justify-content: space-between;
+  line-height: 0.5rem;
+  word-spacing: -0.125rem;
+  mix-blend-mode: color-burn;
+  font-size: 0.65rem;
+  font-weight: lighter;
+  font-family: monospace;
+  text-transform: uppercase;
+  --footer-color: var(--color-text);
+  --footer-color-subtle: var(--color-text);
+}
+[data-bs-theme=dark] .LiveMemory-Footer {
+  mix-blend-mode: overlay;
+}
+
+.LiveMemory-Footer span:first-child {
+  text-align: left;
+}
+
+.LiveMemory-Footer span:last-child {
+  text-align: right;
+}
+
+.LiveMemory-Footer > span {
+  text-align: center;
+}
+
+.LiveMemory-Footer a {
+  font-weight: lighter;
+  text-align: center;
+  color: var(--footer-color);
+}
+
+.LiveMemory-Footer a:hover {
+  color: inherit;
+  opacity: 1;
+  text-decoration: underline;
+}
+
+.LiveMemory-Card {
+  --card-radius: max(8%, 1.5vmin);
+  --card-delta: var(--card-delta, 0);
+  aspect-ratio: 1/1;
+  transform-style: preserve-3d;
+  transition: transform 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275);
+  cursor: pointer;
+  max-width: 12rem;
+}
+
+.LiveMemory-Card--rotating {
+  animation: rotate 1.5s forwards infinite ease-in-out;
+}
+
+.LiveMemory-Card__back {
+  padding: 0;
+  border: 0;
+}
+
+.LiveMemory-Card__front,
+.LiveMemory-Card__back {
+  position: absolute;
+  width: 100%;
+  height: 100%;
+  backface-visibility: hidden;
+  display: flex;
+  justify-content: center;
+  align-items: center;
+  border-radius: var(--card-radius, 1vmin);
+  overflow: hidden;
+}
+
+.LiveMemory-Card__img {
+  width: 100%;
+  height: auto;
+  transition: transform 0.75s ease-in-out, filter 0.75s ease-in-out;
+}
+
+.LiveMemory-Card:not(.LiveMemory-Card--flipped):hover {
+  --delta: calc(5 - var(--card-delta, 0));
+  transform: scale(1.025) rotate3d(calc(0.05 * var(--delta)), 0.01, -0.01, calc(1deg * var(--delta)));
+  filter: drop-shadow(0.2rem 0.2rem 1rem rgba(var(--live-memory-color-rgb), 0.25));
+}
+
+.LiveMemory-Card:hover .LiveMemory-Card__back_img {
+  transform: scale(1.01);
+  transition: all 0.75s ease-in-out;
+}
+
+.LiveMemory-Card__front {
+  transform: rotateY(180deg);
+}
+
+.LiveMemory-Card--flipped {
+  transform: rotateY(180deg);
+}
+
+.LiveMemory-Card--flipped .LiveMemory-Card__img {
+  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
+}
+
+.LiveMemory-Card--matched {
+  animation: matched 1500ms forwards ease-in-out;
+  animation-iteration-count: 1;
+  animation-fill-mode: both;
+  scale: 0.95;
+}
+
+.LiveMemory-Card--matched .LiveMemory-Card__front {
+  border: 4px solid color-mix(in oklab, var(--live-memory-color) 50%, var(--color-text));
+}
+
+.LiveMemory-Card--matched:not(.LiveMemory-Card--paired) {
+  animation-fill-mode: forwards;
+}
+
+@keyframes matched {
+  0% {
+    rotate: -720deg;
+    scale: 1;
+  }
+  50% {
+    scale: 0.55;
+    filter: motionBlur(10px, 90deg);
+  }
+  100% {
+    rotate: 0;
+    scale: 0.95;
+  }
+}
+.LiveMemory-CardDots {
+  position: absolute;
+  inset: 10%;
+  display: grid;
+  grid-template: repeat(3, 1fr)/repeat(3, 1fr);
+  rotate: calc(18deg * var(--slot-delta, 0));
+}
+
+.LiveMemory-CardDots span {
+  animation: live-memory-match 1.5s ease-out;
+  rotate: calc(var(--i) * 57deg);
+  width: calc(var(--i) * 0.1rem);
+  height: calc(var(--i) * 0.1rem);
+  border-radius: 50%;
+  mix-blend-mode: overlay;
+  animation-iteration-count: 1;
+  animation-fill-mode: forwards;
+  z-index: 0;
+}
+
+@keyframes live-memory-match {
+  0% {
+    opacity: 0;
+    transform: translateX(0);
+    scale: 0.5;
+    background: var(--live-memory-color);
+  }
+  5% {
+    scale: 1;
+  }
+  20% {
+    opacity: 0.75;
+    transform: translateX(2vmin);
+    scale: calc(1 + var(--i) * 0.1);
+  }
+  50% {
+    opacity: 0.1;
+    background: #fff;
+  }
+  100% {
+    scale: calc(2 + var(--i) * 0.1);
+    opacity: 0;
+    transform: translateX(calc(var(--i) * 1vmin + 2vmin));
+    background: #fff;
+  }
+}
+.LiveMemory-Debug {
+  position: absolute;
+  top: 3.5rem;
+  left: 0;
+  display: flex;
+  gap: 0.5rem;
+  align-items: center;
+  padding: 0.5rem;
+  font-size: 0.65rem;
+  font-weight: lighter;
+  opacity: 0.01;
+  transition: opacity 250ms;
+}
+.LiveMemory-Debug:hover {
+  opacity: 1;
+}
+
+.LiveMemory-Debug button {
+  font-family: var(--font-code);
+  padding: 0.1rem;
+  color: var(--color-text);
+  cursor: pointer;
+  opacity: 0.75;
+}
+.LiveMemory-Debug button:hover {
+  opacity: 1;
+}
+
+.LiveMemory-Logo {
+  --bottom: 0;
+  max-width: 320px;
+  transition: all 150ms ease-in-out;
+  transform: scale(1);
+  align-items: center;
+  display: flex;
+  flex-direction: column;
+  transform-style: preserve-3d;
+  gap: 0.25rem;
+  opacity: 0.75;
+}
+.LiveMemory-Logo:hover {
+  opacity: 0.95;
+}
+
+.LiveMemory-Logo-Live {
+  width: 58%;
+  height: auto;
+  position: relative;
+}
+.LiveMemory-Logo-Live path {
+  transform: rotateY(0deg);
+  transform-origin: center;
+  transition: transform 900ms ease-in-out;
+  transform-style: preserve-3d;
+  transform-box: fill-box;
+}
+
+.LiveMemory-Logo:hover .LiveMemory-Logo-Live path {
+  transform: rotateY(720deg);
+  transition-delay: calc(var(--delay, 200ms) * var(--idx, 0));
+}
+
+.LiveMemory-Logo-Memory {
+  color: var(--color-text);
+  width: 95%;
+  transform: scale(0.9);
+  height: auto;
+  transition: color 250ms linear, transform 900ms ease-in-out;
+  mix-blend-mode: luminosity;
+}
+
+.LiveMemory-Logo:hover .LiveMemory-Logo-Memory {
+  transform: scale(0.95);
+  transition-delay: 400ms;
+}
+
+.LiveMemory-Metrics dl {
+  --rows: 4;
+  display: grid;
+  gap: 1rem;
+  grid-template-columns: repeat(var(--rows), 1fr);
+  margin: 0;
+}
+
+@media screen and (max-width: 480px) {
+  .LiveMemory-Metrics dl {
+    --rows: 2;
+  }
+}
+.LiveMemory-Metric {
+  --metric-opacity: 1;
+  display: flex;
+  justify-content: space-between;
+  align-content: center;
+  justify-items: center;
+  align-items: center;
+  padding: 0.5rem 0.75rem;
+  border-radius: 0.5rem;
+  font-size: 0.8rem;
+  background: rgb(var(--color-background-rgb)/50%);
+  border: 1px solid var(--color-border);
+  font-family: monospace;
+  text-transform: uppercase;
+  color: var(--color-text);
+}
+
+.LiveMemory-Metric dt {
+  font-weight: lighter;
+  display: flex;
+  align-items: center;
+  gap: 0.65em;
+  opacity: var(--metric-opacity);
+}
+
+.LiveMemory-Metric dt svg {
+  aspect-ratio: 1/1;
+  height: 1rem;
+  display: block;
+  width: auto;
+}
+
+.LiveMemory-Metric dt span {
+  opacity: 0.75;
+}
+
+.LiveMemory-Metric dd {
+  font-size: 1.1em;
+  font-family: var(--font-code);
+  font-weight: 600;
+  margin: 0;
+  opacity: var(--metric-opacity);
+}
+
+.LiveMemory-Metric dd small {
+  font-weight: 300;
+  font-size: 100%;
+}
+
+.LiveMemory-Overlay {
+  position: absolute;
+  inset: 0;
+  height: 105%;
+  background: rgb(var(--color-background-rgb)/50%);
+  backdrop-filter: blur(1rem);
+  visibility: hidden;
+}
+
+.LiveMemory-Overlay.on {
+  visibility: visible;
+}
+
+.LiveMemory-Overlay:before {
+  content: "";
+  position: absolute;
+  inset: 0;
+  background: rgba(0, 0, 0, 0.8);
+  transition: all 2000ms ease-out;
+  transition-duration: 0s;
+  opacity: 0.2;
+}
+
+.LiveMemory-Overlay.on:before {
+  opacity: 0.95;
+  transition-duration: 2s;
+}
+
+.LiveMemory-Overlay div {
+  height: 20%;
+  background: #0A0A0A;
+  width: 100%;
+  position: relative;
+  opacity: 75%;
+  animation-delay: 0ms;
+  animation-duration: 0ms;
+  animation: none;
+  transition-delay: 0ms;
+}
+
+.LiveMemory-Overlay.on div {
+  animation: slide 600ms linear;
+  transform: scaleX(1) scaleY(1.01);
+  transform-origin: left;
+  transition-delay: 750ms;
+  animation-delay: 0ms;
+  animation-fill-mode: forwards;
+}
+
+.LiveMemory-Overlay.on div:nth-child(2n) {
+  transform-origin: right;
+  transition-delay: 0ms;
+  animation-delay: 0ms;
+  animation-duration: 900ms;
+}
+
+@keyframes slide {
+  from {
+    transform: scaleX(0);
+  }
+  to {
+    transform: scaleX(1);
+  }
+}
+.LiveMemory-Score {
+  position: absolute;
+  inset: 0;
+  display: grid;
+  align-items: stretch;
+  justify-items: stretch;
+  border-radius: 1vmin;
+  overflow: hidden;
+  visibility: hidden;
+}
+
+.LiveMemory-Score.on {
+  visibility: visible;
+  z-index: 1;
+}
+
+.LiveMemory-Score.on .result {
+  z-index: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: space-evenly;
+}
+
+.LiveMemory-Score .trophy {
+  height: 20vmin;
+  position: relative;
+  min-height: 130px;
+  aspect-ratio: 1;
+}
+
+.LiveMemory-Score .trophy img {
+  max-height: 100%;
+  max-width: 100%;
+  animation: live-memory-trophy steps(8) 2s infinite alternate-reverse;
+  display: block;
+  object-fit: cover;
+  min-width: 1rem;
+  min-height: 1rem;
+}
+
+.LiveMemory-Score .trophy.gameover img {
+  filter: grayscale(1) opacity(0.2);
+}
+
+@keyframes live-memory-trophy {
+  0% {
+    transform: rotateY(0deg);
+  }
+  49% {
+    transform: rotateY(80deg);
+  }
+  50% {
+    transform: rotateY(100deg);
+  }
+  100% {
+    transform: rotateY(180deg);
+  }
+}
+.LiveMemory-Score {
+  --font-size: .9rem;
+}
+
+.LiveMemory-Score .title {
+  font-family: var(--font-title);
+  font-size: clamp(1rem, 2vw, 2rem);
+  color: rgba(255, 255, 255, 0.75);
+}
+
+.LiveMemory-Score .text {
+  font-family: monospace;
+  font-size: var(--font-size);
+  color: #fff;
+  letter-spacing: 2px;
+  text-transform: uppercase;
+}
+
+.LiveMemory-ScoreRow .typing {
+  --duration: .5s;
+  --wait: 0;
+  --chars: 40;
+  width: 0;
+  animation: live-memory-typing steps(var(--chars)) forwards;
+  animation-duration: var(--duration);
+  animation-delay: calc(var(--wait) * var(--duration));
+  text-overflow: clip;
+  display: inline-block;
+  white-space: nowrap;
+  overflow: clip;
+}
+
+.LiveMemory-ScoreRow.total {
+  margin-block-start: 1vmin;
+}
+
+.LiveMemory-ScoreRow .dots {
+  opacity: 0.25;
+}
+
+@keyframes live-memory-typing {
+  0% {
+    width: 0;
+  }
+  100% {
+    width: 100%;
+  }
+}
+.LiveMemory-Score button {
+  border: 0;
+  background: 0;
+  opacity: 0.75;
+  font-size: 1.1rem;
+  animation: steps(3) live-memory-button-blinking infinite alternate-reverse;
+  cursor: pointer;
+  color: #ffffff;
+}
+.LiveMemory-Score button::after {
+  position: absolute;
+  inset: 0;
+  content: " ";
+  display: block;
+  background: none;
+  z-index: 2;
+}
+
+.LiveMemory-Score button:hover {
+  opacity: 1;
+  animation-play-state: paused;
+}
+
+@keyframes live-memory-button-blinking {
+  70% {
+    opacity: 1;
+  }
+  100% {
+    opacity: 0;
+  }
+}
+.LiveMemory-ThemeSwitch {
+  position: relative;
+  transform-style: preserve-3d;
+  cursor: pointer;
+  border: 0;
+  padding: 0;
+  width: 2.25rem;
+  height: 2.25rem;
+  border-radius: 12%;
+  transition: transform 250ms linear;
+}
+[data-bs-theme=dark] .LiveMemory-ThemeSwitch {
+  transform: rotateY(180deg);
+}
+.LiveMemory-ThemeSwitch[data-switch=dark] {
+  transform: rotateY(180deg);
+}
+.LiveMemory-ThemeSwitch[data-switch=light] {
+  transform: rotateY(0deg);
+}
+
+.LiveMemory-ThemeSwitch-Mode {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: 100%;
+  height: 100%;
+  display: grid;
+  place-content: center;
+  align-items: center;
+  backface-visibility: hidden;
+  color: var(--color-text);
+  background: rgb(var(--color-background-rgb)/50%);
+  border-radius: 4px;
+  opacity: 0.5;
+}
+
+.LiveMemory-ThemeSwitch:hover .LiveMemory-ThemeSwitch-Mode {
+  opacity: 1;
+  transition: 250ms;
+  background: rgb(var(--color-background-rgb)/100%);
+}
+
+.LiveMemory-ThemeSwitch-Dark {
+  transform: rotateY(180deg);
+}
+.LiveMemory-ThemeSwitch-Dark:hover {
+  background: var(--color-background);
+  border: var(--color-border);
+}
+
+.LiveMemory-ThemeSwitch-Light {
+  transform: rotateY(0deg);
+}
+.LiveMemory-ThemeSwitch-Light:hover {
+  background: var(--color-background);
+  border: var(--color-border);
+}
+
+.LiveMemory-Timer {
+  display: flex;
+  flex-direction: row;
+}
+
+.LiveMemory-Timer-Display {
+  display: flex;
+  flex-direction: row;
+  align-items: center;
+}
+
+.LiveMemory-Timer-Separator {
+  display: grid;
+  place-content: center;
+}
+
+.LiveMemory-Timer-Digit {
+  overflow: hidden;
+  position: relative;
+  display: block;
+  height: 1rem;
+}
+
+.LiveMemory-Timer-Digit ul {
+  list-style: none;
+  margin: 0;
+  padding: 0;
+  display: flex;
+  flex-direction: column;
+  animation-play-state: var(--play-state);
+  animation-name: timer-tick;
+  animation-timing-function: steps(var(--steps), end);
+  animation-duration: calc(var(--duration) * 1s);
+  animation-iteration-count: var(--iterations);
+  animation-delay: var(--delay);
+  animation-fill-mode: forwards;
+  align-items: stretch;
+  justify-content: flex-end;
+}
+
+.LiveMemory-Timer-Digit li {
+  margin: 0;
+  display: block;
+  width: 1ch;
+  line-height: 1.25;
+}
+
+@keyframes timer-tick {
+  from {
+    transform: translateY(0%);
+  }
+  to {
+    transform: translateY(-100%);
+  }
+}
+.LiveMemory-Timer--blinking {
+  color: color-mix(in oklab, var(--live-memory-color) 50%, var(--color-text));
+  animation: live-memory-timer-blinking 1s infinite;
+}
+
+@keyframes live-memory-timer-blinking {
+  70% {
+    opacity: 1;
+    scale: 1.1;
+  }
+  100% {
+    opacity: 0;
+    scale: 1;
+  }
+}
+.background-glass {
+  --glass-opacity: .75;
+  --glass-color: 255 255 255;
+  background: rgba(var(--glass-color)/var(--glass-opacity));
+}
+[data-bs-theme=dark] .background-glass {
+  --glass-color: 0 0 0;
+}
+
+.background-cover {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+}
+
+.background-cover img {
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+  filter: blur(var(--blur, 2px));
+  opacity: var(--opacity, 0.15);
+}
+
+.shadow-blur {
+  position: relative;
+}
+.shadow-blur:after {
+  position: absolute;
+  content: "";
+  z-index: -1;
+  background: var(--gradient);
+  inset: 3rem;
+  border-radius: 3rem;
+  bottom: var(--shadow-bottom, 0);
+  filter: blur(3rem);
+}
+
+.shadow-blur--rainbow {
+  --gradient: linear-gradient(113.84deg, #D65831 0%, #D2D631 36.52%, #31D673 71.83%, #3aa3ff 100%) ;
+}
+
+:root {
+  --live-memory-gradient-blue: linear-gradient(90deg, #148f7d 0%, #79eaf8 50%, #237de0 100%);
+  --live-memory-gradient-green: linear-gradient(113.84deg, #518c1c 0%, #0b592b 71.83%, #247939 100%);
+  --live-memory-gradient-orange: radial-gradient(circle, rgb(225, 97, 58) 0%, rgb(195, 168, 34) 50%, rgb(252, 118, 20) 100%);
+  --live-memory-gradient-pink: radial-gradient(circle, rgb(189, 58, 225) 0%, rgb(39, 45, 115) 50%, rgb(197, 82, 201) 100%);
+  --live-memory-blue: #237de0;
+  --live-memory-blue-rgb: 35, 125, 224;
+  --live-memory-green: #518c1c;
+  --live-memory-green-rgb: 81, 140, 28;
+  --live-memory-orange: #FC7614;
+  --live-memory-orange-rgb: 252, 118, 20;
+  --live-memory-pink: #bd3ae1;
+  --live-memory-pink-rgb: 189, 58, 225;
+}
+
+[data-live-memory-theme=blue] {
+  --live-memory-color-rgb: var(--live-memory-blue-rgb);
+  --live-memory-color: var(--live-memory-blue);
+  --live-memory-gradient: var(--live-memory-gradient-blue);
+}
+
+[data-live-memory-theme=green] {
+  --live-memory-color: var(--live-memory-green);
+  --live-memory-color-rgb: var(--live-memory-green-rgb);
+  --live-memory-gradient: var(--live-memory-gradient-green);
+}
+
+[data-live-memory-theme=orange] {
+  --live-memory-color: var(--live-memory-orange);
+  --live-memory-color-rgb: var(--live-memory-orange-rgb);
+  --live-memory-gradient: var(--live-memory-gradient-orange);
+}
+
+[data-live-memory-theme=pink] {
+  --live-memory-color: var(--live-memory-pink);
+  --live-memory-color-rgb: var(--live-memory-pink-rgb);
+  --live-memory-gradient: var(--live-memory-gradient-pink);
+}
diff --git a/ux.symfony.com/assets/styles/demos/live-memory/components/_Board.scss b/ux.symfony.com/assets/styles/demos/live-memory/components/_Board.scss
index f0175923..7be9f4a5 100644
--- a/ux.symfony.com/assets/styles/demos/live-memory/components/_Board.scss
+++ b/ux.symfony.com/assets/styles/demos/live-memory/components/_Board.scss
@@ -110,6 +110,9 @@ body::before {
   line-height: .5rem;
   word-spacing: -.125rem;
   mix-blend-mode: color-burn;
+  [data-bs-theme="dark"] & {
+    mix-blend-mode: overlay;
+  }
   font-size: .65rem;
   font-weight: lighter;
   font-family: monospace;
@@ -117,11 +120,6 @@ body::before {
   --footer-color: var(--color-text);
   --footer-color-subtle: var(--color-text);
 }
-[data-bs-theme="dark"] .LiveMemory-Footer {
-  mix-blend-mode: overlay;
-}
-
-
 .LiveMemory-Footer span:first-child {
   text-align: left;
 }
diff --git a/ux.symfony.com/assets/styles/demos/live-memory/components/_Debug.scss b/ux.symfony.com/assets/styles/demos/live-memory/components/_Debug.scss
index 15a564a6..259b1a3f 100644
--- a/ux.symfony.com/assets/styles/demos/live-memory/components/_Debug.scss
+++ b/ux.symfony.com/assets/styles/demos/live-memory/components/_Debug.scss
@@ -11,9 +11,9 @@
   font-weight: lighter;
   opacity: 0.01;
   transition: opacity 250ms;
-}
-.LiveMemory-Debug:hover {
-  opacity: 1;
+  &:hover {
+    opacity: 1;
+  }
 }
 
 .LiveMemory-Debug button {
@@ -22,4 +22,7 @@
   color: var(--color-text);
   cursor: pointer;
   opacity: .75;
+  &:hover {
+    opacity: 1;
+  }
 }
diff --git a/ux.symfony.com/assets/styles/demos/live-memory/components/_Logo.scss b/ux.symfony.com/assets/styles/demos/live-memory/components/_Logo.scss
index 01535af4..abbf1224 100644
--- a/ux.symfony.com/assets/styles/demos/live-memory/components/_Logo.scss
+++ b/ux.symfony.com/assets/styles/demos/live-memory/components/_Logo.scss
@@ -13,9 +13,9 @@
   transform-style: preserve-3d;
   gap: 0.25rem;
   opacity: .75;
-}
-.LiveMemory-Logo:hover {
-  opacity: .95;
+  &:hover {
+    opacity: .95;
+  }
 }
 
 .LiveMemory-Logo-Live {
diff --git a/ux.symfony.com/assets/styles/demos/live-memory/components/_Score.scss b/ux.symfony.com/assets/styles/demos/live-memory/components/_Score.scss
index dd5db0ce..bdbc685d 100644
--- a/ux.symfony.com/assets/styles/demos/live-memory/components/_Score.scss
+++ b/ux.symfony.com/assets/styles/demos/live-memory/components/_Score.scss
@@ -119,8 +119,8 @@
   animation: steps(3) live-memory-button-blinking infinite alternate-reverse;
   cursor: pointer;
   color: #ffffff;
-}
-.LiveMemory-Score button::after {
+
+  &::after {
     position: absolute;
     inset: 0;
     content: " ";
@@ -128,6 +128,7 @@
     background: none;
     z-index: 2;
   }
+}
 
 .LiveMemory-Score button:hover {
   opacity: 1;
diff --git a/ux.symfony.com/assets/styles/demos/live-memory/components/_ThemeSwitch.scss b/ux.symfony.com/assets/styles/demos/live-memory/components/_ThemeSwitch.scss
index 713fabeb..402acbc6 100644
--- a/ux.symfony.com/assets/styles/demos/live-memory/components/_ThemeSwitch.scss
+++ b/ux.symfony.com/assets/styles/demos/live-memory/components/_ThemeSwitch.scss
@@ -12,16 +12,19 @@
   height: 2.25rem;
   border-radius: 12%;
   transition: transform 250ms linear;
-}
-[data-bs-theme="dark"] .LiveMemory-ThemeSwitch {
-  transform: rotateY(180deg);
-}
-.LiveMemory-ThemeSwitch[data-switch="dark"] {
+
+  [data-bs-theme="dark"] & {
     transform: rotateY(180deg);
-}
-.LiveMemory-ThemeSwitch[data-switch="light"] {
+  }
+
+  &[data-switch="dark"] {
+    transform: rotateY(180deg);
+  }
+
+  &[data-switch="light"] {
     transform: rotateY(0deg);
   }
+}
 
 .LiveMemory-ThemeSwitch-Mode {
   position: absolute;
@@ -45,18 +48,19 @@
   background: rgb(var(--color-background-rgb) / 100%);
 }
 
+
 .LiveMemory-ThemeSwitch-Dark {
   transform: rotateY(180deg);
-}
-.LiveMemory-ThemeSwitch-Dark:hover {
+  &:hover {
     background: var(--color-background);
     border: var(--color-border);
+  }
 }
 
 .LiveMemory-ThemeSwitch-Light {
   transform: rotateY(0deg);
-}
-.LiveMemory-ThemeSwitch-Light:hover {
+  &:hover {
     background: var(--color-background);
     border: var(--color-border);
+  }
 }
diff --git a/ux.symfony.com/composer.lock b/ux.symfony.com/composer.lock
index 0f7cc0d9..7ea39b6f 100644
--- a/ux.symfony.com/composer.lock
+++ b/ux.symfony.com/composer.lock
@@ -517,16 +517,16 @@
         },
         {
             "name": "doctrine/dbal",
-            "version": "3.8.2",
+            "version": "3.8.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/dbal.git",
-                "reference": "a19a1d05ca211f41089dffcc387733a6875196cb"
+                "reference": "c9ea252cdce4da324ede3d6c5913dd89f769afd2"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/dbal/zipball/a19a1d05ca211f41089dffcc387733a6875196cb",
-                "reference": "a19a1d05ca211f41089dffcc387733a6875196cb",
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/c9ea252cdce4da324ede3d6c5913dd89f769afd2",
+                "reference": "c9ea252cdce4da324ede3d6c5913dd89f769afd2",
                 "shasum": ""
             },
             "require": {
@@ -610,7 +610,7 @@
             ],
             "support": {
                 "issues": "https://github.com/doctrine/dbal/issues",
-                "source": "https://github.com/doctrine/dbal/tree/3.8.2"
+                "source": "https://github.com/doctrine/dbal/tree/3.8.1"
             },
             "funding": [
                 {
@@ -626,7 +626,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-12T18:36:36+00:00"
+            "time": "2024-02-03T17:33:49+00:00"
         },
         {
             "name": "doctrine/deprecations",
@@ -677,16 +677,16 @@
         },
         {
             "name": "doctrine/doctrine-bundle",
-            "version": "2.11.3",
+            "version": "2.11.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/DoctrineBundle.git",
-                "reference": "492725310ae9a1b5b20d6ae09fb5ae6404616e68"
+                "reference": "4089f1424b724786c062aea50aae5f773449b94b"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/492725310ae9a1b5b20d6ae09fb5ae6404616e68",
-                "reference": "492725310ae9a1b5b20d6ae09fb5ae6404616e68",
+                "url": "https://api.github.com/repos/doctrine/DoctrineBundle/zipball/4089f1424b724786c062aea50aae5f773449b94b",
+                "reference": "4089f1424b724786c062aea50aae5f773449b94b",
                 "shasum": ""
             },
             "require": {
@@ -707,18 +707,18 @@
             },
             "conflict": {
                 "doctrine/annotations": ">=3.0",
-                "doctrine/orm": "<2.17 || >=4.0",
+                "doctrine/orm": "<2.14 || >=4.0",
                 "twig/twig": "<1.34 || >=2.0 <2.4"
             },
             "require-dev": {
                 "doctrine/annotations": "^1 || ^2",
                 "doctrine/coding-standard": "^12",
                 "doctrine/deprecations": "^1.0",
-                "doctrine/orm": "^2.17 || ^3.0",
+                "doctrine/orm": "^2.14 || ^3.0",
                 "friendsofphp/proxy-manager-lts": "^1.0",
-                "phpunit/phpunit": "^9.5.26",
+                "phpunit/phpunit": "^9.5.26 || ^10.0",
                 "psalm/plugin-phpunit": "^0.18.4",
-                "psalm/plugin-symfony": "^5",
+                "psalm/plugin-symfony": "^4",
                 "psr/log": "^1.1.4 || ^2.0 || ^3.0",
                 "symfony/phpunit-bridge": "^6.1 || ^7.0",
                 "symfony/property-info": "^5.4 || ^6.0 || ^7.0",
@@ -731,7 +731,7 @@
                 "symfony/web-profiler-bundle": "^5.4 || ^6.0 || ^7.0",
                 "symfony/yaml": "^5.4 || ^6.0 || ^7.0",
                 "twig/twig": "^1.34 || ^2.12 || ^3.0",
-                "vimeo/psalm": "^5.15"
+                "vimeo/psalm": "^4.30"
             },
             "suggest": {
                 "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.",
@@ -776,7 +776,7 @@
             ],
             "support": {
                 "issues": "https://github.com/doctrine/DoctrineBundle/issues",
-                "source": "https://github.com/doctrine/DoctrineBundle/tree/2.11.3"
+                "source": "https://github.com/doctrine/DoctrineBundle/tree/2.11.1"
             },
             "funding": [
                 {
@@ -792,7 +792,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-10T20:56:20+00:00"
+            "time": "2023-11-15T20:01:50+00:00"
         },
         {
             "name": "doctrine/doctrine-migrations-bundle",
@@ -978,16 +978,16 @@
         },
         {
             "name": "doctrine/inflector",
-            "version": "2.0.10",
+            "version": "2.0.9",
             "source": {
                 "type": "git",
                 "url": "https://github.com/doctrine/inflector.git",
-                "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc"
+                "reference": "2930cd5ef353871c821d5c43ed030d39ac8cfe65"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/doctrine/inflector/zipball/5817d0659c5b50c9b950feb9af7b9668e2c436bc",
-                "reference": "5817d0659c5b50c9b950feb9af7b9668e2c436bc",
+                "url": "https://api.github.com/repos/doctrine/inflector/zipball/2930cd5ef353871c821d5c43ed030d39ac8cfe65",
+                "reference": "2930cd5ef353871c821d5c43ed030d39ac8cfe65",
                 "shasum": ""
             },
             "require": {
@@ -1049,7 +1049,7 @@
             ],
             "support": {
                 "issues": "https://github.com/doctrine/inflector/issues",
-                "source": "https://github.com/doctrine/inflector/tree/2.0.10"
+                "source": "https://github.com/doctrine/inflector/tree/2.0.9"
             },
             "funding": [
                 {
@@ -1065,7 +1065,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-18T20:23:39+00:00"
+            "time": "2024-01-15T18:05:13+00:00"
         },
         {
             "name": "doctrine/instantiator",
@@ -6835,12 +6835,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/stimulus-bundle.git",
-                "reference": "c113ab8e92c6b14659f6be4e9bef5c1f4c4a000e"
+                "reference": "da6b4a4f95a91390d38151ef67c77901a1210646"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/c113ab8e92c6b14659f6be4e9bef5c1f4c4a000e",
-                "reference": "c113ab8e92c6b14659f6be4e9bef5c1f4c4a000e",
+                "url": "https://api.github.com/repos/symfony/stimulus-bundle/zipball/da6b4a4f95a91390d38151ef67c77901a1210646",
+                "reference": "da6b4a4f95a91390d38151ef67c77901a1210646",
                 "shasum": ""
             },
             "require": {
@@ -6850,7 +6850,7 @@
                 "symfony/deprecation-contracts": "^2.0|^3.0",
                 "symfony/finder": "^5.4|^6.0|^7.0",
                 "symfony/http-kernel": "^5.4|^6.0|^7.0",
-                "twig/twig": "^2.15.3|~3.8.0"
+                "twig/twig": "^2.15.3|^3.4.3"
             },
             "require-dev": {
                 "symfony/asset-mapper": "^6.3|^7.0",
@@ -6881,7 +6881,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/stimulus-bundle/tree/v2.15.0"
+                "source": "https://github.com/symfony/stimulus-bundle/tree/v2.14.1"
             },
             "funding": [
                 {
@@ -6897,7 +6897,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-14T16:26:57+00:00"
+            "time": "2024-02-03T01:11:47+00:00"
         },
         {
             "name": "symfony/stopwatch",
@@ -7493,18 +7493,17 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-autocomplete.git",
-                "reference": "6f900c939b284ebcb743b63f324591aef3774a76"
+                "reference": "0966e60072be7a5b5cb433675254c7e9e4c93517"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-autocomplete/zipball/6f900c939b284ebcb743b63f324591aef3774a76",
-                "reference": "6f900c939b284ebcb743b63f324591aef3774a76",
+                "url": "https://api.github.com/repos/symfony/ux-autocomplete/zipball/0966e60072be7a5b5cb433675254c7e9e4c93517",
+                "reference": "0966e60072be7a5b5cb433675254c7e9e4c93517",
                 "shasum": ""
             },
             "require": {
                 "php": ">=8.1",
                 "symfony/dependency-injection": "^6.3|^7.0",
-                "symfony/deprecation-contracts": "^2.5|^3",
                 "symfony/http-foundation": "^6.3|^7.0",
                 "symfony/http-kernel": "^6.3|^7.0",
                 "symfony/property-access": "^6.3|^7.0",
@@ -7516,7 +7515,7 @@
             "require-dev": {
                 "doctrine/collections": "^1.6.8|^2.0",
                 "doctrine/doctrine-bundle": "^2.4.3",
-                "doctrine/orm": "^2.9.4|^3.0",
+                "doctrine/orm": "^2.9.4",
                 "fakerphp/faker": "^1.22",
                 "mtdowling/jmespath.php": "^2.6",
                 "symfony/form": "^6.3|^7.0",
@@ -7562,7 +7561,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-autocomplete/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-autocomplete/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -7578,7 +7577,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-20T16:11:17+00:00"
+            "time": "2024-01-30T15:40:36+00:00"
         },
         {
             "name": "symfony/ux-chartjs",
@@ -7586,12 +7585,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-chartjs.git",
-                "reference": "9f9d9b33627c6f6dcf59f29f4f955172da3c437d"
+                "reference": "d38c57ece51c0a06bd2196f8c24be1c4b009a7ba"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-chartjs/zipball/9f9d9b33627c6f6dcf59f29f4f955172da3c437d",
-                "reference": "9f9d9b33627c6f6dcf59f29f4f955172da3c437d",
+                "url": "https://api.github.com/repos/symfony/ux-chartjs/zipball/d38c57ece51c0a06bd2196f8c24be1c4b009a7ba",
+                "reference": "d38c57ece51c0a06bd2196f8c24be1c4b009a7ba",
                 "shasum": ""
             },
             "require": {
@@ -7643,7 +7642,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-chartjs/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-chartjs/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -7659,7 +7658,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-20T16:11:17+00:00"
+            "time": "2024-01-30T15:40:36+00:00"
         },
         {
             "name": "symfony/ux-cropperjs",
@@ -7729,7 +7728,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-cropperjs/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-cropperjs/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -7809,7 +7808,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-dropzone/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-dropzone/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -7833,12 +7832,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-lazy-image.git",
-                "reference": "3d34714eb60d105e32ddbe12234172c05a99c295"
+                "reference": "5dd07ae887f7cdfb825d649a445b0a76b3ca1883"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-lazy-image/zipball/3d34714eb60d105e32ddbe12234172c05a99c295",
-                "reference": "3d34714eb60d105e32ddbe12234172c05a99c295",
+                "url": "https://api.github.com/repos/symfony/ux-lazy-image/zipball/5dd07ae887f7cdfb825d649a445b0a76b3ca1883",
+                "reference": "5dd07ae887f7cdfb825d649a445b0a76b3ca1883",
                 "shasum": ""
             },
             "require": {
@@ -7888,7 +7887,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-lazy-image/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-lazy-image/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -7904,7 +7903,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-03T21:01:32+00:00"
+            "time": "2024-01-14T07:27:47+00:00"
         },
         {
             "name": "symfony/ux-live-component",
@@ -7912,19 +7911,19 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-live-component.git",
-                "reference": "3af533fd0bf01c5c5a5daa7801afef9103834236"
+                "reference": "ab8710fa0990efb7b9d34b7ec91acac7b6e60d17"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/3af533fd0bf01c5c5a5daa7801afef9103834236",
-                "reference": "3af533fd0bf01c5c5a5daa7801afef9103834236",
+                "url": "https://api.github.com/repos/symfony/ux-live-component/zipball/ab8710fa0990efb7b9d34b7ec91acac7b6e60d17",
+                "reference": "ab8710fa0990efb7b9d34b7ec91acac7b6e60d17",
                 "shasum": ""
             },
             "require": {
                 "php": ">=8.1",
                 "symfony/property-access": "^5.4.5|^6.0|^7.0",
                 "symfony/ux-twig-component": "^2.8",
-                "twig/twig": "~3.8.0"
+                "twig/twig": "^2.14.7|^3.0.4"
             },
             "conflict": {
                 "symfony/config": "<5.4.0"
@@ -7943,7 +7942,7 @@
                 "symfony/options-resolver": "^5.4|^6.0|^7.0",
                 "symfony/phpunit-bridge": "^6.0|^7.0",
                 "symfony/property-info": "^5.4|^6.0|^7.0",
-                "symfony/security-bundle": "^5.4|^6.0|^7.0",
+                "symfony/security-csrf": "^5.4|^6.0|^7.0",
                 "symfony/serializer": "^5.4|^6.0|^7.0",
                 "symfony/twig-bundle": "^5.4|^6.0|^7.0",
                 "symfony/validator": "^5.4|^6.0|^7.0",
@@ -7981,7 +7980,7 @@
                 "twig"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-live-component/tree/2.x"
+                "source": "https://github.com/symfony/ux-live-component/tree/v2.14.1"
             },
             "funding": [
                 {
@@ -7997,7 +7996,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-21T00:40:57+00:00"
+            "time": "2024-02-03T01:11:47+00:00"
         },
         {
             "name": "symfony/ux-notify",
@@ -8064,7 +8063,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-notify/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-notify/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -8141,7 +8140,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-react/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-react/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -8222,7 +8221,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-svelte/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-svelte/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -8290,7 +8289,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-swup/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-swup/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -8310,7 +8309,7 @@
         },
         {
             "name": "symfony/ux-toggle-password",
-            "version": "v2.15.0",
+            "version": "v2.14.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-toggle-password.git",
@@ -8370,7 +8369,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-toggle-password/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-toggle-password/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -8394,12 +8393,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-translator.git",
-                "reference": "d29bd7b9b3e1c82fd3521001c86404ba83e877ac"
+                "reference": "802ced1bc3292f65f48acd8704d7b29f1cbdd51c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-translator/zipball/d29bd7b9b3e1c82fd3521001c86404ba83e877ac",
-                "reference": "d29bd7b9b3e1c82fd3521001c86404ba83e877ac",
+                "url": "https://api.github.com/repos/symfony/ux-translator/zipball/802ced1bc3292f65f48acd8704d7b29f1cbdd51c",
+                "reference": "802ced1bc3292f65f48acd8704d7b29f1cbdd51c",
                 "shasum": ""
             },
             "require": {
@@ -8447,7 +8446,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-translator/tree/2.x"
+                "source": "https://github.com/symfony/ux-translator/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -8463,7 +8462,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-21T00:36:36+00:00"
+            "time": "2024-01-14T07:27:47+00:00"
         },
         {
             "name": "symfony/ux-turbo",
@@ -8471,12 +8470,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-turbo.git",
-                "reference": "d3590a43fee73304855dfc8022ccb57b0df9f03d"
+                "reference": "7806d3960ff40f76094015644742041376218c76"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/d3590a43fee73304855dfc8022ccb57b0df9f03d",
-                "reference": "d3590a43fee73304855dfc8022ccb57b0df9f03d",
+                "url": "https://api.github.com/repos/symfony/ux-turbo/zipball/7806d3960ff40f76094015644742041376218c76",
+                "reference": "7806d3960ff40f76094015644742041376218c76",
                 "shasum": ""
             },
             "require": {
@@ -8544,7 +8543,7 @@
                 "turbo-stream"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-turbo/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-turbo/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -8560,7 +8559,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-20T16:11:17+00:00"
+            "time": "2023-11-14T10:21:35+00:00"
         },
         {
             "name": "symfony/ux-twig-component",
@@ -8568,12 +8567,12 @@
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/ux-twig-component.git",
-                "reference": "09c27e13d48dfa4a8d93f55d4b11a6f13d174060"
+                "reference": "b5f593295bd68485cedc59144d285db18ccfb776"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/09c27e13d48dfa4a8d93f55d4b11a6f13d174060",
-                "reference": "09c27e13d48dfa4a8d93f55d4b11a6f13d174060",
+                "url": "https://api.github.com/repos/symfony/ux-twig-component/zipball/b5f593295bd68485cedc59144d285db18ccfb776",
+                "reference": "b5f593295bd68485cedc59144d285db18ccfb776",
                 "shasum": ""
             },
             "require": {
@@ -8582,7 +8581,7 @@
                 "symfony/deprecation-contracts": "^2.2|^3.0",
                 "symfony/event-dispatcher": "^5.4|^6.0|^7.0",
                 "symfony/property-access": "^5.4|^6.0|^7.0",
-                "twig/twig": "~3.8.0"
+                "twig/twig": "^2.14.7|^3.0.4"
             },
             "conflict": {
                 "symfony/config": "<5.4.0"
@@ -8629,7 +8628,7 @@
                 "twig"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-twig-component/tree/2.x"
+                "source": "https://github.com/symfony/ux-twig-component/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -8645,7 +8644,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-21T00:38:54+00:00"
+            "time": "2024-01-30T15:40:36+00:00"
         },
         {
             "name": "symfony/ux-typed",
@@ -8697,7 +8696,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-typed/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-typed/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -8778,7 +8777,7 @@
                 "symfony-ux"
             ],
             "support": {
-                "source": "https://github.com/symfony/ux-vue/tree/v2.15.0"
+                "source": "https://github.com/symfony/ux-vue/tree/v2.14.0"
             },
             "funding": [
                 {
@@ -9209,16 +9208,16 @@
         },
         {
             "name": "symfonycasts/dynamic-forms",
-            "version": "v0.1.2",
+            "version": "v0.1.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/SymfonyCasts/dynamic-forms.git",
-                "reference": "1bfe8441e3be250b14dc2bd0ae0323dd4acbfd2c"
+                "reference": "dae0100d759f5282edd249232cdf5025c3e0f421"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/SymfonyCasts/dynamic-forms/zipball/1bfe8441e3be250b14dc2bd0ae0323dd4acbfd2c",
-                "reference": "1bfe8441e3be250b14dc2bd0ae0323dd4acbfd2c",
+                "url": "https://api.github.com/repos/SymfonyCasts/dynamic-forms/zipball/dae0100d759f5282edd249232cdf5025c3e0f421",
+                "reference": "dae0100d759f5282edd249232cdf5025c3e0f421",
                 "shasum": ""
             },
             "require": {
@@ -9228,9 +9227,8 @@
             "require-dev": {
                 "phpstan/phpstan": "^1.11",
                 "symfony/framework-bundle": "^6.3|^7.0",
-                "symfony/options-resolver": "^5.4|^6.3|^7.0",
                 "symfony/phpunit-bridge": "^5.4|^6.3|^7.0",
-                "symfony/twig-bundle": "^5.4|^6.3|^7.0",
+                "symfony/twig-bundle": "^6.3|^7.0",
                 "twig/twig": "^2.15|^3.0",
                 "zenstruck/browser": "^1.4"
             },
@@ -9257,9 +9255,9 @@
             ],
             "support": {
                 "issues": "https://github.com/SymfonyCasts/dynamic-forms/issues",
-                "source": "https://github.com/SymfonyCasts/dynamic-forms/tree/v0.1.2"
+                "source": "https://github.com/SymfonyCasts/dynamic-forms/tree/v0.1.1"
             },
-            "time": "2024-02-20T11:55:15+00:00"
+            "time": "2023-09-22T11:00:48+00:00"
         },
         {
             "name": "symfonycasts/sass-bundle",
@@ -9318,16 +9316,16 @@
         },
         {
             "name": "twbs/bootstrap",
-            "version": "v5.3.3",
+            "version": "v5.3.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/twbs/bootstrap.git",
-                "reference": "6e1f75f420f68e1d52733b8e407fc7c3766c9dba"
+                "reference": "344e912d04b5b6a04482113eff20ab416ff01048"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/twbs/bootstrap/zipball/6e1f75f420f68e1d52733b8e407fc7c3766c9dba",
-                "reference": "6e1f75f420f68e1d52733b8e407fc7c3766c9dba",
+                "url": "https://api.github.com/repos/twbs/bootstrap/zipball/344e912d04b5b6a04482113eff20ab416ff01048",
+                "reference": "344e912d04b5b6a04482113eff20ab416ff01048",
                 "shasum": ""
             },
             "replace": {
@@ -9362,9 +9360,9 @@
             ],
             "support": {
                 "issues": "https://github.com/twbs/bootstrap/issues",
-                "source": "https://github.com/twbs/bootstrap/tree/v5.3.3"
+                "source": "https://github.com/twbs/bootstrap/tree/v5.3.2"
             },
-            "time": "2024-02-20T15:14:29+00:00"
+            "time": "2023-09-14T14:19:27+00:00"
         },
         {
             "name": "twig/extra-bundle",
@@ -11796,44 +11794,44 @@
         },
         {
             "name": "symfony/maker-bundle",
-            "version": "v1.55.1",
+            "version": "v1.53.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/symfony/maker-bundle.git",
-                "reference": "11a9d3125c5b93ab4043f0f2e9927fdc55881c17"
+                "reference": "8d2f3f96704766837548d177fe3ae39ae94822d9"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/11a9d3125c5b93ab4043f0f2e9927fdc55881c17",
-                "reference": "11a9d3125c5b93ab4043f0f2e9927fdc55881c17",
+                "url": "https://api.github.com/repos/symfony/maker-bundle/zipball/8d2f3f96704766837548d177fe3ae39ae94822d9",
+                "reference": "8d2f3f96704766837548d177fe3ae39ae94822d9",
                 "shasum": ""
             },
             "require": {
                 "doctrine/inflector": "^2.0",
                 "nikic/php-parser": "^4.18|^5.0",
                 "php": ">=8.1",
-                "symfony/config": "^6.4|^7.0",
-                "symfony/console": "^6.4|^7.0",
-                "symfony/dependency-injection": "^6.4|^7.0",
+                "symfony/config": "^6.3|^7.0",
+                "symfony/console": "^6.3|^7.0",
+                "symfony/dependency-injection": "^6.3|^7.0",
                 "symfony/deprecation-contracts": "^2.2|^3",
-                "symfony/filesystem": "^6.4|^7.0",
-                "symfony/finder": "^6.4|^7.0",
-                "symfony/framework-bundle": "^6.4|^7.0",
-                "symfony/http-kernel": "^6.4|^7.0",
-                "symfony/process": "^6.4|^7.0"
+                "symfony/filesystem": "^6.3|^7.0",
+                "symfony/finder": "^6.3|^7.0",
+                "symfony/framework-bundle": "^6.3|^7.0",
+                "symfony/http-kernel": "^6.3|^7.0",
+                "symfony/process": "^6.3|^7.0"
             },
             "conflict": {
-                "doctrine/doctrine-bundle": "<2.10",
-                "doctrine/orm": "<2.15"
+                "doctrine/doctrine-bundle": "<2.4",
+                "doctrine/orm": "<2.10"
             },
             "require-dev": {
                 "composer/semver": "^3.0",
                 "doctrine/doctrine-bundle": "^2.5.0",
-                "doctrine/orm": "^2.15|^3",
-                "symfony/http-client": "^6.4|^7.0",
-                "symfony/phpunit-bridge": "^6.4.1|^7.0",
-                "symfony/security-core": "^6.4|^7.0",
-                "symfony/yaml": "^6.4|^7.0",
+                "doctrine/orm": "^2.10.0",
+                "symfony/http-client": "^6.3|^7.0",
+                "symfony/phpunit-bridge": "^6.3|^7.0",
+                "symfony/security-core": "^6.3|^7.0",
+                "symfony/yaml": "^6.3|^7.0",
                 "twig/twig": "^3.0|^4.x-dev"
             },
             "type": "symfony-bundle",
@@ -11868,7 +11866,7 @@
             ],
             "support": {
                 "issues": "https://github.com/symfony/maker-bundle/issues",
-                "source": "https://github.com/symfony/maker-bundle/tree/v1.55.1"
+                "source": "https://github.com/symfony/maker-bundle/tree/v1.53.0"
             },
             "funding": [
                 {
@@ -11884,7 +11882,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-02-21T13:41:51+00:00"
+            "time": "2024-02-01T10:05:38+00:00"
         },
         {
             "name": "symfony/phpunit-bridge",
@@ -12160,16 +12158,16 @@
         },
         {
             "name": "zenstruck/browser",
-            "version": "v1.8.0",
+            "version": "v1.7.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/zenstruck/browser.git",
-                "reference": "2c504ce46f76abf6e8725218c0eab519f60b62a6"
+                "reference": "cf514edcf66cd889baeb29870c29a0494d32aff4"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/zenstruck/browser/zipball/2c504ce46f76abf6e8725218c0eab519f60b62a6",
-                "reference": "2c504ce46f76abf6e8725218c0eab519f60b62a6",
+                "url": "https://api.github.com/repos/zenstruck/browser/zipball/cf514edcf66cd889baeb29870c29a0494d32aff4",
+                "reference": "cf514edcf66cd889baeb29870c29a0494d32aff4",
                 "shasum": ""
             },
             "require": {
@@ -12217,13 +12215,12 @@
             "description": "A fluent interface for your Symfony functional tests.",
             "homepage": "https://github.com/zenstruck/browser",
             "keywords": [
-                "dev",
                 "symfony",
                 "test"
             ],
             "support": {
                 "issues": "https://github.com/zenstruck/browser/issues",
-                "source": "https://github.com/zenstruck/browser/tree/v1.8.0"
+                "source": "https://github.com/zenstruck/browser/tree/v1.7.0"
             },
             "funding": [
                 {
@@ -12231,7 +12228,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2024-02-07T17:32:28+00:00"
+            "time": "2023-12-14T21:04:01+00:00"
         },
         {
             "name": "zenstruck/callback",
diff --git a/ux.symfony.com/importmap.php b/ux.symfony.com/importmap.php
index 800576c0..b2ce3c77 100644
--- a/ux.symfony.com/importmap.php
+++ b/ux.symfony.com/importmap.php
@@ -141,7 +141,7 @@ return [
         'version' => '11.7.0',
     ],
     'intl-messageformat' => [
-        'version' => '10.5.11',
+        'version' => '10.3.5',
     ],
     '@vue/runtime-dom' => [
         'version' => '3.3.4',
@@ -165,10 +165,10 @@ return [
         'version' => '2.6.2',
     ],
     '@formatjs/icu-messageformat-parser' => [
-        'version' => '2.7.6',
+        'version' => '2.6.2',
     ],
     '@formatjs/icu-skeleton-parser' => [
-        'version' => '1.8.0',
+        'version' => '1.6.2',
     ],
     '@formatjs/fast-memoize' => [
         'version' => '2.2.0',
diff --git a/ux.symfony.com/mercure/.platform.app.yaml b/ux.symfony.com/mercure/.platform.app.yaml
index 3e95d9b8..23ccac7e 100644
--- a/ux.symfony.com/mercure/.platform.app.yaml
+++ b/ux.symfony.com/mercure/.platform.app.yaml
@@ -7,7 +7,7 @@ build:
 
 variables:
     env:
-        MERCURE_TRANSPORT_URL: bolt:///app/.local/mercure.db?size=1000&cleanup_frequency=0.5
+        MERCURE_TRANSPORT_URL: bolt:///app/.local/database.db
         # Defined as platform.sh vars
         # MERCURE_PUBLISHER_JWT_KEY: ''
         # MERCURE_SUBSCRIBER_JWT_KEY: ''
@@ -17,7 +17,7 @@ web:
         socket_family: tcp
         protocol: http
     commands:
-        start: SERVER_NAME=":${PORT}" ./mercure run --config Caddyfile.dev
+        start: SERVER_NAME=":${PORT}" ./mercure run -config Caddyfile.dev
     locations:
         /:
             allow: false
@@ -33,4 +33,4 @@ hooks:
         set -e -x
 
         curl -s https://get.symfony.com/cloud/configurator | (>&2 bash)
-        curl -s -H 'Accept: application/octet-stream' -J -L "https://github.com/dunglas/mercure/releases/download/v0.15.9/mercure_Linux_x86_64.tar.gz" | tar -C /app/ -zxpf -
+        curl -s -H 'Accept: application/octet-stream' -J -L "https://github.com/dunglas/mercure/releases/download/v0.13.0/mercure_0.13.0_Linux_x86_64.tar.gz" | tar -C /app/ -zxpf -
diff --git a/ux.symfony.com/src/LiveMemory/Asset/CssCompiler.php b/ux.symfony.com/src/LiveMemory/Asset/CssCompiler.php
deleted file mode 100644
index fc4b61d7..00000000
--- a/ux.symfony.com/src/LiveMemory/Asset/CssCompiler.php
+++ /dev/null
@@ -1,58 +0,0 @@
-<?php
-
-/*
- * This file is part of the Symfony package.
- *
- * (c) Fabien Potencier <fabien@symfony.com>
- *
- * For the full copyright and license information, please view the LICENSE
- * file that was distributed with this source code.
- */
-
-namespace App\LiveMemory\Asset;
-
-use Symfony\Component\AssetMapper\AssetMapperInterface;
-use Symfony\Component\AssetMapper\Compiler\AssetCompilerInterface;
-use Symfony\Component\AssetMapper\MappedAsset;
-use Symfony\Component\Filesystem\Path;
-
-/**
- * Experimental CSS compiler
- *
- * This compiler combines all the SCSS files imported in `live-memory.min.css`
- * into a single CSS file, and removes SASS comments and empty lines.
- *
- * @internal
- */
-class CssCompiler implements AssetCompilerInterface
-{
-    public function supports(MappedAsset $asset): bool
-    {
-        return 'css' === $asset->publicExtension && str_starts_with($asset->logicalPath, 'styles/demos/live-memory');
-    }
-
-    public function compile(string $content, MappedAsset $asset, AssetMapperInterface $assetMapper): string
-    {
-        return preg_replace_callback('#@import (?:"|\')(.*)(?:\'|");#m', function ($matches) use ($asset) {
-
-            $file = $matches[1];
-            $file = substr_replace($file, '/_', strrpos($file, '/'), 1);
-            $file = './'.$file.'.scss';
-
-            $filePath = Path::makeAbsolute($file, \dirname($asset->sourcePath));
-            if (!file_exists($filePath)) {
-                throw new \RuntimeException(sprintf('The file "%s" does not exist.', $filePath));
-            }
-            $content = file_get_contents($filePath);
-
-            $asset->addFileDependency($filePath);
-
-            // Remove SASS comments
-            $content = preg_replace('#^//.*\n?#m', '', $content);
-            // Remove empty lines
-            $content = preg_replace('/^\s*\n/m', '', $content);
-
-            return $content;
-        }, $content);
-    }
-}


Test patch:
diff --git a/src/LiveComponent/assets/test/Directive/directives_parser.test.ts b/src/LiveComponent/assets/test/Directive/directives_parser.test.ts
index 8c13c25d..ececf9a9 100644
--- a/src/LiveComponent/assets/test/Directive/directives_parser.test.ts
+++ b/src/LiveComponent/assets/test/Directive/directives_parser.test.ts
@@ -29,6 +29,7 @@ describe('directives parser', () => {
         assertDirectiveEquals(directives[0], {
             action: 'hide',
             args: [],
+            named: {},
             modifiers: [],
         })
     });
@@ -39,6 +40,7 @@ describe('directives parser', () => {
         assertDirectiveEquals(directives[0], {
             action: 'addClass',
             args: ['opacity-50'],
+            named: {},
             modifiers: [],
         })
     });
@@ -49,6 +51,7 @@ describe('directives parser', () => {
         assertDirectiveEquals(directives[0], {
             action: 'addClass',
             args: ['opacity-50 disabled'],
+            named: {},
             modifiers: [],
         })
     });
@@ -60,6 +63,7 @@ describe('directives parser', () => {
             action: 'addClass',
             // space between arguments is trimmed
             args: ['opacity-50', 'disabled'],
+            named: {},
             modifiers: [],
         })
     });
@@ -70,11 +74,13 @@ describe('directives parser', () => {
         assertDirectiveEquals(directives[0], {
             action: 'addClass',
             args: ['opacity-50'],
+            named: {},
             modifiers: [],
         })
         assertDirectiveEquals(directives[1], {
             action: 'addAttribute',
             args: ['disabled'],
+            named: {},
             modifiers: [],
         })
     });
@@ -85,16 +91,63 @@ describe('directives parser', () => {
         assertDirectiveEquals(directives[0], {
             action: 'hide',
             args: [],
+            named: {},
             modifiers: [],
         })
         assertDirectiveEquals(directives[1], {
             action: 'addClass',
             args: ['opacity-50 disabled'],
+            named: {},
             modifiers: [],
         })
         assertDirectiveEquals(directives[2], {
             action: 'addAttribute',
             args: ['disabled'],
+            named: {},
+            modifiers: [],
+        })
+    });
+
+    it('parses single named argument', () => {
+        const directives = parseDirectives('save(foo=bar)');
+        expect(directives).toHaveLength(1);
+        assertDirectiveEquals(directives[0], {
+            action: 'save',
+            args: [],
+            named: { foo: 'bar' },
+            modifiers: [],
+        })
+    });
+
+    it('parses multiple named arguments', () => {
+        const directives = parseDirectives('save(foo=bar, baz=bazzles)');
+        expect(directives).toHaveLength(1);
+        assertDirectiveEquals(directives[0], {
+            action: 'save',
+            args: [],
+            named: { foo: 'bar', baz: 'bazzles' },
+            modifiers: [],
+        })
+    });
+
+    it('parses arguments and spaces are kept', () => {
+        const directives = parseDirectives('save(foo= bar)');
+        expect(directives).toHaveLength(1);
+        assertDirectiveEquals(directives[0], {
+            action: 'save',
+            args: [],
+            named: { foo: ' bar' },
+            modifiers: [],
+        })
+    });
+
+    it('parses argument names with space is trimmed', () => {
+        const directives = parseDirectives('save(foo  =bar)');
+        expect(directives).toHaveLength(1);
+        assertDirectiveEquals(directives[0], {
+            action: 'save',
+            args: [],
+            named: { foo: 'bar' },
             modifiers: [],
         })
     });
@@ -105,6 +158,7 @@ describe('directives parser', () => {
         assertDirectiveEquals(directives[0], {
             action: 'addClass',
             args: ['disabled'],
+            named: {},
             modifiers: [
                 { name: 'delay', value: null }
             ],
@@ -117,6 +171,7 @@ describe('directives parser', () => {
         assertDirectiveEquals(directives[0], {
             action: 'addClass',
             args: ['disabled'],
+            named: {},
             modifiers: [
                 { name: 'delay', value: '400' },
             ],
@@ -124,11 +179,12 @@ describe('directives parser', () => {
     });
 
     it('parses multiple modifiers', () => {
-        const directives = parseDirectives('prevent|debounce(400)|save');
+        const directives = parseDirectives('prevent|debounce(400)|save(foo=bar)');
         expect(directives).toHaveLength(1);
         assertDirectiveEquals(directives[0], {
             action: 'save',
             args: [],
+            named: { foo: 'bar' },
             modifiers: [
                 { name: 'prevent', value: null },
                 { name: 'debounce', value: '400' },
@@ -155,10 +211,22 @@ describe('directives parser', () => {
             }).toThrow('Missing space after addClass()')
         });
 
+        it('named and unnamed arguments cannot be mixed', () => {
+            expect(() => {
+                parseDirectives('save(foo=bar, baz)');
+            }).toThrow('Normal and named arguments cannot be mixed inside "save()"')
+        });
+
         it('modifier cannot have multiple arguments', () => {
             expect(() => {
                 parseDirectives('debounce(10, 20)|save');
             }).toThrow('The modifier "debounce()" does not support multiple arguments.')
         });
+
+        it('modifier cannot have named arguments', () => {
+            expect(() => {
+                parseDirectives('debounce(foo=bar)|save');
+            }).toThrow('The modifier "debounce()" does not support named arguments.')
+        });
     });
 });
diff --git a/src/LiveComponent/assets/test/controller/action.test.ts b/src/LiveComponent/assets/test/controller/action.test.ts
index d4f7ba9e..cd71646a 100644
--- a/src/LiveComponent/assets/test/controller/action.test.ts
+++ b/src/LiveComponent/assets/test/controller/action.test.ts
@@ -23,7 +23,7 @@ describe('LiveController Action Tests', () => {
             <div ${initComponent(data)}>
                 ${data.isSaved ? 'Comment Saved!' : ''}
 
-                <button data-action="live#action" data-live-action-param="save">Save</button>
+                <button data-action="live#action" data-action-name="save">Save</button>
             </div>
         `);
 
@@ -46,7 +46,7 @@ describe('LiveController Action Tests', () => {
 
                 ${data.isSaved ? 'Comment Saved!' : ''}
 
-                <button data-action="live#action" data-live-action-param="save">Save</button>
+                <button data-action="live#action" data-action-name="save">Save</button>
             </div>
         `);
 
@@ -73,22 +73,16 @@ describe('LiveController Action Tests', () => {
 
     it('Sends action with named args', async () => {
         const test = await createTest({ isSaved: false}, (data: any) => `
-            <div ${initComponent(data)}>
-                ${data.isSaved ? 'Component Saved!' : ''}
+           <div ${initComponent(data)}>
+               ${data.isSaved ? 'Component Saved!' : ''}
 
-                <button
-                    data-action="live#action"
-                    data-live-action-param="sendNamedArgs"
-                    data-live-a-param="1"
-                    data-live-b-param="2"
-                    data-live-c-param="banana"
-                >Send named args</button>
-            </div>
+               <button data-action="live#action" data-action-name="sendNamedArgs(a=1, b=2, c=3)">Send named args</button>
+           </div>
        `);
 
         // ONLY a post is sent, not a re-render GET
         test.expectsAjaxCall()
-            .expectActionCalled('sendNamedArgs', {a: 1, b: 2, c: 'banana'})
+            .expectActionCalled('sendNamedArgs', {a: '1', b: '2', c: '3'})
             .serverWillChangeProps((data: any) => {
                 // server marks component as "saved"
                 data.isSaved = true;
@@ -105,7 +99,7 @@ describe('LiveController Action Tests', () => {
                <select
                    data-model="on(change)|food"
                    data-action="live#action"
-                   data-live-action-param="changeFood"
+                   data-action-name="changeFood"
                >
                    <option value="" ${data.food === '' ? 'selected' : ''}>Choose a food</option>
                    <option value="pizza" ${data.pizza === '' ? 'selected' : ''}>Pizza</option>
@@ -137,7 +131,7 @@ describe('LiveController Action Tests', () => {
 
                 <span>${data.comment}</span>
 
-                <button data-action="live#action" data-live-action-param="save">Save</button>
+                <button data-action="live#action" data-action-name="save">Save</button>
             </div>
         `);
 
@@ -176,8 +170,8 @@ describe('LiveController Action Tests', () => {
         const test = await createTest({ isSaved: false }, (data: any) => `
             <div ${initComponent(data)}>
                 ${data.isSaved ? 'Component Saved!' : ''}
-                <button data-action="live#action" data-live-action-param="debounce(10)|save">Save</button>
-                <button data-action="live#action" data-live-action-param="debounce(10)|sync" data-live-sync-all-param="1">Sync</button>
+                <button data-action="live#action" data-action-name="debounce(10)|save">Save</button>
+                <button data-action="live#action" data-action-name="debounce(10)|sync(syncAll=1)">Sync</button>
             </div>
         `);
 
@@ -185,7 +179,7 @@ describe('LiveController Action Tests', () => {
         test.expectsAjaxCall()
             // 3 actions called
             .expectActionCalled('save')
-            .expectActionCalled('sync', { syncAll: 1 })
+            .expectActionCalled('sync', { syncAll: '1' })
             .expectActionCalled('save')
             .serverWillChangeProps((data: any) => {
                 data.isSaved = true;
diff --git a/src/LiveComponent/assets/test/controller/emit.test.ts b/src/LiveComponent/assets/test/controller/emit.test.ts
index 69f0adb3..15f739ad 100644
--- a/src/LiveComponent/assets/test/controller/emit.test.ts
+++ b/src/LiveComponent/assets/test/controller/emit.test.ts
@@ -26,17 +26,17 @@ describe('LiveController Emit Tests', () => {
                 Render Count: ${data.renderCount}
                 <button
                     data-action="live#emit"
-                    data-live-event-param="fooEvent"
+                    data-event="fooEvent"
                 >Emit Simple</button>
 
                 <button
                     data-action="live#emit"
-                    data-live-event-param="name(simple-component)|fooEvent"
+                    data-event="name(simple-component)|fooEvent"
                 >Emit Named Matching</button>
 
                 <button
                     data-action="live#emit"
-                    data-live-event-param="name(other-component)|fooEvent"
+                    data-event="name(other-component)|fooEvent"
                 >Emit Named Not Matching</button>
             </div>
         `);
diff --git a/src/LiveComponent/assets/test/controller/error.test.ts b/src/LiveComponent/assets/test/controller/error.test.ts
index 5096d5b9..7d79823b 100644
--- a/src/LiveComponent/assets/test/controller/error.test.ts
+++ b/src/LiveComponent/assets/test/controller/error.test.ts
@@ -26,7 +26,7 @@ describe('LiveController Error Handling', () => {
         const test = await createTest({ counter: 4 }, (data: any) => `
             <div ${initComponent(data)}>
                 Current count: ${data.counter}
-                <button data-action="live#action" data-live-action-param="save">Save</button>
+                <button data-action="live#action" data-action-name="save">Save</button>
                 <button data-action="live#$render">Render</button>
             </div>
         `);
@@ -62,7 +62,7 @@ describe('LiveController Error Handling', () => {
         const test = await createTest({ }, (data: any) => `
             <div ${initComponent(data)}>
                 Original component text
-                <button data-action="live#action" data-live-action-param="save">Save</button>
+                <button data-action="live#action" data-action-name="save">Save</button>
             </div>
         `);
 
diff --git a/src/LiveComponent/assets/test/controller/loading.test.ts b/src/LiveComponent/assets/test/controller/loading.test.ts
index 6326661f..ee33967e 100644
--- a/src/LiveComponent/assets/test/controller/loading.test.ts
+++ b/src/LiveComponent/assets/test/controller/loading.test.ts
@@ -81,8 +81,8 @@ describe('LiveController data-loading Tests', () => {
             <div ${initComponent(data)}> 
                 <span data-loading="action(save)|show" data-testid="loading-element">Loading...</span>
 
-                <button data-action="live#action" data-live-action-param="save">Save</button>
-                <button data-action="live#action" data-live-action-param="otherAction">Other Action</button>
+                <button data-action="live#action" data-action-name="save">Save</button>
+                <button data-action="live#action" data-action-name="otherAction">Other Action</button>
                 <button data-action="live#$render">Re-Render</button>
             </div>
         `);
@@ -176,8 +176,8 @@ describe('LiveController data-loading Tests', () => {
             <div ${initComponent(data)}> 
                 <span data-loading="action(otherAction)|show" data-testid="loading-element">Loading...</span>
 
-                <button data-action="live#action" data-live-action-param="debounce(50)|save">Save</button>
-                <button data-action="live#action" data-live-action-param="otherAction">Other Action</button>
+                <button data-action="live#action" data-action-name="debounce(50)|save">Save</button>
+                <button data-action="live#action" data-action-name="otherAction">Other Action</button>
             </div>
         `);
 
@@ -203,7 +203,7 @@ describe('LiveController data-loading Tests', () => {
            <div ${initComponent(data)}> 
                <span data-loading="action(save)|delay(50)|show" data-testid="loading-element">Loading...</span>
 
-               <button data-action="live#action" data-live-action-param="save">Save</button>
+               <button data-action="live#action" data-action-name="save">Save</button>
            </div>
        `);
 
diff --git a/src/LiveComponent/assets/test/controller/query-binding.test.ts b/src/LiveComponent/assets/test/controller/query-binding.test.ts
index f62754fa..a028bed9 100644
--- a/src/LiveComponent/assets/test/controller/query-binding.test.ts
+++ b/src/LiveComponent/assets/test/controller/query-binding.test.ts
@@ -149,7 +149,7 @@ describe('LiveController query string binding', () => {
         const test = await createTest({ prop: ''}, (data: any) => `
             <div ${initComponent(data, {queryMapping: {prop: {name: 'prop'}}})}>
                 Prop: ${data.prop}
-                <button data-action="live#action" data-live-action-param="changeProp">Change prop</button>
+                <button data-action="live#action" data-action-name="changeProp">Change prop</button>
             </div>
         `);
 
@@ -165,4 +165,4 @@ describe('LiveController query string binding', () => {
 
         expectCurrentSearch().toEqual('?prop=foo');
     });
-})
+})
\ No newline at end of file
diff --git a/src/LiveComponent/src/Test/TestLiveComponent.php b/src/LiveComponent/src/Test/TestLiveComponent.php
index 4e9234e4..ac9404a7 100644
--- a/src/LiveComponent/src/Test/TestLiveComponent.php
+++ b/src/LiveComponent/src/Test/TestLiveComponent.php
@@ -43,7 +43,7 @@ final class TestLiveComponent
         private UrlGeneratorInterface $router,
     ) {
         $this->client->catchExceptions(false);
-        $this->data['attributes']['data-live-id'] ??= 'in-a-real-scenario-it-would-already-have-one---provide-one-yourself-if-needed';
+        $this->data['attributes']['id'] ??= 'in-a-real-scenario-it-would-already-have-one---provide-one-yourself-if-needed';
     }
 
     public function render(): RenderedComponent
diff --git a/src/LiveComponent/tests/Fixtures/Component/DeferredComponentWithPlaceholder.php b/src/LiveComponent/tests/Fixtures/Component/DeferredComponentWithPlaceholder.php
new file mode 100644
index 00000000..e7daba08
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/Component/DeferredComponentWithPlaceholder.php
@@ -0,0 +1,18 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component;
+
+use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
+use Symfony\UX\LiveComponent\Attribute\LiveProp;
+use Symfony\UX\LiveComponent\DefaultActionTrait;
+
+#[AsLiveComponent('deferred_component_with_placeholder', method: 'get')]
+final class DeferredComponentWithPlaceholder
+{
+    use DefaultActionTrait;
+
+    #[LiveProp]
+    public int $rows = 6;
+}
diff --git a/src/LiveComponent/tests/Fixtures/templates/components/deferred_component_with_placeholder.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/deferred_component_with_placeholder.html.twig
new file mode 100644
index 00000000..fd546aa2
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/templates/components/deferred_component_with_placeholder.html.twig
@@ -0,0 +1,9 @@
+<div {{ attributes }}>
+    {# for row in this.rows ... #}
+</div>
+
+{% macro placeholder(props) %}
+    {%- for i in 1..props.rows -%}
+        <span class="loading-row"></span>
+    {%- endfor -%}
+{% endmacro %}
diff --git a/src/LiveComponent/tests/Fixtures/templates/render_component_with_placeholder.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_component_with_placeholder.html.twig
new file mode 100644
index 00000000..c9b8d4ab
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/templates/render_component_with_placeholder.html.twig
@@ -0,0 +1,1 @@
+<twig:deferred_component_with_placeholder rows="2" />
diff --git a/src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_placeholder.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_placeholder.html.twig
new file mode 100644
index 00000000..ce06cae0
--- /dev/null
+++ b/src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_placeholder.html.twig
@@ -0,0 +1,1 @@
+<twig:deferred_component_with_placeholder rows="2" defer />
diff --git a/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php
index d243ccba..420fce3b 100644
--- a/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php
+++ b/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php
@@ -77,6 +77,27 @@ final class DeferLiveComponentSubscriberTest extends KernelTestCase
         $this->assertStringContainsString('Long awaited data', $div->html());
     }
 
+    public function testItIncludesComponentTemplateBlockAsPlaceholder(): void
+    {
+        $div = $this->browser()
+            ->visit('/render-template/render_deferred_component_with_placeholder')
+            ->assertSuccessful()
+            ->crawler()
+            ->filter('div');
+
+        $this->assertSame('<span class="loading-row"></span><span class="loading-row"></span>', trim($div->html()));
+    }
+
+    public function testItDoesNotIncludesPlaceholderWhenRendered(): void
+    {
+        $div = $this->browser()
+            ->visit('/render-template/render_component_with_placeholder')
+            ->assertSuccessful()
+            ->crawler();
+
+        $this->assertStringNotContainsString('<span class="loading-row">', $div->html());
+    }
+
     public function testItAllowsToSetCustomLoadingHtmlTag(): void
     {
         $crawler = $this->browser()
diff --git a/src/LiveComponent/tests/Functional/EventListener/InterceptChildComponentRenderSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/InterceptChildComponentRenderSubscriberTest.php
index 3dfd77bb..19cd1740 100644
--- a/src/LiveComponent/tests/Functional/EventListener/InterceptChildComponentRenderSubscriberTest.php
+++ b/src/LiveComponent/tests/Functional/EventListener/InterceptChildComponentRenderSubscriberTest.php
@@ -128,7 +128,7 @@ final class InterceptChildComponentRenderSubscriberTest extends KernelTestCase
             ->assertSuccessful()
             ->assertHtml()
             ->assertElementCount('ul li', 3)
-            // check for the live-id we expect based on the key
+            // check for the id we expect based on the key
             ->assertContains('id="live-521026374-the-key0"')
             ->assertNotContains('key="the-key0"')
             ->visit($urlWithChangedFingerprints)