{
  "schema": "shapcn-recipes/1",
  "$id": "https://shapcn.com/r/recipes.json",
  "version": "1.2.0",
  "engine": "shape-aware-morph@^0.2.2",
  "playbook": "https://shapcn.com/agents",
  "description": "The role catalog for coding agents. Find the role of each icon, then apply exactly one behavior from its entry.",
  "allowed": {
    "hover": [
      "grow",
      "bend",
      "bold",
      "cascade",
      "move",
      "none"
    ],
    "press": [
      "pop",
      "squash",
      "squat-jump",
      "burst",
      "none"
    ],
    "easing": [
      "smooth",
      "spring",
      "elastic"
    ]
  },
  "attributes": {
    "hover": "hover on <morph-icon>",
    "press": "press on <morph-icon>",
    "easing": "easing on <morph-icon>",
    "pressTo": "icon.pressTo(name): the press plays and the new icon morphs in one motion",
    "name": "setting name morphs the icon",
    "legacyNames": "Old or removed names you can still meet in code. Detect them, but never write them: use the names in iconNames and behavior."
  },
  "rules": [
    "Apply exactly one entry to each icon. Do not combine entries.",
    "Never change what a button does or means. Only change how its icon moves.",
    "Keep every visual state the old code showed (fill, color, size, stroke width). List them before you change the icon. A fill that the app already had stays (keepFill). New code does not add a fill.",
    "Keep the app's own icon mapping for toggles (theme, password visibility, mute and so on). The stateMeaning of an entry is a default for new code only. Never flip the app's mapping to match it.",
    "Priority when two roles match: a control inside a nav bar, tab bar or sidebar menu uses nav-icon. Otherwise the specific role wins (settings, search, share and so on). Use generic-action only when no specific role fits and the purpose is clear.",
    "Only an icon inside a button or a link is animated. An icon outside a button or a link is decoration (for example the search icon inside an input). Leave it as it is.",
    "Every icon name in a morph pair must exist in the package's lucide module. Check before you write it.",
    "Register only the icons the app uses. Do not register all of Lucide.",
    "Keep the visual size of the icon.",
    "Do not animate icons in dense lists or tables unless the user asks. Dense means one list or table with 8 or more repeated icon buttons.",
    "If the role of an icon is unclear, ask the user. Do not guess.",
    "If an icon sits in a shadcn ui/* file (components/ui/...), tell the user. A later shadcn add can overwrite that file.",
    "Icons that sit next to a text label are decorative: aria-hidden=\"true\". Icon-only buttons need an aria-label on the button.",
    "Do not edit components/ui/* unless the icon sits inside that file."
  ],
  "unknown": {
    "action": "ask-user",
    "text": "If the purpose of the control is unclear, ask the user. If the purpose is clear but no role matches, use generic-action."
  },
  "roles": [
    {
      "id": "theme-toggle",
      "title": "Theme toggle (light and dark)",
      "detect": {
        "labels": [
          "theme",
          "dark mode",
          "light mode",
          "appearance",
          "toggle theme"
        ],
        "iconNames": [
          "sun",
          "moon",
          "sun-moon"
        ],
        "aria": [
          "aria-label contains theme or dark"
        ],
        "reactNames": [
          "Sun",
          "Moon",
          "SunMoon"
        ]
      },
      "behavior": {
        "type": "toggle",
        "states": [
          {
            "state": "off",
            "icon": "sun"
          },
          {
            "state": "on",
            "icon": "moon"
          }
        ],
        "hover": "grow",
        "press": "squash",
        "easing": "spring",
        "howToChange": "pressTo",
        "stateMeaning": "Default for new code only: icon shows the current theme: sun in light, moon in dark"
      },
      "why": "The icon turns into the other theme, so the change is visible at the click."
    },
    {
      "id": "password-visibility",
      "title": "Show or hide password",
      "detect": {
        "labels": [
          "show password",
          "hide password",
          "reveal",
          "toggle password visibility"
        ],
        "iconNames": [
          "eye",
          "eye-off"
        ],
        "aria": [
          "aria-pressed on a button inside a password field",
          "aria-label contains password"
        ],
        "reactNames": [
          "Eye",
          "EyeOff"
        ]
      },
      "behavior": {
        "type": "toggle",
        "states": [
          {
            "state": "off",
            "icon": "eye-off"
          },
          {
            "state": "on",
            "icon": "eye"
          }
        ],
        "hover": "grow",
        "press": "pop",
        "easing": "spring",
        "howToChange": "pressTo",
        "stateMeaning": "Default for new code only: eye-off while the password is hidden, eye while it is shown. Keep the same meaning your code had before."
      },
      "why": "A small, quick swap. The user watches the field, not the icon."
    },
    {
      "id": "mute",
      "title": "Mute and unmute",
      "detect": {
        "labels": [
          "mute",
          "unmute",
          "sound",
          "volume",
          "microphone",
          "mic"
        ],
        "iconNames": [
          "volume-2",
          "volume-x",
          "mic",
          "mic-off"
        ],
        "aria": [
          "aria-pressed",
          "aria-label contains mute"
        ],
        "reactNames": [
          "Volume2",
          "VolumeX",
          "Mic",
          "MicOff"
        ]
      },
      "behavior": {
        "type": "toggle",
        "states": [
          {
            "state": "off",
            "icon": "volume-2"
          },
          {
            "state": "on",
            "icon": "volume-x"
          }
        ],
        "hover": "grow",
        "press": "pop",
        "easing": "spring",
        "howToChange": "pressTo",
        "variants": [
          {
            "icons": [
              "mic",
              "mic-off"
            ],
            "for": "microphone buttons"
          }
        ],
        "stateMeaning": "Default for new code only: the icon shows the current sound state"
      },
      "why": "The slash appears and disappears, so the state is clear."
    },
    {
      "id": "play-pause",
      "title": "Play and pause",
      "detect": {
        "labels": [
          "play",
          "pause",
          "resume"
        ],
        "iconNames": [
          "play",
          "pause"
        ],
        "aria": [
          "aria-label contains play or pause"
        ],
        "reactNames": [
          "Play",
          "Pause"
        ]
      },
      "behavior": {
        "type": "toggle",
        "states": [
          {
            "state": "off",
            "icon": "play"
          },
          {
            "state": "on",
            "icon": "pause"
          }
        ],
        "hover": "grow",
        "press": "pop",
        "easing": "smooth",
        "howToChange": "pressTo",
        "note": "Allowed as a morph. Keep the duration short (about 300 ms) so a fast tap never waits for the icon.",
        "duration": 300
      },
      "why": "The shapes are simple. A morph is allowed, but the hover stays a quiet grow."
    },
    {
      "id": "like",
      "title": "Like or favorite",
      "detect": {
        "labels": [
          "like",
          "favorite",
          "favourite",
          "love",
          "heart"
        ],
        "iconNames": [
          "heart",
          "star"
        ],
        "aria": [
          "aria-pressed",
          "aria-label contains like or favorite"
        ],
        "reactNames": [
          "Heart",
          "Star"
        ]
      },
      "behavior": {
        "type": "style",
        "icon": "heart",
        "hover": "grow",
        "press": "pop",
        "easing": "spring",
        "keepFill": true,
        "addFill": false,
        "note": "The icon keeps its shape. Do not add a fill and do not swap to another icon. If the old code showed a filled heart (a liked state), keep that fill with the filled prop (or the CSS in the playbook) and keep the same color."
      },
      "why": "The pop confirms the tap and the icon keeps its shape."
    },
    {
      "id": "bookmark",
      "title": "Bookmark or save",
      "detect": {
        "labels": [
          "bookmark",
          "save",
          "save for later"
        ],
        "iconNames": [
          "bookmark",
          "bookmark-check"
        ],
        "aria": [
          "aria-pressed",
          "aria-label contains bookmark or save"
        ],
        "reactNames": [
          "Bookmark",
          "BookmarkCheck"
        ]
      },
      "behavior": {
        "type": "toggle",
        "states": [
          {
            "state": "off",
            "icon": "bookmark"
          },
          {
            "state": "on",
            "icon": "bookmark-check"
          }
        ],
        "hover": "grow",
        "press": "pop",
        "easing": "spring",
        "howToChange": "pressTo",
        "keepFill": true,
        "addFill": false,
        "stateMeaning": "Default for new code only: bookmark when not saved, bookmark-check when saved",
        "note": "If the old code showed the saved state as a solid (filled) bookmark, do not morph to bookmark-check. Keep the name bookmark and keep the fill for the saved state (filled prop). Use bookmark-check only when the old code had no fill for the saved state."
      },
      "why": "The check appears inside the bookmark, so saved is clear."
    },
    {
      "id": "copy",
      "title": "Copy to clipboard",
      "detect": {
        "labels": [
          "copy",
          "copy link",
          "copy code",
          "clipboard"
        ],
        "iconNames": [
          "copy",
          "clipboard",
          "clipboard-copy"
        ],
        "aria": [
          "aria-label contains copy"
        ],
        "reactNames": [
          "Copy",
          "Clipboard",
          "ClipboardCopy"
        ]
      },
      "behavior": {
        "type": "status-chain",
        "states": [
          {
            "state": "idle",
            "icon": "copy"
          },
          {
            "state": "done",
            "icon": "check"
          }
        ],
        "hover": "grow",
        "press": "pop",
        "easing": "spring",
        "howToChange": "pressTo",
        "revertAfterMs": 1500,
        "note": "After the copy works, pressTo('check'). After 1500 ms, set the name back to copy. If the copy fails, do not show the check."
      },
      "why": "A short confirmation that the copy happened."
    },
    {
      "id": "delete",
      "title": "Delete",
      "detect": {
        "labels": [
          "delete",
          "remove",
          "trash",
          "discard"
        ],
        "iconNames": [
          "trash"
        ],
        "aria": [
          "aria-label contains delete or remove"
        ],
        "legacyNames": [
          "Trash2",
          "XCircle",
          "trash-2",
          "x-circle"
        ],
        "reactNames": [
          "Trash"
        ]
      },
      "behavior": {
        "type": "press-change",
        "states": [
          {
            "state": "idle",
            "icon": "trash"
          },
          {
            "state": "done",
            "icon": "check"
          }
        ],
        "hover": "grow",
        "press": "burst",
        "easing": "spring",
        "howToChange": "pressTo",
        "revertAfterMs": 1200,
        "note": "Never change what the button does. Use the check only if the button stays on screen after the delete (for example an undo state). If the row or the button disappears, play the burst only and skip the check."
      },
      "why": "A burst feels final. The check says it worked."
    },
    {
      "id": "send",
      "title": "Send or submit",
      "detect": {
        "labels": [
          "send",
          "submit",
          "post",
          "reply"
        ],
        "iconNames": [
          "send",
          "send-horizontal"
        ],
        "aria": [
          "aria-label contains send or submit"
        ],
        "legacyNames": [
          "paper-plane"
        ],
        "reactNames": [
          "Send",
          "SendHorizontal"
        ]
      },
      "behavior": {
        "type": "status-chain",
        "states": [
          {
            "state": "idle",
            "icon": "send"
          },
          {
            "state": "done",
            "icon": "check"
          }
        ],
        "hover": "grow",
        "press": "squat-jump",
        "easing": "spring",
        "howToChange": "pressTo",
        "revertAfterMs": 1500,
        "note": "Show the check only after the request succeeds. On failure keep the send icon."
      },
      "why": "The icon jumps as the message leaves, then confirms."
    },
    {
      "id": "follow",
      "title": "Follow or add a user",
      "detect": {
        "labels": [
          "follow",
          "add friend",
          "add user",
          "connect",
          "invite"
        ],
        "iconNames": [
          "user-plus",
          "user-check",
          "user-round-plus",
          "user-round-check"
        ],
        "aria": [
          "aria-pressed",
          "aria-label contains follow"
        ],
        "reactNames": [
          "UserPlus",
          "UserCheck",
          "UserRoundPlus",
          "UserRoundCheck"
        ]
      },
      "behavior": {
        "type": "toggle",
        "states": [
          {
            "state": "off",
            "icon": "user-plus"
          },
          {
            "state": "on",
            "icon": "user-check"
          }
        ],
        "hover": "grow",
        "press": "squat-jump",
        "easing": "spring",
        "howToChange": "pressTo",
        "stateMeaning": "Default for new code only: user-plus when not following, user-check when following"
      },
      "why": "The person gets a check. The jump makes the gain feel good."
    },
    {
      "id": "notifications",
      "title": "Notifications on and off",
      "detect": {
        "labels": [
          "notifications",
          "alerts",
          "bell",
          "subscribe",
          "mute notifications"
        ],
        "iconNames": [
          "bell",
          "bell-off",
          "bell-ring"
        ],
        "aria": [
          "aria-pressed",
          "aria-label contains notification"
        ],
        "reactNames": [
          "Bell",
          "BellOff",
          "BellRing"
        ]
      },
      "behavior": {
        "type": "toggle",
        "states": [
          {
            "state": "off",
            "icon": "bell"
          },
          {
            "state": "on",
            "icon": "bell-off"
          }
        ],
        "hover": "grow",
        "press": "pop",
        "easing": "spring",
        "howToChange": "pressTo",
        "stateMeaning": "Default for new code only: bell when on, bell-off when off",
        "note": "If the button only opens a list of notifications and has no on and off state, use hover grow and press pop and keep the bell."
      },
      "why": "The slash shows that alerts are off."
    },
    {
      "id": "transfer-status",
      "title": "Upload or download progress",
      "detect": {
        "labels": [
          "upload",
          "download",
          "uploading",
          "downloading",
          "sync"
        ],
        "iconNames": [
          "upload",
          "download",
          "loader",
          "check"
        ],
        "aria": [
          "aria-busy",
          "aria-live status text near it"
        ],
        "reactNames": [
          "Upload",
          "Download",
          "Loader",
          "Check"
        ]
      },
      "behavior": {
        "type": "style",
        "hover": "grow",
        "press": "pop",
        "easing": "smooth",
        "note": "Default: the icon keeps its shape (upload or download). Add the chain only if the app already has an async state for the transfer (idle, working, done).",
        "withAsyncState": {
          "states": [
            {
              "state": "idle",
              "icon": "upload",
              "alt": "download"
            },
            {
              "state": "working",
              "icon": "loader"
            },
            {
              "state": "done",
              "icon": "check"
            }
          ],
          "howToChange": "name",
          "note": "Set the name when the real state changes: idle, then loader while the work runs, then check when it ends. Use download in place of upload for downloads. On failure go back to the idle icon and show your own error."
        }
      },
      "why": "Without a real state the icon only needs a quiet hover and press. With a real state the icon follows it."
    },
    {
      "id": "menu",
      "title": "Menu open and close",
      "detect": {
        "labels": [
          "menu",
          "open menu",
          "close menu",
          "navigation menu",
          "hamburger"
        ],
        "iconNames": [
          "menu",
          "x"
        ],
        "aria": [
          "aria-expanded",
          "aria-controls"
        ],
        "reactNames": [
          "Menu",
          "X"
        ]
      },
      "behavior": {
        "type": "toggle",
        "states": [
          {
            "state": "off",
            "icon": "menu"
          },
          {
            "state": "on",
            "icon": "x"
          }
        ],
        "hover": "grow",
        "press": "pop",
        "easing": "spring",
        "howToChange": "pressTo",
        "stateMeaning": "Default for new code only: menu while closed, x while open. Tie the icon to the same state as aria-expanded."
      },
      "why": "This is the classic morph: the three lines become a cross."
    },
    {
      "id": "expand-collapse",
      "title": "Expand and collapse",
      "detect": {
        "labels": [
          "expand",
          "collapse",
          "show more",
          "show less",
          "accordion",
          "toggle section"
        ],
        "iconNames": [
          "chevron-down",
          "chevron-up"
        ],
        "aria": [
          "aria-expanded"
        ],
        "reactNames": [
          "ChevronDown",
          "ChevronUp"
        ]
      },
      "behavior": {
        "type": "toggle",
        "states": [
          {
            "state": "off",
            "icon": "chevron-down"
          },
          {
            "state": "on",
            "icon": "chevron-up"
          }
        ],
        "hover": "grow",
        "press": "none",
        "easing": "smooth",
        "howToChange": "pressTo",
        "stateMeaning": "Default for new code only: chevron-down when collapsed, chevron-up when expanded. Tie the icon to aria-expanded.",
        "duration": 280
      },
      "why": "The chevron turns with the panel. A press style would add noise to a repeated control."
    },
    {
      "id": "search",
      "title": "Search",
      "detect": {
        "labels": [
          "search",
          "find",
          "lookup"
        ],
        "iconNames": [
          "search",
          "zoom-in"
        ],
        "aria": [
          "role=search nearby",
          "aria-label contains search"
        ],
        "reactNames": [
          "Search",
          "ZoomIn"
        ]
      },
      "behavior": {
        "type": "style",
        "icon": "search",
        "hover": "grow",
        "press": "pop",
        "easing": "spring"
      },
      "why": "The icon stays a lens. A small grow invites the click."
    },
    {
      "id": "share",
      "title": "Share",
      "detect": {
        "labels": [
          "share",
          "send to",
          "share link"
        ],
        "iconNames": [
          "share",
          "share-2"
        ],
        "aria": [
          "aria-label contains share"
        ],
        "reactNames": [
          "Share",
          "Share2"
        ]
      },
      "behavior": {
        "type": "style",
        "icon": "share",
        "hover": "grow",
        "press": "pop",
        "easing": "spring",
        "onSuccess": {
          "icon": "check",
          "revertAfterMs": 1500,
          "onlyIf": "the old code already swaps to a check after a share or a copy"
        },
        "note": "No morph by default. If the app already shows a check after the share, morph share to check with pressTo and back after 1500 ms."
      },
      "why": "A quiet grow and a pop. The icon changes only if the app already confirmed the share."
    },
    {
      "id": "comment",
      "title": "Comment or reply",
      "detect": {
        "labels": [
          "comment",
          "reply",
          "discuss",
          "message",
          "chat"
        ],
        "iconNames": [
          "message-circle",
          "message-square"
        ],
        "aria": [
          "aria-label contains comment or reply"
        ],
        "reactNames": [
          "MessageCircle",
          "MessageSquare"
        ]
      },
      "behavior": {
        "type": "style",
        "icon": "message-circle",
        "hover": "grow",
        "press": "pop",
        "easing": "spring",
        "onSuccess": {
          "icon": "check",
          "revertAfterMs": 1500,
          "onlyIf": "the old code already swaps to a check after the comment is posted"
        },
        "note": "No morph by default. If the app already shows a check after posting, morph to check and back after 1500 ms."
      },
      "why": "A quiet grow and a pop. The icon changes only if the app already confirmed the action."
    },
    {
      "id": "settings",
      "title": "Settings",
      "detect": {
        "labels": [
          "settings",
          "preferences",
          "options",
          "configure"
        ],
        "iconNames": [
          "settings",
          "sliders-horizontal",
          "cog"
        ],
        "aria": [
          "aria-label contains settings"
        ],
        "reactNames": [
          "Settings",
          "SlidersHorizontal",
          "Cog"
        ]
      },
      "behavior": {
        "type": "style",
        "icon": "settings",
        "hover": "bend",
        "press": "pop",
        "easing": "spring"
      },
      "why": "The gear bends a little. It fits a tool icon."
    },
    {
      "id": "nav-icon",
      "title": "Navigation icons (tab bars, sidebars)",
      "detect": {
        "labels": [
          "home",
          "profile",
          "inbox",
          "messages",
          "dashboard",
          "explore",
          "calendar",
          "schedule",
          "map",
          "cart",
          "files",
          "people",
          "wallet"
        ],
        "iconNames": [
          "house",
          "user",
          "inbox",
          "message-circle",
          "layout-dashboard",
          "compass",
          "calendar",
          "map",
          "shopping-cart",
          "folder",
          "users",
          "wallet",
          "layout-grid",
          "chart-bar"
        ],
        "aria": [
          "role=tab",
          "aria-current in a nav",
          "the control is inside a nav bar, tab bar or sidebar menu"
        ],
        "reactNames": [
          "House",
          "User",
          "Inbox",
          "MessageCircle",
          "LayoutDashboard",
          "Compass",
          "Calendar",
          "Map",
          "ShoppingCart",
          "Folder",
          "Users",
          "Wallet",
          "LayoutGrid",
          "ChartBar"
        ]
      },
      "behavior": {
        "type": "style",
        "hover": "move",
        "press": "squash",
        "easing": "spring",
        "note": "Apply the same hover to every icon of one bar. Do not give each icon a different style."
      },
      "why": "The shapes shift in place. It feels the same on every tab of the bar."
    },
    {
      "id": "close",
      "title": "Close or dismiss",
      "detect": {
        "labels": [
          "close",
          "dismiss",
          "cancel",
          "clear"
        ],
        "iconNames": [
          "x",
          "circle-x"
        ],
        "aria": [
          "aria-label contains close or dismiss"
        ],
        "legacyNames": [
          "x-circle",
          "XCircle"
        ],
        "reactNames": [
          "X",
          "CircleX"
        ]
      },
      "behavior": {
        "type": "style",
        "icon": "x",
        "hover": "grow",
        "press": "none",
        "easing": "smooth",
        "morph": false,
        "note": "No morph. The button closes something, so the icon stays an x."
      },
      "why": "A quiet grow only. The action is final, so the icon must not change."
    },
    {
      "id": "external-link",
      "title": "External links",
      "detect": {
        "labels": [
          "external",
          "opens in a new tab",
          "visit"
        ],
        "iconNames": [
          "external-link",
          "arrow-up-right",
          "link"
        ],
        "aria": [
          "target=_blank",
          "rel contains noopener"
        ],
        "reactNames": [
          "ExternalLink",
          "ArrowUpRight",
          "Link"
        ]
      },
      "behavior": {
        "type": "none",
        "animate": false,
        "note": "Do not animate. Leave the icon as it is."
      },
      "why": "A link marker is a signal, not a control. Motion there is noise."
    },
    {
      "id": "generic-action",
      "title": "Any other action button or link",
      "detect": {
        "labels": [
          "any button or link whose purpose is clear but matches no other role"
        ],
        "iconNames": [],
        "aria": [
          "the icon is inside a button or a link"
        ],
        "legacyNames": [],
        "reactNames": []
      },
      "behavior": {
        "type": "style",
        "hover": "grow",
        "press": "pop",
        "easing": "spring",
        "note": "Use only when the control is a button or a link, its purpose is clear, and no other role fits. If the purpose is unclear, ask the user."
      },
      "why": "The safe default: a small grow and a pop that never changes the icon."
    }
  ],
  "priority": [
    "inside a nav bar, tab bar or sidebar menu: nav-icon",
    "otherwise the most specific role",
    "otherwise generic-action, if the purpose is clear",
    "otherwise ask the user"
  ],
  "behaviorTypes": {
    "style": "hover and press only, the icon keeps its shape",
    "toggle": "two icons that follow an on and off state",
    "status-chain": "the icon changes for a short time after an action, then goes back",
    "press-change": "the press plays and the icon changes to a check",
    "none": "no animation"
  },
  "component": {
    "reactProp": "role",
    "note": "The React MorphIcon component of the shadcn item has a role prop. role=\"theme-toggle\" sets hover, press and easing (and duration) from this catalog. Explicit props override it. It is not the HTML role attribute."
  }
}
