Generate, modify, or review Symfony UX Toolkit kit recipes (Shadcn, Flowbite, future kits). Enforces conventions for manifest, Twig component docblocks, sub-components, asChild `<recipe>_<role>_attrs` pattern, outer-scope propagation, Stimulus controllers, examples, snapshots, and PR hygiene. Use when adding/editing files under `src/Toolkit/kits/` or reviewing PRs touching the Toolkit.
Symfony UX Toolkit — Kit Recipe Skill
Author + review recipes for UX Toolkit. Recipes = unit shipped to end-users (Twig components + optional Stimulus controllers + examples).
When to Activate
User says "add recipe", "new kit recipe", "Toolkit component", "port shadcn X", or similar.
Any file change under src/Toolkit/kits/<kit>/<recipe>/.
Reviewing PR titled [Toolkit][...].
Core Rules
One PR per recipe. Never batch multiple recipes single PR. PR title:
[Toolkit][<Kit>] Add <recipe> recipe or [Toolkit][<Kit>] Align <recipe> with <upstream> reference.
Target 3.x. CHANGELOG entry under active 3.x section in src/Toolkit/CHANGELOG.md.
Visual + behavioral parity with upstream reference (Shadcn UI / Flowbite). Verify manually; attach screenshot/video to PR body for animated/interactive components.
Reuse all upstream examples. No subset. Read both component source and every example file (see Shadcn UI / Flowbite v4).
Companion PR on symfony/ux.symfony.com for visual preview/docs. Link in recipe PR body.
Regenerate snapshots after every recipe change + commit. CI + reviewers reject stale snapshots.
Use GitHub PR template (Bug fix / Feature / License: MIT / Issues: Part of #3233). Fabbot fails otherwise.
Prefer Stimulus controller over native browser features (e.g. <details>) when parity needs animations, ARIA sync, coordinated state. Native fine only when matches upstream UX exactly.
Recipe Directory Layout
src/Toolkit/kits/<kit>/<recipe>/
├── manifest.json
├── examples/
│ ├── Usage.html.twig # mandatory, minimal API showcase
│ ├── Demo.html.twig # mandatory, rich preview (used on ux.symfony.com)
│ └── <Variant Name>.html.twig # one per upstream example, Title Case with spaces
├── templates/components/
│ ├── <Component>.html.twig # root component
│ └── <Component>/<SubName>.html.twig # e.g. Trigger, Close, Header, Item, Content
└── assets/controllers/ # optional, only if interactive behavior is needed
└── <recipe>_controller.js
Sub-component file path Component/SubName.html.twig consumed as <twig:Component:SubName>.
Shadcn UI
Always emit data-slot="<recipe-name>" on root + data-slot="<recipe-name>-<sub>" on every sub-component root. Shadcn-specific convention driven by its CSS selectors.
Upstream sources
Read both files per recipe: component source carries canonical classes + data-* surface; examples show usage patterns.
Flowbite docs page = primary source: ships copy-pasteable HTML with Tailwind classes + lists every variant. Read full page before writing any template.
Local Visual Testing
ux and ux.symfony.commust be on matching branches; mismatch causes assetmap failures:
The asset "./vendor/symfony/ux-toolkit/kits///assets/controllers/_controller.js" cannot be found in any asset map paths.
cd /path/to/ux && git checkout feat/toolkit-<kit>-<recipe>
cd /path/to/ux.symfony.com && git checkout docs/<kit>-<recipe>
# In ux.symfony.com:
php ../link
symfony php bin/console tailwind:build
symfony serve -d
Companion PR on ux.symfony.com
Every recipe PR needs companion PR on symfony/ux.symfony.com. Contents:
{"$schema":"../../../schema-kit-recipe-v1.json","type":"component","name":"<Human Name>","description":"<short, ends with a period>","copy-files":{"assets/":"assets/","templates/":"templates/"},"dependencies":{"composer":["twig/extra-bundle","twig/html-extra:^3.12.0","tales-from-a-dev/twig-tailwind-extra:^1.0.0"],"recipe":["<other-recipe>"]}}
Rules:
Drop assets/ from copy-files if no Stimulus controller.
Add "symfony/ux-icons" to composer whenever templates use <twig:ux:icon>.
Bump twig/html-extra constraint when using newer filters (e.g. ^3.24.0 for current html_attr_type).
Declare dependencies.recipe for inter-recipe deps (e.g. toggle-group depends on toggle).
Twig Component Patterns
1. Header docblock (mandatory)
Every component starts with one {# @prop ... #} per declared prop + one {# @block content ... #}:
{# @prop id string Unique identifier used to generate internal Dialog IDs #}
{# @prop open boolean Whether the dialog is open on initial render. Defaults to `false` #}
{# @block content The dialog structure, typically includes `Dialog:Trigger` and `Dialog:Content` #}
{%- props id, open = false -%}
Type uses Twig/PHP-flavored union syntax: 'default'|'secondary'|..., string|array<string>|null, boolean, number.
"Defaults to ``" when default exists.
Reference sub-components by Twig tag name (\Dialog:Trigger``).
Preferred: provide() / inject() (needs symfony/ux-twig-component:^3.1). Parent publishes values, any descendant at any depth reads them. Works for self-closing children, crosses intermediate components without forwarding, replaces brittle outer-scope pattern.
Local variable name for injected values: _<camelCaseRecipe>_<key> (e.g. _tabs_defaultValue, _toggleGroup_variant). The _ prefix + recipe name prevents collision with the child's own props or Twig globals.
Always pass fallback to inject() when child can render standalone.
Place provide() at top of parent template, before{% block content %} — descendants only see values published before their render.
Keys for ID-driven a11y wiring: derive <recipe>.id, <recipe>.titleId, <recipe>.descriptionId, <recipe>.contentId, <recipe>.triggerId from parent's id prop.
Values flow top-down only; siblings never share state; provides dropped once parent finishes rendering.
Legacy: outer-scope _<recipe>_<key> variables. Older recipes use {%- set _dialog_title_id = ... -%} read by children with ?? fallback. Still works for body-form children but breaks for self-closing components (<twig:X:Item .../> compiles without outer context). Migrate to provide()/inject() when touching such recipes.
5. The <recipe>_<role>_attrs (asChild) pattern
Sub-templates like Trigger.html.twig, Close.html.twig, Cancel.html.twig MUST NOT wrap user's element in own <button>. Instead expose attrs bag consumer spreads onto own element:
{# templates/components/Dialog/Trigger.html.twig #}
{# @block content The trigger element (e.g., a `Button`) that opens the dialog when clicked #}
{%- set dialog_trigger_attrs = {
'data-action': 'click->dialog#open'|html_attr_type('sst'),
'data-dialog-target': 'trigger',
'aria-haspopup': 'dialog',
} -%}
{%- block content %}{% endblock -%}
{# example consumer #}
<twig:Dialog:Trigger>
<twig:Button {{ ...dialog_trigger_attrs }}>Open</twig:Button>
</twig:Dialog:Trigger>
Apply |html_attr_type('sst') to data-action. 'sst' = Stimulus Shorthand Token — marks value appendable, so consumer spreading {{ ...dialog_trigger_attrs }} alongside own data-action gets both merged rather than first overwritten.
Template body = {%- block content %}{% endblock -%} only — no wrapping element, otherwise variable not visible to outer scope.
Variant (when wrapping known component acceptable, e.g. AlertDialog:Action):
Pipe through |html_attr_type('sst') when exposing via <recipe>_<role>_attrs so consumers can append own actions.
Hover/focus-triggered components — never use group-hover + group-focus-within + tabindex=0; use Stimulus controller with openDelay/closeDelay values instead (see anti-patterns).
Nested open-state — never use in-data-[state=open]:visible on nested components; use named Tailwind groups (group/<recipe>-menu, group/<recipe>-sub) instead (see anti-patterns).
Examples Conventions
File names: Title Case with spaces, e.g. Custom close button.html.twig, With Icon.html.twig, Different sizes.html.twig, RTL.html.twig, File Tree.html.twig.
Mandatory: Usage.html.twig (minimal call surface) + Demo.html.twig (rich showcase used as kit preview).
One example per upstream variant. Match upstream copy/structure where possible.
When upstream uses cross-cutting JS (e.g. shadcn's language-selector), replicate intent without inventing new infrastructure (e.g. stack two independent components for RTL+LTR side-by-side, see collapsible/RTL).
Tests & Snapshots
cd src/Toolkit
# When examples were renamed/removed, blow them away firstrm -fr tests/Functional/__snapshots__/*<recipe>*
# Regenerate
php vendor/bin/simple-phpunit -d --update-snapshots
# Re-run normally to confirm green
php vendor/bin/simple-phpunit
git add tests/Functional/__snapshots__
Orphan snapshots: when recipe rewritten (e.g. <details> → Stimulus), old snapshot files with old naming scheme (e.g. Demo.html__1.html without .twig suffix) never regenerated + silently persist. After regenerating, inspect git status for leftover files + git rm them.
After rebase on 3.x: snapshot formatter may have evolved upstream. Re-run --update-snapshots once more after final rebase to avoid "diff in snapshots" CI failures.
Authoring Workflow
Locate upstream reference (see Shadcn UI / Flowbite v4) — list every example variant before writing any code
Scaffold recipe directory + manifest.json
Root component, sub-components (with <recipe>_<role>_attrs), Stimulus controller if needed
Examples: Usage.html.twig, Demo.html.twig, then every upstream variant
Snapshots — regenerate, inspect HTML diff, commit
Lint/format, CHANGELOG entry, open PR + companion PR
PR / Review Checklist
Single recipe per PR
Targets 3.x
PR template filled (Bug/Feature, License: MIT, Issues: Part of #3233)
CHANGELOG entry under 3.x
All upstream examples present, file names Title Case