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.
Installation
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
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 all source files per recipe: component source carries canonical classes + data-* surface; examples show usage patterns; MDX drives docs and manifest.
RTL variant — classes that differ per text direction (see RTL class variants)
apps/v4/examples/radix/<recipe>-*.tsx
Usage examples — one file per variant, drives examples list
apps/v4/content/docs/components/radix/*.mdx
Docs + manifest metadata — single source of truth for titles, descriptions, section order; description copied verbatim to ux/src/Toolkit/kits/shadcn/**/manifest.json
Enumerate every example file for recipe:
gh api "repos/shadcn-ui/ui/git/trees/main?recursive=1" --jq '.tree[].path' \
| grep "apps/v4/examples/radix/<recipe>"
Always diff both files. For each class that differs between LTR and RTL, apply the rtl: Tailwind variant in addition to the LTR class — do not create separate templates.
Use ltr: to scope the LTR class when RTL replaces it with a different one. Keep rtl: classes from the RTL file verbatim when they are already prefixed (e.g. rtl:translate-x-1/2).
Example: if ui/table.tsx has [&:has([role=checkbox])]:pr-0 and ui-rtl/table.tsx has [&:has([role=checkbox])]:pe-0, write:
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 only for recipes required by the component templates themselves (e.g. toggle-group depends on toggle). Do NOT declare recipe deps for components used only in examples — examples are demo files, not shipped dependencies.
Twig Component Patterns
1. Header docblock (mandatory)
One {# @prop ... #} per declared prop + one {# @block ... #} per block the component renders:
{# @prop id string Unique identifier used to generate internal Dialog IDs. #}
{# @prop open boolean Whether the dialog is open on initial render. #}
{# @block content The dialog structure, typically includes `Dialog:Trigger` and `Dialog:Content`. #}
{%- props id, open = false -%}
Format is enforced by bin/ux-toolkit-kit-lint (CI fails on any warning — see Docblock linting):
@prop <name> <type> <Description.> — name first (camelCase Twig variable, [a-z][a-zA-Z0-9]*), then type, then description.
Type = valid PHPDoc/PHPStan type with no spaces: 'default'|'secondary', string|array<string>|null, boolean, number. A space breaks the type/description boundary, so 'a' | 'b' is rejected — write 'a'|'b'.
No Defaults to in the docblock. Default values live only in {%- props -%} (single source of truth); the linter and ux.symfony.com read them from there.
@block <name> <Description.> — name must match a block actually rendered in the template ({% block x %}, block(outerBlocks.x), or block('x')). Never document a slot the component doesn't render, and never leave a rendered block undocumented.
Descriptions start with a capital letter and end with a period.
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):
IDs deterministic + shared between trigger/content via parent's id prop (e.g. aria-controls={{ _accordion_item_content_id }}).
Docblock linting
bin/ux-toolkit-kit-lint validates every component's @prop/@block docblocks against the template. CI runs it per kit with --fail-on-warning, so any violation fails the build.
cd src/Toolkit
php bin/ux-toolkit-kit-lint --fail-on-warning kits/<kit>
Checks:
@prop — camelCase name, valid spaceless PHPStan type, description Capitalized + ending with a period. Every @prop maps to a prop in {%- props -%} and vice versa.
@block — valid Twig block name, description Capitalized + ending with a period. Every @block maps to a rendered block ({% block x %} / block(outerBlocks.x) / block('x')) and vice versa.
Default values are not documented in the docblock — they are read from {%- props -%}.
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).
RTL examples
The RTL example file must be named RTL.html.twig (all caps, matching the collapsible kit convention).
The Twig RTL example must show both the Arabic and Hebrew versions (dir="rtl"), stacked vertically — no side-by-side LTR/RTL comparison.
The LTR card is intentionally omitted: it duplicates the Demo example and adds no value.
The RTL section in the doc must use ### RTL (not ## RTL) — it is a subsection of Examples, not a top-level section.
The RTL section description in the doc must always be: To enable RTL support, set the \dir="rtl"` attribute on the root element.`
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
Docblocks: @prop/@block present; descriptions Capitalized + ending with a period; prop types are spaceless PHPStan types; no Defaults to (defaults live in {%- props -%}); every @block matches a rendered block
Trigger/Close sub-components use <recipe>_<role>_attrs (no wrapping <button>)
data-action Stimulus actions piped through |html_attr_type('sst') when concatenable
Inter-recipe deps declared in manifest.jsondependencies.recipe
No orphan snapshot files after rework/rename (git status clean after --update-snapshots)
Every shipped file ends with trailing newline (.html.twig, .json, .js, .css, .md)