Agent skill

Symfony UX Toolkit Kit

by symfony in symfony/ux

Generate, modify, or review Symfony UX Toolkit kit recipes (shadcn, flowbite-4, bootstrap, common).

MITAuto-check passedDevelopment

Install Symfony UX Toolkit Kit

skills CLI
$ npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a claude-code

Project install by default; add -g for ~/.claude/skills/.

GitHub CLI
$ gh skill install symfony/ux symfony-ux-toolkit-kit --agent claude-code

Project scope by default; add --scope user for a personal install. Needs GitHub CLI 2.90.0 or later (public preview).

Manual copy
$ git clone --depth 1 https://github.com/symfony/ux.git skills-src && mkdir -p .claude/skills && cp -r skills-src/.agents/skills/symfony-ux-toolkit-kit .claude/skills/symfony-ux-toolkit-kit && rm -rf skills-src

Use ~/.claude/skills/ instead of .claude/skills for a personal install. The folder must contain SKILL.md.

Claude Code skills documentation · loads skills from .claude/skills/

Facts

Skill name
symfony-ux-toolkit-kit
GitHub stars
1.1k
Token cost
~10k tokens
SKILL.md length
3,808 words
Files
1
Skills in repo
5
Repo updated
First seen
Licence
MIT

At a glance

Generate, modify, or review Symfony UX Toolkit kit recipes (shadcn, flowbite-4, bootstrap, common).

  • Works in 7 steps: Prop & block documentation (mandatory) → Root element → Variant systems with html_cva → …
  • Adding/editing files under src/Toolkit/kits/
  • SKILL.md covers Core Rules, Recipe Directory Layout, Shadcn UI and Flowbite v4, plus 9 more sections
  • Calls git, php and pnpm; reaches flowbite.com and github.com

What it does

Symfony UX Toolkit Kit is an agent skill from symfony/ux. 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 <recipe<roleattrs 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.

Its SKILL.md is about 10k tokens, which your agent loads only when the skill is triggered. It is a single SKILL.md file with no bundled scripts.

It sits in Development, covering Design systems and Technical documentation. It works with Symfony and shadcn/ui. The repository describes itself as: Symfony UX initiative: a JavaScript ecosystem for Symfony. The licence is MIT.

When your agent uses it

  • Adding/editing files under src/Toolkit/kits/
  • Reviewing PRs touching the Toolkit

Example prompts

  • “/symfony-ux-toolkit-kit”

Requirements

  • Docker

Workflow steps

7 steps, taken from the step headings in SKILL.md.

  1. Prop & block documentation (mandatory)
  2. Root element
  3. Variant systems with html_cva
  4. Parent → descendant context propagation
  5. The __attrs (asChild) pattern
  6. Collapse/expand animation — grid-template-rows, never hidden
  7. ARIA & data-state surfaces

What it can do on your machine

Read from SKILL.md and the folder at commit 7da41b9. It shows what the files ask for, not the result of running them.

  • Tool permissions

    Pre-approves nothing: there is no allowed-tools line, so your agent's usual permission prompts apply.

    From allowed-tools in the SKILL.md frontmatter.

  • Runs code

    Shell commands in SKILL.md call:

    • git
    • php
    • pnpm
    • gh

    From the folder's file list and the shell code blocks in SKILL.md.

  • Network

    Hosts in commands or code, which the agent is likely to contact:

    • flowbite.com
    • github.com
    • raw.githubusercontent.com
    • ux.symfony.com

    From URLs in SKILL.md, links to its own repository left out.

  • Credentials

    Names no API keys, tokens, secrets or passwords.

    From names ending in _API_KEY, _TOKEN, _SECRET, _KEY or _PASSWORD in SKILL.md.

Context cost

Symfony UX Toolkit Kit loads about 10k tokens when it runs. Until then it costs about 108 tokens; SKILL.md has 3,808 words of instructions outside code blocks.

Always · name and description, kept in context so the agent knows when to use it
~108
When it runs · the whole SKILL.md, loaded when a task matches
~10k

Estimates: characters ÷ 4, the usual rule of thumb; real counts depend on the model's tokenizer. Scripts and assets cost tokens only if the agent reads them.

Safety

Auto-check passed

The automated check found no risky patterns in SKILL.md.

Automated static check — not a guarantee. Review scripts before installing. It scans the text of SKILL.md for risky patterns (piping downloads into a shell, reading credential files, hidden Unicode, destructive commands); files beside SKILL.md are not scanned.

SKILL.md

The full file from symfony/ux at commit 7da41b9, republished under its MIT licence (© symfony). 3,808 words, ~10,411 tokens.

Download SKILL.mdSave it as .claude/skills/symfony-ux-toolkit-kit/SKILL.md (or your agent's skills folder).
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 `<recipe>_<role>_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][<Kit>] Add <recipe> recipe or [Toolkit][<Kit>] Align <recipe> with <upstream> 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).
  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 <kit>/<recipe> 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. <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
├── README.md                              # single doc source: description + inline live-preview examples
├── 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>.

There is no examples/ directory — examples live inline in README.md (see Examples). Recipe copy-files only copies templates/ (+ assets/); the README is doc-only, never shipped to the user's app.

README.md structure
markdown
# <Human Name>

<One-sentence description.>    <!-- first paragraph; read by Recipe::getDescription(), the manifest has no description -->

```twig {"preview":true,"height":"300px"}
<!-- hero preview: rich showcase -->
<twig:Recipe ... />
```

## Installation

::: installation    <!-- directive, expanded by the renderer -->

## Usage

```twig
<!-- STATIC block (no preview): minimal API surface -->
<twig:Recipe prop="a | b" />
```

## Examples

### <Variant Name>

<Optional sentence describing the variant.>

```twig {"preview":true,"height":"150px"}
<!-- one live-preview block per upstream example -->
<twig:Recipe ... />
```

### RTL    <!-- always last Examples subsection (see RTL examples) -->

## API Reference

::: api-reference    <!-- directive, expanded by the renderer -->
```

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":"<px>" — 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="<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 the structure + data-* surface, the stylesheet carries the classes, examples show usage patterns, MDX drives docs and manifest.

FilePurpose
apps/v4/registry/bases/radix/ui/<recipe>.tsxComponent source — sub-component structure, data-slot/data-state surface, variant axes. Carries cn-* class names, not Tailwind utilities
apps/v4/registry/styles/style-nova.cssCanonical classes — the .cn-<recipe>* 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/<recipe>-*.tsxUsage examples — one file per variant, drives examples list
apps/v4/content/docs/components/radix/*.mdxDocs + 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/<recipe>"

Fetch each:

https://raw.githubusercontent.com/shadcn-ui/ui/refs/heads/main/apps/v4/examples/radix/<example>.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
SourcePurpose
https://flowbite.com/docs/components/<recipe>/Reference page — canonical markup, variants, accessibility notes
https://github.com/themesberg/flowbite/blob/main/src/components/<recipe>/index.tsJS 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/<kit>/<recipe>/assets/controllers/<recipe>_controller.js" cannot be found in any asset map paths.

bash
cd /path/to/ux             && git checkout feat/toolkit-<kit>-<recipe>
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/<kit>/manifest.json)
json
{
    "$schema": "../../schema-kit-v1.json",
    "name": "<Display Name>",
    "description": "...",
    "license": "MIT",
    "homepage": "https://ux.symfony.com/toolkit/kits/<kit>"
}
Recipe-level (src/Toolkit/kits/<kit>/<recipe>/manifest.json)
json
{
    "$schema": "../../../schema-kit-recipe-v1.json",
    "type": "component",
    "name": "<Human Name>",
    "version-added": "<Toolkit version the recipe first ships in, e.g. 3.5>",
    "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": ["<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 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 ## <type> <Description.> comment on the line above it inside the {% props %} tag, and every rendered block with a {##- <Description.> -#} 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
-%}
...
<div ...>
    {##- The dialog structure, typically includes `Dialog:Trigger` and `Dialog:Content`. -#}
    {%- block content %}{% endblock -%}
</div>

Format is enforced by bin/ux-toolkit-kit-lint (CI fails on any warning — see Docblock linting):

  • Prop ## <type> <Description.> — 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<string>|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 {##- <Description.> -#} — 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: '<base>'|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 ('<base> ' ~ 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. <twig:ux:icon>, which validates class as a scalar string and rejects the tailwind_classes object. Spreading onto a mergeable Toolkit child (<twig:Button>, <twig:Label>, <twig:Separator>, …) is fine: defaults() chains the mergeables as base < wrapper < caller, matching React/Vue's cn(base, className).
  • data-slot → literal attribute (data-slot="<recipe-name>"). 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 <twig:Button data-slot="alert-dialog-action">, 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-<recipe>-<key>-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) #}
<div
    id="{{ id }}"
    data-slot="<recipe-name>"
    data-<recipe>-<key>-value="{{ value }}"
    data-orientation="{{ orientation }}"
    aria-labelledby="{{ _<recipe>_title_id }}"
    {{ attributes.defaults({
        class: '<base classes>'|tailwind_classes,
        'data-controller': '<recipe>',
        'data-action': 'click-><recipe>#toggle',
    }) }}
>
    {%- block content %}{% endblock -%}
</div>

{# WITHOUT a controller/action (static component) #}
<div
    data-slot="<recipe-name>"
    {{ attributes.defaults({
        class: '<base classes>'|tailwind_classes,
    }) }}
>
    {%- block content %}{% endblock -%}
</div>
  • 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: '<base classes>',
    variants: {
        variant: { default: '...', outline: '...' },
        size: { default: '...', sm: '...', lg: '...' },
    },
) -%}
<button {{ attributes.defaults({ class: style.apply({variant: variant, size: size})|tailwind_classes }) }}>
4. Parent → descendant context propagation

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.

twig
{# parent — InputOtp.html.twig #}
{%- props maxLength = 6 -%}
{%- do provide('inputOtp.maxLength', maxLength) -%}
{%- do provide('inputOtp.id', 'input-otp-' ~ id) -%}
<div ...>{%- block content %}{% endblock -%}</div>
twig
{# descendant — InputOtp/Slot.html.twig (works even self-closing) #}
{%- set _inputOtp_maxLength = inject('inputOtp.maxLength', 6) -%}
{%- set _inputOtp_id = inject('inputOtp.id') -%}

Conventions:

  • Key format: '<camelCaseRecipe>.<key>' (e.g. 'inputOtp.maxLength', 'tabs.active', 'toggleGroup.variant'). Prefix avoids collisions across recipes.
  • 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:

twig
{# templates/components/Dialog/Trigger.html.twig #}
{%- set dialog_trigger_attrs = {
    'data-action': 'click->dialog#open'|html_attr_type('sst'),
    'data-dialog-target': 'trigger',
    'aria-haspopup': 'dialog',
} -%}
{##- The trigger element (e.g., a `Button`) that opens the dialog when clicked. -#}
{%- block content %}{% endblock -%}
twig
{# example consumer #}
<twig:Dialog:Trigger>
    <twig:Button {{ ...dialog_trigger_attrs }}>Open</twig:Button>
</twig:Dialog:Trigger>

Rules:

  • Variable name: <snake_case_recipe>_<role>_attrs — dialog_trigger_attrs, dialog_close_attrs, tooltip_trigger_attrs, collapsible_trigger_attrs, alert_dialog_trigger_attrs.
  • 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):
twig
{%- props
    ## 'default'|'destructive' The visual style variant.
    variant = 'default'
-%}
<twig:Button variant="{{ variant }}" {{ ...attributes }}>
    {##- The action button label. -#}
    {{- block(outerBlocks.content) -}}
</twig:Button>
6. Collapse/expand animation — grid-template-rows, never hidden

hidden (display:none) causes layout jumps. Use grid-template-rows: 0fr + overflow:hidden for smooth transitions:

twig
<div class="grid overflow-hidden transition-[grid-template-rows] duration-300 ease-out"
     style="grid-template-rows: {{ open ? '1fr' : '0fr' }};">
    <div class="min-h-0 min-w-0 overflow-hidden">
        {%- block content %}{% endblock -%}
    </div>
</div>

Stimulus controller toggles style.gridTemplateRows between '1fr' and '0fr' on open/close.

7. ARIA & data-state surfaces

Emit as literal attributes (outside defaults()), always present with an explicit value — never {% if %}-guarded, never x ? 'attr="y"' (see section 2):

  • role, aria-haspopup, aria-expanded, aria-controls, aria-labelledby, aria-describedby, aria-disabled, aria-pressed, aria-hidden, aria-current, aria-selected, aria-checked.
  • data-state="open|closed|active|inactive", data-orientation="vertical|horizontal", data-variant, data-size, data-disabled, data-open, data-closed, data-active.
  • Booleans render as strings: data-open="{{ open ? 'true' : 'false' }}", aria-expanded="{{ open ? 'true' : 'false' }}" (a bare : false renders empty/ambiguous).
  • IDs deterministic + shared between trigger/content via parent's id prop (e.g. aria-controls={{ _accordion_item_content_id }}).
  • Structural/config attributes that are only valid in one state stay conditional (e.g. aria-orientation only when a separator is not decorative).
  • aria-label is not part of this surface: it is an accessible name the consumer must be able to replace, so it goes inside defaults() (see section 2).

Docblock linting

bin/ux-toolkit-kit-lint validates every component's prop and block documentation against the template. CI runs it per kit with --fail-on-warning, so any violation fails the build.

bash
cd src/Toolkit
php bin/ux-toolkit-kit-lint --fail-on-warning kits/<kit>

Checks:

  • Prop ## <type> <Description.> — valid spaceless PHPStan type, description Capitalized + ending with a period. Every declared prop in {%- props -%} has a ## ... doc comment above it and vice versa.
  • Block {##- <Description.> -#} — description Capitalized + ending with a period. Every rendered block ({% block x %} / block(outerBlocks.x) / block('x')) has a {##- ... -#} doc comment on the line above it.
  • Default values are not documented — they are read from {%- props -%}.

Stimulus Controller Conventions

assets/controllers/<recipe>_controller.js:

js
import { Controller } from '@hotwired/stimulus';

/**
 * @value  open     Whether the component is open on initial render.
 * @target trigger  The element that toggles the component and reflects its expanded state.
 * @target content  The region shown or hidden when the component toggles.
 * @action open     Opens the component.
 * @action close    Closes the component.
 */
export default class extends Controller {
    static targets = ['trigger', 'content'];
    static values = { open: Boolean };

    connect() {
        if (this.openValue) this.open();
    }

    open() { /* ...sync ARIA after transitions... */ }
    close() { /* ... */ }
}
  • ESM, default export, @hotwired/stimulus.
  • Sync ARIA from JS on state changes (aria-expanded, data-state).
  • Respect transitions: if (el.getAnimations().length > 0) el.addEventListener('transitionend', ..., { once: true });.
  • Naming: <recipe>_controller.js, controller identifier <recipe> (kebab-case in Twig).
  • Keyboard actions — use Stimulus descriptor syntax in Twig, not raw JS keydown listeners:
    twig
    data-action="keydown.enter->{{ recipe }}#toggle keydown.space->{{ recipe }}#toggle"
    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).
Show full SKILL.md (1,625 more words)Show less
Controller docblocks (API reference)

A controller's public API is documented with a /** ... */ docblock placed immediately before export default class, using @value/@target/@action tags. RecipeDocRenderer renders these under ::: api-reference (the same block that documents Twig component props), which is the only way a controller-only recipe — one with no templates/components/*.html.twig — surfaces an API reference. bin/ux-toolkit-kit-lint validates them via StimulusControllerDocChecker (see Docblock linting).

Add the docblock only to controllers whose API you want shown. It is a deliberate, per-controller choice — not a blanket requirement. Document a controller when its data-controller/data-* surface is meant to be used or overridden directly by the consumer (always true for a controller-only recipe). Omit it — leaving the controller undocumented and rendering no API reference — when the controller is an internal implementation detail the consumer never touches directly (they drive it through the recipe's Twig components, which carry their own prop/block documentation). Documenting such a controller would surface data-* internals as if they were public API.

  • Format: @<tag> <name> <Description.> — name first, then a one-sentence description that starts Capitalized and ends with a period. Align columns for readability (whitespace is normalized). Do not document types or defaults in the docblock — the renderer reads value types/defaults from the static values declaration.
  • @value <name> — one per key in static values. For object-form values (open: { type: Boolean, default: false }), document the top-level key (open), never the inner type/default.
  • @target <name> — one per string in static targets.
  • @action <name> — opt-in: document only the public methods actually wired via data-action in the recipe's templates (find them with grep -rhoE '<identifier>#[a-zA-Z]+' templates). Never document private methods (_/# prefixed) or lifecycle methods (connect/disconnect). Every @action must match a real method.
  • All-or-nothing once you opt in: a controller with no tags at all renders no API reference and is left untouched by the linter. But once any tag is present, the linter requires every static values key and every static targets string to have a matching tag (and vice versa) — partial documentation fails CI. So the choice is per-controller: document its whole surface, or leave it entirely undocumented.
  • The controller identifier is derived from the filename (<recipe>_controller.js → <recipe>; nested _ → -, e.g. alert_dialog_controller.js → alert-dialog), and each value's data-* attribute is derived from it (autoClose on widget → data-widget-auto-close-value).

Examples Conventions

Examples are inline in README.md, not separate files. Each is an ### <Variant Name> subsection under ## Examples, followed by an optional one-sentence description and a live-preview block:

markdown
### With Icon

You can render an icon inside the badge.

```twig {"preview":true,"height":"150px"}
<twig:Badge variant="secondary">
    <twig:ux:icon name="lucide:badge-check" data-icon="inline-start" />
    Verified
</twig:Badge>
```
  • Heading is Title Case with spaces (With Icon, Custom Colors, Different Sizes, File Tree).
  • Two mandatory blocks live outside ## Examples: the hero preview right after the description (rich showcase) and the ## Usage static ```twig block — minimal call surface, no preview.
  • One ### <Variant> 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 in one block, see collapsible's ### RTL).
RTL examples
  • RTL is the ### RTL subsection under ## Examples — always last, ### (not ##).
  • The preview block must show both the Arabic and Hebrew versions (dir="rtl"), stacked vertically — no side-by-side LTR/RTL comparison.
  • No LTR card: it duplicates the hero preview and adds no value.
  • The subsection description must always be: To enable RTL support, set the \dir="rtl"` attribute on the root element.`

Tests & Snapshots

Two kinds of baselines guard each recipe:

  • ComponentsRenderingTest renders every preview block of the recipe README.md. It snapshots the HTML in src/Toolkit/tests/Functional/__snapshots__/, keyed by example index (... Kit shadcn, component badge, example 5__1.html). The key is the position in the README, not a name.
  • Playwright screenshots every preview example in light and dark mode, and saves them in kits/<kit>/<recipe>/tests/screenshots/<example>-<theme>.png. The file name comes from the slug of the heading above the example (default under the title). Renaming a heading renames its screenshots.

Regenerate both with one command from the repository root (Docker required):

bash
bin/update_toolkit_tests.sh <kit>/<recipe>   # or <kit>, or nothing for every kit
git add src/Toolkit/tests/Functional/__snapshots__ src/Toolkit/kits/<kit>/<recipe>/tests

The script only rewrites a screenshot when it no longer matches, using the tolerance of Playwright's comparison, so rendering noise doesn't produce a diff. It also deletes the snapshots and screenshots that no test uses anymore, like the ones of a removed, reordered or renamed example. Check git status for deleted files + commit the deletions too.

After rebase on 3.x: the snapshot formatter may have evolved upstream. Run the script once more after the final rebase to avoid "diff in snapshots" CI failures.

Interaction specs

A recipe is interactive in three cases. It ships a Stimulus controller. It depends on a recipe that ships one (shadcn/sheet depends on dialog). Or it uses Bootstrap's JS (data-bs-toggle, data-bs-dismiss, data-bs-ride, data-bs-slide). Every interactive recipe needs a spec in kits/<kit>/<recipe>/tests/<recipe>.spec.ts. src/Toolkit/assets/test/browser/interactions.spec.ts enforces this rule. A new interactive recipe ships with its spec.

A spec follows the flow idle -> screenshot -> action -> screenshot. The generic spec already takes the idle screenshot. The recipe spec performs the action. It asserts the result. Then it screenshots the new state with testState():

ts
import { describeRecipe, expect, test, testState } from '../../../../assets/test/browser/fixtures';

describeRecipe('shadcn/popover', () => {
    testState('opens on click', {
        example: 'default',
        state: 'open',
        act: async (page) => {
            const trigger = page.getByRole('button', { name: 'Open Popover' });

            await trigger.click();

            await expect(page.getByRole('dialog')).toBeVisible();
            await expect(trigger).toHaveAttribute('aria-expanded', 'true');
        },
    });

    test('closes on Escape and gives focus back to the trigger', async ({ page, gotoExample }) => {
        // ...
    });
});

testState() runs once per theme. It saves <example>-<state>-<theme>.png in the recipe's tests/screenshots/. Behavior that shows no new visual state (closing, focus, keyboard) goes in a plain test(). gotoExample() only fixes the date with timers: 'fake'. Playwright's fake clock also fakes requestAnimationFrame. So keep the default timers: 'real' when a controller waits on animation frames. Always take a screenshot through testState(), never with a direct toHaveScreenshot() call, because bin/update_toolkit_tests.sh deletes any screenshot that no test declares.


Authoring Workflow

  1. Locate upstream reference (see Shadcn UI / Flowbite v4) — list every example variant before writing any code
  2. Scaffold recipe directory + manifest.json
  3. Root component, sub-components (with <recipe>_<role>_attrs), Stimulus controller if needed
  4. Write README.md: description + hero preview, ## Installation (::: installation), ## Usage static block, ## Examples with one ### <Variant> live-preview per upstream example (+ ### RTL last), ## API Reference (::: api-reference)
  5. Snapshots + screenshots: run bin/update_toolkit_tests.sh <kit>/<recipe>, inspect the HTML diff + the images, commit
  6. Lint/format, CHANGELOG entry, open PR

PR / Review Checklist

  • Single recipe per PR
  • Targets 3.x
  • PR template filled (Bug/Feature, License: MIT, Issues: Part of #3233 for shadcn)
  • CHANGELOG entry under 3.x
  • All upstream examples present as inline {"preview":true} blocks in README.md, ### <Variant> headings Title Case
  • README.md has the hero preview + ## Usage static block + ::: installation / ::: api-reference directives
  • Visual + behavioral parity verified manually (screenshot/video attached)
  • Snapshots + screenshots regenerated + committed (no stale entries)
  • Interactive recipe: spec in tests/<recipe>.spec.ts
  • php-cs-fixer, twig-cs-fixer, pnpm run fmt, pnpm run lint clean
  • bin/ux-toolkit-kit-lint --fail-on-warning kits/<kit> clean
  • Docs: ## <type> <Description.> above each prop in {% props %} + {##- <Description.> -#} on the line above each rendered block (trim mirrors the block); descriptions Capitalized + ending with a period; prop types are spaceless PHPStan types; no Defaults to (defaults live in {%- props -%}); every rendered block documented
  • attributes.defaults() holds the merged class ('<base>'|tailwind_classes), data-controller/data-action, aria-label (+ overridable HTML defaults); data-slot, state data-*, state aria-* and Stimulus data-*-value are literal attributes; state attrs always emitted with an explicit value (tailwind_merge kept only in different-element / external-component exceptions)
  • 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.json dependencies.recipe
  • No orphan snapshot files after rework/rename (deletions made by bin/update_toolkit_tests.sh committed)
  • Every shipped file ends with trailing newline (.html.twig, .json, .js, .css, .md)

Anti-patterns (flag in review)

Anti-patternFix
{{ attributes.defaults({}) }} with empty or no meaningful defaults{{ attributes }} when no defaults needed; {{ attributes.defaults({...}) }} for the merged class (tailwind_classes), data-controller/data-action, aria-label (+ overridable HTML defaults like type)
data-slot, state aria-*, Stimulus data-*-value or state data-* inside defaults()Render them as literal attributes outside defaults(); the only data-*/aria-* keys left in defaults() are data-controller/data-action and aria-label (enforced by AttributesDefaultsChecker)
Literal aria-label="..." / aria-label="{{ label }}" on the element carrying the attributes sinkMove it into defaults() ('aria-label': label) — a literal one is emitted twice and the browser keeps the hard-coded first occurrence
State attr conditionally emitted ({{ open ? 'data-state="open"' }}) or bare : false (data-open="{{ open ? 'true' : false }}")Always emit with explicit string value (data-state="{{ open ? 'open' : 'closed' }}", ... : 'false')
Separate class="{{ ('<base> ' ~ attributes.render('class'))|tailwind_merge }}" + trailing {{ attributes }} (Tailwind kits)Merge inside defaults(): {{ attributes.defaults({ class: '<base>'|tailwind_classes }) }} (keep tailwind_merge only for different-element cases or spreads onto an external non-mergeable component like <twig:ux:icon>)
Variant via {% if variant == ... %} chainsattributes.defaults({ class: html_cva(base, variants).apply({...})|tailwind_classes })
Trigger.html.twig wraps own <button>Expose <recipe>_trigger_attrs + use {%- block content %}{% endblock -%} only
data-action="click->x#y" not piped'click->x#y'|html_attr_type('sst')
Missing data-slot on root/sub-roots (Shadcn)Add data-slot="<recipe>" / data-slot="<recipe>-<sub>"
Missing ## <type> <desc> prop comment / {##- <desc> -#} block commentAdd ## ... above the prop in {% props %} / {##- ... -#} above the block
Defaults to \...`in a## ...` prop commentRemove it — the default lives only in {%- props -%}
Prop type with spaces ('a' | 'b')Remove spaces ('a'|'b')
Docblock description not Capitalized / no trailing periodCapitalize + end with a period
Rendered block ({% block x %}) with no {##- ... -#} doc commentAdd {##- <Description.> -#} on the line above the block
Block doc comment that shifts rendered whitespace (wrong trim)Mirror the block's trim: {##- ... -#} for {%-/{{-, {## ... -#} for {%/{{
Self-closing item reading _parent_var (outer-scope)Use provide() in parent + inject() in child
Recipe depends on another recipe but dependencies.recipe emptyDeclare it (e.g. toggle-group → toggle)
Snapshots not regenerated / partially staleRegenerate via bin/update_toolkit_tests.sh <kit>/<recipe>
Multiple recipes in one PRSplit into one PR per recipe
PR targets 2.xRetarget to 3.x, move CHANGELOG entry
Companion PR opened on symfony/ux.symfony.comClose it: docs, controllers and Tailwind classes are picked up from the recipe automatically
Native <details>/<summary> when upstream has animation/ARIA parityReplace with <div> + Stimulus controller
Example as a separate examples/*.html.twig file or ::: example directiveInline as a ```twig {"preview":true} block in README.md
Subset of upstream examplesReuse full set, inline in README.md
hidden class for collapse/expandgrid-template-rows: 0fr + overflow:hidden + CSS transition
group-hover + group-focus-within for hover-triggered componentsStimulus controller with openDelay/closeDelay values
in-data-[state=open]:visible on nested open-stateNamed Tailwind groups (group/<recipe>-menu, group/<recipe>-sub)
Orphan snapshots after recipe rework/renameCommit the deletions made by bin/update_toolkit_tests.sh

Bad / Good

BadGood
<button class="..." data-action="click->dialog#open">{% block content %}{% endblock %}</button>{%- set dialog_trigger_attrs = { 'data-action': 'click->dialog#open'|html_attr_type('sst'), 'data-dialog-target': 'trigger', 'aria-haspopup': 'dialog' } -%}{%- block content %}{% endblock -%}
<div class="text-lg leading-none font-semibold {{ attributes.render('class') }}" {{ attributes.defaults({'data-slot': 'dialog-title'}) }}><div data-slot="dialog-title" {{ attributes.defaults({ class: 'text-lg leading-none font-semibold'|tailwind_classes }) }}> (data-slot literal; class merged inside defaults() via tailwind_classes)
<twig:RadioGroup:Item value="a" /> reading {% set _radio_group_name = ... %} from parentKeep name as explicit prop on RadioGroup:Item (self-closing)

© symfony, MIT. Rendered from Markdown: HTML in the file is shown as text, images as links, and headings moved down two levels. Raw file

Files

Just SKILL.md in .agents/skills/symfony-ux-toolkit-kit of symfony/ux.

Open the folder on GitHubat commit 7da41b9

Compare with similar skills

Symfony UX Toolkit Kit next to the 5 skills that share the most tags, products or categories with it. Stars are the repository's; “used in” counts other GitHub owners with a copy.

Symfony UX Toolkit Kit compared with similar skills
SkillStarsUsed inTokensAuto-checkLicenceRepo updated
Symfony UX Toolkit Kit this skillsymfony/ux1.1k—~10kAutomated safety check: PassMIT
UithingBayBreezy/ui-thing729—~1.3kAutomated safety check: PassCustom licence
README Badges and Headersjal-co/shieldcn919—~4.3kAutomated safety check: PassMIT
Scaffold Nextjsmblode/agent-skills144—~2.8kAutomated safety check: PassMIT
Svelte5 Best PracticesSikandarJODD/cnblocks4311 repos~810Automated safety check: PassMIT
Plate UIudecode/plate17k—~2.6kAutomated safety check: PassCustom licence

Similar skills

  • Uithing

    BayBreezy/ui-thing

    Manages UI Thing components, prose, blocks, themes, shortcuts, docs, and MCP-driven workflows in Nuxt projects and in the UI Thing source repo.

    729 GitHub stars~1.3k tokensUpdated 2 days ago
    Frontend & DesignAuto-check passed
  • Adds shadcn/ui-styled README badges, badge groups, download charts, header banners and sponsor or contributor grids using the shieldcn service.

    919 GitHub stars~4.3k tokensUpdated yesterday
    DevelopmentAuto-check passed
  • Scaffold Nextjs

    mblode/agent-skills

    Scaffolds a Next.js turborepo with Blode UI, icons, Ultracite (oxlint/shadcn), workspace hooks, and GitHub/Vercel setup.

    144 GitHub stars~2.8k tokensUpdated 2 days ago
    DevelopmentAuto-check passed
  • Svelte5 Best Practices

    SikandarJODD/cnblocks

    Svelte 5 runes, snippets, SvelteKit patterns, and modern best practices for TypeScript and component development.

    431 GitHub starsUsed in 1 repo~810 tokens
    DevelopmentAuto-check passed
  • Plate UI

    udecode/plate

    Build new shadcn-style components for Plate's registry and editor surfaces.

    17k GitHub stars~2.6k tokensUpdated yesterday
    Frontend & DesignAuto-check passed
  • React Render Types Composition

    HorusGoul/eslint-plugin-react-render-types

    Composition patterns for building React components with @renders type annotations from eslint-plugin-react-render-types.

    111 GitHub stars~1.1k tokensUpdated 2 mo ago
    Frontend & DesignAuto-check passed

More from symfony/ux

  • Merge Up

    symfony/ux

    Cascade-merge the maintained Symfony UX branches from oldest to newest (2.x - 3.x), resolve conflicts, run the affected packages' tests and prepare the push.

    1.1k GitHub stars~3.4k tokensUpdated yesterday
    Auto-check passed
  • Principles for rigorously reviewing a Symfony UX pull request and making it merge-ready.

    1.1k GitHub stars~4.6k tokensUpdated yesterday
    Auto-check passed
  • Security Triage

    symfony/ux

    Triage a security finding in a Symfony UX package into a disposition: a private CVE (coordinated disclosure through the Symfony security process), a public hardening PR (fix in the open, no CVE), or…

    1.1k GitHub stars~3.2k tokensUpdated yesterday
    Auto-check passed
  • Review a change (a PR, the current branch diff, or a set of files) or audit a Symfony UX package or the whole src/ tree for missing or incorrect security hardening.

    1.1k GitHub stars~2.9k tokensUpdated yesterday
    Auto-check passed

Questions about Symfony UX Toolkit Kit

What does Symfony UX Toolkit Kit do?

Generate, modify, or review Symfony UX Toolkit kit recipes (shadcn, flowbite-4, bootstrap, common). Symfony UX Toolkit Kit is an agent skill from symfony/ux. Generate, modify, or review Symfony UX Toolkit kit recipes (shadcn, flowbite-4, bootstrap, common).

When should I use Symfony UX Toolkit Kit?

Symfony UX Toolkit Kit fits situations like: adding/editing files under src/Toolkit/kits/; reviewing PRs touching the Toolkit.

How do I install Symfony UX Toolkit Kit in Claude Code?

Run `npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a claude-code`. Or copy the skill folder (.agents/skills/symfony-ux-toolkit-kit in symfony/ux) into .claude/skills/symfony-ux-toolkit-kit in your project. Claude Code loads it when a task matches its description.

How do I install Symfony UX Toolkit Kit in Codex?

Run `npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a codex`. Or copy the skill folder (.agents/skills/symfony-ux-toolkit-kit in symfony/ux) into .agents/skills/symfony-ux-toolkit-kit in your project. Codex loads it when a task matches its description.

Can I use Symfony UX Toolkit Kit in Cursor, Gemini CLI or GitHub Copilot?

Cursor, Gemini CLI, GitHub Copilot and OpenCode also load SKILL.md folders. With the skills CLI, run `npx skills add symfony/ux --skill symfony-ux-toolkit-kit -a cursor` (or -a gemini-cli, github-copilot or opencode for the others). To copy it by hand, put the folder in .cursor/skills/symfony-ux-toolkit-kit, .gemini/skills/symfony-ux-toolkit-kit, .github/skills/symfony-ux-toolkit-kit and .opencode/skills/symfony-ux-toolkit-kit in your project.

What does Symfony UX Toolkit Kit need to run?

Going by SKILL.md and its folder, Symfony UX Toolkit Kit needs the command-line tools its instructions call (git, php, pnpm and gh). Our summary lists: Docker.

Does Symfony UX Toolkit Kit access the network?

SKILL.md names 4 domains. In commands or code: flowbite.com, github.com, raw.githubusercontent.com and ux.symfony.com; the agent is likely to contact these when it follows the instructions. This is read from the text; nothing was executed.

Is Symfony UX Toolkit Kit safe to install?

Our automated static check of SKILL.md found no risky patterns, such as piping downloads into a shell, reading credential files or hidden Unicode. It is not a guarantee. Review the folder before installing.

What licence does Symfony UX Toolkit Kit use?

Symfony UX Toolkit Kit is published under the MIT licence (the repository's licence). It allows redistribution, so the full SKILL.md is shown on this page.

How many tokens does Symfony UX Toolkit Kit use?

About 10k tokens (SKILL.md is roughly 42k characters). Agents keep only the skill's name and description in context until a task matches; then they load SKILL.md in full.

What are the alternatives to Symfony UX Toolkit Kit?

Skills that share tags, products or a category with Symfony UX Toolkit Kit: Uithing (BayBreezy/ui-thing, 729 stars), README Badges and Headers (jal-co/shieldcn, 919 stars), Scaffold Nextjs (mblode/agent-skills, 144 stars) and Svelte5 Best Practices (SikandarJODD/cnblocks, 431 stars). The comparison table on this page puts their stars, adoption, token cost, safety result and licence side by side.

Who maintains Symfony UX Toolkit Kit?

symfony (a GitHub organization) maintains it in symfony/ux, which has 1,080 GitHub stars. The repository holds 5 skills in this directory. The repository was last updated on October 10, 2026.

Source: symfony/ux on GitHub. Facts on this page come from the repository at the commit we read; the author's words are quoted as theirs.