Repository: symfony/ux
Pull request #1416 (2024-01-23 23:54:32)

Introduce CVA to style TwigComponent
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Issues        | 
| License       | MIT

This PR introduces a new concept CVA (Class Variance Authority), by adding a1 twig function, to help you manage your class in your component.

Let's take an example an Alert component. In your app, an alert can have a lot of different styles one for success, one for alert, one for warning, and different sizes, with icons or not... You need something that lets you completely change the style of your component without creating a new component, and without creating too much complexity in your template.

Here is the reason came CVA.

Your Alert component can now look like this:

```twig
{% props color = 'blue', size = 'md' %}

{% set alert =  cva({
     base: 'alert rounded-lg',
     variants: {
        color: {
            blue: 'text-blue-800 bg-blue-50 dark:bg-gray-800 dark:text-blue-400',
            red: 'text-red-800 bg-red-50 dark:bg-gray-800 dark:text-red-400',
            green: 'text-green-800 bg-green-50 dark:bg-gray-800 dark:text-green-400',
            yellow: 'text-yellow-800 bg-yellow-50 dark:bg-gray-800 dark:text-yellow-400',
        },
        size: {
            sm: 'px-4 py-3 text-sm',
            md: 'px-6 py-4 text-base',
            lg: 'px-8 py-5 text-lg',
        }
    },
    compoundVariants: [{
           color: ['red'],
           size: ['lg'],
          class: 'font-semibold'
    }],
    defaultVariants: {
           rounded: 'md'
    }
}) %}

<div class="{{ cva.apply({color, size}, attribute.render('class'), 'flex p-4') }}">
    ...
</div>
```

So here you have a `cva` function that lets you define different variants of your component.

You can now use your component like this:
```twig
<twig:Alert color="red" size="md"/>

<twig:Alert color="green" size="sm"/>

<twig:Alert color="yellow" size="lg"/>

<twig:Alert color="red" size="md" class="dark:bg-gray-800"/>
```

And then you get the following result:

<img width="1269" alt="Capture d’écran 2024-01-24 à 00 52 33" src="https://github.com/symfony/ux/assets/32077734/6a5e25be-5b81-4ae7-8385-0fa5422d0396">

If you want to know more about the concept I implement here you can look at:
- CVA (js version): https://cva.style/docs
- tailwind merge: https://github.com/gehrisandro/tailwind-merge-php, https://github.com/dcastil/tailwind-merge
- this implementation by using tailwind-merge and cva is inspired a lot by: https://ui.shadcn.com/ (shadcn is the most starred library on github in 2023) 
- a really good article that explains the philosophy behind https://manupa.dev/blog/anatomy-of-shadcn-ui
- this PR works great in a LASTstack: https://symfonycasts.com/screencast/last-stack/last-stack

Tell me what you think about it! Thanks for your time! Cheers 🧡


kbond: Can you show what `twm` and `clm` stand for?

Is there resources to provide some context on `cva`, `twm`, and `clm`?
norkunas: I'd probably better have something like https://styled-components.com/ or https://emotion.sh/docs/introduction because now it looks like that ux is going all tailwind :stuck_out_tongue: 
WebMamba: @kbond description updated! 😁

> I'd probably better have something like https://styled-components.com/ or https://emotion.sh/docs/introduction because now it looks like that ux is going all tailwind 😛

Yes, go LASTstack! No, you can actually use all of this without tailwind by using `clm`instead of `twm`but yeah I think you have to architecture your style in a class-utility way. I don't believe in styled-components and emotion because you write css in to string so you don't have proper autocomplete, and  it requires another heavy build step.
norkunas: > @kbond description updated! 😁
> 
> > I'd probably better have something like https://styled-components.com/ or https://emotion.sh/docs/introduction because now it looks like that ux is going all tailwind 😛
> 
> Yes, go LASTstack! No, you can actually use all of this without tailwind by using `clm`instead of `twm`but yeah I think you have to architecture your style in a class-utility way. I don't believe in styled-components and emotion because you write css in to string so you don't have proper autocomplete, and  it requires another heavy build step.

Objects could be allowed instead of string :) no need for build step,as css could be inlined into head
kbond: What about the following syntax:

```twig
{% props color = 'blue', size = 'md' %}

<div{{ attributes.defaults({
    class: cva('alert', {
        'base': 'rounded-lg',
        'variants': {
            'color': {
                'blue': 'text-blue-800 bg-blue-50 dark:bg-gray-800 dark:text-blue-400',
                'red': 'text-red-800 bg-red-50 dark:bg-gray-800 dark:text-red-400',
                'green': 'text-green-800 bg-green-50 dark:bg-gray-800 dark:text-green-400',
                'yellow': 'text-yellow-800 bg-yellow-50 dark:bg-gray-800 dark:text-yellow-400',
            },
            'size': {
                'sm': 'px-4 py-3 text-sm',
                'md': 'px-6 py-4 text-base',
                'lg': 'px-8 py-5 text-lg',
            }
        }
    }).apply(color, size),
})>
    ...
</div>
```

clm/twm functions wouldn't exist but be applied automatically.
WebMamba: I am not sure about this syntax, because inside your div you can have other elements to tag with recipes and things can get really big and confusing. If I made two functions it's because I want the style to be separate from html, to make everything more readable. 
WebMamba: > Objects could be allowed instead of string :) no need for build step,as css could be inlined into head

Actually, the idea is not to make the perfect PR to deal with your component style, but to give utilities to manage your class. Maybe in another PR we can think about CSS in components, but this is not the topic of this PR. And actually don't believe in CSS in components but you can still convince me 😁
norkunas: > Actually, the idea is not to make the perfect PR to deal with your component style, but to give utilities to manage your class. Maybe in another PR we can think about CSS in components, but this is not the topic of this PR.

Yes, sorry, just wanted to bring this in :grin: 

> And actually don't believe in CSS in components but you can still convince me 😁

Why not ? :) if you don't use css frameworks, then it would be nice to have "non detached" css from components :) and that could work for every element not only the components :)
WebMamba: I am playing with the ideas from @CMH-Benny  https://github.com/symfony/ux/pull/1416#discussion_r1465072527 and @kbond 
What do you think of that:
```twig
{% props color = 'blue', size = 'md', class = '' %}

{% set alert =  cva('alert', {
    'base': 'rounded-lg',
    'variants': {
        'color': {
            'blue': 'text-blue-800 bg-blue-50 dark:bg-gray-800 dark:text-blue-400',
            'red': 'text-red-800 bg-red-50 dark:bg-gray-800 dark:text-red-400',
            'green': 'text-green-800 bg-green-50 dark:bg-gray-800 dark:text-green-400',
            'yellow': 'text-yellow-800 bg-yellow-50 dark:bg-gray-800 dark:text-yellow-400',
        },
        'size': {
            'sm': 'px-4 py-3 text-sm',
            'md': 'px-6 py-4 text-base',
            'lg': 'px-8 py-5 text-lg',
        }
    }
}) }} %}

<div class="{{ alert.apply({color, size})|tw_merge }}">
    ...
</div>
```
Kocal: That's really great, I prefer this solution 
CMH-Benny: > I am playing with the ideas from @CMH-Benny [#1416 (comment)](https://github.com/symfony/ux/pull/1416#discussion_r1465072527) and @kbond and @Kocal What do you think of that:
> 
> ```
> {% props color = 'blue', size = 'md', class = '' %}
> 
> {% set alert =  cva('alert', {
>     'base': 'rounded-lg',
>     'variants': {
>         'color': {
>             'blue': 'text-blue-800 bg-blue-50 dark:bg-gray-800 dark:text-blue-400',
>             'red': 'text-red-800 bg-red-50 dark:bg-gray-800 dark:text-red-400',
>             'green': 'text-green-800 bg-green-50 dark:bg-gray-800 dark:text-green-400',
>             'yellow': 'text-yellow-800 bg-yellow-50 dark:bg-gray-800 dark:text-yellow-400',
>         },
>         'size': {
>             'sm': 'px-4 py-3 text-sm',
>             'md': 'px-6 py-4 text-base',
>             'lg': 'px-8 py-5 text-lg',
>         }
>     }
> }) }} %}
> 
> <div class="{{ alert.apply({color, size})|tw_merge }}">
>     ...
> </div>
> ```
I like it!
You know what, if tw_marge becomes a filter instead of a function, you could even leave it out, so people that don't use tailwind don't have that dependency without a choice but if they tailwind, they could install https://github.com/tales-from-a-dev/twig-tailwind-extra It makes it harder to setup, but it would be more modual. Just a thought :) 

What do you think?

I have another question, if i do:
```twig
{{ alert.apply({color, size})|tw_merge }}
```
it will automatically pass it as key value pairs and would `alert.apply({color})` also work if I don't want the size classes?
Kocal: > I am not deep into the twig syntax, to me {} is an assoc array notation and I am not aware if it's similar to JS where you can put a variable `{ x, y, z }` and it will automatically become `{ x: x, y: y, z: z }` - So is: `alert.apply({color}) === alert.apply({color: color})` ?

Yus, like JavaScript `{ color }` expands to `{ color: color }`.
WebMamba: > Also, do you need to pass 'alert' as cva() 1st parameter?

No, it's typo

> t makes it harder to setup, but it would be more modual. Just a thought :) What do you think?

I am agree. 😁

I gonna try do to a new proposal this week-end
weaverryan: Hi!

Great iterating here! If I understand correctly, the currently-agreed-upon version that @WebMamba is going to work on is this:

```twig
{% props color = 'blue', size = 'md' %}

{% set alert =  cva('alert', {
    base: 'rounded-lg',
    variants: {
        color: {
            blue: 'text-blue-800 bg-blue-50 dark:bg-gray-800 dark:text-blue-400',
            red: 'text-red-800 bg-red-50 dark:bg-gray-800 dark:text-red-400',
            green: 'text-green-800 bg-green-50 dark:bg-gray-800 dark:text-green-400',
            yellow: 'text-yellow-800 bg-yellow-50 dark:bg-gray-800 dark:text-yellow-400',
        },
        size: {
            sm: 'px-4 py-3 text-sm',
            md: 'px-6 py-4 text-base',
            lg: 'px-8 py-5 text-lg',
        }
    }
}) }} %}

<div class="{{ alert.apply({color, size})|tw_merge }}">
    ...
</div>
```

(note, I removed all of the `'` in the keys - they're not needed, and this looks better 😉 ).

If so, yes, I like this! But 2 things remain:

A) How can this work with `attributes`? Like this:

```
<div {{ attributes.defaults({
    class: alert.apply({color, size})|tw_merge
}) }}>
```

B) And related, instead of creating a new `tw_merge` function, can we just tell people to use https://github.com/tales-from-a-dev/twig-tailwind-extra? AND, if that package is installed, could we automatically "merge" the `class` attribute on all `ComponentAttributes`? I think this may be tricky from an implementation standpoint, but if it's the best API, we should make it happen.
kbond: > And related, instead of creating a new tw_merge function, can we just tell people to use https://github.com/tales-from-a-dev/twig-tailwind-extra?

+1 to this
WebMamba: Hey! Thanks everyone for all of your ideas! So here is the details of why I did in my last commit:
- no more dependencie on https://github.com/tales-from-a-dev/twig-tailwind-extra
- remove twm function
- remove clm function
- use cva in a setter
Here is how to use CVA now (from the tests implemented in this PR):
```twig
{% props color = 'blue', size = 'md', class = '' %}

{% set alert = cva({
    base: 'alert',
    variants: {
        color: {
            blue: 'alert-blue',
            red: 'alert-red',
            green: 'alert-green',
            yellow: 'alert-yellow',
        },
        size: {
            sm: 'alert-sm',
            md: 'alert-md',
            lg: 'alert-lg',
        }
    },
    compounds: [
        {
            color: ['red'],
            size: ['lg'],
            class: 'font-semibold'
        }
    ]
}) %}

<div {{ attributes.defaults({
    class: alert.apply({color, size}, class)
}) }}>
    ...
</div>
```
Then you can use your component like so:
```twig
<twig:Alert2 color='red' size='lg' class='dark:bg-gray-600'/>
```
And you will get the following result:
```html
<div  class="alert alert-red alert-lg font-semibold dark:bg-gray-600">
    ...
</div>
```

I didn't introduce compounds in my description but the idea is if the condition in the compounds are fullfield then the classes are apply.
CMH-Benny: Looks pretty good to me 👍 

It says it needs review - I am pretty new to follow Prs like this 😅  
Is there anything a rando like me can do to help to move forward or will this be reviewed as soon as somebody from the maintainers finds the time?
smnandre: I need to insist on that point, but if `cva` does not "output" something, it should be either a tag or in this case... a filter

> Most of the time though, a tag is not needed:
    * If your tag generates some output, use a function instead.
    * If your tag modifies some content and returns it, use a filter instead.
    For instance, if you want to create a tag that converts a Markdown formatted text to HTML, create a markdown filter instead: (...)

https://twig.symfony.com/doc/3.x/advanced.html#tags

So it should be

```twig

{% set alert = {
    base: 'alert',
    variants: {
        color: {
            blue: 'alert-blue',
            red: 'alert-red',
            green: 'alert-green',
            yellow: 'alert-yellow',
        },
        size: {
            sm: 'alert-sm',
            md: 'alert-md',
            lg: 'alert-lg',
        }
    },
    compounds: [
        {
            color: ['red'],
            size: ['lg'],
            class: 'font-semibold'
        }
    ]
}|cva %}

```

:) 






CMH-Benny: Somehow I can't answer, so I need to send a reply 🙈 

> I need to insist on that point, but if `cva` does not "output" something, it should be either a tag or in this case... a filter
> 
> > Most of the time though, a tag is not needed:
> > * If your tag generates some output, use a function instead.
> > * If your tag modifies some content and returns it, use a filter instead.
> > For instance, if you want to create a tag that converts a Markdown formatted text to HTML, create a markdown filter instead: (...)
> 
> https://twig.symfony.com/doc/3.x/advanced.html#tags
> 
> So it should be
> 
> ```
> {% set alert = {
>     base: 'alert',
>     variants: {
>         color: {
>             blue: 'alert-blue',
>             red: 'alert-red',
>             green: 'alert-green',
>             yellow: 'alert-yellow',
>         },
>         size: {
>             sm: 'alert-sm',
>             md: 'alert-md',
>             lg: 'alert-lg',
>         }
>     },
>     compounds: [
>         {
>             color: ['red'],
>             size: ['lg'],
>             class: 'font-semibold'
>         }
>     ]
> }|cva %}
> ```
> 
> :)

But it outputs something now, it's a CVA object you call apply on to output the fitting classes based on given properties, so a function now makes sense? Otherwise you would have to do:

```twig
{% props color='blue', size = 'md', class = '' %}
{% set alert = {
     base: 'alert',
     variants: {
         color: {
             blue: 'alert-blue',
             red: 'alert-red',
             green: 'alert-green',
             yellow: 'alert-yellow',
         },
         size: {
             sm: 'alert-sm',
             md: 'alert-md',
             lg: 'alert-lg',
         }
     },
     compounds: [
         {
             color: ['red'],
             size: ['lg'],
             class: 'font-semibold'
         }
     ]
}|cva({color, size}, class) %}
```
smnandre: Alert is an CVA instance in both cases 

```
{# cva function #}
{% set alert = cva({foo: bar}) %}

{# cva filter #}
{% set alert = {foo: bar}|cva %}
```

Those two syntaxes happen to give the same result. But the implementation is not the same, the optimisations neither, and ... it does not follow Twig (or Symfony) conventions.




smnandre: Exactly ! 
Kocal: Yes the first one is more easier to read and review
yguedidi: if it get changed to a function, as it seems the first level of the structure is fixed, what about making them arguments?
```twig
{% set alertCva = cva(
    base='alert',
    variants={
        color: {
            blue: 'alert-blue',
            red: 'alert-red',
            green: 'alert-green',
            yellow: 'alert-yellow',
        },
        size: {
            sm: 'alert-sm',
            md: 'alert-md',
            lg: 'alert-lg',
        }
    },
    compounds=[
        {
            color: ['red'],
            size: ['lg'],
            class: 'font-semibold'
        }
    ]
) %}
```

so, without advanced compound it will look like this:
```twig
{% set alertCva = cva('alert', {
    color: {
        blue: 'alert-blue',
        red: 'alert-red',
        green: 'alert-green',
        yellow: 'alert-yellow',
    },
    size: {
        sm: 'alert-sm',
        md: 'alert-md',
        lg: 'alert-lg',
    }
}) %}
```
smnandre: I understand your opinions on the syntax / readability. 

My point is: this would not be accepted as a function in twig/twig or twig/extra-**, probably neither in symfony/symfony.

We can 100% ignore that.. i'm just making sure it is an informed choice, especially if we later ask for Twig internal changes ;) 
Kocal: > So if it's a function it needs to be used in  `{{ cva(...) }}`
> But since it's only providing an object that needs to get applied() to actually create output, it should become a filter!

Why should it be a filter? Is this an opinionated rule or..? 
CMH-Benny: > > So if it's a function it needs to be used in  `{{ cva(...) }}`
> > But since it's only providing an object that needs to get applied() to actually create output, it should become a filter!
> 
> Why should it be a filter? Is this an opinionated rule or am I missing something...?

As it was pointed out by @smnandre, it seems to be best practice. 

I myself, to be fully honest, don't really care so much about that. I also think the function reads/looks better, but if that breaks some conventions of twig then I am open to adapt to the filter. In the end I am just thankful that @WebMamba opened this PR, I am just looking forward to be able to use CVA in twig to build amazing components, so I am fine with either option, as long as it's merged at some point 😁 
WebMamba: > Since this isn't really twig-component specific (I don't think), should we at least try and get this into twig/html-extra? I know that is easier said than done.

@kbond this is an interesting proposal. I think to get the all potential of this PR is to use it in a laststack, so with TwigComponent installed. So I think in any case we should document this feature in the TwigComponent documentation. TwigComponent doesn't have a dependency on twig/html-extra do you think we should add it then? What do think about a new ux-extra package? 
WebMamba: @smnandre I see your point about the filter but here is not really `transforming` but more `creating` a CVA, so I think using function is still semantically correct.


WebMamba: > if it get changed to a function, as it seems the first level of the structure is fixed, what about making them arguments?

Hummmm I am not sure about this. What do people think?
smnandre: > `creating`

It's not about creating thing.. it's about generating output or not.. :)  Twig "things" that create new objects are filters (today).

Again, i just want you to take the decision with all information. :) 
smnandre: > TwigComponent doesn't have a dependency on twig/html-extra do you think we should add it then? What do think about a new ux-extra package?

I don't feel the absolute necessity TwigComponent is only about the "component" function (or HTML Syntax). 

If this `cva` filt.. function helps manage the style of _components_, it feel to me very legitimate to have it in the `ux-twig-component` package, no ?  


smnandre: > Why should it be a filter? Is this an opinionated rule or am I missing something...?

https://github.com/symfony/ux/pull/1416#issuecomment-1921941431
kbond: > Hummmm I am not sure about this. What do people think?

I think this makes sense but the syntax for named parameters in twig is a bit unfortunate ([that might change in the future though](https://github.com/twigphp/Twig/issues/3635)).
kbond: > TwigComponent doesn't have a dependency on twig/html-extra do you think we should add it then?

I'd actually be in favor of this as the `html_classes()` function from html-extra is quite useful.

> What do think about a new ux-extra package?

I don't think that's necessary. I'm totally fine having it live here for the time being. If it ever was added to html-extra, it'd likely be named `html_cva()` so there wouldn't be a conflict. At that point we could perhaps deprecate the function here.
Kocal: > > Why should it be a filter? Is this an opinionated rule or am I missing something...?
> 
> [#1416 (comment)](https://github.com/symfony/ux/pull/1416#issuecomment-1921941431)

Thanks!

Well I think it depends of how you read it...? 🤔 

I'm more in favor on a function, it takes one (or many, depends of how you implement it) argument, a Twig object which defines base/variants, which returns a new `CVA` object
yguedidi: Just to share some thoughts about the filter vs function thing.

First, let's distinguish between "outputs" and "returns".
The only thing that really "outputs" is the use of `{{ }}`, it's doing the `echo`. We usually don't echo in functions nor filters, but we "return" a value.
Then, both of them deal with data, be it a string, a string-able object or any object.
A filter supports a single input. A function supports 0+ inputs (its arguments).
The forced single input in filters makes them good for "transforming" the data, they are like a funnel.
A function can be called with 0 or more arguments, and can "create" a data from nothing, or based on the arguments (and it could also transform one of its arguments like a filter).

To me, here we are "creating" a CVA object, based on some arguments, that's why I would say the function is a better fit here.
It's like the constructor of a CVA instance.
Being like a constructor to me, I'd not use an array to construct it (aaaah good-old `$options` arrays...) but precise arguments.

voila, hope this helps in the discussion/decision.
WebMamba: I currently traveling for a couple of days but when I am back:
- I think to change to function argument, instead of having this array keys
- I created a PR on twig/html-extra in parallel with this one
- I think we can keep the function and not have a filter I agree with the argument brought by @yguedidi here.

Sounds good for everyone here?
CMH-Benny: Any updates on this? 😅
WebMamba: Hey all! I just pushed https://github.com/symfony/ux/pull/1416#issuecomment-1936354904 the following change, add a line to the change log and some documentation. Cheers 🧡
WebMamba: @weaverryan thanks for your review 🧡 I addressed your comment! Sorry about the syntax mistake, I make this kind of error even in French 😭 
@kbond  do I use the arguments functions the right way? Is it what you have in mind?
CMH-Benny: Amazing progress, I am looking forward to be able to use this feature!
Thank you so much everone! 😄 
kbond: @WebMamba, is the PR description in sync with the current code?

> `{% set alert =  cva('alert', 'rounded-lg',`

The second arg cannot be a string, correct?
kbond: For this example, is both `alert` and `rounded-lg` considered the "base"?

```twig
{% set alert = cva('alert', {
    base: 'rounded-lg',
    variants: {
        color: {
            blue: 'text-blue-800 bg-blue-50 dark:bg-gray-800 dark:text-blue-400',
            red: 'text-red-800 bg-red-50 dark:bg-gray-800 dark:text-red-400',
            green: 'text-green-800 bg-green-50 dark:bg-gray-800 dark:text-green-400',
            yellow: 'text-yellow-800 bg-yellow-50 dark:bg-gray-800 dark:text-yellow-400',
        },
        size: {
            sm: 'px-4 py-3 text-sm',
            md: 'px-6 py-4 text-base',
            lg: 'px-8 py-5 text-lg',
        }
    }
}) %}
```

What about making the only argument to the cva twig function an array so we can use it as quasi-named parameters:

```twig
{% set alert =  cva({
    base: 'alert rounded-lg', {# or ['alert', 'rounded-lg'] #}
    variants: {
        color: {
            blue: 'text-blue-800 bg-blue-50 dark:bg-gray-800 dark:text-blue-400',
            red: 'text-red-800 bg-red-50 dark:bg-gray-800 dark:text-red-400',
            green: 'text-green-800 bg-green-50 dark:bg-gray-800 dark:text-green-400',
            yellow: 'text-yellow-800 bg-yellow-50 dark:bg-gray-800 dark:text-yellow-400',
        },
        size: {
            sm: 'px-4 py-3 text-sm',
            md: 'px-6 py-4 text-base',
            lg: 'px-8 py-5 text-lg',
        }
    }
}) %}
```

Is this intended to be a port of https://cva.style/docs/api-reference? If so, you use the term _compound_ but cva.style has _compoundVariants_ - should we match? Also, cva style has _defaultVariants_?
WebMamba: OK, thanks @kbond! I am on it 
WebMamba: Hey all! I updated the description of the PR but here is what the last commit brings:
So now the CVA function has just one argument a big array key:
- base: to define all the classes that are present in all variations, it's a string
- variants: your different variants
- compoundVariants: let you the ability to apply a set of classes only if a specific set of variants match
- defaultVariants: let you define a default variant to pick if none of the variations match

The function `apply can accept has many arguments you want to give you the ability to add as many sets of class as you want.
And I made some renaming.
Cheers 🧡

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 8453d284..5f3c5cb8 100644
--- a/src/LiveComponent/doc/index.rst
+++ b/src/LiveComponent/doc/index.rst
@@ -96,9 +96,9 @@ needed if you're using AssetMapper):
     $ yarn install --force
     $ yarn watch
 
-If your project is localized in different languages (either via the `_locale route parameter`_
-or by `setting the locale in the request`_) add the ``{ _locale}`` attribute to
-the UX Live Components route definition to keep the locale between re-renders:
+In case your project `localizes its URLs`_ by adding the special
+``{_locale}`` parameter to the paths of its route definitions,
+you need to do the same with the UX Live Components route definition:
 
 .. code-block:: diff
 
@@ -929,9 +929,9 @@ a class:
     <div data-loading="removeClass(opacity-50)">...</div>
 
     <!-- add multiple classes when loading -->
-    <div data-loading="addClass(opacity-50 text-muted)">...</div>
+    <div data-loading="addClass(opacity-50 disabled)">...</div>
 
-Sometimes you may want to add or remove an HTML attribute when loading. That
+Sometimes you may want to add or remove an attribute when loading. That
 can be accomplished with ``addAttribute`` or ``removeAttribute``:
 
 .. code-block:: html+twig
@@ -939,12 +939,6 @@ 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:
 
@@ -1042,21 +1036,14 @@ the work::
         // ...
     }
 
-.. versionadded:: 2.16
-
-    The ``data-live-action-param`` attribute way of specifying the action
-    was added in Live Components 2.16. Previously, this was done with
-    ``data-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
@@ -1071,12 +1058,14 @@ You can also add several "modifiers" to the action:
     <form>
         <button
             data-action="live#action"
-            data-live-action-param="debounce(300)|save"
+            data-action-name="prevent|debounce(300)|save"
         >Save</button>
     </form>
 
-The ``debounce(300)`` adds 300ms of "debouncing" before the action is executed.
-In other words, if you click really fast 5 times, only one Ajax request will be made!
+The ``prevent`` modifier would prevent the form from submitting
+(``event.preventDefault()``). The ``debounce(300)`` modifier will add
+300ms of "debouncing" before the action is executed. In other words, if
+you click really fast 5 times, only one Ajax request will be made!
 
 Actions & Services
 ~~~~~~~~~~~~~~~~~~
@@ -1110,28 +1099,22 @@ This means that, for example, you can use action autowiring::
 Actions & Arguments
 ~~~~~~~~~~~~~~~~~~~
 
-.. versionadded:: 2.16
+.. versionadded:: 2.1
 
-    The ``data-live-{NAME}-param`` attribute way of specifying action
-    arguments was added in Live Components 2.16. Previously, this was done
-    inside the ``data-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
@@ -1152,6 +1135,10 @@ the ``#[LiveArg()]`` attribute::
         }
     }
 
+Normally, the argument name in PHP - e.g. ``$id`` - should match the
+argument name used in Twig ``id={{ item.id }}``. But if they don't
+match, you can pass an argument to ``LiveArg``, like we did with ``itemName``.
+
 Actions and CSRF Protection
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
@@ -1229,11 +1216,10 @@ to handle the files and tell the component when the file should be sent:
 
 .. code-block:: html+twig
 
-    <input type="file" name="my_file" />
-    <button
-        data-action="live#action"
-        data-live-action-param="files|my_action"
-    />
+    <p>
+        <input type="file" name="my_file" />
+        <button data-action="live#action" data-action-name="files|my_action" />
+    </p>
 
 To send a file (or files) with an action use ``files`` modifier.
 Without an argument it will send all pending files to your action.
@@ -1247,11 +1233,11 @@ You can also specify a modifier parameter to choose which files should be upload
         <input type="file" name="multiple[]" multiple />
 
         {# Send only file from first input #}
-        <button data-action="live#action" data-live-action-param="files(my_file)|myAction" />
+        <button data-action="live#action" data-action-name="files(my_file)|myAction" />
         {# You can chain modifiers to send multiple files #}
-        <button data-action="live#action" data-live-action-param="files(my_file)|files(multiple[])|myAction" />
+        <button data-action="live#action" data-action-name="files(my_file)|files(multiple[])|myAction" />
         {# Or send all pending files #}
-        <button data-action="live#action" data-live-action-param="files|myAction" />
+        <button data-action="live#action" data-action-name="files|myAction" />
     </p>
 
 The files will be available in a regular ``$request->files`` files bag::
@@ -1470,8 +1456,8 @@ Next, tell the ``form`` element to use this action:
 
     {{ form_start(form, {
         attr: {
-            'data-action': 'live#action:prevent',
-            'data-live-action-param': 'save'
+            'data-action': 'live#action',
+            'data-action-name': 'prevent|save'
         }
     }) }}
 
@@ -1771,8 +1757,7 @@ and ``removeComment()`` actions:
             {% for key, commentForm in form.comments %}
                 <button
                     data-action="live#action"
-                    data-live-action-param="removeComment"
-                    data-live-index-param="{{ key }}"
+                    data-action-name="removeComment(index={{ key }})"
                     type="button"
                 >X</button>
 
@@ -1784,7 +1769,7 @@ and ``removeComment()`` actions:
 
             <button
                 data-action="live#action"
-                data-live-action-param="addComment"
+                data-action-name="addComment"
                 type="button"
             >+ Add Comment</button>
 
@@ -1946,7 +1931,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) }}
@@ -2187,8 +2172,8 @@ re-rendered. In your template, render errors using an ``_errors`` variable:
 
     <button
         type="submit"
-        data-action="live#action:prevent"
-        data-live-action-param="save"
+        data-action="live#action"
+        data-action-name="prevent|save"
     >Save</button>
 
 Once a component has been validated, the component will "remember" that
@@ -2248,12 +2233,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
@@ -2265,82 +2246,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
@@ -2527,18 +2447,13 @@ Emitting an Event
 
 There are three ways to emit an event:
 
-.. versionchanged:: 2.16
-
-    The ``data-live-event-param`` attribute was added in Live Components 2.16.
-    Previously, it was called ``data-event``.
-
 1. From Twig:
 
    .. code-block:: html+twig
 
        <button
            data-action="live#emit"
-           data-live-event-param="productAdded"
+           data-event="productAdded"
        >
 
 2. From your PHP component via ``ComponentToolsTrait``::
@@ -2635,7 +2550,7 @@ If you want to emit an event to only the parent components, use the
 
     <button
         data-action="live#emitUp"
-        data-live-event-param="productAdded"
+        data-event="productAdded"
     >
 
 Or, in PHP::
@@ -2652,7 +2567,7 @@ use the ``name()`` modifier:
 
     <button
         data-action="live#emit"
-        data-live-event-param="name(ProductList)|productAdded"
+        data-event="name(ProductList)|productAdded"
     >
 
 Or, in PHP::
@@ -2668,7 +2583,7 @@ To emit an event to only yourself, use the ``emitSelf()`` method:
 
     <button
         data-action="live#emitSelf"
-        data-live-event-param="productAdded"
+        data-event="productAdded"
     >
 
 Or, in PHP::
@@ -2851,7 +2766,7 @@ suppose your child component has:
 
 .. code-block:: html
 
-    <button data-action="live#action" data-live-action-param="save">Save</button>
+    <button data-action="live#action" data-action-name="save">Save</button>
 
 When the user clicks that button, it will attempt to call the ``save``
 action in the *child* component only, even if the ``save`` action
@@ -3015,7 +2930,7 @@ In the ``EditPost`` template, you render the
 
             <button
                 data-action="live#action"
-                data-live-action-param="save"
+                data-action-name="save"
             >Save</button>
         </form>
     </div>
@@ -3541,6 +3456,7 @@ bound to Symfony's BC policy for the moment.
 .. _`experimental`: https://symfony.com/doc/current/contributing/code/experimental.html
 .. _`dependent form fields`: https://ux.symfony.com/live-component/demos/dependent-form-fields
 .. _StimulusBundle configured in your app: https://symfony.com/bundles/StimulusBundle/current/index.html
+.. _`localizes its URLs`: https://symfony.com/doc/current/translation/locale.html#translation-locale-url
 .. _`attributes variable`: https://symfony.com/bundles/ux-twig-component/current/index.html#component-attributes
 .. _`CollectionType`: https://symfony.com/doc/current/form/form_collections.html
 .. _`the traditional collection type`: https://symfony.com/doc/current/form/form_themes.html#fragment-naming-for-collections
@@ -3551,6 +3467,3 @@ bound to Symfony's BC policy for the moment.
 .. _`PostMount hook`: https://symfony.com/bundles/ux-twig-component/current/index.html#postmount-hook
 .. _`validation groups`: https://symfony.com/doc/current/form/validation_groups.html
 .. _morphing library: https://github.com/bigskysoftware/idiomorph
-.. _`_locale route parameter`: https://symfony.com/doc/current/translation.html#the-locale-and-the-url
-.. _`setting the locale in the request`: https://symfony.com/doc/current/translation.html#translation-locale
-.. _`Stimulus action parameter`: https://stimulus.hotwired.dev/reference/actions#action-parameters
diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
index 06e61ea9..8683214a 100644
--- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
+++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
@@ -253,7 +253,7 @@ final class LiveComponentExtension extends Extension implements PrependExtension
         ;
 
         $container->register('ux.live_component.twig.template_mapper', TemplateMap::class)
-            ->setArguments(['%kernel.build_dir%/'.self::TEMPLATES_MAP_FILENAME]);
+            ->setArguments(['%kernel.cache_dir%/'.self::TEMPLATES_MAP_FILENAME]);
 
         $container->register('ux.live_component.twig.cache_warmer', TemplateCacheWarmer::class)
             ->setArguments([
diff --git a/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/DeferLiveComponentSubscriber.php
index 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/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md
index f3cc7362..c3d52468 100644
--- a/src/TwigComponent/CHANGELOG.md
+++ b/src/TwigComponent/CHANGELOG.md
@@ -5,12 +5,14 @@
 -   Add the ability to render specific attributes from the `attributes` variable #1442
 -   Restrict Twig 3.9 for now #1486
 -   Build reproducible TemplateMap to fix possible post-deploy breakage #1497
+-   Add CVA (Class variant authority) integration #1416
 
 ## 2.14.0
 
 -   Make `ComponentAttributes` traversable/countable
 -   Fixed lexing some `{# twig comments #}` with HTML Twig syntax
 -   Fix various usages of deprecated Twig code
+-   Add attribute rendering system
 
 ## 2.13.0
 
diff --git a/src/TwigComponent/doc/index.rst b/src/TwigComponent/doc/index.rst
index 45e3d00a..cc439c95 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
@@ -1058,6 +1058,198 @@ Exclude specific attributes:
       My Component!
     </div>
 
+Component with Complex Variants (CVA)
+-------------------------------------
+
+CVA (Class Variant Authority) is a concept from the JS world (https://cva.style/docs/getting-started/variants).
+It's a concept used by the famous shadcn/ui library (https://ui.shadcn.com).
+CVA allows you to display a component with different variants (color, size, etc.),
+to create highly reusable and customizable components.
+You can use the cva function to define variants for your component.
+The cva function take as argument an array key-value pairs.
+The base key allow you define a set of classes commune to all variants.
+In the variants key you define the different variants of your component.
+
+.. code-block:: html+twig
+
+    {# templates/components/Alert.html.twig #}
+    {% props color = 'blue', size = 'md' %}
+
+     {% set alert = cva({
+        base: 'alert ',
+        variants: {
+            color: {
+                blue: 'bg-blue',
+                red: 'bg-red',
+                green: 'bg-green',
+            },
+            size: {
+                sm: 'text-sm',
+                md: 'text-md',
+                lg: 'text-lg',
+            }
+        }
+    }) %}
+
+    <div class="{{ alert.apply({color, size}, attributes.render('class')) }}">
+         {% block content %}{% endblock %}
+    </div>
+
+
+    {# index.html.twig #}
+
+    <twig:Alert color="red" size="lg">
+        <div>My content</div>
+    </twig:Alert>
+    // class="alert bg-red text-lg"
+
+    <twig:Alert color="green" size="sm">
+        <div>My content</div>
+    </twig:Alert>
+    // class="alert bg-green text-sm"
+
+    <twig:Alert class="flex items-center justify-center">
+        <div>My content</div>
+    </twig:Alert>
+    // class="alert bg-blue text-md flex items-center justify-center"
+
+CVA and Tailwind CSS
+~~~~~~~~~~~~~~~~~~~~
+
+CVA work perfectly with tailwindcss. The only drawback is you can have class conflicts,
+to have a better control you can use this following bundle (
+https://github.com/tales-from-a-dev/twig-tailwind-extra
+) in addition to the cva function:
+
+.. code-block:: terminal
+
+    $ composer require tales-from-a-dev/twig-tailwind-extra
+
+.. code-block:: html+twig
+
+    {# templates/components/Alert.html.twig #}
+    {% props color = 'blue', size = 'md' %}
+
+   {% set alert = cva({
+        base: 'alert ',
+        variants: {
+            color: {
+                blue: 'bg-blue',
+                red: 'bg-red',
+                green: 'bg-green',
+            },
+            size: {
+                sm: 'text-sm',
+                md: 'text-md',
+                lg: 'text-lg',
+            }
+        }
+    }) %}
+
+    <div class="{{ alert.apply({color, size}, attributes.render('class')) | tailwind_merge }}">
+         {% block content %}{% endblock %}
+    </div>
+
+Compounds variants
+~~~~~~~~~~~~~~~~~~
+
+You can define compound variants. A compound variant is a variants that apply
+when multiple other variant conditions are met.
+
+.. code-block:: html+twig
+
+    {# templates/components/Alert.html.twig #}
+    {% props color = 'blue', size = 'md' %}
+
+    {% set alert = cva({
+        base: 'alert ',
+        variants: {
+            color: {
+                blue: 'bg-blue',
+                red: 'bg-red',
+                green: 'bg-green',
+            },
+            size: {
+                sm: 'text-sm',
+                md: 'text-md',
+                lg: 'text-lg',
+            }
+        },
+        compound: {
+            colors: ['red'],
+            size: ['md', 'lg'],
+            class: 'font-bold'
+        }
+    }) %}
+
+    <div class="{{ alert.apply({color, size}) }}">
+         {% block content %}{% endblock %}
+    </div>
+
+    {# index.html.twig #}
+
+    <twig:Alert color="red" size="lg">
+        <div>My content</div>
+    </twig:Alert>
+    // class="alert bg-red text-lg font-bold"
+
+    <twig:Alert color="green" size="sm">
+        <div>My content</div>
+    </twig:Alert>
+    // class="alert bg-green text-sm"
+
+    <twig:Alert color="red" size="md">
+        <div>My content</div>
+    </twig:Alert>
+    // class="alert bg-green text-lg font-bold"
+
+Default variants
+~~~~~~~~~~~~~~~~
+
+You can define defaults variants, so if no variants are matching you
+can still defined a default set of class to apply.
+
+.. code-block:: html+twig
+
+    {# templates/components/Alert.html.twig #}
+    {% props color = 'blue', size = 'md' %}
+
+    {% set alert = cva({
+        base: 'alert ',
+        variants: {
+            color: {
+                blue: 'bg-blue',
+                red: 'bg-red',
+                green: 'bg-green',
+            },
+            size: {
+                sm: 'text-sm',
+                md: 'text-md',
+                lg: 'text-lg',
+            },
+            rounded: {
+                sm: 'rounded-sm',
+                md: 'rounded-md',
+                lg: 'rounded-lg',
+            }
+        },
+        defaultsVariants: {
+            rounded: 'rounded-md',
+        }
+    }) %}
+
+    <div class="{{ alert.apply({color, size}) }}">
+         {% block content %}{% endblock %}
+    </div>
+
+    {# index.html.twig #}
+
+    <twig:Alert color="red" size="lg">
+        <div>My content</div>
+    </twig:Alert>
+    // class="alert bg-red text-lg font-bold rounded-md"
+
+
 Test Helpers
 ------------
 
diff --git a/src/TwigComponent/src/CVA.php b/src/TwigComponent/src/CVA.php
new file mode 100644
index 00000000..50019a01
--- /dev/null
+++ b/src/TwigComponent/src/CVA.php
@@ -0,0 +1,102 @@
+<?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 Symfony\UX\TwigComponent;
+
+/**
+ * @author Mathéo Daninos <matheo.daninos@gmail.com>
+ *
+ * CVA (class variant authority), is a concept from the js world.
+ * https://cva.style/docs
+ * The UI library shadcn is build on top of this principle
+ * https://ui.shadcn.com
+ * The concept behind CVA is to let you build component with a lot of different variations called recipes.
+ *
+ * @experimental
+ */
+final class CVA
+{
+    /**
+     * @var string|list<string|null>|null
+     * @var array<string, array<string, string>>|null the array should have the following format [variantCategory => [variantName => classes]]
+     *                                                ex: ['colors' => ['primary' => 'bleu-8000', 'danger' => 'red-800 text-bold'], 'size' => [...]]
+     * @var array<array<string, string[]>>|null       the array should have the following format ['variantsCategory' => ['variantName', 'variantName'], 'class' => 'text-red-500']
+     * @var array<string, string>|null
+     */
+    public function __construct(
+        private string|array|null $base = null,
+        private ?array $variants = null,
+        private ?array $compoundVariants = null,
+        private ?array $defaultVariants = null,
+    ) {
+    }
+
+    public function apply(array $recipes, string ...$classes): string
+    {
+        return trim($this->resolve($recipes).' '.implode(' ', $classes));
+    }
+
+    public function resolve(array $recipes): string
+    {
+        if (\is_array($this->base)) {
+            $classes = implode(' ', $this->base);
+        } else {
+            $classes = $this->base ?? '';
+        }
+
+        foreach ($recipes as $recipeName => $recipeValue) {
+            if (!isset($this->variants[$recipeName][$recipeValue])) {
+                continue;
+            }
+
+            $classes .= ' '.$this->variants[$recipeName][$recipeValue];
+        }
+
+        if (null !== $this->compoundVariants) {
+            foreach ($this->compoundVariants as $compound) {
+                $isCompound = true;
+                foreach ($compound as $compoundName => $compoundValues) {
+                    if ('class' === $compoundName) {
+                        continue;
+                    }
+
+                    if (!isset($recipes[$compoundName])) {
+                        $isCompound = false;
+                        break;
+                    }
+
+                    if (!\in_array($recipes[$compoundName], $compoundValues)) {
+                        $isCompound = false;
+                        break;
+                    }
+                }
+
+                if ($isCompound) {
+                    if (!isset($compound['class']) || !\is_string($compound['class'])) {
+                        throw new \LogicException('A compound recipe matched but no classes are registered for this match');
+                    }
+
+                    $classes .= ' '.$compound['class'];
+                }
+            }
+        }
+
+        if (null !== $this->defaultVariants) {
+            foreach ($this->defaultVariants as $defaultVariantName => $defaultVariantValue) {
+                if (!isset($recipes[$defaultVariantName])) {
+                    $classes .= ' '.$this->variants[$defaultVariantName][$defaultVariantValue];
+                }
+            }
+        }
+
+        return trim($classes);
+    }
+}
diff --git a/src/TwigComponent/src/Twig/ComponentExtension.php b/src/TwigComponent/src/Twig/ComponentExtension.php
index 1df860e9..351b5b49 100644
--- a/src/TwigComponent/src/Twig/ComponentExtension.php
+++ b/src/TwigComponent/src/Twig/ComponentExtension.php
@@ -14,6 +14,7 @@ namespace Symfony\UX\TwigComponent\Twig;
 use Psr\Container\ContainerInterface;
 use Symfony\Contracts\Service\ServiceSubscriberInterface;
 use Symfony\UX\TwigComponent\ComponentRenderer;
+use Symfony\UX\TwigComponent\CVA;
 use Symfony\UX\TwigComponent\Event\PreRenderEvent;
 use Twig\Error\RuntimeError;
 use Twig\Extension\AbstractExtension;
@@ -41,6 +42,7 @@ final class ComponentExtension extends AbstractExtension implements ServiceSubsc
     {
         return [
             new TwigFunction('component', [$this, 'render'], ['is_safe' => ['all']]),
+            new TwigFunction('cva', [$this, 'cva']),
         ];
     }
 
@@ -84,6 +86,29 @@ final class ComponentExtension extends AbstractExtension implements ServiceSubsc
         $this->container->get(ComponentRenderer::class)->finishEmbeddedComponentRender();
     }
 
+    /**
+     * @param array{
+     *     base: string|string[]|null,
+     *     variants: array<string, array<string, string>>,
+     *     compoundVariants: array<array<string, string>>,
+     *     defaultVariants: array<string, string>
+     *  } $cva
+     *
+     * base some base class you want to have in every matching recipes
+     * variants your recipes class
+     * compoundVariants compounds allow you to add extra class when multiple variation are matching in the same time
+     * defaultVariants allow you to add a default class when no recipe is matching
+     */
+    public function cva(array $cva): CVA
+    {
+        return new CVA(
+            $cva['base'] ?? null,
+            $cva['variants'] ?? null,
+            $cva['compoundVariants'] ?? null,
+            $cva['defaultVariants'] ?? null,
+        );
+    }
+
     private function throwRuntimeError(string $name, \Throwable $e): void
     {
         // if it's already a Twig RuntimeError, just rethrow it
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/mercure/.platform.app.yaml b/ux.symfony.com/mercure/.platform.app.yaml
index 3e95d9b8..23ccac7e 100644
--- a/ux.symfony.com/mercure/.platform.app.yaml
+++ b/ux.symfony.com/mercure/.platform.app.yaml
@@ -7,7 +7,7 @@ build:
 
 variables:
     env:
-        MERCURE_TRANSPORT_URL: bolt:///app/.local/mercure.db?size=1000&cleanup_frequency=0.5
+        MERCURE_TRANSPORT_URL: bolt:///app/.local/database.db
         # Defined as platform.sh vars
         # MERCURE_PUBLISHER_JWT_KEY: ''
         # MERCURE_SUBSCRIBER_JWT_KEY: ''
@@ -17,7 +17,7 @@ web:
         socket_family: tcp
         protocol: http
     commands:
-        start: SERVER_NAME=":${PORT}" ./mercure run --config Caddyfile.dev
+        start: SERVER_NAME=":${PORT}" ./mercure run -config Caddyfile.dev
     locations:
         /:
             allow: false
@@ -33,4 +33,4 @@ hooks:
         set -e -x
 
         curl -s https://get.symfony.com/cloud/configurator | (>&2 bash)
-        curl -s -H 'Accept: application/octet-stream' -J -L "https://github.com/dunglas/mercure/releases/download/v0.15.9/mercure_Linux_x86_64.tar.gz" | tar -C /app/ -zxpf -
+        curl -s -H 'Accept: application/octet-stream' -J -L "https://github.com/dunglas/mercure/releases/download/v0.13.0/mercure_0.13.0_Linux_x86_64.tar.gz" | tar -C /app/ -zxpf -
diff --git a/ux.symfony.com/src/LiveMemory/Asset/CssCompiler.php b/ux.symfony.com/src/LiveMemory/Asset/CssCompiler.php
deleted file mode 100644
index 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/Fixtures/templates/class_merge.html.twig b/src/TwigComponent/tests/Fixtures/templates/class_merge.html.twig
new file mode 100644
index 00000000..588f63e4
--- /dev/null
+++ b/src/TwigComponent/tests/Fixtures/templates/class_merge.html.twig
@@ -0,0 +1,1 @@
+<twig:Alert color='red' size='lg' class='dark:bg-gray-600'/>
\ No newline at end of file
diff --git a/src/TwigComponent/tests/Fixtures/templates/components/Alert.html.twig b/src/TwigComponent/tests/Fixtures/templates/components/Alert.html.twig
new file mode 100644
index 00000000..e88a5795
--- /dev/null
+++ b/src/TwigComponent/tests/Fixtures/templates/components/Alert.html.twig
@@ -0,0 +1,35 @@
+{% props color = 'blue', size = 'md' %}
+
+{% set alert = cva({
+        base: ['alert'],
+        variants: {
+            color: {
+                blue: 'alert-blue',
+                red: 'alert-red',
+                green: 'alert-green',
+                yellow: 'alert-yellow',
+            },
+            size: {
+                sm: 'alert-sm',
+                md: 'alert-md',
+                lg: 'alert-lg',
+            },
+            rounded: {
+                sm: 'rounded-sm',
+                md: 'rounded-md',
+                lg: 'rounded-lg',
+            }
+        },
+        compoundVariants: [{
+            color: ['red'],
+            size: ['lg'],
+            class: 'font-semibold'
+        }],
+        defaultVariants: {
+            rounded: 'md'
+        }
+}) %}
+
+<div class="{{ alert.apply({color, size}, attributes.render('class'), 'flex p-4') }}">
+    ...
+</div>
\ No newline at end of file
diff --git a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php
index aa61825e..a6933710 100644
--- a/src/TwigComponent/tests/Integration/ComponentExtensionTest.php
+++ b/src/TwigComponent/tests/Integration/ComponentExtensionTest.php
@@ -256,6 +256,13 @@ final class ComponentExtensionTest extends KernelTestCase
         ];
     }
 
+    public function testComponentWithClassMerge(): void
+    {
+        $output = self::getContainer()->get(Environment::class)->render('class_merge.html.twig');
+
+        $this->assertStringContainsString('class="alert alert-red alert-lg font-semibold rounded-md dark:bg-gray-600 flex p-4"', $output);
+    }
+
     private function renderComponent(string $name, array $data = []): string
     {
         return self::getContainer()->get(Environment::class)->render('render_component.html.twig', [
diff --git a/src/TwigComponent/tests/Unit/CVATest.php b/src/TwigComponent/tests/Unit/CVATest.php
new file mode 100644
index 00000000..49d105cc
--- /dev/null
+++ b/src/TwigComponent/tests/Unit/CVATest.php
@@ -0,0 +1,339 @@
+<?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 Symfony\UX\TwigComponent\Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\TwigComponent\CVA;
+
+/**
+ * @author Mathéo Daninos <matheo.daninos@gmail.com>
+ */
+class CVATest extends TestCase
+{
+    /**
+     * @dataProvider recipeProvider
+     */
+    public function testRecipes(array $recipe, array $recipes, string $expected): void
+    {
+        $recipeClass = new CVA($recipe['base'] ?? '', $recipe['variants'] ?? [], $recipe['compounds'] ?? [], $recipe['defaultVariants'] ?? []);
+
+        $this->assertEquals($expected, $recipeClass->resolve($recipes));
+    }
+
+    public static function recipeProvider(): iterable
+    {
+        yield 'base null' => [
+            ['variants' => [
+                'colors' => [
+                    'primary' => 'text-primary',
+                    'secondary' => 'text-secondary',
+                ],
+                'sizes' => [
+                    'sm' => 'text-sm',
+                    'md' => 'text-md',
+                    'lg' => 'text-lg',
+                ],
+            ]],
+            ['colors' => 'primary', 'sizes' => 'sm'],
+            'text-primary text-sm',
+        ];
+
+        yield 'base empty' => [
+            [
+                'base' => '',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                ]],
+            ['colors' => 'primary', 'sizes' => 'sm'],
+            'text-primary text-sm',
+        ];
+
+        yield 'no recipes match' => [
+            [
+                'base' => 'font-semibold border rounded',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                ],
+            ],
+            ['colors' => 'red', 'sizes' => 'test'],
+            'font-semibold border rounded',
+        ];
+
+        yield 'simple variants' => [
+            [
+                'base' => 'font-semibold border rounded',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                ],
+            ],
+            ['colors' => 'primary', 'sizes' => 'sm'],
+            'font-semibold border rounded text-primary text-sm',
+        ];
+
+        yield 'simple variants with custom' => [
+            [
+                'base' => 'font-semibold border rounded',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                ],
+            ],
+            ['colors' => 'secondary', 'sizes' => 'md'],
+            'font-semibold border rounded text-secondary text-md',
+        ];
+
+        yield 'compound variants' => [
+            [
+                'base' => 'font-semibold border rounded',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                ],
+                'compounds' => [
+                    [
+                        'colors' => ['primary'],
+                        'sizes' => ['sm'],
+                        'class' => 'text-red-500',
+                    ],
+                ],
+            ],
+            ['colors' => 'primary', 'sizes' => 'sm'],
+            'font-semibold border rounded text-primary text-sm text-red-500',
+        ];
+
+        yield 'multiple compound variants' => [
+            [
+                'base' => 'font-semibold border rounded',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                ],
+                'compounds' => [
+                    [
+                        'colors' => ['primary'],
+                        'sizes' => ['sm'],
+                        'class' => 'text-red-500',
+                    ],
+                    [
+                        'colors' => ['primary'],
+                        'sizes' => ['md'],
+                        'class' => 'text-blue-500',
+                    ],
+                ],
+            ],
+            ['colors' => 'primary', 'sizes' => 'sm'],
+            'font-semibold border rounded text-primary text-sm text-red-500',
+        ];
+
+        yield 'compound with multiple variants' => [
+            [
+                'base' => 'font-semibold border rounded',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                ],
+                'compounds' => [
+                    [
+                        'colors' => ['primary', 'secondary'],
+                        'sizes' => ['sm'],
+                        'class' => 'text-red-500',
+                    ],
+                ],
+            ],
+            ['colors' => 'primary', 'sizes' => 'sm'],
+            'font-semibold border rounded text-primary text-sm text-red-500',
+        ];
+
+        yield 'compound doesn\'t match' => [
+            [
+                'base' => 'font-semibold border rounded',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                ],
+                'compounds' => [
+                    [
+                        'colors' => ['danger', 'secondary'],
+                        'sizes' => ['sm'],
+                        'class' => 'text-red-500',
+                    ],
+                ],
+            ],
+            ['colors' => 'primary', 'sizes' => 'sm'],
+            'font-semibold border rounded text-primary text-sm',
+        ];
+        yield 'default variables' => [
+            [
+                'base' => 'font-semibold border rounded',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                    'rounded' => [
+                        'sm' => 'rounded-sm',
+                        'md' => 'rounded-md',
+                        'lg' => 'rounded-lg',
+                    ],
+                ],
+                'compounds' => [
+                    [
+                        'colors' => ['danger', 'secondary'],
+                        'sizes' => ['sm'],
+                        'class' => 'text-red-500',
+                    ],
+                ],
+                'defaultVariants' => [
+                    'colors' => 'primary',
+                    'sizes' => 'sm',
+                    'rounded' => 'md',
+                ],
+            ],
+            ['colors' => 'primary', 'sizes' => 'sm'],
+            'font-semibold border rounded text-primary text-sm rounded-md',
+        ];
+        yield 'default variables all overwrite' => [
+            [
+                'base' => 'font-semibold border rounded',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                    'rounded' => [
+                        'sm' => 'rounded-sm',
+                        'md' => 'rounded-md',
+                        'lg' => 'rounded-lg',
+                    ],
+                ],
+                'compounds' => [
+                    [
+                        'colors' => ['danger', 'secondary'],
+                        'sizes' => ['sm'],
+                        'class' => 'text-red-500',
+                    ],
+                ],
+                'defaultVariants' => [
+                    'colors' => 'primary',
+                    'sizes' => 'sm',
+                    'rounded' => 'md',
+                ],
+            ],
+            ['colors' => 'primary', 'sizes' => 'sm', 'rounded' => 'lg'],
+            'font-semibold border rounded text-primary text-sm rounded-lg',
+        ];
+        yield 'default variables without matching variants' => [
+            [
+                'base' => 'font-semibold border rounded',
+                'variants' => [
+                    'colors' => [
+                        'primary' => 'text-primary',
+                        'secondary' => 'text-secondary',
+                    ],
+                    'sizes' => [
+                        'sm' => 'text-sm',
+                        'md' => 'text-md',
+                        'lg' => 'text-lg',
+                    ],
+                    'rounded' => [
+                        'sm' => 'rounded-sm',
+                        'md' => 'rounded-md',
+                        'lg' => 'rounded-lg',
+                    ],
+                ],
+                'compounds' => [
+                    [
+                        'colors' => ['danger', 'secondary'],
+                        'sizes' => ['sm'],
+                        'class' => 'text-red-500',
+                    ],
+                ],
+                'defaultVariants' => [
+                    'colors' => 'primary',
+                    'sizes' => 'sm',
+                    'rounded' => 'md',
+                ],
+            ],
+            [],
+            'font-semibold border rounded text-primary text-sm rounded-md',
+        ];
+    }
+}