Repository: symfony/ux
Pull request #1538 (2024-02-22 16:00:21)

[Doc] Add more examples of adding attributes in Live Components
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | no
| Issues        | -
| License       | MIT

Note: I tried this in a Symfony app and it didn't work ... so I'm opening this PR to ask you if this should work or if `addAttribute()` is only for empty attributes that don't define a value.

Thanks!

smnandre: It only works with attributes in a toggle way .. you may want to use addClass there i think
javiereguiluz: Thanks for the review. I updated the PR to show an example that won't work and explain that explicitly. Thanks.
kbond: > I did all the changes that you suggested.

@javiereguiluz, did you miss pushing these changes?
javiereguiluz: @kbond yes, sorry 😊 

Patch:
diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js
index f7536c00..8e515104 100644
--- a/src/Autocomplete/assets/dist/controller.js
+++ b/src/Autocomplete/assets/dist/controller.js
@@ -201,6 +201,7 @@ class default_1 extends Controller {
         const filteredNewOptions = newOptions.filter((option) => option.value !== '');
         const originalPlaceholderOption = this.originalOptions.find((option) => option.value === '');
         const newPlaceholderOption = newOptions.find((option) => option.value === '');
+        console.log(originalPlaceholderOption, newPlaceholderOption);
         if (originalPlaceholderOption &&
             newPlaceholderOption &&
             originalPlaceholderOption.text !== newPlaceholderOption.text) {
diff --git a/src/Autocomplete/assets/src/controller.ts b/src/Autocomplete/assets/src/controller.ts
index 8be04e8a..412095e3 100644
--- a/src/Autocomplete/assets/src/controller.ts
+++ b/src/Autocomplete/assets/src/controller.ts
@@ -431,7 +431,7 @@ export default class extends Controller {
 
         const originalPlaceholderOption = this.originalOptions.find((option) => option.value === '');
         const newPlaceholderOption = newOptions.find((option) => option.value === '');
-
+        console.log(originalPlaceholderOption, newPlaceholderOption);
         if (
             originalPlaceholderOption &&
             newPlaceholderOption &&
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/Autocomplete/translations/AutocompleteBundle.bg.php b/src/Autocomplete/translations/AutocompleteBundle.bg.php
index e16b6aea..a700a3e7 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.bg.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.bg.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/Autocomplete/translations/AutocompleteBundle.ca.php b/src/Autocomplete/translations/AutocompleteBundle.ca.php
index 4b0ddaaa..419e764a 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.ca.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.ca.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'S\'estan carregant més resultats...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'No s\'han trobat resultats',
     'No more results' => 'No hi ha més resultats',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.cs.php b/src/Autocomplete/translations/AutocompleteBundle.cs.php
index c90cbcf4..fba7b1ac 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.cs.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.cs.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Načítání dalších výsledků...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Nenalezeny žádné položky',
-    'No more results' => 'Žádné další výsledky',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.da.php b/src/Autocomplete/translations/AutocompleteBundle.da.php
index d77714f9..ae810fe7 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.da.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.da.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Indlæser flere resultater...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Ingen resultater fundet',
-    'No more results' => 'Ingen flere resultater',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.el.php b/src/Autocomplete/translations/AutocompleteBundle.el.php
index 6ded7859..09842d19 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.el.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.el.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Φόρτωση περισσότερων αποτελεσμάτων...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Δεν βρέθηκαν αποτελέσματα',
-    'No more results' => 'Δεν υπάρχουν άλλα αποτελέσματα',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.es.php b/src/Autocomplete/translations/AutocompleteBundle.es.php
index 6d7345ab..1606d614 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.es.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.es.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Cargando más resultados...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'No se han encontrado resultados',
     'No more results' => 'No hay más resultados',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.eu.php b/src/Autocomplete/translations/AutocompleteBundle.eu.php
index 98cb18d7..14ac876f 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.eu.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.eu.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Emaitza gehiago kargatzen...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Ez da bat datorrenik aurkitu',
-    'No more results' => 'Ez dago emaitza gehiagorik',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.fa.php b/src/Autocomplete/translations/AutocompleteBundle.fa.php
index 06db64f4..8209bc4b 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.fa.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.fa.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/Autocomplete/translations/AutocompleteBundle.fi.php b/src/Autocomplete/translations/AutocompleteBundle.fi.php
index de081b9f..4a55386e 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.fi.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.fi.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Ladataan lisää tuloksia...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Ei tuloksia',
-    'No more results' => 'Ei enempää tuloksia',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.gl.php b/src/Autocomplete/translations/AutocompleteBundle.gl.php
index 76975202..30572137 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.gl.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.gl.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Cargando máis resultados...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Non se atoparon resultados',
-    'No more results' => 'Non hai máis resultados',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.hr.php b/src/Autocomplete/translations/AutocompleteBundle.hr.php
index 57f7aeca..1da9a49f 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.hr.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.hr.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Učitavanje više rezultata...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Nema rezultata',
-    'No more results' => 'Nema više rezultata',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.id.php b/src/Autocomplete/translations/AutocompleteBundle.id.php
index 8ca5f81f..255d755e 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.id.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.id.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Memuat hasil lebih banyak...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Tidak ada hasil yang ditemukan',
     'No more results' => 'Tidak ada hasil lagi',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.it.php b/src/Autocomplete/translations/AutocompleteBundle.it.php
index 815238b1..3e3a115e 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.it.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.it.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Caricamento di altri risultati...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Nessun risultato trovato',
     'No more results' => 'Non ci sono altri risultati',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.lb.php b/src/Autocomplete/translations/AutocompleteBundle.lb.php
index 79be23c3..c1ce1bad 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.lb.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.lb.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Lueden méi Resultater...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Keng Resultater fonnt',
     'No more results' => 'Keng weider Resultater',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.lt.php b/src/Autocomplete/translations/AutocompleteBundle.lt.php
index ed464e45..1e73aeaa 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.lt.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.lt.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Kraunama daugiau rezultatų...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Atitikmenų nerasta',
-    'No more results' => 'Daugiau rezultatų nėra',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.pl.php b/src/Autocomplete/translations/AutocompleteBundle.pl.php
index a5f8b2c2..3625ce5e 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.pl.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.pl.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Wczytywanie więcej wyników...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Brak wyników',
-    'No more results' => 'Brak więcej wyników',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.pt.php b/src/Autocomplete/translations/AutocompleteBundle.pt.php
index bd8d827f..2afd3b91 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.pt.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.pt.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Carregando mais resultados...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Sem resultados',
-    'No more results' => 'Não há mais resultados',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.pt_BR.php b/src/Autocomplete/translations/AutocompleteBundle.pt_BR.php
index 4e136e00..44f780e8 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.pt_BR.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.pt_BR.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Carregando mais resultados...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Nenhum resultado encontrado',
-    'No more results' => 'Não há mais resultados',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.ro.php b/src/Autocomplete/translations/AutocompleteBundle.ro.php
index 7eabfd85..07697f87 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.ro.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.ro.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Se încarcă mai multe rezultate...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Nu au fost găsite rezultate',
-    'No more results' => 'Nu mai sunt alte rezultate',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.ru.php b/src/Autocomplete/translations/AutocompleteBundle.ru.php
index 1277f42f..37da5677 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.ru.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.ru.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Загрузка дополнительных результатов...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Совпадений не найдено',
-    'No more results' => 'Больше результатов нет',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.sl.php b/src/Autocomplete/translations/AutocompleteBundle.sl.php
index 39437a01..15835995 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.sl.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.sl.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Nalaganje več rezultatov...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Ni zadetkov',
-    'No more results' => 'Ni več rezultatov',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.sr_RS.php b/src/Autocomplete/translations/AutocompleteBundle.sr_RS.php
index efeafae2..21a631f7 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.sr_RS.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.sr_RS.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Учитавање још резултата...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Nema rezultata',
     'No more results' => 'Nema više rezultata',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.sv.php b/src/Autocomplete/translations/AutocompleteBundle.sv.php
index 918db3d8..c4b76c81 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.sv.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.sv.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Laddar fler resultat...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Inga träffar',
-    'No more results' => 'Inga fler resultat',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.tr.php b/src/Autocomplete/translations/AutocompleteBundle.tr.php
index f8d1776f..7204a365 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.tr.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.tr.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Daha fazla sonuç yükleniyor...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Sonuç bulunamadı',
-    'No more results' => 'Başka sonuç yok',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.uk.php b/src/Autocomplete/translations/AutocompleteBundle.uk.php
index 365114c1..86ace1e4 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.uk.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.uk.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => 'Завантаження додаткових результатів...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => 'Нічого не знайдено',
-    'No more results' => 'Більше результатів немає',
+    // 'No more results' => 'No more results',
 ];
diff --git a/src/Autocomplete/translations/AutocompleteBundle.zh_CN.php b/src/Autocomplete/translations/AutocompleteBundle.zh_CN.php
index 7a969be6..f36d16dc 100644
--- a/src/Autocomplete/translations/AutocompleteBundle.zh_CN.php
+++ b/src/Autocomplete/translations/AutocompleteBundle.zh_CN.php
@@ -10,7 +10,7 @@
  */
 
 return [
-    'Loading more results...' => '加载更多结果...',
+    // 'Loading more results...' => 'Loading more results...',
     'No results found' => '未找到结果',
-    'No more results' => '没有更多结果',
+    // 'No more results' => '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 169366ae..6a55dae2 100644
--- a/src/LiveComponent/CHANGELOG.md
+++ b/src/LiveComponent/CHANGELOG.md
@@ -1,53 +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"`.
-
--   Reverted setting `ignoreActiveValue: true` in Idiomorph
-
 ## 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 1b106871..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'));
@@ -1349,6 +1378,7 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements,
         syncAttributes(newElement, oldElement);
     });
     Idiomorph.morph(rootFromElement, rootToElement, {
+        ignoreActiveValue: true,
         callbacks: {
             beforeNodeMorphed: (fromEl, toEl) => {
                 if (!(fromEl instanceof Element) || !(toEl instanceof Element)) {
@@ -1374,11 +1404,6 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements,
                     if (modifiedFieldElements.includes(fromEl)) {
                         setValueOnElement(toEl, getElementValue(fromEl));
                     }
-                    if (fromEl === document.activeElement
-                        && fromEl !== document.body
-                        && null !== getModelDirectiveFromElement(fromEl, false)) {
-                        setValueOnElement(toEl, getElementValue(fromEl));
-                    }
                     const elementChanges = externalMutationTracker.getChangedElement(fromEl);
                     if (elementChanges) {
                         elementChanges.applyToElement(toEl);
@@ -2917,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();
             });
@@ -2963,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);
@@ -2987,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);
     }
@@ -2997,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) => {
@@ -3019,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/Component/UnsyncedInputsTracker.ts b/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts
index f56331c6..a13a2065 100644
--- a/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts
+++ b/src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts
@@ -73,7 +73,7 @@ export default class {
 /**
  * Tracks field & models whose values are "unsynced".
  *
- * For a model, unsynced means that the value has been updated inside
+ * For a model, unsynced means that the value has been updated inside of
  * a field (e.g. an input), but that this new value hasn't
  * yet been set onto the actual model data. It is "unsynced"
  * from the underlying model data.
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/assets/src/morphdom.ts b/src/LiveComponent/assets/src/morphdom.ts
index 534ba95a..92a31080 100644
--- a/src/LiveComponent/assets/src/morphdom.ts
+++ b/src/LiveComponent/assets/src/morphdom.ts
@@ -1,4 +1,4 @@
-import { cloneHTMLElement, getModelDirectiveFromElement, setValueOnElement } from './dom_utils';
+import { cloneHTMLElement, setValueOnElement } from './dom_utils';
 // @ts-ignore
 import { Idiomorph } from 'idiomorph/dist/idiomorph.esm.js';
 import { normalizeAttributesForComparison } from './normalize_attributes_for_comparison';
@@ -53,6 +53,12 @@ export function executeMorphdom(
     });
 
     Idiomorph.morph(rootFromElement, rootToElement, {
+        // We handle updating the value of fields that have been changed
+        // since the HTML was requested. However, the active element is
+        // a special case: replacing the value isn't enough. We need to
+        // prevent the value from being changed in the first place so the
+        // user's cursor position is maintained.
+        ignoreActiveValue: true,
         callbacks: {
             beforeNodeMorphed: (fromEl: Element, toEl: Element) => {
                 // Idiomorph loop also over Text node
@@ -100,23 +106,6 @@ export function executeMorphdom(
                         setValueOnElement(toEl, getElementValue(fromEl));
                     }
 
-                    // Special handling for the active element of a model field.
-                    // Make the "to" element match the "from" element's value
-                    // to avoid any value change during the morphing. After morphing,
-                    // the SetValuesOntoModelFieldsPlugin handles setting the value
-                    // to whatever is in the data store.
-                    // Avoiding changing the value during morphing is important
-                    // to maintain the cursor position.
-                    // We skip this for non-model elements and allow this to either
-                    // maintain the value if changed (see code above) or for the
-                    // morphing process to update it to the value from the server.
-                    if (fromEl === document.activeElement
-                        && fromEl !== document.body
-                        && null !== getModelDirectiveFromElement(fromEl, false)
-                    ) {
-                        setValueOnElement(toEl, getElementValue(fromEl));
-                    }
-
                     // handle any external changes to this element
                     const elementChanges = externalMutationTracker.getChangedElement(fromEl);
                     if (elementChanges) {
diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst
index 70a0bb39..32a8b99c 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
 
@@ -929,9 +929,9 @@ a class:
     <div data-loading="removeClass(opacity-50)">...</div>
 
     <!-- add multiple classes when loading -->
-    <div data-loading="addClass(opacity-50 disabled)">...</div>
+    <div data-loading="addClass(opacity-50 text-muted)">...</div>
 
-Sometimes you may want to add or remove an attribute when loading. That
+Sometimes you may want to add or remove an HTML attribute when loading. That
 can be accomplished with ``addAttribute`` or ``removeAttribute``:
 
 .. code-block:: html+twig
@@ -939,6 +939,12 @@ can be accomplished with ``addAttribute`` or ``removeAttribute``:
     <!-- add the "disabled" attribute when loading -->
     <div data-loading="addAttribute(disabled)">...</div>
 
+.. caution::
+
+    The ``addAttribute()`` and ``removeAttribute()`` functions only work with
+    empty HTML attributes (``disabled``, ``readonly``, ``required``, etc.) and
+    not with attributes that define their values (e.g. this won't work: ``addAttribute(style='color: red')``).
+
 You can also combine any number of directives by separating them with a
 space:
 
@@ -1036,21 +1042,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-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 +1064,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 +1105,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-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 +1141,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 +1222,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 +1239,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 +1462,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 +1763,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 +1775,7 @@ and ``removeComment()`` actions:
 
             <button
                 data-action="live#action"
-                data-live-action-param="addComment"
+                data-action-name="addComment"
                 type="button"
             >+ Add Comment</button>
 
@@ -1940,7 +1937,7 @@ Inline rendering:
             {% endfor %}
 
             {# render the add button #}
-            {{ form_widget(form.comments.vars.button_add, { label: '+ Add comment', attr: { class: 'btn btn-outline-primary' } }) }}
+            {{ form_widget(form.comments.vars.button_add, { label: '+ Add comment', class: 'btn btn-outline-primary' }) }}
 
             {# render rest of the form #}
             {{ form_row(form) }}
@@ -2181,8 +2178,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
@@ -2242,12 +2239,8 @@ Deferring / Lazy Loading Components
 If a component is heavy to render, you can defer rendering it until after
 the page has loaded. To do this, add the ``defer`` option:
 
-.. code-block:: html+twig
-
-    {# With the HTML syntax #}
-    <twig:SomeHeavyComponent defer />
+.. code-block:: twig
 
-    {# With the component function #}
     {{ component('SomeHeavyComponent', { defer: true }) }}
 
 This renders an empty ``<div>`` tag, but triggers an Ajax call to render the
@@ -2259,82 +2252,21 @@ real component once the page has loaded.
     page load, but it isn't rendered. So keep your heavy work to methods in
     your component (e.g. ``getProducts()``) that are only called when rendering.
 
-You can define some content to be rendered while the component is loading, either
-inside the component template (the ``placeholder`` macro) or from the calling template
-(the ``loading-template`` attribute and the ``loadingContent`` block).
-
-.. versionadded:: 2.17.0
-
-    Defining a placeholder macro into the component template was added in Live Components 2.17.0.
-
-In the component template, define a ``placeholder`` macro, outside of the
-component's main content. This macro will be called when the component is deferred:
-
-.. code-block:: html+twig
-
-    {# templates/recommended-products.html.twig #}
-    <div {{ attributes }}>
-        {# This will be rendered when the component is fully loaded #}
-        {% for product in this.products %}
-            <div>{{ product.name }}</div>
-        {% endfor %}
-    </div>
-
-    {% macro placeholder(props) %}
-        {# This content will (only) be rendered as loading content #}
-        <span class="loading-row"></span>
-    {% endmacro %}
-
-The ``props`` argument contains the props passed to the component.
-You can use it to customize the placeholder content. Let's say your
-component shows a certain number of products (defined with the ``size``
-prop). You can use it to define a placeholder that shows the same
-number of rows:
-
-.. code-block:: html+twig
-
-    {# In the calling template #}
-    <twig:RecommendedProducts size="3" defer />
-
-.. code-block:: html+twig
-
-    {# In the component template #}
-    {% macro placeholder(props) %}
-        {% for i in 1..props.size %}
-            <div class="loading-product">
-                ...
-            </div>
-        {% endfor %}
-    {% endmacro %}
-
-To customize the loading content from the calling template, you can use
-the ``loading-template`` option to point to a template:
-
-.. code-block:: html+twig
+To add some loading text before the real component is loaded, use the
+``loading-template`` option to point to a template:
 
-    {# With the HTML syntax #}
-    <twig:SomeHeavyComponent defer loading-template="spinning-wheel.html.twig" />
+.. code-block:: twig
 
-    {# With the component function #}
     {{ component('SomeHeavyComponent', { defer: true, loading-template: 'spinning-wheel.html.twig' }) }}
 
 Or override the ``loadingContent`` block:
 
-.. code-block:: html+twig
-
-    {# With the HTML syntax #}
-    <twig:SomeHeavyComponent defer>
-        <twig:block name="loadingContent">Custom Loading Content...</twig:block>
-    </twig:SomeHeavyComponent>
+.. code-block:: twig
 
-    {# With the component tag #}
     {% component SomeHeavyComponent with { defer: true } %}
         {% block loadingContent %}Loading...{% endblock %}
     {% endcomponent %}
 
-When ``loading-template`` or ``loadingContent`` is defined, the ``placeholder``
-macro is ignored.
-
 To change the initial tag from a ``div`` to something else, use the ``loading-tag`` option:
 
 .. code-block:: twig
@@ -2521,18 +2453,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``::
@@ -2629,7 +2556,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::
@@ -2646,7 +2573,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::
@@ -2662,7 +2589,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::
@@ -2845,7 +2772,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
@@ -3009,7 +2936,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>
@@ -3535,6 +3462,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
@@ -3545,6 +3473,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 d7e598cf..19928e8b 100644
--- a/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php
+++ b/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php
@@ -52,7 +52,6 @@ final class DeferLiveComponentSubscriber implements EventSubscriberInterface
             return;
         }
 
-        $componentTemplate = $event->getMetadata()->getTemplate();
         $event->setTemplate('@LiveComponent/deferred.html.twig');
 
         $variables = $event->getVariables();
@@ -67,8 +66,6 @@ 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 3e6f4e9a..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 b55ec440..0705be8e 100644
--- a/src/LiveComponent/templates/deferred.html.twig
+++ b/src/LiveComponent/templates/deferred.html.twig
@@ -2,11 +2,6 @@
     {% 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/StimulusBundle/src/AssetMapper/AutoImportLocator.php b/src/StimulusBundle/src/AssetMapper/AutoImportLocator.php
index 06dc7842..a5dafe59 100644
--- a/src/StimulusBundle/src/AssetMapper/AutoImportLocator.php
+++ b/src/StimulusBundle/src/AssetMapper/AutoImportLocator.php
@@ -64,7 +64,7 @@ class AutoImportLocator
 
         $entry = $this->importMapConfigReader->findRootImportMapEntry($path);
         if (!$entry) {
-            throw new \LogicException(sprintf('The autoimport "%s" could not be found in importmap.php. Try running "php bin/console importmap:require %s".', $path, $path));
+            throw new \LogicException(sprintf('The autoimport "%s" could not be found in importmap.php. Try running "importmap:require %s".', $path, $path));
         }
 
         return new MappedControllerAutoImport($path, true);
diff --git a/src/Translator/assets/dist/translator_controller.js b/src/Translator/assets/dist/translator_controller.js
index a92243af..d4f3317a 100644
--- a/src/Translator/assets/dist/translator_controller.js
+++ b/src/Translator/assets/dist/translator_controller.js
@@ -38,34 +38,41 @@ function format(id, parameters = {}, locale) {
         parts = id.split('|');
     }
     else {
-        parts = id.match(/(?:\|\||[^|])+/g) || [];
+        const matches = id.match(/(?:\|\||[^|])+/g);
+        if (matches !== null) {
+            parts = matches;
+        }
     }
     const intervalRegex = /^(?<interval>({\s*(-?\d+(\.\d+)?[\s*,\s*\-?\d+(.\d+)?]*)\s*})|(?<left_delimiter>[[\]])\s*(?<left>-Inf|-?\d+(\.\d+)?)\s*,\s*(?<right>\+?Inf|-?\d+(\.\d+)?)\s*(?<right_delimiter>[[\]]))\s*(?<message>.*?)$/s;
     const standardRules = [];
     for (let part of parts) {
         part = part.trim().replace(/\|\|/g, '|');
-        const matches = part.match(intervalRegex);
-        if (matches) {
-            const matchGroups = matches.groups || {};
+        let matches = part.match(intervalRegex);
+        if (matches !== null) {
             if (matches[2]) {
                 for (const n of matches[3].split(',')) {
                     if (number === Number(n)) {
-                        return strtr(matchGroups.message, parameters);
+                        return strtr(matches.groups['message'], parameters);
                     }
                 }
             }
             else {
-                const leftNumber = '-Inf' === matchGroups.left ? Number.NEGATIVE_INFINITY : Number(matchGroups.left);
-                const rightNumber = ['Inf', '+Inf'].includes(matchGroups.right) ? Number.POSITIVE_INFINITY : Number(matchGroups.right);
-                if (('[' === matchGroups.left_delimiter ? number >= leftNumber : number > leftNumber)
-                    && (']' === matchGroups.right_delimiter ? number <= rightNumber : number < rightNumber)) {
-                    return strtr(matchGroups.message, parameters);
+                const leftNumber = '-Inf' === matches.groups['left'] ? Number.NEGATIVE_INFINITY : Number(matches.groups['left']);
+                const rightNumber = ['Inf', '+Inf'].includes(matches.groups['right']) ? Number.POSITIVE_INFINITY : Number(matches.groups['right']);
+                if (('[' === matches.groups['left_delimiter'] ? number >= leftNumber : number > leftNumber)
+                    && (']' === matches.groups['right_delimiter'] ? number <= rightNumber : number < rightNumber)) {
+                    return strtr(matches.groups['message'], parameters);
                 }
             }
         }
         else {
-            const ruleMatch = part.match(/^\w+:\s*(.*?)$/);
-            standardRules.push(ruleMatch ? ruleMatch[1] : part);
+            matches = part.match(/^\w+:\s*(.*?)$/);
+            if (matches !== null) {
+                standardRules.push(matches[1]);
+            }
+            else {
+                standardRules.push(part);
+            }
         }
     }
     const position = getPluralizationRule(number, locale);
diff --git a/src/Translator/assets/src/formatters/formatter.ts b/src/Translator/assets/src/formatters/formatter.ts
index 590a4a0c..e50305ab 100644
--- a/src/Translator/assets/src/formatters/formatter.ts
+++ b/src/Translator/assets/src/formatters/formatter.ts
@@ -62,7 +62,10 @@ export function format(id: string, parameters: Record<string, string | number> =
     if (/^\|+$/.test(id)) {
         parts = id.split('|');
     } else {
-        parts = id.match(/(?:\|\||[^|])+/g) || [];
+        const matches = id.match(/(?:\|\||[^|])+/g);
+        if (matches !== null) {
+            parts = matches;
+        }
     }
 
     const intervalRegex = /^(?<interval>({\s*(-?\d+(\.\d+)?[\s*,\s*\-?\d+(.\d+)?]*)\s*})|(?<left_delimiter>[[\]])\s*(?<left>-Inf|-?\d+(\.\d+)?)\s*,\s*(?<right>\+?Inf|-?\d+(\.\d+)?)\s*(?<right_delimiter>[[\]]))\s*(?<message>.*?)$/s;
@@ -71,31 +74,31 @@ export function format(id: string, parameters: Record<string, string | number> =
     for (let part of parts) {
         part = part.trim().replace(/\|\|/g, '|');
 
-        const matches = part.match(intervalRegex);
-        if (matches) {
-            /**
-             * @type {interval: string, left_delimiter: string, left: string, right_delimiter: string, right: string, message: string}
-             */
-            const matchGroups: { [p: string]: string } = matches.groups || {};
+        let matches = part.match(intervalRegex);
+        if (matches !== null) {
             if (matches[2]) {
                 for (const n of matches[3].split(',')) {
                     if (number === Number(n)) {
-                        return strtr(matchGroups.message, parameters);
+                        return strtr(matches.groups!['message'], parameters);
                     }
                 }
             } else {
-                const leftNumber = '-Inf' === matchGroups.left ? Number.NEGATIVE_INFINITY : Number(matchGroups.left);
-                const rightNumber = ['Inf', '+Inf'].includes(matchGroups.right) ? Number.POSITIVE_INFINITY : Number(matchGroups.right);
+                const leftNumber = '-Inf' === matches.groups!['left'] ? Number.NEGATIVE_INFINITY : Number(matches.groups!['left']);
+                const rightNumber = ['Inf', '+Inf'].includes(matches.groups!['right']) ? Number.POSITIVE_INFINITY : Number(matches.groups!['right']);
 
-                if (('[' === matchGroups.left_delimiter ? number >= leftNumber : number > leftNumber)
-                    && (']' === matchGroups.right_delimiter ? number <= rightNumber : number < rightNumber)
+                if (('[' === matches.groups!['left_delimiter'] ? number >= leftNumber : number > leftNumber)
+                    && (']' === matches.groups!['right_delimiter'] ? number <= rightNumber : number < rightNumber)
                 ) {
-                    return strtr(matchGroups.message, parameters);
+                    return strtr(matches.groups!['message'], parameters);
                 }
             }
         } else {
-            const ruleMatch = part.match(/^\w+:\s*(.*?)$/);
-            standardRules.push(ruleMatch ? ruleMatch[1] : part);
+            matches = part.match(/^\w+:\s*(.*?)$/);
+            if (matches !== null) {
+                standardRules.push(matches[1]);
+            } else {
+                standardRules.push(part);
+            }
         }
     }
 
diff --git a/src/Translator/assets/src/translator.ts b/src/Translator/assets/src/translator.ts
index 8103ccad..903bac46 100644
--- a/src/Translator/assets/src/translator.ts
+++ b/src/Translator/assets/src/translator.ts
@@ -25,7 +25,6 @@ export type ParametersOf<M, D extends DomainType> = M extends Message<infer Tran
         : never
     : never;
 
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
 export interface Message<Translations extends TranslationsType, Locale extends LocaleType> {
     id: string;
     translations: {
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/src/LiveMemory/Asset/CssCompiler.php b/ux.symfony.com/src/LiveMemory/Asset/CssCompiler.php
deleted file mode 100644
index eb8ef3ec..00000000
--- a/ux.symfony.com/src/LiveMemory/Asset/CssCompiler.php
+++ /dev/null
@@ -1,57 +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);
-    }
-}
diff --git a/ux.symfony.com/templates/ux_packages/lazy-image.html.twig b/ux.symfony.com/templates/ux_packages/lazy-image.html.twig
index c3d5d52f..021d5536 100644
--- a/ux.symfony.com/templates/ux_packages/lazy-image.html.twig
+++ b/ux.symfony.com/templates/ux_packages/lazy-image.html.twig
@@ -42,7 +42,7 @@
     <img
         src="{{ data_uri_thumbnail(legosFilePath, 40, 30) }}"
         data-controller="symfony--ux-lazy-image--lazy-image"
-        data-symfony--ux-lazy-image--lazy-image-src-value="{{ asset('images/legos.jpg') }}"
+        data-symfony--ux-lazy-image--lazy-image-src="{{ asset('images/legos.jpg') }}"
         alt="A blurhash image that is replaced by the real image on load"
         width="578"
         height="275"


Test patch:
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index d9ae077e..f4d8b7f6 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -3,11 +3,9 @@ name: Symfony UX
 on:
     push:
         paths-ignore:
-            - 'src/*/doc/**'
             - 'ux.symfony.com/**'
     pull_request:
         paths-ignore:
-            - 'src/*/doc/**'
             - 'ux.symfony.com/**'
 
 jobs:
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/assets/test/controller/render.test.ts b/src/LiveComponent/assets/test/controller/render.test.ts
index e32d4055..70493eb8 100644
--- a/src/LiveComponent/assets/test/controller/render.test.ts
+++ b/src/LiveComponent/assets/test/controller/render.test.ts
@@ -157,7 +157,7 @@ describe('LiveController rendering Tests', () => {
         `);
 
         test.expectsAjaxCall()
-            .expectUpdatedData({ name: 'Hello' });
+            .expectUpdatedData({ name: 'Hello' })
 
         const input = test.queryByDataModel('name') as HTMLInputElement;
         userEvent.type(input, 'Hello');
@@ -171,29 +171,6 @@ describe('LiveController rendering Tests', () => {
         expect(input.value).toBe('Hel!lo');
     });
 
-    it('uses the new value of an unmapped field that was NOT modified even if active', async () => {
-        const test = await createTest({ title: 'greetings' }, (data: any) => `
-            <div ${initComponent(data)}>
-                <!-- An unmapped field -->
-                <input value="${data.title}">
-
-                Title: "${data.title}"
-            </div>
-        `);
-
-        test.expectsAjaxCall()
-            .serverWillChangeProps((data: any) => {
-                // change the data on the server so the template renders differently
-                data.title = 'Hello';
-            });
-
-        const input = test.element.querySelector('input') as HTMLInputElement;
-        // focus the input, but don't change it
-        userEvent.type(input, '');
-        await test.component.render();
-        expect(input.value).toEqual('Hello');
-    });
-
     it('does not render over elements with data-live-ignore', async () => {
         const test = await createTest({ firstName: 'Ryan' }, (data: any) => `
             <div ${initComponent(data)}>
diff --git a/src/LiveComponent/src/Test/TestLiveComponent.php b/src/LiveComponent/src/Test/TestLiveComponent.php
index ac9404a7..4e9234e4 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']['id'] ??= 'in-a-real-scenario-it-would-already-have-one---provide-one-yourself-if-needed';
+        $this->data['attributes']['data-live-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
deleted file mode 100644
index e7daba08..00000000
--- a/src/LiveComponent/tests/Fixtures/Component/DeferredComponentWithPlaceholder.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?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
deleted file mode 100644
index fd546aa2..00000000
--- a/src/LiveComponent/tests/Fixtures/templates/components/deferred_component_with_placeholder.html.twig
+++ /dev/null
@@ -1,9 +0,0 @@
-<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
deleted file mode 100644
index c9b8d4ab..00000000
--- a/src/LiveComponent/tests/Fixtures/templates/render_component_with_placeholder.html.twig
+++ /dev/null
@@ -1,1 +0,0 @@
-<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
deleted file mode 100644
index ce06cae0..00000000
--- a/src/LiveComponent/tests/Fixtures/templates/render_deferred_component_with_placeholder.html.twig
+++ /dev/null
@@ -1,1 +0,0 @@
-<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 420fce3b..d243ccba 100644
--- a/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php
+++ b/src/LiveComponent/tests/Functional/EventListener/DeferLiveComponentSubscriberTest.php
@@ -77,27 +77,6 @@ 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 19cd1740..3dfd77bb 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 id we expect based on the key
+            // check for the live-id we expect based on the key
             ->assertContains('id="live-521026374-the-key0"')
             ->assertNotContains('key="the-key0"')
             ->visit($urlWithChangedFingerprints)
diff --git a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php
index aa61825e..ff891610 100644
--- a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php
+++ b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php
@@ -14,6 +14,7 @@ namespace Symfony\UX\TwigComponent\Tests\Integration;
 use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
 use Symfony\UX\TwigComponent\Tests\Fixtures\User;
 use Twig\Environment;
+use Twig\Error\RuntimeError;
 
 /**
  * @author Kevin Bond <kevinbond@gmail.com>