--- name: symfony-ux-toolkit-kit description: > Generate, modify, or review Symfony UX Toolkit kit recipes (shadcn, flowbite-4, bootstrap, common). Enforces conventions for manifest, README examples, Twig prop/block doc comments, sub-components, asChild `__attrs` pattern, provide()/inject() context, Stimulus controllers, 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). Each recipe carries a `README.md` — its single doc source (description, live-preview examples, install/API), rendered as-is on ux.symfony.com. ## Core Rules 1. **One PR per recipe.** Never batch multiple recipes single PR. PR title: `[Toolkit][] Add recipe` or `[Toolkit][] Align with reference`. 2. **Target `3.x`.** CHANGELOG entry under active `3.x` section in `src/Toolkit/CHANGELOG.md`. 3. **Visual + behavioral parity** with upstream reference (Shadcn UI / Flowbite). Verify manually; attach screenshot/video to PR body for animated/interactive components. 4. **Reuse all upstream examples.** No subset. Read both component source **and** every upstream example, then inline each as a live-preview block in the recipe `README.md` (see [Examples](#examples-conventions)). 5. **No companion PR on `symfony/ux.symfony.com`.** It renders the docs page from the recipe `README.md`, registers kit Stimulus controllers from a build-time loader, and compiles kit Tailwind classes through `@source`. Only a new external asset dependency not already vendored there needs one. 6. **Regenerate snapshots and screenshots** after every recipe change + commit them: `bin/update_toolkit_tests.sh /` from the repository root (Docker required). CI + reviewers reject stale ones. 7. **Use GitHub PR template** (Bug fix / Feature / License: MIT / Issues: `Part of #3233` for shadcn recipes, the shadcn tracking issue). Fabbot fails otherwise. 8. **Prefer Stimulus controller** over native browser features (e.g. `
`) when parity needs animations, ARIA sync, coordinated state. Native fine only when matches upstream UX exactly. --- ## Recipe Directory Layout ``` src/Toolkit/kits/// ├── manifest.json ├── README.md # single doc source: description + inline live-preview examples ├── templates/components/ │ ├── .html.twig # root component │ └── /.html.twig # e.g. Trigger, Close, Header, Item, Content └── assets/controllers/ # optional, only if interactive behavior is needed └── _controller.js ``` Sub-component file path `Component/SubName.html.twig` consumed as ``. There is **no `examples/` directory** — examples live inline in `README.md` (see [Examples](#examples-conventions)). Recipe `copy-files` only copies `templates/` (+ `assets/`); the README is doc-only, never shipped to the user's app. ### `README.md` structure ````markdown # ```twig {"preview":true,"height":"300px"} ``` ## Installation ::: installation ## Usage ```twig ``` ## Examples ### ```twig {"preview":true,"height":"150px"} ``` ### RTL ## API Reference ::: api-reference ``` ```` Info-string options on a preview block (JSON after the language): - **`"preview":true`** — required marker that turns the block into a live iframe + Code tab. Without it the block is a plain static snippet. - **`"height":""`** — iframe height (e.g. `"150px"`, `"300px"`); default `200px`. - **`"collapseClass":true`** — collapse long `class="..."` attributes in the Code tab (use for examples with long Tailwind class lists, e.g. `post-link`). READMEs use exactly two directives: `::: installation` and `::: api-reference`. --- ## Shadcn UI Always emit `data-slot=""` on root + `data-slot="-"` on every sub-component root. Shadcn-specific convention driven by its CSS selectors. ### Upstream sources Read all source files per recipe: component source carries the structure + `data-*` surface, the stylesheet carries the classes, examples show usage patterns, MDX drives docs and manifest. | File | Purpose | | --- | --- | | `apps/v4/registry/bases/radix/ui/.tsx` | **Component source** — sub-component structure, `data-slot`/`data-state` surface, variant axes. Carries `cn-*` class names, *not* Tailwind utilities | | `apps/v4/registry/styles/style-nova.css` | **Canonical classes** — the `.cn-*` rules those names resolve to, written as `@apply` Tailwind utilities. This is what a recipe's class strings are ported from | | `apps/v4/examples/radix/-*.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 as the first paragraph of the recipe `README.md` | Enumerate every example file for recipe: ```bash gh api "repos/shadcn-ui/ui/git/trees/main?recursive=1" --jq '.tree[].path' \ | grep "apps/v4/examples/radix/" ``` Fetch each: ``` https://raw.githubusercontent.com/shadcn-ui/ui/refs/heads/main/apps/v4/examples/radix/.tsx ``` ### RTL class variants **Upstream ships no RTL class variants.** The `.cn-*` rules in `style-nova.css` use physical properties (`text-left`, `mr-1`, `ml-1`). RTL support is therefore authored here, not ported — the upstream classes are the LTR reading, and adapting them is the recipe's job. **Reach for a logical utility first.** `ms-*`, `me-*`, `ps-*`, `pe-*`, `start-*`, `end-*`, `text-start`, `text-end` already flip with the text direction, so they need no variant prefix at all. Porting `mr-1` gives `me-1`, and `text-left` gives `text-start` — one token, correct in both directions. **Never pair a physical class with its own logical equivalent.** `ltr:text-left rtl:text-start` renders exactly like a bare `text-start`, and `ltr:before:mr-1 rtl:before:me-1` exactly like `before:me-1`. The pair costs two tokens for one rule, has to be kept in sync on every edit, and makes a `rtl:` grep return noise instead of the handful of places that genuinely differ. **Use `rtl:` only where no logical property exists** — a mirrored glyph or transform, typically. Keep those verbatim: ``` rtl:rotate-180 # a chevron that must point the other way rtl:translate-x-1/2 # no logical equivalent for translate ``` Scope them tightly: an icon inside a vertically-oriented component is not direction-dependent, so `rtl:rotate-180` there points the arrow the wrong way. --- ## Flowbite v4 Kit identifier: `flowbite-4`. ### Upstream sources | Source | Purpose | | --- | --- | | `https://flowbite.com/docs/components//` | **Reference page** — canonical markup, variants, accessibility notes | | `https://github.com/themesberg/flowbite/blob/main/src/components//index.ts` | **JS source** — behavior, state, options (when Stimulus controller needed) | 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.com` **must 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. ```bash cd /path/to/ux && git checkout feat/toolkit-- cd /path/to/ux.symfony.com && git checkout main # In ux.symfony.com: php ../link symfony php bin/console tailwind:build symfony serve -d ``` --- ## `manifest.json` ### Kit-level (`src/Toolkit/kits//manifest.json`) ```json { "$schema": "../../schema-kit-v1.json", "name": "", "description": "...", "license": "MIT", "homepage": "https://ux.symfony.com/toolkit/kits/" } ``` ### Recipe-level (`src/Toolkit/kits///manifest.json`) ```json { "$schema": "../../../schema-kit-recipe-v1.json", "type": "component", "name": "", "version-added": "", "copy-files": { "assets/": "assets/", "templates/": "templates/" }, "dependencies": { "composer": [ "twig/extra-bundle", "twig/html-extra:^3.24.0", "symfony/ux-twig-component:^3.5", "tales-from-a-dev/twig-tailwind-extra:^1.3.0" ], "recipe": [""] } } ``` Rules: - Drop `assets/` from `copy-files` if no Stimulus controller. - Add `"symfony/ux-icons"` to `composer` whenever templates use ``. - Bump `twig/html-extra` to `^3.24.0` for `html_attr_type` / `tailwind_classes`. The `tailwind_classes` class-merge idiom also needs `tales-from-a-dev/twig-tailwind-extra:^1.3.0` and `symfony/ux-twig-component:^3.5` (enforced by `ComposerSymbolChecker`). - 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. Prop & block documentation (mandatory) Document every prop with a `## ` comment on the line above it **inside** the `{% props %}` tag, and every rendered block with a `{##- -#}` doc comment on its own line right above it. These are Twig 3.29 documentation comments — Twig attaches them to the following node as metadata, so they carry no runtime cost and the Toolkit reads them natively: ```twig {%- props ## string Unique identifier used to generate internal Dialog IDs. id, ## boolean Whether the dialog is open on initial render. open = false -%} ...
{##- The dialog structure, typically includes `Dialog:Trigger` and `Dialog:Content`. -#} {%- block content %}{% endblock -%}
``` Format is enforced by `bin/ux-toolkit-kit-lint` (CI fails on any warning — see [Docblock linting](#docblock-linting)): - **Prop `## `** — one per line, on the line directly above the prop name, inside `{% props %}`. Type first (camelCase name matches the declared prop), then the description. - **Type** = valid PHPDoc/PHPStan type with **no spaces**: `'default'|'secondary'`, `string|array|null`, `boolean`, `number`. A space breaks the type/description boundary, so `'a' | 'b'` is rejected — write `'a'|'b'`. - **No `Defaults to`** in the documentation. Default values live **only** in `{%- props -%}` (single source of truth); the linter and ux.symfony.com read them from there. - **Block `{##- -#}`** — a doc comment (double `#`) on its own line directly above a block actually rendered in the template (`{% block x %}`, `block(outerBlocks.x)`, or `block('x')`). **Mirror the block's whitespace-trim** so the rendered output is unchanged: `{##- ... -#}` when the block opens with `{%-`/`{{-`, `{## ... -#}` when it opens with `{%`/`{{`. 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\``). - Requires `twig/twig >= 3.29` (documentation comments) and `symfony/ux-twig-component` with `PropsNode::getPropDocumentation()`. ### 2. Root element There is one home for each kind of attribute. `attributes.defaults({...})` carries the consumer-overridable values: the merged **`class`** (as a `tailwind_classes` mergeable), `data-controller`, `data-action`, `aria-label`, and genuinely overridable HTML defaults (`type: 'button'`, `alt: ''`). Everything that identifies or reflects the component's state is rendered **directly as a literal attribute**, so it is always present and can neither be dropped by the merge nor overridden: - **`class`** → merged **inside `defaults()`**: `class: ''|tailwind_classes` (or `class: style.apply({...})|tailwind_classes` with `html_cva`). `tailwind_classes` returns a mergeable value that `defaults()` merges with the consumer's `class` (consumer wins). No separate `class="..."` / `render('class')`. - **Exception — keep `(' ' ~ attributes.render('class'))|tailwind_merge`** when `class` and the attributes sink are on **different elements** (base on an outer element, sink on an inner one), or when `class` is spread onto an **external non-mergeable component** — e.g. ``, which validates `class` as a scalar string and rejects the `tailwind_classes` object. Spreading onto a **mergeable Toolkit child** (``, ``, ``, …) is fine: `defaults()` chains the mergeables as `base < wrapper < caller`, matching React/Vue's `cn(base, className)`. - **`data-slot`** → literal attribute (`data-slot=""`). Structural Shadcn marker, never overridable. - **Exception: a component other components pass their own slot to** (`Button`, `Input`, `Textarea`, `Label`, `Separator`, `Field:Group`, …, e.g. `AlertDialog:Action` renders ``, or an asChild bag carries `'data-slot': 'dialog-trigger'`) reads it first: `data-slot="{{ attributes.render('data-slot')|default('button') }}"`. A second `data-slot` would be lost, since the browser keeps the first one; `render()` marks it as rendered, so `defaults()` does not print it again. A parent style hook on such a component cannot rely on its default slot: give it a dedicated marker, as upstream does with `data-sidebar="menu-action"`. `ComponentsRenderingTest` fails on any element rendered with two `data-slot`. - **State `data-*` and state `aria-*`** → literal attributes, **always emitted with an explicit value** (never `{% if %}`-guarded, never `x ? 'attr="y"'`): `data-state`, `data-open`, `data-closed`, `data-active`, `data-disabled`, `data-orientation`, `data-size`, `data-variant`, `data-side`, `data-selected`, `data-checked`; `aria-expanded`, `aria-selected`, `aria-hidden`, `aria-disabled`, `aria-checked`, `aria-pressed`, `aria-current`. Boolean values render as strings: `data-open="{{ open ? 'true' : 'false' }}"` (a bare `: false` renders empty/ambiguous — always use `: 'false'`). - **Stimulus value attrs** (`data---value`), `id`, `role`, ARIA id-refs (`aria-controls`/`aria-labelledby`/`aria-describedby`) → literal attributes. - **`aria-label`** → the one ARIA attribute that belongs **inside `defaults()`**: `'aria-label': 'pagination'`. It is an accessible name, not a state, so a consumer must be able to replace it to name a landmark more precisely or to translate it. `defaults()` lets the consumer's value win and emits the attribute only once; written as a literal next to the sink it would be emitted twice, and the browser keeps the first occurrence, so the hard-coded label would always win. Same for a `label`/`ariaLabel` prop: pass it through `defaults()` (`'aria-label': label`), not as a literal. - **`class` + `data-controller` / `data-action` + `aria-label` (+ overridable HTML defaults)** → `attributes.defaults({...})`. A bare `{{ attributes }}` remains only where there is no `class` base and no default label. ```twig {# WITH a controller/action (interactive component) #}
--value="{{ value }}" data-orientation="{{ orientation }}" aria-labelledby="{{ __title_id }}" {{ attributes.defaults({ class: ''|tailwind_classes, 'data-controller': '', 'data-action': 'click->#toggle', }) }} > {%- block content %}{% endblock -%}
{# WITHOUT a controller/action (static component) #}
'|tailwind_classes, }) }} > {%- block content %}{% endblock -%}
``` - Do NOT put `data-slot`, state `data-*`, state `aria-*` or Stimulus `data-*-value` into `defaults()` — they belong as literal attributes (enforced by `AttributesDefaultsChecker`). Of the `data-*`/`aria-*` keys, only `data-controller`, `data-action` and `aria-label` belong in `defaults()`. - Do NOT put hardcoded HTML element attributes (like `type="checkbox"`) into `defaults()` — those are structural, not overridable. - **Structural / config / marker attributes stay conditional** (they are not "always render" state): `aria-orientation` on a decorative separator, `data-bs-parent`, presence-marker attributes like `data-horizontal`/`data-vertical`. - **Non-Tailwind kits (Bootstrap, Common)** keep their own idiom: `class` is merged *inside* `defaults()` as a plain string (`attributes.defaults({class: '...'})`, no `tailwind_classes`), there is no `data-slot`, and no `tailwind_merge`. Only the `data-slot` rule and the state-attribute rule apply there; the linter's Tailwind-only checks are skipped for them. ### 3. Variant systems with `html_cva` ```twig {%- set style = html_cva( base: '', variants: { variant: { default: '...', outline: '...' }, size: { default: '...', sm: '...', lg: '...' }, }, ) -%} ` | `{%- set dialog_trigger_attrs = { 'data-action': 'click->dialog#open'\|html_attr_type('sst'), 'data-dialog-target': 'trigger', 'aria-haspopup': 'dialog' } -%}{%- block content %}{% endblock -%}` | | `
` | `
` (`data-slot` literal; `class` merged inside `defaults()` via `tailwind_classes`) | | `` reading `{% set _radio_group_name = ... %}` from parent | Keep `name` as explicit prop on `RadioGroup:Item` (self-closing) |