{
  "catalogVersion": 1,
  "name": "echo-ui",
  "homepage": "https://echo-ui.com",
  "components": [
    {
      "name": "echo-modal",
      "title": "Modal",
      "version": "0.3.0",
      "description": "Base UI Dialog (focus trap, Esc, scroll-lock, a11y) skinned as an Echo device panel. Enter uses motion.summon; exit is a GSAP tween that completes before unmount via useGsapPresence + actionsRef. sound.transition on open.",
      "summary": "Base UI Dialog (focus trap, Esc, scroll-lock, a11y) skinned as an Echo device panel. motion.summon enter, GSAP exit that completes before unmount, sound.transition on open.",
      "group": "Overlays",
      "type": "registry:component",
      "dependencies": [
        "gsap",
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add modal",
      "props": [
        {
          "name": "trigger",
          "type": "ReactNode",
          "required": false,
          "description": "What opens the modal. An element (e.g. a HardwareButton) becomes the trigger itself; text is wrapped in a plain trigger."
        },
        {
          "name": "title",
          "type": "ReactNode",
          "required": false,
          "description": "The dialog title — also its accessible name. Always pass one."
        },
        {
          "name": "description",
          "type": "ReactNode",
          "required": false,
          "description": "One line under the title, announced with it."
        },
        {
          "name": "footer",
          "type": "ReactNode",
          "required": false,
          "description": "Actions row under the body, e.g. confirm and cancel buttons."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "The panel's width: sm 22rem, md (the default) 30rem, lg 42rem, each held inside a narrow screen."
        },
        {
          "name": "showClose",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Show the × in the corner. Default true; turn it off only when the dialog's own actions close it."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "container",
          "type": "unknown",
          "required": false,
          "description": "Where the popup mounts; defaults to <body>. Pass an element inside a scoped theme so the popup wears that theme, not the :root one."
        }
      ],
      "examples": [
        {
          "title": "Basic modal",
          "description": "A trigger plus title/description/body.",
          "code": "<Modal\n  trigger={<HardwareButton>Settings</HardwareButton>}\n  title=\"Settings\"\n  description=\"Tune the device.\"\n>\n  <p>Body content…</p>\n</Modal>"
        },
        {
          "title": "With footer actions",
          "description": "Footer for confirm/cancel.",
          "code": "<Modal trigger=\"Delete\" title=\"Are you sure?\" footer={<HardwareButton variant=\"primary\">Confirm</HardwareButton>} />"
        }
      ],
      "antiPatterns": [
        "Do not add a CSS transition to the popup — GSAP owns its transform/opacity; a CSS transition will fight it.",
        "Do not skip actionsRef when animating exit with JS — Base UI can't detect GSAP and will unmount too early.",
        "The package is `@base-ui/react`, not `@base-ui-components/react` (agents have stale training data)."
      ],
      "usage": {
        "imports": "import { HardwareButton } from \"@/echo/hardware-button\";\nimport { Modal } from \"@/echo/modal\";",
        "code": "<Modal\n  trigger={<HardwareButton>Settings</HardwareButton>}\n  title=\"Settings\"\n  description=\"Tune the device.\"\n>\n  <p>Body content…</p>\n</Modal>"
      },
      "composition": "Modal",
      "a11y": [
        "Base UI Dialog: `role=\"dialog\"` and `aria-modal`, named by `title` and described by `description`.",
        "Focus moves into the panel, stays there while it is open, and returns to the trigger when it closes. The page behind cannot scroll.",
        "Escape or a click on the backdrop closes it."
      ],
      "motion": {
        "tokens": [
          "island",
          "summon",
          "follow"
        ],
        "reducedMotion": "The panel fades in and out without blooming from its pill; the backdrop fades faster."
      },
      "sound": [
        "`transition` when it opens."
      ],
      "feel": "A device panel that swells out of a small pill in the middle of the room, like an island opening, and gathers back into it when dismissed.",
      "api": {
        "Modal": {
          "props": [
            "children",
            "container",
            "defaultOpen",
            "description",
            "footer",
            "showClose",
            "size",
            "title",
            "trigger"
          ]
        }
      },
      "weight": {
        "js": 771,
        "withDependencies": 55275,
        "css": 953
      }
    },
    {
      "name": "echo-menu",
      "title": "Menu",
      "version": "0.3.0",
      "description": "Base UI Menu (typeahead, roving focus, submenu timing, checkbox/radio item semantics) skinned as an Echo floating panel — the Select's molded cap trigger and the same lit dot for what is on. Two sounds: transition when the panel arrives, select when an item commits. Exports ContextMenu from the same item for the right-click case. Compound API.",
      "summary": "Base UI Menu (typeahead, roving focus, submenu timing, checkbox/radio items) skinned as an Echo floating panel — the Select's molded cap trigger and the same lit dot for what is on. sound.transition on open, sound.select on item commit. Exports ContextMenu for the right-click case.",
      "group": "Overlays",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add menu",
      "props": [
        {
          "name": "Content.side",
          "type": "\"top\" | \"right\" | \"bottom\" | \"left\"",
          "required": false,
          "defaultValue": "\"bottom\"",
          "description": "Preferred side of the trigger. Flips automatically when there is no room."
        },
        {
          "name": "Content.align",
          "type": "\"start\" | \"center\" | \"end\"",
          "required": false,
          "defaultValue": "\"start\"",
          "description": "Alignment along that side. A menu hangs off its trigger, so start is the default."
        },
        {
          "name": "Content.sideOffset",
          "type": "number",
          "required": false,
          "defaultValue": "8",
          "description": "Gap between trigger and panel, in px."
        },
        {
          "name": "Content.container",
          "type": "unknown",
          "required": false,
          "description": "Where the panel mounts; defaults to <body>. Pass an element inside a scoped theme so the panel wears that theme, not the :root one."
        }
      ],
      "examples": [
        {
          "title": "Dropdown",
          "description": "Content wraps Base UI's Portal, Positioner and Popup in one.",
          "code": "<Menu>\n  <Menu.Trigger>Actions</Menu.Trigger>\n  <Menu.Content>\n    <Menu.Item onClick={rename}>Rename</Menu.Item>\n    <Menu.Item onClick={duplicate}>Duplicate</Menu.Item>\n    <Menu.Separator />\n    <Menu.Item onClick={archive}>Archive</Menu.Item>\n  </Menu.Content>\n</Menu>"
        },
        {
          "title": "Your own trigger",
          "description": "render swaps the default cap for any component, keeping the menu wiring.",
          "code": "<Menu.Trigger render={<HardwareButton>Actions</HardwareButton>} />"
        },
        {
          "title": "Checkable items",
          "description": "The lit dot marks what is on — the same indicator Select uses.",
          "code": "<Menu.CheckboxItem checked={grid} onCheckedChange={setGrid}>\n  Show grid\n</Menu.CheckboxItem>"
        },
        {
          "title": "Right-click menu",
          "description": "Same panel, same items; the Trigger is the region, not a button.",
          "code": "<ContextMenu>\n  <ContextMenu.Trigger className=\"track\">…</ContextMenu.Trigger>\n  <ContextMenu.Content>\n    <ContextMenu.Item onClick={split}>Split at playhead</ContextMenu.Item>\n  </ContextMenu.Content>\n</ContextMenu>"
        }
      ],
      "antiPatterns": [
        "Do not use a Menu as navigation for a whole site — it hides every destination behind a press. A menu is for ACTIONS on the thing you already have; a nav is a list you can see.",
        "Never put the destructive action adjacent to the routine one in the same group. Separate it, or better, send it through an AlertDialog — a menu item fires on the first click, with no second chance.",
        "Do not reach for a Menu when the choice sets a value — that is Select, which reports the current value in its trigger. A Menu trigger says what it does, not what is chosen.",
        "Do not nest more than one submenu level. Base UI will happily render it; a person will not find it."
      ],
      "usage": {
        "imports": "import { Menu } from \"@/echo/menu\";",
        "code": "<Menu>\n  <Menu.Trigger>Actions</Menu.Trigger>\n  <Menu.Content>\n    <Menu.Item onClick={rename}>Rename</Menu.Item>\n    <Menu.Separator />\n    <Menu.Item onClick={archive}>Archive</Menu.Item>\n  </Menu.Content>\n</Menu>"
      },
      "composition": "Menu\n  Menu.Trigger\n  Menu.Content\n    Menu.Item\n    Menu.LinkItem\n    Menu.CheckboxItem\n    Menu.RadioGroup\n      Menu.RadioItem\n    Menu.Group\n      Menu.GroupLabel\n    Menu.Separator\n    Menu.SubmenuTrigger\nContextMenu\n  ContextMenu.Content",
      "a11y": [
        "Base UI Menu: the trigger is a button with `aria-haspopup`; the list is a `menu` of `menuitem`s, with `menuitemcheckbox` and `menuitemradio` for the marked items.",
        "Arrow keys move through items, typing jumps to a match, Enter or Space activates, Escape closes and returns focus to the trigger.",
        "`ContextMenu` opens on right-click and on long-press; give the same actions another way in too, since neither is discoverable."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-pop",
          "--echo-duration-swap"
        ],
        "reducedMotion": "The list appears with a short fade and does not slide, and the button does not travel when pressed."
      },
      "sound": [
        "`transition` when it opens.",
        "`select` when an item is chosen."
      ],
      "feel": "A short list that drops from its button, which presses like the library's keys; each choice clicks like a key.",
      "api": {
        "Menu": {
          "props": [
            "actionsRef",
            "children",
            "closeParentOnEsc",
            "defaultOpen",
            "defaultTriggerId",
            "disabled",
            "handle",
            "highlightItemOnHover",
            "loopFocus",
            "modal",
            "onOpenChange",
            "onOpenChangeComplete",
            "open",
            "orientation",
            "triggerId"
          ]
        },
        "Menu.Item": {
          "props": [
            "closeOnClick",
            "key",
            "nativeButton",
            "ref",
            "render"
          ],
          "html": true
        },
        "Menu.LinkItem": {
          "props": [
            "closeOnClick",
            "key",
            "ping",
            "ref",
            "referrerPolicy",
            "render"
          ],
          "html": true
        },
        "Menu.Separator": {
          "props": [
            "key",
            "orientation",
            "ref",
            "render"
          ],
          "html": true
        },
        "Menu.Group": {
          "props": [
            "key",
            "ref",
            "render"
          ],
          "html": true
        },
        "Menu.GroupLabel": {
          "props": [
            "key",
            "ref",
            "render"
          ],
          "html": true
        },
        "Menu.CheckboxItem": {
          "props": [
            "closeOnClick",
            "key",
            "nativeButton",
            "onCheckedChange",
            "ref",
            "render"
          ],
          "html": true
        },
        "Menu.RadioGroup": {
          "props": [
            "key",
            "onValueChange",
            "ref",
            "render"
          ],
          "html": true
        },
        "Menu.RadioItem": {
          "props": [
            "closeOnClick",
            "key",
            "nativeButton",
            "ref",
            "render"
          ],
          "html": true,
          "required": [
            "value"
          ]
        },
        "Menu.Submenu": {
          "props": [
            "actionsRef",
            "children",
            "closeParentOnEsc",
            "defaultOpen",
            "disabled",
            "highlightItemOnHover",
            "loopFocus",
            "onOpenChange",
            "onOpenChangeComplete",
            "open",
            "orientation"
          ]
        },
        "Menu.SubmenuTrigger": {
          "props": [
            "closeDelay",
            "delay",
            "key",
            "nativeButton",
            "openOnHover",
            "ref",
            "render"
          ],
          "html": true
        },
        "Menu.Trigger": {
          "props": [
            "closeDelay",
            "delay",
            "handle",
            "key",
            "nativeButton",
            "openOnHover",
            "payload",
            "ref",
            "render"
          ],
          "html": true
        },
        "Menu.Content": {
          "props": [
            "align",
            "container",
            "finalFocus",
            "key",
            "ref",
            "render",
            "side",
            "sideOffset"
          ],
          "html": true
        },
        "ContextMenu": {
          "props": [
            "actionsRef",
            "children",
            "closeParentOnEsc",
            "defaultOpen",
            "disabled",
            "highlightItemOnHover",
            "loopFocus",
            "onOpenChange",
            "onOpenChangeComplete",
            "open",
            "orientation"
          ]
        },
        "ContextMenu.Item": {
          "props": [
            "closeOnClick",
            "key",
            "nativeButton",
            "ref",
            "render"
          ],
          "html": true
        },
        "ContextMenu.LinkItem": {
          "props": [
            "closeOnClick",
            "key",
            "ping",
            "ref",
            "referrerPolicy",
            "render"
          ],
          "html": true
        },
        "ContextMenu.Separator": {
          "props": [
            "key",
            "orientation",
            "ref",
            "render"
          ],
          "html": true
        },
        "ContextMenu.Group": {
          "props": [
            "key",
            "ref",
            "render"
          ],
          "html": true
        },
        "ContextMenu.GroupLabel": {
          "props": [
            "key",
            "ref",
            "render"
          ],
          "html": true
        },
        "ContextMenu.CheckboxItem": {
          "props": [
            "closeOnClick",
            "key",
            "nativeButton",
            "onCheckedChange",
            "ref",
            "render"
          ],
          "html": true
        },
        "ContextMenu.RadioGroup": {
          "props": [
            "key",
            "onValueChange",
            "ref",
            "render"
          ],
          "html": true
        },
        "ContextMenu.RadioItem": {
          "props": [
            "closeOnClick",
            "key",
            "nativeButton",
            "ref",
            "render"
          ],
          "html": true,
          "required": [
            "value"
          ]
        },
        "ContextMenu.Submenu": {
          "props": [
            "actionsRef",
            "children",
            "closeParentOnEsc",
            "defaultOpen",
            "disabled",
            "highlightItemOnHover",
            "loopFocus",
            "onOpenChange",
            "onOpenChangeComplete",
            "open",
            "orientation"
          ]
        },
        "ContextMenu.SubmenuTrigger": {
          "props": [
            "closeDelay",
            "delay",
            "key",
            "nativeButton",
            "openOnHover",
            "ref",
            "render"
          ],
          "html": true
        },
        "ContextMenu.Trigger": {
          "props": [
            "key",
            "ref",
            "render"
          ],
          "html": true
        },
        "ContextMenu.Content": {
          "props": [
            "align",
            "container",
            "finalFocus",
            "key",
            "ref",
            "render",
            "side",
            "sideOffset"
          ],
          "html": true
        }
      },
      "weight": {
        "js": 1152,
        "withDependencies": 85467,
        "css": 1486
      }
    },
    {
      "name": "echo-sheet",
      "title": "Sheet",
      "version": "0.3.0",
      "description": "A molded chassis panel that slides in from an edge — the console side panel and the mobile nav. Built on Base UI's Drawer, so it carries swipe-to-dismiss and a backdrop that tracks the swipe rather than a Dialog translated sideways. sound.transition on open. Compound API.",
      "summary": "A molded chassis panel that slides in from an edge — the console side panel and the mobile nav. Built on Base UI's Drawer, so it carries swipe-to-dismiss and a backdrop that tracks the swipe. sound.transition on open.",
      "group": "Overlays",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add sheet",
      "props": [
        {
          "name": "side",
          "type": "\"left\" | \"right\" | \"top\" | \"bottom\"",
          "required": false,
          "defaultValue": "\"right\"",
          "description": "Edge the sheet is pinned to AND the direction it swipes away — one decision, so they cannot drift apart."
        },
        {
          "name": "Content.title",
          "type": "unknown",
          "required": true,
          "description": "Accessible name for the panel. A drawer without one is an unlabelled region."
        },
        {
          "name": "Content.description",
          "type": "unknown",
          "required": true,
          "description": "What the panel is for, under the title."
        },
        {
          "name": "Content.showClose",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Show the corner close control."
        },
        {
          "name": "Content.container",
          "type": "unknown",
          "required": false,
          "description": "Where the panel mounts; defaults to <body>. Pass an element inside a scoped theme so the panel wears that theme, not the :root one."
        }
      ],
      "examples": [
        {
          "title": "Side panel",
          "description": "Defaults to the right edge, swiping right to dismiss.",
          "code": "<Sheet>\n  <Sheet.Trigger>Channels</Sheet.Trigger>\n  <Sheet.Content title=\"Channels\" description=\"Pick an input to monitor.\">\n    …\n  </Sheet.Content>\n</Sheet>"
        },
        {
          "title": "Mobile nav",
          "description": "From the left, where a hamburger button lives.",
          "code": "<Sheet side=\"left\">\n  <Sheet.Trigger>Menu</Sheet.Trigger>\n  <Sheet.Content title=\"Navigate\">…</Sheet.Content>\n</Sheet>"
        },
        {
          "title": "Bottom sheet",
          "description": "Swipes down. Good for a one-handed action list on a phone.",
          "code": "<Sheet side=\"bottom\"><Sheet.Content title=\"Export\">…</Sheet.Content></Sheet>"
        }
      ],
      "antiPatterns": [
        "Always pass a title. Without one the panel is an unlabelled region, and a screen-reader user is dropped into content with no idea what opened.",
        "Do not put a form that needs a confirm-or-cancel decision in a Sheet — a sheet can be swiped away mid-edit by design. That is a Modal, which cannot.",
        "Do not stack sheets. A second sheet over the first leaves no way back except dismissing both, and the swipe gesture becomes ambiguous.",
        "Do not use a Sheet where a Popover would do. A sheet takes the whole edge of the screen and a backdrop; a small anchored panel does not need either."
      ],
      "usage": {
        "imports": "import { Sheet } from \"@/echo/sheet\";",
        "code": "<Sheet>\n  <Sheet.Trigger>Channels</Sheet.Trigger>\n  <Sheet.Content title=\"Channels\" description=\"Pick an input to monitor.\">\n    …\n  </Sheet.Content>\n</Sheet>"
      },
      "composition": "Sheet\n  Sheet.Trigger\n  Sheet.Content\n    Sheet.Close",
      "a11y": [
        "Base UI Drawer: a modal dialog named by `title` and described by `description`.",
        "Focus is trapped inside while open and returns to the trigger when it closes. Escape or a click on the backdrop closes it.",
        "Swiping it away is a touch extra; every way of closing it also works from the keyboard."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-pop"
        ],
        "reducedMotion": "The panel and backdrop fade instead of sliding, and its keys do not travel when pressed."
      },
      "sound": [
        "`transition` when it opens."
      ],
      "feel": "A panel that slides in from the edge and can be swiped back out. Its trigger, close key and actions press like the library's keys.",
      "api": {
        "Sheet": {
          "props": [
            "actionsRef",
            "children",
            "defaultOpen",
            "defaultSnapPoint",
            "defaultTriggerId",
            "disablePointerDismissal",
            "handle",
            "modal",
            "onOpenChange",
            "onOpenChangeComplete",
            "onSnapPointChange",
            "open",
            "side",
            "snapPoint",
            "snapPoints",
            "snapToSequentialPoints",
            "triggerId"
          ]
        },
        "Sheet.Trigger": {
          "props": [
            "handle",
            "key",
            "nativeButton",
            "payload",
            "ref",
            "render"
          ],
          "html": true
        },
        "Sheet.Content": {
          "props": [
            "container",
            "description",
            "finalFocus",
            "initialFocus",
            "key",
            "ref",
            "render",
            "showClose"
          ],
          "html": true
        },
        "Sheet.Close": {
          "props": [
            "key",
            "nativeButton",
            "ref",
            "render"
          ],
          "html": true
        }
      },
      "weight": {
        "js": 754,
        "withDependencies": 68976,
        "css": 1736
      }
    },
    {
      "name": "echo-alert-dialog",
      "title": "AlertDialog",
      "version": "0.3.0",
      "description": "A destructive confirm. Modal covers the structure; this is the semantics — role=alertdialog, a required description, and no dismissal by clicking away, so the question has to be answered. Its own sound: caution (the one rough waveform) on open, error on a destructive confirm. Severity reads as the deep end of the accent ramp, never an imported red.",
      "summary": "A destructive confirm. Modal covers the structure; this is the semantics — role=alertdialog, a required description, and no dismissal by clicking away. sound.caution on open, sound.error on a destructive confirm. Severity is the deep end of the accent ramp, never an imported red.",
      "group": "Overlays",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add alert-dialog",
      "props": [
        {
          "name": "trigger",
          "type": "unknown",
          "required": true,
          "description": "Element or text that opens the dialog."
        },
        {
          "name": "title",
          "type": "unknown",
          "required": true,
          "description": "The decision, phrased as a question."
        },
        {
          "name": "description",
          "type": "unknown",
          "required": true,
          "description": "What happens if they say yes. Required — a confirm without its consequence has already decided for the user."
        },
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "Extra detail between the description and the answers."
        },
        {
          "name": "confirmLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"Confirm\"",
          "description": "Name the ACTION (\"Erase\", \"Discard\"), never \"OK\" — the label is the last thing read before it happens."
        },
        {
          "name": "cancelLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"Cancel\"",
          "description": "The answer that backs out, which has focus first. Default \"Cancel\"."
        },
        {
          "name": "destructive",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Defaults TRUE: this component exists for the destructive case, so the dangerous spelling is not the quiet one."
        },
        {
          "name": "onConfirm",
          "type": "function",
          "required": false,
          "description": "Fired when the confirming answer is pressed."
        },
        {
          "name": "onCancel",
          "type": "function",
          "required": false,
          "description": "Called when the backing-out answer is pressed."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false,
          "description": "Controlled open state."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false
        },
        {
          "name": "onOpenChange",
          "type": "function",
          "required": false
        },
        {
          "name": "container",
          "type": "unknown",
          "required": false,
          "description": "Where the dialog mounts; defaults to <body>. Pass an element inside a scoped theme so the dialog wears that theme, not the :root one."
        }
      ],
      "examples": [
        {
          "title": "Erase confirm",
          "description": "Name the action in the button, state the consequence above it.",
          "code": "<AlertDialog\n  trigger={<HardwareButton>Erase takes</HardwareButton>}\n  title=\"Erase all takes?\"\n  description=\"Twelve recordings will be removed. This cannot be undone.\"\n  confirmLabel=\"Erase\"\n  onConfirm={erase}\n/>"
        },
        {
          "title": "A serious but non-destructive decision",
          "description": "destructive={false} keeps the modality and drops the heavy cap and the sound.",
          "code": "<AlertDialog\n  title=\"Publish to the public registry?\"\n  description=\"Anyone will be able to install this item.\"\n  confirmLabel=\"Publish\"\n  destructive={false}\n  onConfirm={publish}\n/>"
        }
      ],
      "antiPatterns": [
        "Never label the confirming answer \"OK\" or \"Yes\". By the time someone reads the button they have often stopped reading the prose — the label has to say what will happen.",
        "Do not use an AlertDialog for anything reversible. It blocks the whole screen and cannot be dismissed by clicking away; spend that interruption only on decisions that cannot be undone. For a reversible action, act and offer an undo in a Toast.",
        "Do not put the destructive answer first in the DOM. Cancel comes first so it is what Tab reaches and what a reflex Enter confirms.",
        "Never restate the title in the description. The title is the question; the description is the consequence. If the description only rephrases the question, the user has been told nothing.",
        "Do not import a red. Severity here is the deep end of the accent ramp so it survives a reseed — a hardcoded #d00 is the one thing that will not relight."
      ],
      "usage": {
        "imports": "import { AlertDialog } from \"@/echo/alert-dialog\";\nimport { HardwareButton } from \"@/echo/hardware-button\";",
        "code": "<AlertDialog\n  trigger={<HardwareButton>Erase takes</HardwareButton>}\n  title=\"Erase all takes?\"\n  description=\"Twelve recordings will be removed. This cannot be undone.\"\n  confirmLabel=\"Erase\"\n  onConfirm={erase}\n/>"
      },
      "composition": "AlertDialog",
      "a11y": [
        "`role=\"alertdialog\"`, named by `title` and described by `description`, so a screen reader announces a decision, not a panel.",
        "Clicking outside does not dismiss it — it has to be answered.",
        "Cancel comes first in the tab order, so a reflexive Enter never confirms the destructive action.",
        "Focus is trapped while it is open and returns to the trigger afterwards."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-glide",
          "--echo-duration-swap"
        ],
        "reducedMotion": "It appears in place with a short fade, and its buttons no longer move when pressed."
      },
      "sound": [
        "`caution` when it opens.",
        "`error` when a destructive action is confirmed; nothing when a non-destructive one is."
      ],
      "feel": "A warning that has to be answered: a rough buzz on arrival, and Cancel under your finger first. Both answers press like the library's keys, down onto their seam.",
      "api": {
        "AlertDialog": {
          "props": [
            "cancelLabel",
            "children",
            "confirmLabel",
            "container",
            "defaultOpen",
            "description",
            "destructive",
            "onCancel",
            "onConfirm",
            "onOpenChange",
            "open",
            "title",
            "trigger"
          ],
          "required": [
            "description",
            "title"
          ]
        }
      },
      "weight": {
        "js": 785,
        "withDependencies": 54867,
        "css": 1202
      }
    },
    {
      "name": "echo-size",
      "title": "SizeProvider",
      "version": "0.3.0",
      "description": "One size scale for everything with a height: sm, md and lg are the control heights (32, 44 and 60px at default density), so a button, a field and a select of the same size line up in a row. Wrap a toolbar, a filter bar or a card footer in SizeProvider and every control inside takes that size; a size passed to a control wins. Controls read it with useControlSize.",
      "summary": "One size scale for everything with a height: sm, md and lg are the control heights, so a button, a field and a select of the same size line up in a row.",
      "group": "Foundations",
      "type": "registry:component",
      "dependencies": [],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add size",
      "props": [
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": true,
          "description": "The size every control inside takes, unless it's given its own."
        },
        {
          "name": "children",
          "type": "unknown",
          "required": false
        }
      ],
      "examples": [
        {
          "title": "A dense toolbar",
          "description": "Every control inside is sm without saying so.",
          "code": "<SizeProvider size=\"sm\">\n  <Field aria-label=\"Search\" placeholder=\"Search\" />\n  <HardwareButton>Filter</HardwareButton>\n</SizeProvider>"
        },
        {
          "title": "A field and its button",
          "description": "The same size is the same height.",
          "code": "<Field label=\"Invite\" placeholder=\"name@example.com\" />\n<HardwareButton>Send</HardwareButton>"
        }
      ],
      "antiPatterns": [
        "Never use size for global density: that's the density setting, which scales every step.",
        "Never make the one action a screen is built around sm: give it lg."
      ],
      "usage": {
        "imports": "import { SizeProvider } from \"@/echo/size\";",
        "code": "<SizeProvider size=\"sm\">{toolbar}</SizeProvider>"
      },
      "composition": "SizeProvider → context read by every sized control",
      "a11y": [
        "Adds no element and no role.",
        "sm is 32px, above the 24px minimum target; on a touch screen sm buttons grow to a 44px target."
      ],
      "feel": "Controls that sit in a row line up, because a size means one height everywhere.",
      "api": {
        "SizeProvider": {
          "props": [
            "children",
            "size"
          ],
          "required": [
            "size"
          ]
        }
      },
      "weight": {
        "js": 196,
        "withDependencies": 196,
        "css": 0
      }
    },
    {
      "name": "echo-scope",
      "title": "Scope",
      "version": "0.3.0",
      "description": "Part of the page speaking with its own vocabulary. accent gives what's inside a colour of its own for identity (a category, a filter pad, a person, a creature): the theme engine run on your colour, so its lit caps, glow and LEDs are derived and contrast-checked like your main accent, and every component inside picks them up. It's for telling things apart, never for state. molding=\"soft\" rounds the caps inside and fills them out with a pillow of light. It draws nothing and takes no space.",
      "summary": "A part of the page with its own accent or softer molding, derived by the theme engine.",
      "group": "Foundations",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add scope",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "What speaks with this vocabulary."
        },
        {
          "name": "accent",
          "type": "string",
          "required": false,
          "description": "A colour of its own for what's inside, as a hex: \"#F2674E\"."
        },
        {
          "name": "seed",
          "type": "object",
          "required": false,
          "description": "Your theme's chassis and paper, when they aren't Echo's defaults: the accent's caps and glow are checked against them."
        },
        {
          "name": "molding",
          "type": "\"soft\"",
          "required": false,
          "description": "soft: rounder, fuller caps with a pillow of light."
        }
      ],
      "examples": [
        {
          "title": "A colour per category",
          "description": "Each project's key lights in its own colour.",
          "code": "<Stack direction=\"row\" gap={4}>\n  <Scope accent=\"#F2674E\"><HardwareButton variant=\"primary\">Design</HardwareButton></Scope>\n  <Scope accent=\"#3D8EF0\"><HardwareButton variant=\"primary\">Research</HardwareButton></Scope>\n  <Scope accent=\"#DE3B74\"><HardwareButton variant=\"primary\">Build</HardwareButton></Scope>\n</Stack>"
        },
        {
          "title": "Soft plastic",
          "description": "Puffier caps for a friendlier device.",
          "code": "<Scope molding=\"soft\">\n  <Keypad label=\"Amount\" prefix=\"$\" />\n</Scope>"
        },
        {
          "title": "Your own theme",
          "description": "Check the colour against your chassis and paper.",
          "code": "<Scope accent=\"#DE3B74\" seed={{ chassis: \"#26222B\", paper: \"#F5EDE0\" }}>\n  <Switch aria-label=\"Live\" defaultChecked />\n</Scope>"
        }
      ],
      "antiPatterns": [
        "Never use an accent to mean good, bad or warning: that's a second hue for state, and it fails anyone who can't see it. Accents are for identity.",
        "Never scope every part of a screen in a different colour: a few categories, a few pads. The page's own accent stays the one that means \"this\".",
        "Never pick an accent for its exact shade: the engine moves it until its caps and glow are readable, so a bright yellow comes out amber."
      ],
      "usage": {
        "imports": "import { Scope } from \"@/echo/scope\";\nimport { HardwareButton } from \"@/echo/hardware-button\";",
        "code": "<Scope accent=\"#F2674E\">\n  <HardwareButton variant=\"primary\">Design</HardwareButton>\n</Scope>"
      },
      "composition": "Scope\n  anything",
      "a11y": [
        "Its accent is the theme engine run on your colour, so text on a lit cap inside still clears AA, exactly as with your main accent.",
        "An accent tells things apart; it never carries a state on its own. Say the state in words, as everywhere in Echo.",
        "It adds no element anyone can reach and changes no roles or names inside it."
      ],
      "feel": "The same device in another colour, or molded softer: every part inside changes together.",
      "api": {
        "Scope": {
          "props": [
            "accent",
            "children",
            "molding",
            "seed"
          ],
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 466,
        "withDependencies": 8998,
        "css": 478
      }
    },
    {
      "name": "echo-hardware-button",
      "title": "HardwareButton",
      "version": "0.3.0",
      "description": "A molded cap in a recessed well with real press travel and sound. primary/neutral/ghost; sm/md/lg on the one control scale (32/44/60px outer, so it lines up with a Field of the same size); fullWidth, icon slots, loading, engraved. It presses like a key switch (useCapPhysics), with the clicks on the mechanism; plastic color from createEchoTheme.",
      "summary": "The molded tactile button: a cap in a recessed well with real press travel + sound. primary/neutral/ghost, sm/md/lg, icon slots, icon-only, loading, engraved. Built on semantic vars + useTactile.",
      "group": "Buttons",
      "type": "registry:component",
      "dependencies": [
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add hardware-button",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "onClick",
          "type": "function",
          "required": false
        },
        {
          "name": "type",
          "type": "string",
          "required": false,
          "description": "\"button\" (default), \"submit\" or \"reset\"."
        },
        {
          "name": "form",
          "type": "string",
          "required": false,
          "description": "The id of the form this button submits, when it sits outside it."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Submitted with the form when this button is the one pressed."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "description": "The browser's own hover tooltip. Never the accessible name."
        },
        {
          "name": "onPointerDown",
          "type": "function",
          "required": false,
          "description": "Runs before Echo's own tactile handler, so the sound lands with the finger."
        },
        {
          "name": "onPointerUp",
          "type": "function",
          "required": false
        },
        {
          "name": "variant",
          "type": "\"primary\" | \"neutral\" | \"ghost\"",
          "required": false,
          "defaultValue": "\"neutral\"",
          "description": "primary is the lit cap for the one main action of a decision; neutral is plastic, for the rest; ghost has no cap, for a low-emphasis action (on a drawn device, its label underlined)."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Outer height on the control scale, the same as a Field or Select of that size: sm 32px (toolbars, table rows, inline), md 44px (the default: forms, dialogs, a button beside a field), lg 60px (the one action a screen is built around). Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "fullWidth",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "The button takes the full width of the row it's in."
        },
        {
          "name": "icon",
          "type": "ReactNode",
          "required": false,
          "description": "Leading icon (a ReactNode)."
        },
        {
          "name": "trailingIcon",
          "type": "ReactNode",
          "required": false,
          "description": "Trailing icon (a ReactNode)."
        },
        {
          "name": "iconOnly",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Square cap with no text, so it needs aria-label or aria-labelledby: without one it's a type error."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "The button's name when it shows no text. With iconOnly, this or aria-labelledby is required: leaving both out is a type error. A text button is named by its text."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Id of visible text that names the button, instead of aria-label."
        },
        {
          "name": "loading",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Shows the loading dots and stops presses, keeping the label for screen readers."
        },
        {
          "name": "engraved",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Debossed label carved into the cap. Buys the carved look at a real cost: 1.13-1.87:1 on a neutral cap, ~1.05-1.3:1 on a primary one, against AA's 4.5:1. Use only where the label is not the only thing telling you what the control does. Unsupported with variant=\"primary\", and warns in development."
        },
        {
          "name": "caption",
          "type": "string",
          "required": false,
          "description": "A small machine caption under the cap. It must not repeat the label: say what the label can't, like a shortcut or a state."
        },
        {
          "name": "href",
          "type": "string",
          "required": false,
          "description": "Renders the cap as a link (<a href>) with the same material, press and sound. Use it whenever the button navigates."
        },
        {
          "name": "target",
          "type": "string",
          "required": false,
          "description": "With `href`: where to open the link, e.g. \"_blank\"."
        },
        {
          "name": "rel",
          "type": "string",
          "required": false,
          "description": "With `href`: the link relationship, e.g. \"noopener\"."
        },
        {
          "name": "impact",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "The press lands in the whole device: the well dips with the bottom-out and a ring of light runs round it. For the one action a screen is built around, not every button."
        },
        {
          "name": "fill",
          "type": "number",
          "required": false,
          "description": "How full the cap is, 0–1: the primary plastic fills it from the start edge, like a level. For a cap that is also a quantity (a timer running down, a note playing). Say the quantity in the label too."
        },
        {
          "name": "held",
          "type": "boolean",
          "required": false,
          "description": "Holds the cap down from outside, with its click, the way a real key going down would; false lets it come home. For a drawn keyboard that follows the typing. It moves the cap only: it never clicks the button or sends an event."
        }
      ],
      "examples": [
        {
          "title": "Primary record button",
          "description": "The one place hardware turns accent.",
          "code": "<HardwareButton variant=\"primary\" onClick={record}>Record</HardwareButton>"
        },
        {
          "title": "Beside a field",
          "description": "The same size is the same height, so an inline form lines up.",
          "code": "<Field aria-label=\"Email\" placeholder=\"name@example.com\" />\n<HardwareButton variant=\"primary\">Invite</HardwareButton>"
        },
        {
          "title": "The screen's main action",
          "description": "lg for the one action a screen is built around; fullWidth to take the row.",
          "code": "<HardwareButton variant=\"primary\" size=\"lg\" fullWidth type=\"submit\">Sign in</HardwareButton>"
        },
        {
          "title": "Icon-only, accessible",
          "description": "Always give icon-only buttons an aria-label.",
          "code": "<HardwareButton iconOnly aria-label=\"Play\" icon={<PlayIcon />} />"
        },
        {
          "title": "The one action that matters",
          "description": "impact carries the press into the device. Keep it for the single action a screen is built around.",
          "code": "<HardwareButton variant=\"primary\" impact onClick={record}>Record</HardwareButton>"
        },
        {
          "title": "Loading",
          "description": "Loading disables interaction and shows the dot pulse.",
          "code": "<HardwareButton loading>Saving</HardwareButton>"
        },
        {
          "title": "A cap that is a quantity",
          "description": "`fill` pours the primary plastic in from the start edge. Say the quantity in the label too: the fill alone isn't read aloud.",
          "code": "<HardwareButton fill={left / total} onClick={pause}>\n  {`Pause, ${left}s left`}\n</HardwareButton>"
        },
        {
          "title": "Keys that follow the typing",
          "description": "`held` holds a cap down from outside, with its click, as a real key goes down. It moves the cap only: it never presses the button.",
          "code": "<>\n  {keys.map((key: string) => (\n    <HardwareButton key={key} held={down.has(key)} onClick={() => type(key)}>\n      {key}\n    </HardwareButton>\n  ))}\n</>"
        }
      ],
      "antiPatterns": [
        "Never ship an icon-only button without an aria-label — there is no accessible name otherwise.",
        "Do not set `transform` on the cap or its well in CSS — useCapPhysics/GSAP owns transform; you'll fight the press.",
        "Do not hardcode plastic colors; the cap composes var(--echo-*) so retheming works.",
        "Engraved labels need contrast headroom — avoid on sm ghost buttons.",
        "Don't make every button lg: it's for the one action a screen is built around. A form's buttons are md, a toolbar's sm.",
        "Do not put `impact` on every button. It marks the one action a screen is built around; everywhere, it marks nothing."
      ],
      "usage": {
        "imports": "import { HardwareButton } from \"@/echo/hardware-button\";",
        "code": "<HardwareButton variant=\"primary\" onClick={save}>\n  Save\n</HardwareButton>"
      },
      "composition": "HardwareButton",
      "a11y": [
        "A native `<button>`, or an `<a>` when `href` is set, so focus, Enter and Space work as they always do.",
        "Space and Enter give the same press travel and sound as a pointer.",
        "The click fires the moment you press, like any button: the stroke is only the picture catching up.",
        "`iconOnly` has no visible text: give it an `aria-label`.",
        "`sm` is 32px, above the 24px minimum target; on a touch screen it grows to 44px.",
        "`loading` sets `aria-busy` and disables the button; the label stays in the accessibility tree underneath the dots.",
        "`engraved` lowers the label's contrast well below AA. Use it only where the label is not the only thing saying what the button does."
      ],
      "motion": {
        "tokens": [
          "lift",
          "light"
        ],
        "reducedMotion": "The cap does not travel, the well does not dip, the hover glint stops, and the loading dots hold still."
      },
      "sound": [
        "`press` as the cap lands on its seam, `release` as it tops out on the way home, brighter the longer it was held."
      ],
      "feel": "A key switch in a recessed well: it resists, gives and clicks onto its seam, keeps sinking while you hold it, and snaps home faster than it went down, harder the longer you held.",
      "api": {
        "HardwareButton": {
          "props": [
            "autoFocus",
            "caption",
            "children",
            "disabled",
            "engraved",
            "fill",
            "form",
            "fullWidth",
            "held",
            "href",
            "icon",
            "iconOnly",
            "id",
            "impact",
            "key",
            "loading",
            "name",
            "onBlur",
            "onClick",
            "onFocus",
            "onKeyDown",
            "onPointerDown",
            "onPointerUp",
            "readOnly",
            "ref",
            "rel",
            "required",
            "size",
            "target",
            "title",
            "trailingIcon",
            "type",
            "value",
            "variant"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ],
            "when": "iconOnly"
          }
        }
      },
      "weight": {
        "js": 960,
        "withDependencies": 31107,
        "css": 2349
      }
    },
    {
      "name": "echo-popover",
      "title": "Popover",
      "version": "0.3.0",
      "description": "Base UI Popover (anchoring, focus, dismiss, a11y) skinned as an Echo panel. Same useGsapPresence bridge as Modal (summon enter, GSAP exit before unmount), sound.select on open.",
      "summary": "Base UI Popover (anchoring, focus, dismiss, a11y) skinned as an Echo panel. useGsapPresence summon-in / GSAP exit before unmount; sound.select on open.",
      "group": "Overlays",
      "type": "registry:component",
      "dependencies": [
        "gsap",
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add popover",
      "props": [
        {
          "name": "trigger",
          "type": "ReactNode",
          "required": false,
          "description": "What opens the popover. An element becomes the trigger itself; text is wrapped in a plain trigger."
        },
        {
          "name": "title",
          "type": "ReactNode",
          "required": false,
          "description": "Heading inside the popover, and its accessible name."
        },
        {
          "name": "side",
          "type": "\"top\" | \"right\" | \"bottom\" | \"left\"",
          "required": false,
          "defaultValue": "\"bottom\"",
          "description": "Which side of its trigger it opens on: bottom (the default), top, left or right."
        },
        {
          "name": "align",
          "type": "\"start\" | \"center\" | \"end\"",
          "required": false,
          "defaultValue": "\"center\"",
          "description": "Where along that side it lines up with the trigger: start, center (the default) or end."
        },
        {
          "name": "sideOffset",
          "type": "number",
          "required": false,
          "defaultValue": "8",
          "description": "The gap between the trigger and the panel, in px. Default 8."
        },
        {
          "name": "container",
          "type": "unknown",
          "required": false,
          "description": "Where the popup mounts; defaults to <body>. Pass an element inside a scoped theme so the popup wears that theme, not the :root one."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false,
          "description": "Open, controlled. Leave it out and the trigger opens and closes it."
        },
        {
          "name": "onOpenChange",
          "type": "function",
          "required": false,
          "description": "Called with true or false as it opens or closes: from its trigger, Escape, a press outside, or a change to `open`."
        },
        {
          "name": "initialFocus",
          "type": "unknown",
          "required": false,
          "description": "A ref to what takes focus as it opens (a date picker's chosen day). Default: the first thing in it that can take focus."
        },
        {
          "name": "anchor",
          "type": "unknown",
          "required": false,
          "description": "A ref to what it's placed against: a whole field rather than the key in it. Default: its trigger."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Names the popup when it has no `title`, so a screen reader says what it is and not only \"dialog\"."
        }
      ],
      "examples": [
        {
          "title": "Basic",
          "description": "A trigger with a titled panel.",
          "code": "<Popover trigger={<HardwareButton>Info</HardwareButton>} title=\"Details\">\n  <p>Anchored content.</p>\n</Popover>"
        },
        {
          "title": "Controlled",
          "description": "Close it from inside: pass `open` and `onOpenChange`, and it still animates out.",
          "code": "<Popover\n  open={open}\n  onOpenChange={setOpen}\n  trigger={<HardwareButton>Filters</HardwareButton>}\n  title=\"Filters\"\n>\n  <HardwareButton onClick={() => setOpen(false)}>Apply</HardwareButton>\n</Popover>"
        }
      ],
      "antiPatterns": [
        "Do not add a CSS transition to the popup — GSAP owns its transform/opacity.",
        "Do not use a Popover where a Tooltip fits (hover hints) — Popover takes focus and traps dismissal.",
        "The package is `@base-ui/react`, not `@base-ui-components/react`."
      ],
      "usage": {
        "imports": "import { HardwareButton } from \"@/echo/hardware-button\";\nimport { Popover } from \"@/echo/popover\";",
        "code": "<Popover trigger={<HardwareButton>Info</HardwareButton>} title=\"Details\">\n  <p>Anchored content.</p>\n</Popover>"
      },
      "composition": "Popover",
      "a11y": [
        "Base UI Popover: the trigger is a button that toggles a dialog-like popup, named by `title`.",
        "Focus moves into the popup when it opens. Escape or a click outside closes it and returns focus to the trigger.",
        "Unlike a Tooltip it is reachable by touch and can hold interactive content."
      ],
      "motion": {
        "tokens": [
          "summon"
        ],
        "reducedMotion": "It fades in and out without moving."
      },
      "sound": [
        "`select` when it opens."
      ],
      "feel": "A small glass panel anchored to its button, holding more than a tooltip can.",
      "api": {
        "Popover": {
          "props": [
            "align",
            "anchor",
            "children",
            "container",
            "initialFocus",
            "onOpenChange",
            "open",
            "side",
            "sideOffset",
            "title",
            "trigger"
          ]
        }
      },
      "weight": {
        "js": 742,
        "withDependencies": 70325,
        "css": 517
      }
    },
    {
      "name": "echo-tooltip",
      "title": "Tooltip",
      "version": "0.3.0",
      "description": "Base UI Tooltip (hover/focus, delay, a11y) skinned as a compact molded chip. Same useGsapPresence bridge as Modal/Popover; no sound.",
      "summary": "Base UI Tooltip (hover/focus, delay, a11y) skinned as a compact molded chip. Same useGsapPresence bridge as Modal/Popover.",
      "group": "Overlays",
      "type": "registry:component",
      "dependencies": [
        "gsap",
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add tooltip",
      "props": [
        {
          "name": "trigger",
          "type": "ReactNode",
          "required": false,
          "description": "The element the tooltip describes. An element becomes the trigger itself, so it keeps its own role and name."
        },
        {
          "name": "side",
          "type": "\"top\" | \"right\" | \"bottom\" | \"left\"",
          "required": false,
          "defaultValue": "\"top\"",
          "description": "Which side of its trigger it shows on: top (the default), bottom, left or right."
        },
        {
          "name": "sideOffset",
          "type": "number",
          "required": false,
          "defaultValue": "8",
          "description": "The gap between the trigger and the label, in px. Default 8."
        },
        {
          "name": "delay",
          "type": "number",
          "required": false,
          "defaultValue": "400",
          "description": "How long a pointer rests on the trigger before it shows, in ms. Default 400."
        },
        {
          "name": "container",
          "type": "unknown",
          "required": false,
          "description": "Where the popup mounts; defaults to <body>. Pass an element inside a scoped theme so the popup wears that theme, not the :root one."
        }
      ],
      "examples": [
        {
          "title": "Hint",
          "description": "Wrap any focusable trigger.",
          "code": "<Tooltip trigger={<HardwareButton iconOnly aria-label=\"Record\" icon={<RecIcon />} />}>Record</Tooltip>"
        }
      ],
      "antiPatterns": [
        "Never put essential or interactive content in a Tooltip — it is hover/focus-transient and not reachable by touch.",
        "Do not add sound to tooltips; they fire constantly.",
        "The package is `@base-ui/react`, not `@base-ui-components/react`."
      ],
      "usage": {
        "imports": "import { HardwareButton } from \"@/echo/hardware-button\";\nimport { Tooltip } from \"@/echo/tooltip\";",
        "code": "<Tooltip trigger={<HardwareButton>Record</HardwareButton>}>\n  Start a new take\n</Tooltip>"
      },
      "composition": "Tooltip",
      "a11y": [
        "Base UI Tooltip: shown on hover after `delay` (400ms by default) and on keyboard focus; Escape hides it.",
        "It describes its trigger — it never replaces the trigger's own name. An icon-only button still needs its `aria-label`.",
        "Touch has no hover: never put the only copy of important information in a tooltip."
      ],
      "motion": {
        "tokens": [
          "summon"
        ],
        "reducedMotion": "It fades in and out without moving."
      },
      "feel": "A quiet label that appears after a moment of hovering, and never makes a sound.",
      "api": {
        "Tooltip": {
          "props": [
            "children",
            "container",
            "delay",
            "side",
            "sideOffset",
            "trigger"
          ]
        }
      },
      "weight": {
        "js": 532,
        "withDependencies": 63003,
        "css": 451
      }
    },
    {
      "name": "echo-select",
      "title": "Select",
      "version": "0.3.0",
      "description": "Base UI Select (typeahead, keyboard, a11y) skinned as an Echo dropdown — a molded cap trigger and a floating panel with a lit dot on the selected option. CSS fade in and out (Base UI Select cannot hold a node for a GSAP exit); sound.select on change.",
      "summary": "Base UI Select (typeahead, keyboard, a11y) skinned as an Echo dropdown — a molded cap trigger and a floating panel with a lit dot on the selected option. CSS fade/scale; sound.select on change.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add select",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "options",
          "type": "object[]",
          "required": true,
          "description": "The choices. `value` is what you get back; `label` is what is shown."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Controlled value."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "description": "Uncontrolled initial value."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Fired with the newly selected value."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "defaultValue": "\"Select…\"",
          "description": "Shown while nothing is selected."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Names the trigger. This or aria-labelledby is required: leaving both out is a type error."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Id of the visible label, when there is one."
        },
        {
          "name": "container",
          "type": "unknown",
          "required": false,
          "description": "Where the list mounts; defaults to <body>. Pass an element inside a scoped theme so the list wears that theme, not the :root one."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale: sm 32px, md 44px, lg 60px, the same as a HardwareButton or Field of that size, so they line up in a row. Defaults to the nearest SizeProvider's size."
        }
      ],
      "examples": [
        {
          "title": "Basic",
          "description": "Options as {value,label}.",
          "code": "<Select aria-label=\"Session length\" defaultValue=\"25\" options={[{ value: \"25\", label: \"25 minutes\" }, { value: \"50\", label: \"50 minutes\" }]} />"
        }
      ],
      "antiPatterns": [
        "Give the Select an accessible name (aria-label or aria-labelledby).",
        "Do not use a Select for a handful of peer views — that is Tabs/Segmented; Select is for many options.",
        "The package is `@base-ui/react`, not `@base-ui-components/react`."
      ],
      "usage": {
        "imports": "import { Select } from \"@/echo/select\";",
        "code": "<Select\n  aria-label=\"Session length\"\n  defaultValue=\"25\"\n  options={[\n    { value: \"25\", label: \"25 minutes\" },\n    { value: \"50\", label: \"50 minutes\" },\n  ]}\n/>"
      },
      "composition": "Select",
      "a11y": [
        "Base UI Select: the trigger opens a listbox. Arrow keys move, typing jumps to a matching option, Enter or Space chooses, Escape closes.",
        "Name it with `aria-label` or `aria-labelledby`.",
        "Focus returns to the trigger when the list closes."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-pop",
          "--echo-duration-swap"
        ],
        "reducedMotion": "The list appears with a short fade and does not slide, and the cap does not travel when pressed."
      },
      "sound": [
        "`select` when an option is chosen."
      ],
      "feel": "A molded dropdown whose list unfolds from the cap. The cap presses like the library's buttons: it lifts toward you, gives, and lands on its seam as the list opens.",
      "api": {
        "Select": {
          "props": [
            "autoFocus",
            "container",
            "defaultValue",
            "disabled",
            "id",
            "key",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "options",
            "placeholder",
            "readOnly",
            "ref",
            "required",
            "size",
            "value"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          },
          "required": [
            "options"
          ]
        }
      },
      "weight": {
        "js": 1011,
        "withDependencies": 76928,
        "css": 1335
      }
    },
    {
      "name": "echo-command",
      "title": "Command",
      "version": "0.3.0",
      "description": "The command palette as a dot-matrix readout: the query line is the LcdChip's recessed screen well, in the LCD voice, with an emissive caret and a scanline wash. Base UI Autocomplete for the input, filtering and listbox semantics; Base UI Dialog for the frame, because a palette is summoned to the middle of the screen rather than anchored to anything. Cmd/Ctrl+K by default; matches on hidden keywords as well as labels.",
      "summary": "The command palette as a dot-matrix readout — the query line is the LcdChip's recessed screen well with an emissive caret and a scanline wash. Base UI Autocomplete for the input and listbox semantics, Base UI Dialog for the frame. Cmd/Ctrl+K by default; matches hidden keywords as well as labels.",
      "group": "Overlays",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add command",
      "props": [
        {
          "name": "groups",
          "type": "object[]",
          "required": true,
          "description": "Grouped commands. Groups keep their order; items are filtered within them, and a group that empties is dropped."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false,
          "description": "Controlled open state."
        },
        {
          "name": "onOpenChange",
          "type": "function",
          "required": false
        },
        {
          "name": "shortcut",
          "type": "string",
          "required": false,
          "defaultValue": "\"k\"",
          "description": "Global hotkey, with Cmd on Apple and Ctrl elsewhere. Pass null for no binding."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "defaultValue": "\"Type a command…\""
        },
        {
          "name": "emptyMessage",
          "type": "unknown",
          "required": true,
          "description": "Shown when nothing matches. Say what would help, not just 'no results'."
        },
        {
          "name": "onRun",
          "type": "function",
          "required": false,
          "description": "Fired with the item that ran, after the palette closes."
        },
        {
          "name": "container",
          "type": "unknown",
          "required": false,
          "description": "Where the palette mounts; defaults to <body>. Pass an element inside a scoped theme so the palette wears that theme, not the :root one."
        }
      ],
      "examples": [
        {
          "title": "A palette",
          "description": "Grouped commands, each with an action. Cmd/Ctrl+K opens it.",
          "code": "<Command\n  groups={[\n    { label: \"Navigate\", items: [\n      { id: \"docs\", label: \"Go to docs\", hint: \"G then D\", run: () => router.push(\"/docs\") },\n    ] },\n    { label: \"Take\", items: [\n      { id: \"rec\", label: \"Start recording\", keywords: [\"arm\", \"capture\"], run: record },\n    ] },\n  ]}\n/>"
        },
        {
          "title": "Hidden keywords",
          "description": "What people TYPE is rarely what the command is called.",
          "code": "const deleteTake: CommandItem = {\n  id: \"delete\",\n  label: \"Delete take\",\n  keywords: [\"trash\", \"remove\", \"bin\"],\n  run: del,\n};"
        },
        {
          "title": "The app owns the hotkey",
          "description": "shortcut={null} unbinds it; drive `open` yourself.",
          "code": "<Command shortcut={null} open={open} onOpenChange={setOpen} groups={groups} />"
        }
      ],
      "antiPatterns": [
        "Never ship a palette as the ONLY way to reach something. It is an accelerator for people who already know the command exists; anything reachable only by typing its name is unreachable to everyone else.",
        "Do not put destructive commands in it without a confirm. A palette is optimised for Enter on the first highlighted row — which is exactly how someone erases their work by typing three letters and committing to muscle memory. Send those through an AlertDialog.",
        "Do not skip `keywords`. Users type the word for the thing, not the label you chose; a palette that only matches its own labels feels broken the first time someone searches 'trash' for 'Delete'.",
        "Do not render two palettes with the same shortcut on one page. Both will answer the keystroke. Pass shortcut={null} to all but one.",
        "Do not use it as a Select. It does not hold a value — it runs an action and forgets. If the choice should be remembered and shown, that is Select."
      ],
      "usage": {
        "imports": "import { Command } from \"@/echo/command\";",
        "code": "<Command\n  groups={[\n    {\n      label: \"Navigate\",\n      items: [{ id: \"docs\", label: \"Go to docs\", run: () => openDocs() }],\n    },\n  ]}\n/>"
      },
      "composition": "Command",
      "a11y": [
        "Opens with ⌘K on Apple devices and Ctrl+K elsewhere. `shortcut` changes the key; `shortcut={null}` unbinds it so your app can own it.",
        "A modal dialog: focus moves to the search input and stays inside until it closes. Escape closes it.",
        "Arrow keys move through the results and Enter runs the highlighted one; the input keeps focus throughout, as a combobox should.",
        "A `disabled` item stays listed but cannot run."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap",
          "--echo-duration-glitch"
        ],
        "reducedMotion": "The palette appears in place with a short fade instead of sliding in."
      },
      "sound": [
        "`transition` when it opens.",
        "`select` when an item runs."
      ],
      "feel": "A dot-matrix command line that drops over the screen: type, arrow down, Enter.",
      "api": {
        "Command": {
          "props": [
            "container",
            "emptyMessage",
            "groups",
            "onOpenChange",
            "onRun",
            "open",
            "placeholder",
            "shortcut"
          ],
          "required": [
            "groups"
          ]
        }
      },
      "weight": {
        "js": 1144,
        "withDependencies": 84262,
        "css": 1476
      }
    },
    {
      "name": "echo-tabs",
      "title": "Tabs",
      "version": "0.3.0",
      "description": "Base UI Tabs (roving focus, arrow-key nav, a11y) marked with the library's one \"you are here\": a 2px lit rail slides under the open tab, over a hairline baseline. Labels stay on the chassis. sound.select on change. Compound API.",
      "group": "Navigation",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add tabs",
      "props": [],
      "examples": [
        {
          "title": "Basic",
          "description": "List of tabs + matching panels.",
          "code": "<Tabs defaultValue=\"a\">\n  <Tabs.List>\n    <Tabs.Tab value=\"a\">Meter</Tabs.Tab>\n    <Tabs.Tab value=\"b\">Log</Tabs.Tab>\n  </Tabs.List>\n  <Tabs.Panel value=\"a\">…</Tabs.Panel>\n  <Tabs.Panel value=\"b\">…</Tabs.Panel>\n</Tabs>"
        },
        {
          "title": "Sizes",
          "description": "`sm` for a card's or a panel's own views, `lg` for the tabs a whole page is built around.",
          "code": "<Tabs defaultValue=\"day\">\n  <Tabs.List size=\"sm\" aria-label=\"Chart range\">\n    <Tabs.Tab value=\"day\">Day</Tabs.Tab>\n    <Tabs.Tab value=\"week\">Week</Tabs.Tab>\n  </Tabs.List>\n</Tabs>"
        }
      ],
      "antiPatterns": [
        "Every Tabs.Tab needs a matching Tabs.Panel with the same value.",
        "Do not put many tabs in a segmented control — it is for a few peer views, not navigation."
      ],
      "usage": {
        "imports": "import { Tabs } from \"@/echo/tabs\";",
        "code": "<Tabs defaultValue=\"meter\">\n  <Tabs.List aria-label=\"Track view\">\n    <Tabs.Tab value=\"meter\">Meter</Tabs.Tab>\n    <Tabs.Tab value=\"log\">Log</Tabs.Tab>\n  </Tabs.List>\n  <Tabs.Panel value=\"meter\">Levels</Tabs.Panel>\n  <Tabs.Panel value=\"log\">History</Tabs.Panel>\n</Tabs>"
      },
      "composition": "Tabs\n  Tabs.List\n    Tabs.Tab\n  Tabs.Panel",
      "a11y": [
        "Renders the ARIA tabs pattern: `Tabs.List` is a `tablist`, each `Tabs.Tab` a `tab` with `aria-selected`, each `Tabs.Panel` a `tabpanel` labelled by its tab.",
        "Arrow keys move focus between tabs and wrap at the ends; Enter or Space opens the focused tab. Pass `activateOnFocus` to `Tabs.List` to open tabs as focus moves.",
        "Name the list: `aria-label` on `Tabs.List`, or `aria-labelledby` pointing at a visible heading.",
        "The open tab is marked by a lit rail and full-strength label text, not by colour alone."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-sweep",
          "--echo-duration-swap"
        ],
        "reducedMotion": "The rail jumps to the new tab instead of sliding."
      },
      "sound": [
        "`select` when the open tab changes. Silent without a `SoundProvider`."
      ],
      "feel": "Picking a tab slides a thin lit rail under its label with a soft click; the other labels stay quiet on the chassis.",
      "api": {
        "Tabs": {
          "props": [
            "key",
            "onValueChange",
            "orientation",
            "ref",
            "render"
          ],
          "html": true
        },
        "Tabs.List": {
          "props": [
            "activateOnFocus",
            "key",
            "loopFocus",
            "ref",
            "render"
          ],
          "html": true
        },
        "Tabs.Tab": {
          "props": [
            "key",
            "nativeButton",
            "ref",
            "render"
          ],
          "html": true,
          "required": [
            "value"
          ]
        },
        "Tabs.Panel": {
          "props": [
            "keepMounted",
            "key",
            "ref",
            "render"
          ],
          "html": true,
          "required": [
            "value"
          ]
        }
      },
      "weight": {
        "js": 1284,
        "withDependencies": 43559,
        "css": 1053
      }
    },
    {
      "name": "echo-accordion",
      "title": "Accordion",
      "version": "0.3.0",
      "description": "Base UI Accordion (keyboard, a11y, height measurement) skinned as molded device panels that expand. Click sound on toggle; height animates via CSS. Compound API.",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add accordion",
      "props": [],
      "examples": [
        {
          "title": "Basic",
          "description": "Items with a trigger and a panel each.",
          "code": "<Accordion multiple={false}>\n  <Accordion.Item value=\"a\">\n    <Accordion.Trigger>Signal</Accordion.Trigger>\n    <Accordion.Panel>…</Accordion.Panel>\n  </Accordion.Item>\n</Accordion>"
        }
      ],
      "antiPatterns": [
        "Do not animate the panel with a soft ease on pixel/LCD content inside — the panel height is glass-smooth, but LCD children stay steppy.",
        "The prop is `multiple` (Base UI 1.x), NOT `openMultiple` (the old RC name) — a wrong name silently falls through to the DOM and React warns. Default is multiple={false}, one panel open at a time; pass multiple to allow several."
      ],
      "usage": {
        "imports": "import { Accordion } from \"@/echo/accordion\";",
        "code": "<Accordion>\n  <Accordion.Item value=\"signal\">\n    <Accordion.Trigger>Signal</Accordion.Trigger>\n    <Accordion.Panel>Input gain, routing and monitoring.</Accordion.Panel>\n  </Accordion.Item>\n</Accordion>"
      },
      "composition": "Accordion\n  Accordion.Item\n    Accordion.Trigger\n    Accordion.Panel",
      "a11y": [
        "Base UI Accordion: each trigger is a button inside a heading, with `aria-expanded` and `aria-controls` pointing at its panel.",
        "Enter or Space opens and closes; arrow keys move between triggers.",
        "One panel open at a time by default; pass `multiple` to allow several."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-pop",
          "--echo-duration-sweep"
        ],
        "reducedMotion": "The panel opens and closes at once, the chevron turns without a spring, and the contents appear without rising."
      },
      "sound": [
        "`tap` when a panel opens or closes."
      ],
      "feel": "Ruled rows that fold open under their heading with a soft tap.",
      "api": {
        "Accordion": {
          "props": [
            "hiddenUntilFound",
            "keepMounted",
            "key",
            "loopFocus",
            "onValueChange",
            "orientation",
            "ref",
            "render"
          ],
          "html": true
        },
        "Accordion.Item": {
          "props": [
            "key",
            "onOpenChange",
            "ref",
            "render"
          ],
          "html": true
        },
        "Accordion.Trigger": {
          "props": [
            "key",
            "nativeButton",
            "ref",
            "render"
          ],
          "html": true
        },
        "Accordion.Panel": {
          "props": [
            "hiddenUntilFound",
            "keepMounted",
            "key",
            "ref",
            "render"
          ],
          "html": true
        }
      },
      "weight": {
        "js": 558,
        "withDependencies": 10004,
        "css": 852
      }
    },
    {
      "name": "echo-switch",
      "title": "Switch",
      "version": "0.3.0",
      "description": "Base UI Switch (role=switch, keyboard, a11y) with a hardware-toggle skin — a molded thumb sliding in a recessed track — and a click sound on toggle.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add switch",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "Identifies the field when a form is submitted."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "checked",
          "type": "boolean",
          "required": false,
          "description": "Controlled state."
        },
        {
          "name": "defaultChecked",
          "type": "boolean",
          "required": false,
          "description": "Uncontrolled initial state."
        },
        {
          "name": "onCheckedChange",
          "type": "function",
          "required": false,
          "description": "Fired with the new state. The click sound plays before it, so this never has to make noise itself."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "The switch's name; the track carries no text. This or aria-labelledby is required: leaving both out is a type error."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Id of the visible label, when there is one."
        }
      ],
      "examples": [
        {
          "title": "Basic",
          "description": "Uncontrolled toggle.",
          "code": "<Switch aria-label=\"Airplane mode\" defaultChecked />"
        },
        {
          "title": "Controlled",
          "description": "Drive it from state.",
          "code": "<Switch checked={on} onCheckedChange={setOn} aria-label=\"Power\" />"
        }
      ],
      "antiPatterns": [
        "Always give a Switch an accessible name (aria-label or aria-labelledby) — the track has no text.",
        "Do not use a Switch for actions that need confirmation; it toggles immediately."
      ],
      "usage": {
        "imports": "import { Switch } from \"@/echo/switch\";",
        "code": "<Switch aria-label=\"Monitor\" defaultChecked />"
      },
      "composition": "Switch",
      "a11y": [
        "Base UI Switch: `role=\"switch\"` with `aria-checked`. Space toggles it.",
        "The track carries no text — name it with `aria-label`, or `aria-labelledby` pointing at a visible label.",
        "On and off differ by the thumb's position, not by colour alone."
      ],
      "motion": {
        "tokens": [
          "lift",
          "rebound"
        ],
        "reducedMotion": "The thumb jumps to its new side instead of travelling, and does not grow or follow a drag; the track still lights with the state."
      },
      "sound": [
        "`tap` each time it toggles."
      ],
      "feel": "A hardware toggle: the thumb slides across its track with a small tap.",
      "api": {
        "Switch": {
          "props": [
            "autoFocus",
            "checked",
            "defaultChecked",
            "disabled",
            "id",
            "key",
            "name",
            "onBlur",
            "onCheckedChange",
            "onFocus",
            "onKeyDown",
            "readOnly",
            "ref",
            "required"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          }
        }
      },
      "weight": {
        "js": 1274,
        "withDependencies": 35894,
        "css": 899
      }
    },
    {
      "name": "echo-checkbox",
      "title": "Checkbox",
      "version": "0.3.0",
      "description": "Base UI Checkbox (keyboard, indeterminate, a11y) with a molded box skin that fills accent and shows a check when set. Select sound on change.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add checkbox",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "Identifies the field when a form is submitted."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "checked",
          "type": "boolean",
          "required": false,
          "description": "Controlled state."
        },
        {
          "name": "defaultChecked",
          "type": "boolean",
          "required": false,
          "description": "Uncontrolled initial state."
        },
        {
          "name": "indeterminate",
          "type": "boolean",
          "required": false,
          "description": "Mixed state — some but not all of a group is set."
        },
        {
          "name": "onCheckedChange",
          "type": "function",
          "required": false,
          "description": "Fired with the new state. The select sound plays before it."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "The box's name. This or aria-labelledby is required (a type error otherwise). Inside a <label>, point aria-labelledby at the label's text."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Id of the visible label, when there is one."
        }
      ],
      "examples": [
        {
          "title": "Basic",
          "description": "Uncontrolled with a label.",
          "code": "<label><Checkbox aria-label=\"Agree\" /> I agree</label>"
        },
        {
          "title": "Indeterminate",
          "description": "Tri-state parent.",
          "code": "<Checkbox indeterminate aria-label=\"Select all\" />"
        }
      ],
      "antiPatterns": [
        "Give every Checkbox an accessible name (a wrapping <label> or aria-label).",
        "Do not swap accent for other colors — accent is the one saturated fill."
      ],
      "usage": {
        "imports": "import { Checkbox } from \"@/echo/checkbox\";",
        "code": "<Checkbox aria-label=\"Loop playback\" defaultChecked />"
      },
      "composition": "Checkbox",
      "a11y": [
        "Base UI Checkbox: `role=\"checkbox\"` with `aria-checked`, which is `mixed` when `indeterminate`. Space toggles it.",
        "Name it with `aria-label` or `aria-labelledby`.",
        "Checked draws a check and indeterminate a dash, so the states differ by shape, not only by colour."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "The key doesn't travel or pop, and the mark appears without a transition; the hole's shading still answers the press."
      },
      "sound": [
        "`select` each time it changes."
      ],
      "feel": "A small molded key that latches down with a click: it gives and lands like the library's buttons, sinks further while held, and checking it pops the lit cap up and draws the tick in one stroke.",
      "api": {
        "Checkbox": {
          "props": [
            "autoFocus",
            "checked",
            "defaultChecked",
            "disabled",
            "id",
            "indeterminate",
            "key",
            "name",
            "onBlur",
            "onCheckedChange",
            "onFocus",
            "onKeyDown",
            "readOnly",
            "ref",
            "required"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          }
        }
      },
      "weight": {
        "js": 1015,
        "withDependencies": 38919,
        "css": 883
      }
    },
    {
      "name": "echo-slider",
      "title": "Slider",
      "version": "0.3.0",
      "description": "Base UI Slider (keyboard, drag, track-press, a11y) with a recessed rail + molded knob and a scrubbing tick sound on each step — very hardware.",
      "summary": "Base UI Slider (keyboard, drag, track-press, a11y) with a recessed rail + molded knob and a scrubbing tick sound on each step.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add slider",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "min",
          "type": "number",
          "required": false,
          "defaultValue": "0"
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "defaultValue": "100"
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "defaultValue": "1"
        },
        {
          "name": "value",
          "type": "number",
          "required": false,
          "description": "Controlled value."
        },
        {
          "name": "defaultValue",
          "type": "number",
          "required": false,
          "defaultValue": "50"
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with the new value on every step (value, eventDetails)."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "md is the fader; sm is the dense-row slider (6px rail, 20px knob)."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "The accessible name. The rail carries no text, so this or aria-labelledby is required (a type error otherwise). It lands on the thumb, which owns the hidden range input."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Id of a visible label, instead of aria-label."
        }
      ],
      "examples": [
        {
          "title": "Basic",
          "description": "0–100, ticks per step.",
          "code": "<Slider defaultValue={40} aria-label=\"Volume\" />"
        },
        {
          "title": "Stepped",
          "description": "Coarser steps tick louder in the hand.",
          "code": "<Slider min={0} max={10} step={1} aria-label=\"Level\" />"
        },
        {
          "title": "Small",
          "description": "For dense rows — a mixer strip, an inline setting.",
          "code": "<Slider size=\"sm\" defaultValue={30} aria-label=\"Send\" />"
        }
      ],
      "antiPatterns": [
        "Give the slider an accessible name (aria-label) — the rail carries no text.",
        "Do not set a tiny step on a wide range if sound is on; the ticks turn into a buzz."
      ],
      "usage": {
        "imports": "import { Slider } from \"@/echo/slider\";",
        "code": "<Slider defaultValue={40} aria-label=\"Volume\" />"
      },
      "composition": "Slider",
      "a11y": [
        "The thumb holds a range input: arrow keys move one step, Page Up and Page Down a large step, Home and End go to the ends.",
        "`aria-label` or `aria-labelledby` is passed to the thumb, which is what a screen reader focuses. The rail has no text of its own.",
        "The value is announced by the input as it changes."
      ],
      "sound": [
        "`tap` once per step as the value changes — dragging across ten steps ticks ten times."
      ],
      "feel": "A fader in a recessed rail that ticks once for every step it crosses.",
      "api": {
        "Slider": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "id",
            "key",
            "max",
            "min",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "size",
            "step",
            "value"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          }
        }
      },
      "weight": {
        "js": 1310,
        "withDependencies": 44014,
        "css": 1040
      }
    },
    {
      "name": "echo-segmented",
      "title": "Segmented",
      "version": "0.3.0",
      "description": "A hardware segmented selector — molded uppercase segments in a recessed well, the active one on the lit primary cap. A radiogroup value selector (roving focus, arrow-key select, select sound), distinct from Tabs which switches content panels. Composes var(--echo-*).",
      "summary": "A hardware segmented selector — molded uppercase segments in a recessed well, the active one on the lit primary cap. A radiogroup value selector (roving focus, arrow-key select, select sound), distinct from Tabs which switches content panels.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add segmented",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "description": "Disables every segment. For one, see `options`."
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "options",
          "type": "string | object[]",
          "required": true,
          "description": "Segment labels, in order. Short — a segment is a cap, not a sentence. Pass `{ label, disabled: true }` to switch one segment off, or `{ label, content }` to show more than a word (a day and its date) while `label` stays its name."
        },
        {
          "name": "value",
          "type": "number",
          "required": false,
          "description": "Selected INDEX (controlled), not the label."
        },
        {
          "name": "defaultValue",
          "type": "number",
          "required": false,
          "defaultValue": "0",
          "description": "Selected index for the uncontrolled case."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Fired with the newly selected index."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Names the radiogroup. This or aria-labelledby is required: leaving both out is a type error."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Id of the visible label, when there is one."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale: sm 32px, md 44px, lg 60px, the same as a HardwareButton or Field of that size, so they line up in a row. Defaults to the nearest SizeProvider's size."
        }
      ],
      "examples": [
        {
          "title": "One segment off",
          "description": "A disabled segment is skipped by the arrow keys.",
          "code": "<Segmented aria-label=\"Output\" options={[\"Line\", { label: \"Optical\", disabled: true }, \"Phones\"]} />"
        },
        {
          "title": "Basic",
          "description": "Uncontrolled, first option selected.",
          "code": "<Segmented aria-label=\"Mode\" options={[\"Focus\", \"Break\", \"Rest\"]} />"
        },
        {
          "title": "Controlled",
          "description": "Drive the index from state.",
          "code": "<Segmented aria-label=\"Mode\" options={modes} value={i} onValueChange={setI} />"
        },
        {
          "title": "More than a word in a segment",
          "description": "`content` shows a day and its date; `label` stays the segment's name, which is what a screen reader says.",
          "code": "<Segmented\n  aria-label=\"Day\"\n  options={days.map((d: { name: string; short: string; date: number }) => ({ label: d.name, content: <><span>{d.short}</span> <span>{d.date}</span></> }))}\n/>"
        }
      ],
      "antiPatterns": [
        "Give the group an accessible name (aria-label / aria-labelledby) — the segments carry no group text.",
        "Use Tabs, not Segmented, when each choice reveals a different panel of content."
      ],
      "usage": {
        "imports": "import { Segmented } from \"@/echo/segmented\";",
        "code": "<Segmented aria-label=\"Mode\" options={[\"Focus\", \"Break\", \"Rest\"]} />"
      },
      "composition": "Segmented",
      "a11y": [
        "A `radiogroup` of `radio` options with `aria-checked`.",
        "Arrow keys move the selection and follow the reading direction (right-to-left needs Base UI's `DirectionProvider`); Home and End jump to the first and last enabled segment. Disabled segments are skipped.",
        "Built on Base UI's RadioGroup: with no segment selected (or `value` out of range), the first enabled one still takes the tab stop.",
        "Name the group with `aria-label` or `aria-labelledby` — the segments' own labels do not say what they choose between."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "The pressed segment changes without a transition."
      },
      "sound": [
        "`select` when the choice changes."
      ],
      "feel": "A row of molded keys where exactly one stays pressed in.",
      "api": {
        "Segmented": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "id",
            "key",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "options",
            "readOnly",
            "ref",
            "required",
            "size",
            "value"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          },
          "required": [
            "options"
          ]
        }
      },
      "weight": {
        "js": 2412,
        "withDependencies": 43202,
        "css": 1494
      }
    },
    {
      "name": "echo-field",
      "title": "Field",
      "version": "0.3.0",
      "description": "A text input sunk into a recessed well. The inner edge lights in the accent on focus; a mono eyebrow label and an optional machine (mono) value voice. Composes var(--echo-*).",
      "summary": "A text input sunk into a recessed well. The inner edge lights in the accent on focus; a mono eyebrow label and an optional machine (mono) value voice.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add field",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "Identifies the field when a form is submitted."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false,
          "description": "Marks the field required, for the browser and assistive tech."
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "description": "A hint, never a substitute for the label."
        },
        {
          "name": "autoComplete",
          "type": "string",
          "required": false,
          "description": "What the value is, for autofill: \"email\", \"current-password\", \"one-time-code\". Set it on any field asking for the user's own information (WCAG 1.3.5)."
        },
        {
          "name": "inputMode",
          "type": "string",
          "required": false,
          "description": "Which on-screen keyboard a phone offers: \"numeric\", \"email\", \"tel\", …"
        },
        {
          "name": "maxLength",
          "type": "number",
          "required": false
        },
        {
          "name": "minLength",
          "type": "number",
          "required": false
        },
        {
          "name": "pattern",
          "type": "string",
          "required": false,
          "description": "A regular expression the value must match for the field to be valid."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "Visible eyebrow label; also the input's accessible name. A field needs label, aria-label or aria-labelledby: leaving all three out is a type error."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Controlled value."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "description": "Uncontrolled initial value."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Fired with the input's new string value."
        },
        {
          "name": "mono",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Render the value in the machine (mono) voice — codes, timers, serials."
        },
        {
          "name": "type",
          "type": "string",
          "required": false,
          "defaultValue": "\"text\"",
          "description": "Native input type."
        },
        {
          "name": "description",
          "type": "unknown",
          "required": false,
          "description": "Help text under the field, linked to the input so a screen reader reads it after the name."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "description": "What's wrong, in words. While set, the field is invalid: the message shows with a square pip, is linked to the input, and the input gets aria-invalid. Clear it when fixed."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted; a visible label wins."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted."
        },
        {
          "name": "aria-describedby",
          "type": "string",
          "required": false,
          "description": "Ids of other text that describes the field, read after its own description and error."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale: sm 32px, md 44px, lg 60px, the same as a HardwareButton or Field of that size, so they line up in a row. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "start",
          "type": "unknown",
          "required": false,
          "description": "Engraved into the well before the text: a search icon, a currency sign, \"https://\". Decoration: say the same in the label."
        },
        {
          "name": "end",
          "type": "unknown",
          "required": false,
          "description": "In the well after the text: a unit (\"ms\"), or a small control (clear, show password) as a size=\"sm\" iconOnly HardwareButton with its own name."
        }
      ],
      "examples": [
        {
          "title": "Search",
          "description": "An icon engraved at the start, a clear key at the end.",
          "code": "<Field\n  aria-label=\"Search\"\n  start={<SearchIcon />}\n  end={<HardwareButton size=\"sm\" iconOnly aria-label=\"Clear search\" icon={<ClearIcon />} />}\n/>"
        },
        {
          "title": "A unit",
          "description": "The unit sits in the well after the number.",
          "code": "<Field label=\"Timeout (ms)\" defaultValue=\"250\" end=\"ms\" mono />"
        },
        {
          "title": "Basic",
          "description": "Labelled text input.",
          "code": "<Field label=\"Name\" placeholder=\"Ada Lovelace\" />"
        },
        {
          "title": "Machine voice",
          "description": "Mono value for codes/timers.",
          "code": "<Field label=\"Session code\" placeholder=\"000-000\" mono />"
        },
        {
          "title": "Controlled",
          "description": "Drive it from state.",
          "code": "<Field label=\"Query\" value={q} onValueChange={setQ} />"
        },
        {
          "title": "Help text and an error",
          "description": "Both are linked to the input. The error shows while `error` is set; clear it when the value is fixed.",
          "code": "<Field\n  label=\"Email\"\n  type=\"email\"\n  autoComplete=\"email\"\n  description=\"We only use it to send the session link.\"\n  error={valid ? undefined : \"Enter an email address, like ada@example.com.\"}\n  value={email}\n  onValueChange={setEmail}\n/>"
        }
      ],
      "antiPatterns": [
        "Don't show an error as a red border or colour alone. Pass `error` with words that say what to fix; that's what gets read out.",
        "The lit focus edge is the one accent; do not tint the well or the text with it."
      ],
      "usage": {
        "imports": "import { Field } from \"@/echo/field\";",
        "code": "<Field label=\"Name\" placeholder=\"Ada Lovelace\" />"
      },
      "composition": "Field",
      "a11y": [
        "`start` is decoration, hidden from assistive tech: whatever it says (\"search\", a currency) belongs in the label too. A control in `end` is its own button with its own name.",
        "`label` renders a real `<label>` tied to the input. With no visible label, pass `aria-label` or `aria-labelledby`.",
        "A native `<input>`; `type` passes through, so `email`, `password` and `search` keep their keyboard and autofill behaviour.",
        "A placeholder is not a label — it disappears as soon as someone types.",
        "`description` and `error` are linked with `aria-describedby`, and `error` sets `aria-invalid`, through Base UI's Field. The error is words plus a square pip, never colour alone.",
        "Set `autoComplete` on a field that asks for the user's own information (`email`, `name`, `current-password`), so autofill works and the purpose is machine-readable (WCAG 1.3.5)."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "The well doesn't press in or take strikes, and the caret is the browser's own. Only the focus edge's colour fades in."
      },
      "feel": "A recessed well you type into. Clicking presses it into the device, each key lands as a small strike on the surface, and the caret glides after your letters with a little weight. Its edge lights while it has focus.",
      "api": {
        "Field": {
          "props": [
            "autoComplete",
            "autoFocus",
            "defaultValue",
            "description",
            "disabled",
            "end",
            "error",
            "id",
            "inputMode",
            "key",
            "label",
            "maxLength",
            "minLength",
            "mono",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "pattern",
            "placeholder",
            "readOnly",
            "ref",
            "required",
            "size",
            "start",
            "type",
            "value"
          ],
          "name": {
            "anyOf": [
              "label",
              "aria-label",
              "aria-labelledby"
            ]
          }
        }
      },
      "weight": {
        "js": 887,
        "withDependencies": 39643,
        "css": 1472
      }
    },
    {
      "name": "echo-date-field",
      "title": "DateField",
      "version": "0.3.0",
      "description": "A date typed into wheels, day, month and year in the order the locale writes them, each a row of small flaps that turn over to the value. Field's recessed well and lit edge, on the same size scale. The value is an ISO date, so no time zone can move a day.",
      "summary": "A date typed into wheels, day, month and year in the order the locale writes them, each a row of small flaps that turn over to the value. Field's recessed well, on the same size scale; the value is an ISO date.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size",
        "@echo/echo-field"
      ],
      "install": "npx echo-ui-cli add date-field",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "Visible eyebrow label; also the name every wheel says before its own. A date field needs label, aria-label or aria-labelledby: leaving all three out is a type error."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Controlled value, an ISO date (\"2026-09-28\"), or \"\" for none."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "description": "Uncontrolled initial value, an ISO date."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Fired with the ISO date once the wheels make a real one, and with \"\" when they stop making one."
        },
        {
          "name": "min",
          "type": "string",
          "required": false,
          "description": "The earliest date, as ISO. The year wheel starts at its year; an earlier date is said to be, in words, under the field."
        },
        {
          "name": "max",
          "type": "string",
          "required": false,
          "description": "The latest date, as ISO. The year wheel ends at its year."
        },
        {
          "name": "unavailable",
          "type": "function",
          "required": false,
          "description": "Asked with each ISO date: true for one that can't be chosen (a weekend, a booked day). The field says so under it."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "description": "Whose order and names, as a BCP 47 tag (\"en-GB\", \"de\"). Default: the browser's. Pass it from the request on a server-rendered page, so the first paint is already in the right order."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale: sm 32px, md 44px, lg 60px, the same as a Field beside it. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "end",
          "type": "unknown",
          "required": false,
          "description": "In the well after the date: a small control, as a size=\"sm\" iconOnly HardwareButton with its own name. DatePicker puts its calendar key here."
        },
        {
          "name": "description",
          "type": "unknown",
          "required": false,
          "description": "Help text under the field, linked to it so a screen reader reads it after the name."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "description": "What's wrong, in words. While set, the field is invalid, and it wins over the field's own min, max and unavailable messages."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted; a visible label wins."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted."
        },
        {
          "name": "aria-describedby",
          "type": "string",
          "required": false,
          "description": "Ids of other text that describes the field, read after its own description and error."
        }
      ],
      "examples": [
        {
          "title": "A date",
          "description": "Tab lands on the first wheel; type the digits and it moves on by itself.",
          "code": "<DateField label=\"Date of birth\" name=\"birthday\" />"
        },
        {
          "title": "Controlled, with limits",
          "description": "A date outside the limits is said in words under the field.",
          "code": "<DateField\n  label=\"Start date\"\n  value={start}\n  onValueChange={setStart}\n  min=\"2026-01-01\"\n  max=\"2026-12-31\"\n  description=\"Any day this year.\"\n/>"
        },
        {
          "title": "In a locale",
          "description": "The wheels follow the locale's order and its letters: TT.MM.JJJJ in German.",
          "code": "<DateField label=\"Datum\" locale=\"de\" defaultValue=\"2026-09-28\" />"
        }
      ],
      "antiPatterns": [
        "Don't use it for a date and a time: it has no time wheels. A time field is coming soon.",
        "Don't parse its value with the Date constructor: an ISO date there is midnight UTC, which is the day before anywhere west of Greenwich. Keep the ISO string, or read it with `fromISO` from this item.",
        "Don't spread react-hook-form's register() on it: there's no text input for it to read. Use a Controller, and pass `value`, `onValueChange`, `onBlur` and `ref`; the ref is the first wheel, so an error can focus it.",
        "Don't use three Selects for a date: they can't be typed into, and a screen reader hears three unrelated lists."
      ],
      "usage": {
        "imports": "import { DateField } from \"@/echo/date-field\";",
        "code": "<DateField label=\"Date\" value={date} onValueChange={setDate} />"
      },
      "composition": "DateField → Field's frame (label, well, help, error) → a wheel per part (day, month, year) → flaps",
      "a11y": [
        "The field is a group named by its label; each wheel is a spin button that says the field's name before its own (\"Date of birth, Day\"), so Tab can land on any of them.",
        "Type digits and a wheel moves on once no other digit could follow; ↑/↓ step it, Page Up and Page Down jump a week, a quarter or a decade, Home and End go to its ends, Backspace clears it.",
        "←/→, or a separator key (/ . - or a space), move between wheels; right-to-left, the arrows follow the reading direction.",
        "An empty wheel reads \"Empty\", and a month reads its name with its number (\"9, September\").",
        "On a phone each wheel opens the numeric keyboard, and a pasted ISO date fills every wheel.",
        "`description` and `error` are linked with `aria-describedby`; an error sets `aria-invalid` on every wheel, and is words plus a square pip, never colour alone."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "Flaps take their new digit without turning, and a new error doesn't shake the field; the lit bar under the focused wheel still fades in."
      },
      "sound": [
        "SplitFlap's dry tick when flaps turn, once however many turn together, inside a SoundProvider."
      ],
      "feel": "A date you dial in. Each digit you type turns a flap over with a dry tick, and the wheel hands you on to the next as soon as it's full. The wheel with focus is lit from under.",
      "api": {
        "DateField": {
          "props": [
            "autoFocus",
            "defaultValue",
            "description",
            "disabled",
            "end",
            "error",
            "id",
            "key",
            "label",
            "locale",
            "max",
            "min",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "size",
            "unavailable",
            "value"
          ],
          "name": {
            "anyOf": [
              "label",
              "aria-label",
              "aria-labelledby"
            ]
          }
        }
      },
      "weight": {
        "js": 4146,
        "withDependencies": 32438,
        "css": 1999
      }
    },
    {
      "name": "echo-calendar",
      "title": "Calendar",
      "version": "0.3.0",
      "description": "A month on a device screen. The days are its lit segments: the chosen day lit with the one lit accent, today on a lit rail, a day that can't be chosen struck through. Two keys page the months, and the month slides the way it went.",
      "summary": "A month on a device screen: the days are its lit segments, the chosen day lit with the one lit accent and today on a lit rail. A grid with one Tab stop and the calendar keys; the value is an ISO date.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-date-field",
        "@echo/echo-hardware-button",
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add calendar",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "Puts the chosen ISO date in a form under this name."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "description": "Faded, and no day can be reached or chosen."
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false,
          "description": "The days can be looked through but not chosen."
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Put focus on the chosen day, or today, when it mounts: for a calendar that opens in a popup."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Controlled chosen date, an ISO date (\"2026-09-28\"), or \"\" for none."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "description": "Uncontrolled initial date, as ISO."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Fired with the ISO date a person chooses."
        },
        {
          "name": "min",
          "type": "string",
          "required": false,
          "description": "The earliest date that can be chosen, as ISO. The previous-month key stops at its month."
        },
        {
          "name": "max",
          "type": "string",
          "required": false,
          "description": "The latest date that can be chosen, as ISO. The next-month key stops at its month."
        },
        {
          "name": "unavailable",
          "type": "function",
          "required": false,
          "description": "Asked with each ISO date: true for one that can't be chosen (a weekend, a booked day). It's struck through, and can still be reached by the keys."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "description": "Whose month and weekday names, and which day starts the week, as a BCP 47 tag. Default: the browser's."
        },
        {
          "name": "firstDayOfWeek",
          "type": "number",
          "required": false,
          "description": "The week's first day, 0 for Sunday to 6 for Saturday. Default: the locale's."
        },
        {
          "name": "defaultMonth",
          "type": "string",
          "required": false,
          "description": "The month shown first, as any ISO date in it. Default: the chosen date's month, or this month."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Each day's size on the control scale: sm 32px, md 44px (a finger's width), lg 60px. In a column too narrow for seven, the days shrink to fit it, and the weekdays go down to one letter; with bigger text, the days grow with it. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "What the calendar is for, when the month's name alone doesn't say (\"Check-in\")."
        },
        {
          "name": "className",
          "type": "string",
          "required": false
        }
      ],
      "examples": [
        {
          "title": "Choose a day",
          "description": "One Tab stop; the arrows move the day, Enter chooses it.",
          "code": "<Calendar value={day} onValueChange={setDay} />"
        },
        {
          "title": "Limits and booked days",
          "description": "The month keys stop at the limits, and a booked day is struck through.",
          "code": "<Calendar\n  aria-label=\"Check-in\"\n  min=\"2026-10-01\"\n  max=\"2026-12-31\"\n  unavailable={(iso) => booked.includes(iso)}\n  onValueChange={setCheckIn}\n/>"
        },
        {
          "title": "Compact, Monday first",
          "description": "The small size for a dense screen, with the week starting on Monday whatever the locale.",
          "code": "<Calendar size=\"sm\" firstDayOfWeek={1} defaultValue=\"2026-09-28\" />"
        }
      ],
      "antiPatterns": [
        "Don't use it where a date is typed faster than found (a birthday): use DateField, or DatePicker for both.",
        "Don't show two lit days for a range: a date range picker is coming soon."
      ],
      "usage": {
        "imports": "import { Calendar } from \"@/echo/calendar\";",
        "code": "<Calendar value={day} onValueChange={setDay} />"
      },
      "composition": "Calendar → a screen well → the month's name between two HardwareButton keys → a grid of days",
      "a11y": [
        "A grid named by its month, with one Tab stop: the chosen day, or today. The month's name is said when it changes.",
        "←/→ move a day and ↑/↓ a week; Home and End go to the week's ends; Page Up and Page Down go a month, and with Shift a year. Moving past the month pages with you. Enter or Space chooses.",
        "Each day is named in full (\"Monday, September 28, 2026\"), the chosen one is selected, and today is the current date.",
        "A day that can't be chosen is struck through, not only dimmed, and says it's unavailable; the keys still reach it, so none is skipped.",
        "Right to left, ←/→ follow the reading direction and the month keys point the way the page reads.",
        "When the month keys stop at a limit, focus moves to the month's day rather than being lost.",
        "`required` and `readOnly` are said on the grid; `disabled` takes every day out of reach."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "The next month appears in place instead of sliding in."
      },
      "sound": [
        "The select click when a day is chosen, and the month keys' own press, inside a SoundProvider."
      ],
      "feel": "A small screen that lights the day you choose. The month slides along as you page it, and every choice clicks.",
      "api": {
        "Calendar": {
          "props": [
            "autoFocus",
            "className",
            "defaultMonth",
            "defaultValue",
            "disabled",
            "firstDayOfWeek",
            "id",
            "key",
            "locale",
            "max",
            "min",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "size",
            "unavailable",
            "value"
          ]
        }
      },
      "weight": {
        "js": 3833,
        "withDependencies": 34001,
        "css": 3243
      }
    },
    {
      "name": "echo-date-picker",
      "title": "DatePicker",
      "version": "0.3.0",
      "description": "A DateField with a calendar key in its well. Type the date, or open a Calendar under the field and choose it; the flaps turn over to the day you chose. The value is an ISO date.",
      "summary": "A DateField with a calendar key in its well: type the date, or choose it on a Calendar that opens under the field. The value is an ISO date.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [],
      "registryDependencies": [
        "@echo/echo-date-field",
        "@echo/echo-calendar",
        "@echo/echo-popover",
        "@echo/echo-hardware-button"
      ],
      "install": "npx echo-ui-cli add date-picker",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "Visible eyebrow label; also the name every wheel says before its own. A date field needs label, aria-label or aria-labelledby: leaving all three out is a type error."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Controlled value, an ISO date (\"2026-09-28\"), or \"\" for none."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "description": "Uncontrolled initial value, an ISO date."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Fired with the ISO date once the wheels make a real one, and with \"\" when they stop making one."
        },
        {
          "name": "min",
          "type": "string",
          "required": false,
          "description": "The earliest date, as ISO. The year wheel starts at its year; an earlier date is said to be, in words, under the field."
        },
        {
          "name": "max",
          "type": "string",
          "required": false,
          "description": "The latest date, as ISO. The year wheel ends at its year."
        },
        {
          "name": "unavailable",
          "type": "function",
          "required": false,
          "description": "Asked with each ISO date: true for one that can't be chosen (a weekend, a booked day). The field says so under it."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "description": "Whose order and names, as a BCP 47 tag (\"en-GB\", \"de\"). Default: the browser's. Pass it from the request on a server-rendered page, so the first paint is already in the right order."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale: sm 32px, md 44px, lg 60px, the same as a Field beside it. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "description",
          "type": "unknown",
          "required": false,
          "description": "Help text under the field, linked to it so a screen reader reads it after the name."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "description": "What's wrong, in words. While set, the field is invalid, and it wins over the field's own min, max and unavailable messages."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted; a visible label wins."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted."
        },
        {
          "name": "aria-describedby",
          "type": "string",
          "required": false,
          "description": "Ids of other text that describes the field, read after its own description and error."
        },
        {
          "name": "firstDayOfWeek",
          "type": "number",
          "required": false,
          "description": "The week's first day on the calendar, 0 for Sunday to 6 for Saturday. Default: the locale's."
        },
        {
          "name": "calendarLabel",
          "type": "string",
          "required": false,
          "description": "The calendar key's name, and the popup's. Default \"Choose from a calendar\"."
        }
      ],
      "examples": [
        {
          "title": "A date, typed or chosen",
          "description": "The key opens the calendar; choosing a day closes it.",
          "code": "<DatePicker label=\"Due\" value={due} onValueChange={setDue} />"
        },
        {
          "title": "With limits",
          "description": "The field says a date outside them in words, and the calendar won't page past them.",
          "code": "<DatePicker\n  label=\"Check-in\"\n  min=\"2026-10-01\"\n  max=\"2026-12-31\"\n  unavailable={(iso) => booked.includes(iso)}\n  value={checkIn}\n  onValueChange={setCheckIn}\n/>"
        }
      ],
      "antiPatterns": [
        "Don't use it for a date that's always typed (a birthday nobody finds on a grid): DateField alone is quicker.",
        "Don't use two for a range: a date range picker is coming soon."
      ],
      "usage": {
        "imports": "import { DatePicker } from \"@/echo/date-picker\";",
        "code": "<DatePicker label=\"Date\" value={date} onValueChange={setDate} />"
      },
      "composition": "DatePicker → DateField (the wheels) + a HardwareButton key in its well → Popover → Calendar",
      "a11y": [
        "The field is DateField, with its wheels and keys; the calendar is Calendar, with its grid and keys.",
        "The calendar key is a named button (`calendarLabel`), and Alt+↓ on a wheel opens the calendar too.",
        "The calendar opens with focus on the chosen day, or today. Enter chooses and closes it; Escape closes it without choosing. Focus goes back to the key.",
        "The popup is named by `calendarLabel`, so a screen reader says what opened."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "The popup fades instead of growing, the month doesn't slide, and the flaps don't turn."
      },
      "sound": [
        "The popup's select click as it opens, the calendar's as a day is chosen, and the flaps' tick as they turn, inside a SoundProvider."
      ],
      "feel": "The field you dial a date into, with a small screen that folds out under it when you'd rather look.",
      "api": {
        "DatePicker": {
          "props": [
            "autoFocus",
            "calendarLabel",
            "defaultValue",
            "description",
            "disabled",
            "error",
            "firstDayOfWeek",
            "id",
            "key",
            "label",
            "locale",
            "max",
            "min",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "size",
            "unavailable",
            "value"
          ],
          "name": {
            "anyOf": [
              "label",
              "aria-label",
              "aria-labelledby"
            ]
          }
        }
      },
      "weight": {
        "js": 7962,
        "withDependencies": 79399,
        "css": 4638
      }
    },
    {
      "name": "echo-file-upload",
      "title": "FileUpload",
      "version": "0.3.0",
      "description": "A recessed tray you press to choose files or drop them on; its inner edge lights while files are over it. Each file becomes a row with a Progress strip while it goes up, and a key to cancel, retry or remove it. It has no upload code: your handler uploads, and reports progress.",
      "summary": "A recessed tray you press to choose files or drop them on; its inner edge lights while files are over it. Each file becomes a row with a Progress strip while your handler uploads it, and a key to cancel, retry or remove it.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-field",
        "@echo/echo-hardware-button",
        "@echo/echo-progress",
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add file-upload",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "Without `upload`, a form submits the files under this name."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false,
          "description": "Accepted with every control, and says nothing on a tray, as on a button. A form that needs a file checks for one, and says so in error."
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "Visible eyebrow label; also the name of the group the tray and its files are in. A file upload needs label, aria-label or aria-labelledby: leaving all three out is a type error."
        },
        {
          "name": "accept",
          "type": "string",
          "required": false,
          "description": "The file types it takes, as a file input's accept: \"image/*\", \".pdf\", \"text/csv\". A file of another type is refused, and says why."
        },
        {
          "name": "multiple",
          "type": "boolean",
          "required": false,
          "description": "Take more than one file. Without it, a new file replaces the one there."
        },
        {
          "name": "maxSize",
          "type": "number",
          "required": false,
          "description": "The largest file it takes, in bytes. A bigger one is refused, and says why."
        },
        {
          "name": "maxFiles",
          "type": "number",
          "required": false,
          "description": "How many files it holds at most. One past it is refused, and says why."
        },
        {
          "name": "upload",
          "type": "function",
          "required": false,
          "description": "Uploads one file: (file, report, signal). Call report with 0 to 1 as it goes, stop when signal aborts, resolve when done, and throw to fail; the error's message is shown on its row. Without it, the files are held for a form."
        },
        {
          "name": "onFilesChange",
          "type": "function",
          "required": false,
          "description": "Called with every row whenever one changes: added, progressing, uploaded, failed or removed."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Row height on the control scale: sm 32px, md 44px, lg 60px; the tray is twice that. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "description",
          "type": "unknown",
          "required": false,
          "description": "Help under the tray, linked to it: what it takes, and how much."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "description": "What's wrong, in words. While set, the field is invalid, and the message is linked to the tray."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "description": "Whose words for sizes (\"2.4 MB\"), as a BCP 47 tag. Default: the browser's."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted; a visible label wins."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted."
        },
        {
          "name": "aria-describedby",
          "type": "string",
          "required": false,
          "description": "Ids of other text that describes the tray, read after its own description and error."
        }
      ],
      "examples": [
        {
          "title": "Upload with progress",
          "description": "Your handler uploads each file and reports how far it's got; throw to fail it, and the message shows on its row.",
          "code": "<FileUpload\n  label=\"Attachments\"\n  multiple\n  accept=\"image/*,.pdf\"\n  maxSize={10_000_000}\n  description=\"Images or PDFs, up to 10 MB each.\"\n  upload={(file, report, signal) => sendFile(file, report, signal)}\n/>"
        },
        {
          "title": "In a form",
          "description": "Without `upload` it holds the files, and the form submits them.",
          "code": "<form action=\"/avatar\" method=\"post\" encType=\"multipart/form-data\">\n  <FileUpload label=\"Avatar\" name=\"avatar\" accept=\"image/*\" />\n</form>"
        }
      ],
      "antiPatterns": [
        "Don't make dropping the only way to add a file: the tray is a button for a reason.",
        "Don't show a failure as a red row alone: throw an Error whose message says what went wrong, and it's shown in words.",
        "Don't keep uploading after a cancel: stop when the signal aborts."
      ],
      "usage": {
        "imports": "import { FileUpload } from \"@/echo/file-upload\";",
        "code": "<FileUpload label=\"Attachments\" multiple upload={sendFile} />"
      },
      "composition": "FileUpload → Field's frame → a tray (a button) + a hidden file input → a row per file: name, status, Progress, HardwareButton keys",
      "a11y": [
        "The tray is a real button: Enter or Space opens the file dialog, so dropping is never the only way in.",
        "The field is a group named by its label; `description` and `error` are linked to the tray, and `error` sets `aria-invalid` on it.",
        "What happens to each file is said aloud: added, uploaded, failed with its reason, refused with its reason, cancelled or removed.",
        "Each row's Progress strip is named by its file, and each key says what it does to which file (\"Cancel report.pdf\"). A key that goes with its row hands focus to the tray; Retry hands it to the row's other key.",
        "A refused file says why in words (its type, its size, one too many), with a square pip, never colour alone."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "Rows appear without sliding in; the tray's edge still changes colour as files come over it."
      },
      "sound": [
        "The select click when files are added, and the error tone when one is refused or fails, inside a SoundProvider."
      ],
      "feel": "A tray set into the device. Files dropped on it light its edge, and each one sits in a row of its own, its strip filling as it goes up.",
      "api": {
        "FileUpload": {
          "props": [
            "accept",
            "autoFocus",
            "description",
            "disabled",
            "error",
            "id",
            "key",
            "label",
            "locale",
            "maxFiles",
            "maxSize",
            "multiple",
            "name",
            "onBlur",
            "onFilesChange",
            "onFocus",
            "onKeyDown",
            "readOnly",
            "ref",
            "required",
            "size",
            "upload"
          ],
          "name": {
            "anyOf": [
              "label",
              "aria-label",
              "aria-labelledby"
            ]
          }
        }
      },
      "weight": {
        "js": 3764,
        "withDependencies": 34047,
        "css": 4164
      }
    },
    {
      "name": "echo-textarea",
      "title": "Textarea",
      "version": "0.3.0",
      "description": "Field's recessed well, multi-line and autosizing. The height is measured from the browser's own layout rather than counted from newlines, so it stays right when lines wrap and when the container resizes. Same well, same focus glow, same eyebrow label as Field.",
      "summary": "Field's recessed well, multi-line and autosizing. The height is measured from the browser's own layout rather than counted from newlines, so it stays right when lines wrap and when the container resizes.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add textarea",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "Identifies the field when a form is submitted."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "description": "A hint, never a substitute for the label."
        },
        {
          "name": "autoComplete",
          "type": "string",
          "required": false,
          "description": "What the value is, for autofill and assistive tech — \"email\", \"one-time-code\". WCAG 1.3.5."
        },
        {
          "name": "inputMode",
          "type": "string",
          "required": false,
          "description": "Which on-screen keyboard a phone offers: \"numeric\", \"email\", \"tel\", …"
        },
        {
          "name": "maxLength",
          "type": "number",
          "required": false
        },
        {
          "name": "minLength",
          "type": "number",
          "required": false
        },
        {
          "name": "pattern",
          "type": "string",
          "required": false,
          "description": "A regular expression the value must match for the field to be valid."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "Visible eyebrow label; also the accessible name. A textarea needs label, aria-label or aria-labelledby: leaving all three out is a type error."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Controlled value."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "description": "Uncontrolled initial value."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Fired with the new string value."
        },
        {
          "name": "mono",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Machine (mono) voice — logs, patch notes, codes."
        },
        {
          "name": "autosize",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Grow to fit the content instead of scrolling."
        },
        {
          "name": "rows",
          "type": "number",
          "required": false,
          "defaultValue": "3",
          "description": "Rows shown before it grows; the fixed height when autosize is off."
        },
        {
          "name": "maxRows",
          "type": "number",
          "required": false,
          "description": "Stop growing here and scroll. Rows, not pixels."
        },
        {
          "name": "description",
          "type": "unknown",
          "required": false,
          "description": "Help text under the textarea, linked to it so a screen reader reads it after the name."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "description": "What's wrong, in words. While set, the message shows with a square pip, is linked to the textarea, and the textarea gets aria-invalid."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted; a visible label wins."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted."
        }
      ],
      "examples": [
        {
          "title": "Autosizing note",
          "description": "Grows with the content; three rows to start.",
          "code": "<Textarea label=\"Session note\" placeholder=\"What happened in this take?\" />"
        },
        {
          "title": "Capped growth",
          "description": "Grows to eight rows, then scrolls inside the well.",
          "code": "<Textarea label=\"Log\" mono maxRows={8} defaultValue={log} />"
        },
        {
          "title": "Fixed height",
          "description": "autosize={false} keeps it exactly `rows` tall.",
          "code": "<Textarea label=\"Notes\" autosize={false} rows={6} />"
        },
        {
          "title": "Help text and an error",
          "description": "Linked to the textarea, like Field's.",
          "code": "<Textarea\n  label=\"Session note\"\n  description=\"Kept with the take.\"\n  error={note ? undefined : \"Write a note before saving.\"}\n  value={note}\n  onValueChange={setNote}\n/>"
        }
      ],
      "antiPatterns": [
        "Do not use a Textarea for one line of text. The taller well promises more room than the field wants, and people write to fill it. That is Field.",
        "Do not set a `height` on the input. Autosize writes that property every time the value changes, so anything you set is overwritten on the next keystroke — cap it with maxRows instead.",
        "Do not re-enable the resize grip while autosize is on. Dragging it sets an inline height the next measurement throws away, which reads as the handle being broken.",
        "Always pass a label. A recessed well with a placeholder looks labelled and is not — the placeholder vanishes the moment anyone types."
      ],
      "usage": {
        "imports": "import { Textarea } from \"@/echo/textarea\";",
        "code": "<Textarea label=\"Session note\" placeholder=\"What happened in this take?\" />"
      },
      "composition": "Textarea",
      "a11y": [
        "`label` renders a real `<label>` tied to the textarea; otherwise pass `aria-label` or `aria-labelledby`.",
        "`description` and `error` are linked with `aria-describedby`, and `error` sets `aria-invalid`, through Base UI's Field.",
        "A native `<textarea>`: every text-editing key works. Past `maxRows` it scrolls, and it is already focusable, so the scroll is reachable by keyboard."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "The well doesn't press in or take strikes, the caret is the browser's own, and the focus transition is removed."
      },
      "feel": "A deeper well that grows with what you write, then scrolls inside itself. Clicking presses it into the device, each key lands as a small strike, and the caret glides along the line after your letters.",
      "api": {
        "Textarea": {
          "props": [
            "autoComplete",
            "autoFocus",
            "autosize",
            "defaultValue",
            "description",
            "disabled",
            "error",
            "id",
            "inputMode",
            "key",
            "label",
            "maxLength",
            "maxRows",
            "minLength",
            "mono",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "pattern",
            "placeholder",
            "readOnly",
            "ref",
            "required",
            "rows",
            "value"
          ],
          "name": {
            "anyOf": [
              "label",
              "aria-label",
              "aria-labelledby"
            ]
          }
        }
      },
      "weight": {
        "js": 926,
        "withDependencies": 39559,
        "css": 1294
      }
    },
    {
      "name": "echo-badge",
      "title": "Badge",
      "version": "0.3.0",
      "description": "A small molded/LCD status label. neutral (plastic), accent (the lit primary cap), lcd (glowing screen text), outline. Static; consumes var(--echo-*).",
      "summary": "A small molded/LCD status label: neutral, accent, lcd, outline. Static; composes var(--echo-*).",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add badge",
      "props": [
        {
          "name": "variant",
          "type": "\"neutral\" | \"accent\" | \"lcd\" | \"outline\"",
          "required": false,
          "defaultValue": "\"neutral\"",
          "description": "neutral (the default) is plastic; accent wears the lit primary cap; lcd is glowing screen text, for a count or a code; outline is a quiet ring with no fill, in dim text."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "sm or md (the default)."
        }
      ],
      "examples": [
        {
          "title": "Status",
          "description": "Default neutral plastic pill.",
          "code": "<Badge>New</Badge>"
        },
        {
          "title": "LCD count",
          "description": "Glowing screen readout.",
          "code": "<Badge variant=\"lcd\">12</Badge>"
        }
      ],
      "antiPatterns": [
        "Do not use accent Badges for non-primary emphasis — accent is the one saturated color; overuse dilutes it.",
        "Do not hardcode colors; the badge recolors with the theme."
      ],
      "usage": {
        "imports": "import { Badge } from \"@/echo/badge\";",
        "code": "<Badge>New</Badge>"
      },
      "composition": "Badge",
      "a11y": [
        "Plain text in a `<span>` — a screen reader reads it as part of the surrounding content, with no role of its own.",
        "Make the text say the whole thing (“3 new”, not a bare “3”) when the badge is read out of context."
      ],
      "feel": "A small molded pill, or a glowing LCD readout, stuck to whatever it describes.",
      "api": {
        "Badge": {
          "props": [
            "variant"
          ],
          "html": true
        }
      },
      "weight": {
        "js": 202,
        "withDependencies": 202,
        "css": 682
      }
    },
    {
      "name": "echo-kbd",
      "title": "Kbd",
      "version": "0.3.0",
      "description": "A molded keycap for keyboard hints — a tiny cap that never presses. Uses the <kbd> element.",
      "summary": "A molded keycap for keyboard hints. Uses the <kbd> element; composes var(--echo-*).",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add kbd",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "The key legend — one key per cap, so ⌘ and K are two Kbds, not one."
        }
      ],
      "examples": [
        {
          "title": "Shortcut",
          "description": "Show a key.",
          "code": "<Kbd>⌘</Kbd> <Kbd>K</Kbd>"
        }
      ],
      "antiPatterns": [
        "Do not use Kbd for interactive buttons — it is a label; use HardwareButton for actions."
      ],
      "usage": {
        "imports": "import { Kbd } from \"@/echo/kbd\";",
        "code": "<Kbd>⌘</Kbd> <Kbd>K</Kbd>"
      },
      "composition": "Kbd",
      "a11y": [
        "A native `<kbd>` element.",
        "Symbols like ⌘ and ⌥ are read inconsistently by screen readers; where it matters, say the shortcut in words nearby too."
      ],
      "feel": "A tiny keycap that shows which key to reach for, and goes down when that key does on your keyboard.",
      "api": {
        "Kbd": {
          "props": [],
          "html": true
        }
      },
      "weight": {
        "js": 669,
        "withDependencies": 30833,
        "css": 457
      }
    },
    {
      "name": "echo-divider",
      "title": "Divider",
      "version": "0.3.0",
      "description": "A panel seam — a hairline with a faint light lip. Horizontal or vertical; role=separator.",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add divider",
      "props": [
        {
          "name": "orientation",
          "type": "\"horizontal\" | \"vertical\"",
          "required": false,
          "defaultValue": "\"horizontal\"",
          "description": "Sets both the seam direction and aria-orientation."
        }
      ],
      "examples": [
        {
          "title": "Horizontal",
          "description": "Between stacked sections.",
          "code": "<Divider />"
        },
        {
          "title": "Vertical",
          "description": "Between inline items (needs a flex parent).",
          "code": "<Divider orientation=\"vertical\" />"
        }
      ],
      "antiPatterns": [
        "Vertical dividers need a parent with a defined height (e.g. a flex row) or they collapse."
      ],
      "usage": {
        "imports": "import { Divider } from \"@/echo/divider\";",
        "code": "<Divider />"
      },
      "composition": "Divider",
      "a11y": [
        "`role=\"separator\"` with `aria-orientation`, so it marks a real boundary between groups.",
        "For a purely visual rule inside one group, use CSS instead — a separator a screen reader announces should separate something."
      ],
      "feel": "The one seam in the library: a dark groove with a faint lit lip.",
      "api": {
        "Divider": {
          "props": [
            "orientation"
          ],
          "html": true
        }
      },
      "weight": {
        "js": 194,
        "withDependencies": 194,
        "css": 295
      }
    },
    {
      "name": "echo-stat-tile",
      "title": "StatTile",
      "version": "0.3.0",
      "description": "A recessed LCD readout: a glowing value over a machine label, seated in a dark well. Static; consumes var(--echo-*).",
      "summary": "A recessed LCD readout: a glowing value over a machine label in a dark well. Static; composes var(--echo-*).",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-roll"
      ],
      "install": "npx echo-ui-cli add stat-tile",
      "props": [
        {
          "name": "value",
          "type": "string",
          "required": true,
          "description": "The readout value."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "description": "The unit, set small after the value (\"ms\", \"%\"): say it here, not in the value."
        },
        {
          "name": "glow",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Emissive accent value. Quiet the routine tiles and light the one being watched — six lit readouts side by side make the accent mean 'a number' rather than 'this one'."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "Machine label; also the group's accessible name."
        },
        {
          "name": "roll",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "A new value rolls to its digits, each stepping through the ones in between, instead of flickering over. For a string or number value."
        }
      ],
      "examples": [
        {
          "title": "Metric",
          "description": "Value + unit + label.",
          "code": "<StatTile value=\"42\" unit=\"ms\" label=\"Latency\" />"
        },
        {
          "title": "Rolling digits",
          "description": "A new reading steps through the digits in between, the way a counter does.",
          "code": "<StatTile value={String(requests)} unit=\"rps\" label=\"Requests\" roll />"
        }
      ],
      "antiPatterns": [
        "Do not leave every tile glowing on a dashboard. The accent marks the one reading that matters; a row of six lit tiles marks nothing. Pass glow={false} to the rest.",
        "Never animate the value with a smooth ease — LCD/pixel elements move steppy (motion.glitch), not glassy.",
        "Always pass a label; it is the tile's accessible name."
      ],
      "usage": {
        "imports": "import { StatTile } from \"@/echo/stat-tile\";",
        "code": "<StatTile value=\"42\" unit=\"ms\" label=\"Latency\" />"
      },
      "composition": "StatTile",
      "a11y": [
        "`role=\"group\"` named by `label`, so the value, unit and label are read together as one reading.",
        "It does not announce changes. For a value that updates live and matters, announce significant changes separately.",
        "With `roll`, the digits in between are drawn but hidden from assistive technology; the value is said once, as it is."
      ],
      "feel": "One number on an emissive screen, with its unit and what it measures engraved underneath. It powers on in steps the first time it scrolls into view, and a new reading ticks over with a short flicker, the way a segment display updates — or, with roll, steps through the digits in between.",
      "api": {
        "StatTile": {
          "props": [
            "glow",
            "roll",
            "unit"
          ],
          "html": true,
          "required": [
            "value"
          ]
        }
      },
      "weight": {
        "js": 1031,
        "withDependencies": 1301,
        "css": 897
      }
    },
    {
      "name": "echo-chart",
      "title": "Chart",
      "version": "0.3.0",
      "description": "StatTile's dot-matrix language applied to a series: a column per sample, cells lit from the bottom, in the same recessed screen well LcdChip and Progress use. Values quantise to whole cells by construction — steppiness is the data model, not a style. No axes, no gridlines, no tooltips, no smoothing: it is a readout, not a charting library.",
      "summary": "StatTile's dot-matrix language applied to a series: a column per sample, cells lit from the bottom, in the recessed screen well LcdChip and Progress use. Quantises to whole cells by construction — a readout, not a charting library.",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add chart",
      "props": [
        {
          "name": "data",
          "type": "number[]",
          "required": true,
          "description": "The series, oldest first. Empty renders an unlit matrix."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What is being measured. Required — it is the accessible name, and a chart nobody can read is a decorative rectangle."
        },
        {
          "name": "variant",
          "type": "\"bar\" | \"line\"",
          "required": false,
          "defaultValue": "\"bar\"",
          "description": "bar lights from the bottom up; line lights only the value's cell."
        },
        {
          "name": "rows",
          "type": "number",
          "required": false,
          "defaultValue": "7",
          "description": "Matrix height in cells. More cells is finer, not smoother."
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "description": "Scale ceiling. Defaults to the largest value present."
        },
        {
          "name": "min",
          "type": "number",
          "required": false,
          "defaultValue": "0",
          "description": "Scale floor."
        },
        {
          "name": "summary",
          "type": "string",
          "required": false,
          "description": "Replaces the generated accessible description."
        },
        {
          "name": "caption",
          "type": "string",
          "required": false,
          "description": "Machine label under the matrix."
        }
      ],
      "examples": [
        {
          "title": "Level over time",
          "description": "The default bar matrix. Always name what it measures.",
          "code": "<Chart data={levels} label=\"Input level, last 60 seconds\" caption=\"input\" />"
        },
        {
          "title": "A single trace",
          "description": "line lights only the value's cell per column.",
          "code": "<Chart data={temps} variant=\"line\" rows={9} label=\"Core temperature\" />"
        },
        {
          "title": "Fixed scale",
          "description": "Pass max so the matrix does not rescale as data arrives.",
          "code": "<Chart data={percent} min={0} max={100} label=\"Buffer fill\" />"
        }
      ],
      "antiPatterns": [
        "Do not use this when the values matter more than the shape. It quantises to whole cells, so a reader can see a trend and cannot read a number — put the number in an LcdChip or a StatTile beside it.",
        "Never omit `label`. Without it the matrix is an unnamed image, and the entire series is invisible to anyone not looking at the screen.",
        "Do not let the scale float when new samples arrive. Without `max`, the ceiling is the largest value present, so the whole chart rescales the moment a peak lands and the history appears to shrink — pass a fixed max for anything live.",
        "Do not reach for this when you need axes, hit-testing or tooltips. It is a device readout; a real chart is a different job and a different library.",
        "Do not animate the cells between values. A screen readout does not ease smoothly — it steps, which is why the quantisation lives in the data rather than in the CSS."
      ],
      "usage": {
        "imports": "import { Chart } from \"@/echo/chart\";",
        "code": "<Chart data={levels} label=\"Input level, last 60 seconds\" />"
      },
      "composition": "Chart",
      "a11y": [
        "One `role=\"img\"` named by `label` and a summary: how many samples, the range, and the last value.",
        "Pass `summary` to say what the shape means (“rising steadily”); the default only states the numbers.",
        "When the numbers themselves matter, put them in a Table as well — a picture of data is not the data."
      ],
      "feel": "A dot-matrix readout of recent values — a device's screen, not a charting library.",
      "api": {
        "Chart": {
          "props": [
            "caption",
            "variant"
          ],
          "html": true,
          "required": [
            "data"
          ]
        }
      },
      "weight": {
        "js": 608,
        "withDependencies": 846,
        "css": 785
      }
    },
    {
      "name": "echo-app-shell",
      "title": "AppShell",
      "version": "0.3.0",
      "description": "The app frame: a molded chassis rail lit from the key angle, a sticky sidebar, and the content between them. Slots, not props — the shell owns the layout, you own the contents. Ships the skip link by construction rather than as an option, and derives the sidebar's sticky offset from the rail's height so the two cannot drift. Below 900px the sidebar column is removed rather than stacked; pair it with a Sheet.",
      "summary": "The app frame: a molded chassis rail, a sticky sidebar, and the content between them. Slots, not props. Ships the skip link by construction and derives the sidebar's sticky offset from the rail's height.",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add app-shell",
      "props": [
        {
          "name": "bare",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Drop the sidebar column — for screens that are all content, where an empty rail is a margin pretending to be navigation."
        },
        {
          "name": "embedded",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "This shell is INSIDE another one (a preview, an embedded editor frame). Drops the skip link and demotes the landmarks — a page gets one <main>. Never set it on the shell that owns the page."
        },
        {
          "name": "Rail.brand",
          "type": "unknown",
          "required": true,
          "description": "The wordmark end: a logo, a product name, a home link."
        },
        {
          "name": "Rail.label",
          "type": "string",
          "required": false,
          "defaultValue": "\"Main\"",
          "description": "Landmark name for the rail's nav."
        },
        {
          "name": "Sidebar.label",
          "type": "string",
          "required": true,
          "description": "Landmark name. Required — 'navigation' twice on one page tells a screen-reader user nothing."
        }
      ],
      "examples": [
        {
          "title": "The frame",
          "description": "Rail, sidebar, content. The skip link comes with it.",
          "code": "<AppShell>\n  <AppShell.Rail brand={<Wordmark />}>\n    <a href=\"/docs\">Docs</a>\n  </AppShell.Rail>\n  <AppShell.Sidebar label=\"Components\">{nav}</AppShell.Sidebar>\n  <AppShell.Content>{children}</AppShell.Content>\n</AppShell>"
        },
        {
          "title": "No sidebar",
          "description": "For a screen that is all content.",
          "code": "<AppShell bare>\n  <AppShell.Rail brand={<Wordmark />} />\n  <AppShell.Content>{children}</AppShell.Content>\n</AppShell>"
        },
        {
          "title": "Retuned metrics",
          "description": "Both are custom properties, and the sidebar offset follows the rail.",
          "code": "<AppShell\n  style={{ \"--echo-shell-rail-h\": \"64px\", \"--echo-shell-side-w\": \"280px\" } as React.CSSProperties}\n>\n  …\n</AppShell>"
        }
      ],
      "antiPatterns": [
        "Do not stack the sidebar above the content on a phone. It pushes every screen down by half a viewport and turns the nav into something you scroll past; below 900px the column is removed and the same list belongs in a Sheet behind a rail toggle.",
        "Never give the rail nav and the sidebar the same landmark name. Two navs called 'navigation' are two identical doors to a screen-reader user.",
        "Do not set the sidebar's sticky offset by hand. It is derived from --echo-shell-rail-h; hardcoding it leaves a dead band at the top of every sidebar the day the rail's height changes.",
        "Do not put page content in the Rail. It is sticky and it is a landmark — anything that scrolls away belongs in Content.",
        "Do not nest a shell inside another without `embedded`. AppShell.Content IS the page's <main>, and a second one is an axe violation — which is how the docs site's own preview of this component failed the suite."
      ],
      "usage": {
        "imports": "import { AppShell } from \"@/echo/app-shell\";",
        "code": "<AppShell>\n  <AppShell.Rail brand={<Wordmark />}>\n    <a href=\"/docs\">Docs</a>\n  </AppShell.Rail>\n  <AppShell.Sidebar label=\"Components\">{nav}</AppShell.Sidebar>\n  <AppShell.Content>{children}</AppShell.Content>\n</AppShell>"
      },
      "composition": "AppShell\n  AppShell.Rail\n  AppShell.Sidebar\n  AppShell.Content",
      "a11y": [
        "Real landmarks: the rail is a `<header>` with a `<nav>`, the sidebar a `<nav>` named by `label`, the content a `<main>`.",
        "A skip link to the content comes first on every page. It is not optional.",
        "`embedded` (for a shell shown inside another page) drops the skip link and turns the landmarks into plain elements, because a page gets one `<main>`.",
        "Put `aria-current=\"page\"` on the link to the current page; the shell draws the lit rail from it, so the mark and the announcement cannot disagree.",
        "Focus is scrolled clear of the rail rather than under it (WCAG 2.4.11), from `scroll-margin-block-start` derived from `--echo-shell-rail-h` — so retuning the rail moves the offset with it. If you nest an `overflow` container inside Content, pad the document scrollport too: WebKit does not carry an element’s scroll-margin out through a nested scrollport."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "Only colour changes on hover and selection; nothing moves."
      },
      "feel": "The chassis the whole app sits in: a top rail, a lit sidebar, and a screen for the content.",
      "api": {
        "AppShell": {
          "props": [
            "bare",
            "embedded"
          ],
          "html": true
        },
        "AppShell.Rail": {
          "props": [
            "brand"
          ],
          "html": true
        },
        "AppShell.Sidebar": {
          "props": [],
          "html": true
        },
        "AppShell.Content": {
          "props": [],
          "html": true
        }
      },
      "weight": {
        "js": 543,
        "withDependencies": 543,
        "css": 1303
      }
    },
    {
      "name": "echo-panel",
      "title": "Panel",
      "version": "0.3.0",
      "description": "The two halves of the device material law as a container: raised molded plastic (things you touch) or a recessed dark screen (things you read). Consumes var(--echo-*).",
      "summary": "The two halves of the device material law as a container: raised molded plastic (things you touch) or a recessed dark screen (things you read).",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add panel",
      "props": [
        {
          "name": "variant",
          "type": "\"raised\" | \"screen\"",
          "required": false,
          "defaultValue": "\"raised\"",
          "description": "raised = molded plastic card; screen = recessed dark LCD well."
        },
        {
          "name": "pressable",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "The surface answers a press like water: a wave runs out from where you pressed, bigger for a longer press, and a key press on a control on it sends one from that control. Decoration only; at rest it looks like any Panel."
        }
      ],
      "examples": [
        {
          "title": "Card",
          "description": "Raised plastic card.",
          "code": "<Panel>Settings</Panel>"
        },
        {
          "title": "Screen",
          "description": "Recessed LCD well for readouts.",
          "code": "<Panel variant=\"screen\"><StatTile value=\"42\" label=\"Latency\" /></Panel>"
        },
        {
          "title": "Pressable",
          "description": "The surface answers a press with a wave; the controls on it keep their own names.",
          "code": "<Panel pressable><HardwareButton>Start</HardwareButton></Panel>"
        }
      ],
      "antiPatterns": [
        "Never make a pressable Panel the only way to do something: it's a surface, not a button. Put the action on a control.",
        "Never put raised controls directly on another raised Panel without a well — plastic sits in recesses, not on plateaus.",
        "Never give the screen variant a background of your own: its field is the theme's, dark glass on a dark device, paper glass on a light one, flat on a drawn one."
      ],
      "usage": {
        "imports": "import { Panel } from \"@/echo/panel\";",
        "code": "<Panel>Settings</Panel>"
      },
      "composition": "Panel",
      "a11y": [
        "A plain `<div>` with no role — it groups visually, not semantically. Give it a heading inside, or a landmark around it, when the grouping matters to a screen reader.",
        "`variant=\"screen\"` is a dark ground: its content must use the text roles, which are contrast-checked against it.",
        "`pressable` adds no role and takes no focus: the wave is decoration, hidden from assistive tech and dropped in forced colors. Keep what the press does on a real control on the Panel."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "With pressable, no wave runs."
      },
      "sound": [
        "With pressable, a soft thud when you press the surface itself, deeper for a longer press, inside a SoundProvider. A control on it makes its own sound instead."
      ],
      "feel": "Either raised molded plastic you can set things on, or a recessed dark screen you look into. Pressable, it gives like water: a wave runs out from where you pressed.",
      "api": {
        "Panel": {
          "props": [
            "pressable",
            "variant"
          ],
          "html": true
        }
      },
      "weight": {
        "js": 1875,
        "withDependencies": 2200,
        "css": 612
      }
    },
    {
      "name": "echo-progress",
      "title": "Progress",
      "version": "0.3.0",
      "description": "A discrete LED cell meter in a recessed strip — filled cells are lit accent LEDs with a bloom. Steppy by construction; consumes var(--echo-*).",
      "summary": "A discrete LED cell meter in a recessed strip — filled cells are lit accent LEDs with a bloom. Steppy by construction; role=progressbar.",
      "group": "Feedback",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add progress",
      "props": [
        {
          "name": "value",
          "type": "number",
          "required": false,
          "defaultValue": "0.5",
          "description": "Fraction filled, 0..1."
        },
        {
          "name": "cells",
          "type": "number",
          "required": false,
          "defaultValue": "14",
          "description": "Number of LED cells."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "What is filling up (\"Upload\", \"Battery\"). This or aria-labelledby is required: leaving both out is a type error."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Id of a visible label, instead of aria-label."
        }
      ],
      "examples": [
        {
          "title": "Upload meter",
          "description": "Value is a 0..1 fraction.",
          "code": "<Progress value={0.65} aria-label=\"Upload progress\" />"
        }
      ],
      "antiPatterns": [
        "Never tween the fill smoothly — LEDs snap on cell by cell (steppy motion), they don't slide.",
        "Never use it as a slider; it is read-only output. For input, use Slider."
      ],
      "usage": {
        "imports": "import { Progress } from \"@/echo/progress\";",
        "code": "<Progress value={0.65} aria-label=\"Upload progress\" />"
      },
      "composition": "Progress",
      "a11y": [
        "`role=\"progressbar\"` with `aria-valuenow` from 0 to 100; `value` is a fraction from 0 to 1.",
        "Name it with `aria-label` — the cells carry no text.",
        "It does not announce each change. For a long task, say when it finishes somewhere that does (a Toast or a live Banner)."
      ],
      "feel": "A segmented meter that fills cell by cell, like an LED bar graph.",
      "api": {
        "Progress": {
          "props": [
            "cells"
          ],
          "html": true,
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          }
        }
      },
      "weight": {
        "js": 353,
        "withDependencies": 586,
        "css": 523
      }
    },
    {
      "name": "echo-loader",
      "title": "Loader",
      "version": "0.3.0",
      "description": "Three accent LEDs breathing in sequence — the device thinking. role=status; reduced motion holds the dots steady. Consumes var(--echo-*).",
      "summary": "Three accent LEDs breathing in sequence — the device thinking. role=status; reduced motion holds the dots steady.",
      "group": "Feedback",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add loader",
      "props": [
        {
          "name": "dot",
          "type": "number",
          "required": false,
          "defaultValue": "9",
          "description": "Dot diameter in px."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "defaultValue": "\"Loading\"",
          "description": "Accessible status text."
        }
      ],
      "examples": [
        {
          "title": "Default",
          "description": "Inline loading indicator.",
          "code": "<Loader />"
        },
        {
          "title": "Small",
          "description": "Fits inside a button or chip.",
          "code": "<Loader dot={6} label=\"Saving\" />"
        }
      ],
      "antiPatterns": [
        "Never spin it — Echo has no spinners; the device pulses, it doesn't rotate.",
        "Never stack Loader with skeletons in the same region; pick one loading voice per surface."
      ],
      "usage": {
        "imports": "import { Loader } from \"@/echo/loader\";",
        "code": "<Loader label=\"Saving\" />"
      },
      "composition": "Loader",
      "a11y": [
        "`role=\"status\"` named by `label`, so it is announced when it appears.",
        "Remove it as soon as the work is done — it is announced when it appears, and a loader left on screen says the wait is still going."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "The dots stop pulsing and hold at a steady glow."
      },
      "feel": "Three dots pulsing in turn — the device is thinking.",
      "api": {
        "Loader": {
          "props": [
            "dot"
          ],
          "html": true
        }
      },
      "weight": {
        "js": 250,
        "withDependencies": 250,
        "css": 388
      }
    },
    {
      "name": "echo-status-dot",
      "title": "StatusDot",
      "version": "0.3.0",
      "description": "A tiny LED socket, filled and blooming or hollow and dead. State is carried by fill-vs-hollow and by a 5.25:1 luminance step, not by hue, so it survives protan/deuteran/tritan and greyscale. Requires `label` or `decorative`. Consumes var(--echo-*).",
      "summary": "A tiny LED — lit accent with a bloom, or dead plastic gray. Decorative (aria-hidden) unless given a label.",
      "group": "Feedback",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add status-dot",
      "props": [
        {
          "name": "on",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Filled, lit socket vs dead, hollow one."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "Accessible name for the state (e.g. \"Online\"). Required unless `decorative` is set."
        },
        {
          "name": "decorative",
          "type": "boolean",
          "required": false,
          "description": "Adjacent visible text already states the state, so the dot is hidden from assistive tech. Exactly one of `label` or `decorative` is required — a dot that has neither signals nothing to anybody."
        }
      ],
      "examples": [
        {
          "title": "With text",
          "description": "Adjacent text carries the state, so say so.",
          "code": "<StatusDot on decorative /> Recording"
        },
        {
          "title": "Standalone",
          "description": "The dot is the only signal.",
          "code": "<StatusDot on={false} label=\"Offline\" />"
        }
      ],
      "antiPatterns": [
        "Never encode more than on/off — an LED has two states; for tones use Badge.",
        "Never leave a dot with neither `label` nor `decorative` — that is a state signalled to nobody.",
        "Never restore hue as the only state difference; the fill/hollow step is what survives colour-vision deficiency."
      ],
      "usage": {
        "imports": "import { StatusDot } from \"@/echo/status-dot\";",
        "code": "<StatusDot on={false} label=\"Offline\" />"
      },
      "composition": "StatusDot",
      "a11y": [
        "With `label`, it is `role=\"img\"` named by the label — use that when the dot is the only signal.",
        "With `decorative`, it is hidden from assistive technology — use that when the text next to it already says the state.",
        "One of the two is required; an unlabelled dot warns in development.",
        "On and off differ by shape and brightness, not only by colour."
      ],
      "feel": "A tiny LED: lit, or dead. It lights and goes dark over a moment, like a lamp, not in a single frame.",
      "api": {
        "StatusDot": {
          "props": [
            "decorative",
            "on"
          ],
          "html": true
        }
      },
      "weight": {
        "js": 227,
        "withDependencies": 227,
        "css": 388
      }
    },
    {
      "name": "echo-toast",
      "title": "Toast",
      "version": "0.3.0",
      "description": "A raised glass notification slab with a status LED, and the toaster that announces it. Mount <Toaster> once around the app and call toast() from useToast(): the toaster's live region is on the page before anything is said, so every toast is announced. Built on Base UI's Toast. Consumes var(--echo-*).",
      "summary": "A raised glass notification slab with a status LED — lit accent when active. Presentational (role=status); queueing stays in the consumer.",
      "group": "Feedback",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add toast",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "description": "Headline of the notification."
        },
        {
          "name": "body",
          "type": "string",
          "required": false,
          "description": "Secondary line."
        },
        {
          "name": "tone",
          "type": "\"info\" | \"active\"",
          "required": false,
          "defaultValue": "\"info\"",
          "description": "Echo's one status vocabulary, the subset a toast slab can express. active lights the LED accent — for something happening, not decoration. info is the plain slab."
        }
      ],
      "examples": [
        {
          "title": "Announce something",
          "description": "Mount the toaster once, then raise toasts from anywhere inside it.",
          "code": "function SaveButton() {\n  const toast = useToast();\n  return <HardwareButton onClick={() => toast({ title: \"Saved\", body: \"Draft stored locally\" })}>Save</HardwareButton>;\n}\n\n<Toaster>\n  <SaveButton />\n</Toaster>"
        },
        {
          "title": "Something is live",
          "description": "`active` lights the LED, for an event that is happening.",
          "code": "toast({ title: \"Recording\", tone: \"active\", timeout: 0 })"
        },
        {
          "title": "The slab alone",
          "description": "For showing one on a page. It announces nothing.",
          "code": "<Toast title=\"Saved\" body=\"Draft stored locally\" />"
        }
      ],
      "antiPatterns": [
        "Don't mount a second toaster for each screen; one around the app announces everything.",
        "Never put the only copy of something important in a toast. It leaves; give people a way to find it again.",
        "Never keep an `active` toast on screen after the event ends; the LED means live, not important."
      ],
      "usage": {
        "imports": "import { Toaster, useToast } from \"@/echo/toast\";",
        "code": "// Once, around the app:\nfunction App({ children }: { children: React.ReactNode }) {\n  return <Toaster>{children}</Toaster>;\n}\n\n// Anywhere inside it:\nfunction SaveButton() {\n  const toast = useToast();\n  return <button onClick={() => toast({ title: \"Saved\" })}>Save</button>;\n}"
      },
      "composition": "Toaster → toasts from useToast()",
      "a11y": [
        "The toaster is a live region named “Notifications”, on the page before any toast, so each toast is announced when it arrives. A status created together with its text often isn't, which is why the slab alone announces nothing.",
        "Toasts are polite by default. `priority: \"high\"` interrupts; keep it for what can't wait.",
        "Each toast has a “Dismiss notification” button. Toasts stay 6 seconds by default; for anything people must act on, use `timeout: 0` or don't use a toast.",
        "The pile fans into a column when the pointer is on it or focus is in it (F6 moves focus there), and the timers pause while it's open, so every toast and its dismiss button can be read and reached. A flick is a pointer extra: the dismiss button does the same."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "The pile and the column jump into place, nothing turns, and a flicked toast fades where it was let go."
      },
      "feel": "A glass slab with a status LED that confirms, quietly, that something happened. Toasts pile like a stack of cards and fan out under your hand; flick one away and it leaves at the speed you threw it, turning from where you held it. Its dismiss key presses a pixel down like the library's keys.",
      "api": {
        "Toast": {
          "props": [
            "body",
            "tone"
          ],
          "html": true,
          "required": [
            "title"
          ]
        },
        "Toaster": {
          "props": [
            "children",
            "limit",
            "timeout"
          ]
        }
      },
      "weight": {
        "js": 1302,
        "withDependencies": 57529,
        "css": 1364
      }
    },
    {
      "name": "echo-status-island",
      "title": "StatusIsland",
      "version": "0.3.0",
      "description": "Work that runs in the background, kept in one small floating slab: at rest a pill with a lit socket and a readout; pressed, it blooms open the way every Echo panel arrives, wider first and then taller, into the detail and what you can do about it. It announces when the work starts, finishes or fails, never every percent. For uploads, exports, timers, recordings and anything \"writing…\".",
      "summary": "Background work in a floating pill that blooms open for detail.",
      "group": "Feedback",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-status-dot",
        "@echo/echo-lcd-chip",
        "@echo/echo-progress",
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add status-island",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What's happening, in a word or two: the pill's label (\"Exporting\", \"Recording\")."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "description": "What it's happening to, heading the open island (\"Q3 report.pdf\")."
        },
        {
          "name": "reading",
          "type": "string",
          "required": false,
          "description": "The pill's readout (\"42%\", \"04:59\"). Default: progress as a percent."
        },
        {
          "name": "progress",
          "type": "number",
          "required": false,
          "description": "How far along, 0–1: a row of lit cells in the open island."
        },
        {
          "name": "detail",
          "type": "unknown",
          "required": false,
          "description": "A line under the progress: what's left, where it went, what went wrong."
        },
        {
          "name": "state",
          "type": "\"running\" | \"paused\" | \"done\" | \"failed\"",
          "required": false,
          "defaultValue": "\"running\"",
          "description": "running lights the socket; paused darkens it; done and failed are said out loud."
        },
        {
          "name": "actions",
          "type": "unknown",
          "required": false,
          "description": "What you can do about it: small HardwareButtons in the open island."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "onOpenChange",
          "type": "function",
          "required": false
        },
        {
          "name": "placement",
          "type": "\"inline\" | \"top\" | \"bottom\"",
          "required": false,
          "defaultValue": "\"inline\"",
          "description": "inline sits where you put it; top and bottom float at the centre of that edge of the screen."
        }
      ],
      "examples": [
        {
          "title": "An export",
          "description": "The pill reads the percent; open, it shows the cells, what's left and a way to stop.",
          "code": "<StatusIsland\n  label=\"Exporting\"\n  title=\"Q3 report.pdf\"\n  progress={0.42}\n  detail=\"12 of 28 pages, about 10 s left\"\n  actions={<HardwareButton onClick={cancel}>Cancel</HardwareButton>}\n/>"
        },
        {
          "title": "A timer, floating",
          "description": "At the top of the screen, clear of the notch, over whatever is there.",
          "code": "<StatusIsland placement=\"top\" label=\"Timer\" reading=\"04:59\" />"
        },
        {
          "title": "Finished",
          "description": "Done is said once; the pill keeps saying so until you dismiss it.",
          "code": "<StatusIsland label=\"Saved\" title=\"Q3 report.pdf\" state=\"done\" detail=\"Saved to Reports\" />"
        }
      ],
      "antiPatterns": [
        "Never put the only copy of a result in a StatusIsland: it's a place to watch work, and the result belongs on the page.",
        "Never keep one showing when nothing is running; take it away once it's done and seen.",
        "Never stack several islands: put a second job's pill beside the first, or open the first to list both."
      ],
      "usage": {
        "imports": "import { StatusIsland } from \"@/echo/status-island\";",
        "code": "<StatusIsland label=\"Uploading\" title=\"Holiday.mov\" progress={0.6} />"
      },
      "composition": "StatusIsland\n  the pill (a button): StatusDot, the label, LcdChip\n  the detail: the title, Progress, the detail line, actions",
      "a11y": [
        "The pill is a button with `aria-expanded`, controlling the detail, named by its label and reading (\"Exporting 42%\"); Escape closes it from anywhere inside and puts focus back on the pill.",
        "A `role=\"status\"` region says the work started, finished or failed, once each; progress is never read out as it climbs.",
        "The progress cells are a Progress named by the title.",
        "`top` and `bottom` keep clear of the screen's notch and home bar (the safe-area insets, which a phone reports once the page's viewport meta has `viewport-fit=cover`), and only the island itself takes the pointer, never the strip it floats in."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "It opens and closes at once, without growing."
      },
      "sound": [
        "A soft chirp as it opens, a tap as it closes; notify when the work is done and error when it fails, inside a SoundProvider."
      ],
      "feel": "A small lit pill that's quietly busy; touch it and it spreads open like a drop, then gathers back.",
      "api": {
        "StatusIsland": {
          "props": [
            "actions",
            "defaultOpen",
            "detail",
            "label",
            "onOpenChange",
            "open",
            "placement",
            "progress",
            "reading",
            "state",
            "title"
          ]
        }
      },
      "weight": {
        "js": 2537,
        "withDependencies": 30831,
        "css": 2071
      }
    },
    {
      "name": "echo-receipt",
      "title": "Receipt",
      "version": "0.3.0",
      "description": "The device prints: a summary feeds out of a slot in the chassis a line at a time, each with a small tick, in dark ink on the paper colour, and waits to be torn off. For order confirmations, payments, invoices, weekly recaps and the summary of what an automated run did. It prints when it arrives after the page has loaded; one that's there from the start is simply there.",
      "summary": "A summary that feeds out of a slot a line at a time, on paper you tear off.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button"
      ],
      "install": "npx echo-ui-cli add receipt",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "description": "Printed at the top, and the receipt's heading."
        },
        {
          "name": "meta",
          "type": "string",
          "required": false,
          "description": "A line under the title: a number, a date, a place."
        },
        {
          "name": "lines",
          "type": "object[]",
          "required": true,
          "description": "What it lists, each with its value across a dotted leader."
        },
        {
          "name": "total",
          "type": "object",
          "required": false,
          "description": "The bottom line, printed bold under a rule."
        },
        {
          "name": "footer",
          "type": "string",
          "required": false,
          "description": "Printed at the very bottom: how it was paid, a thank-you, what to do next."
        },
        {
          "name": "code",
          "type": "string",
          "required": false,
          "description": "A reference printed as a barcode, with the code in text beneath it."
        },
        {
          "name": "onTear",
          "type": "function",
          "required": false,
          "description": "Shows a Tear off key; called once the paper has dropped away."
        },
        {
          "name": "printKey",
          "type": "string | number",
          "required": false,
          "description": "Print it again whenever this changes. Mid-print, it starts over."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4",
          "required": false,
          "defaultValue": "3",
          "description": "The heading's level."
        }
      ],
      "examples": [
        {
          "title": "Order confirmed",
          "description": "Lines, a total, the order number as a barcode.",
          "code": "<Receipt\n  title=\"North Street Coffee\"\n  meta=\"Order 4821, 25 Sep 2026, 14:32\"\n  lines={[\n    { label: \"2 × Oat flat white\", value: \"9.00\" },\n    { label: \"1 × Almond croissant\", value: \"4.50\" },\n  ]}\n  total={{ label: \"Total\", value: \"$13.50\" }}\n  code=\"4821\"\n  footer=\"Paid by card ••4417. Thank you\"\n/>"
        },
        {
          "title": "What a run did",
          "description": "The same paper for an automated job's summary.",
          "code": "<Receipt\n  title=\"Run 12, done\"\n  meta=\"3 min 41 s\"\n  lines={[\n    { label: \"Files changed\", value: \"4\" },\n    { label: \"Tests\", value: \"212 passed\" },\n  ]}\n  footer=\"Nothing was published\"\n/>"
        },
        {
          "title": "Torn off",
          "description": "A Tear off key; your handler runs once the paper has dropped.",
          "code": "<Receipt title=\"Order 4821\" lines={items} onTear={() => setShowing(false)} />"
        }
      ],
      "antiPatterns": [
        "Never make a Receipt the only record of something: it's a confirmation, and the order or the run belongs somewhere people can find again.",
        "Never print a receipt for a form that hasn't gone through: it means it's done.",
        "Never put controls on the paper: it's printed; the only thing you do with it is tear it off."
      ],
      "usage": {
        "imports": "import { Receipt } from \"@/echo/receipt\";",
        "code": "<Receipt title=\"Order 4821\" lines={[{ label: \"Flat white\", value: \"4.50\" }]} total={{ label: \"Total\", value: \"$4.50\" }} />"
      },
      "composition": "Receipt\n  the slot\n  the paper: the heading, the lines (a description list), the total, a barcode, the footer\n  HardwareButton (Tear off)",
      "a11y": [
        "A `section` named by its heading; the lines are a description list, each label with its value, and the total is the list's last pair.",
        "Everything printed is in the page from the start: the feed is drawing, so a screen reader never waits for it.",
        "The barcode is a picture and is hidden; the code is printed beneath it as text.",
        "The ink is held to AA contrast on the paper under every theme.",
        "Tearing it off is a button, and `onTear` runs once the paper has gone.",
        "Keyboard: Tab reaches the tear button; Enter or Space tears the paper off."
      ],
      "motion": {
        "tokens": [
          "flood",
          "pop"
        ],
        "reducedMotion": "It's there at once; tearing it off fades it where it is."
      },
      "sound": [
        "A soft tick for each line that leaves the slot, and a tap as it's torn, inside a SoundProvider."
      ],
      "feel": "A till's printer: the paper steps out of the slot with a soft tick per line, and tears away when you're done with it.",
      "api": {
        "Receipt": {
          "props": [
            "code",
            "footer",
            "headingLevel",
            "lines",
            "meta",
            "onTear",
            "printKey",
            "title",
            "total"
          ],
          "required": [
            "lines",
            "title"
          ]
        }
      },
      "weight": {
        "js": 2096,
        "withDependencies": 32147,
        "css": 3142
      }
    },
    {
      "name": "echo-ticket",
      "title": "Ticket",
      "version": "0.3.0",
      "description": "A paper pass: what it's for on the main part, the code on a stub across a perforation. Use it and the stub tears off along the perforation and drops away, leaving the torn edge and \"Used\". For event tickets, boarding passes, coupons, invites and gift cards. The same paper and ink as Receipt; on a narrow screen the stub goes under the main part.",
      "summary": "A paper pass whose stub tears off along its perforation.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-receipt"
      ],
      "install": "npx echo-ui-cli add ticket",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "description": "What it's for, printed large, and the ticket's heading."
        },
        {
          "name": "subtitle",
          "type": "string",
          "required": false,
          "description": "Where, or who it's from."
        },
        {
          "name": "details",
          "type": "object[]",
          "required": false,
          "description": "The facts on the main part: a date, a door, a seat."
        },
        {
          "name": "code",
          "type": "string",
          "required": true,
          "description": "The code on the stub, as a barcode and in text."
        },
        {
          "name": "stubLabel",
          "type": "string",
          "required": false,
          "description": "Printed on the stub over its code: \"Admit one\", \"Boarding\"."
        },
        {
          "name": "used",
          "type": "boolean",
          "required": false,
          "description": "Torn off (controlled)."
        },
        {
          "name": "defaultUsed",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Whether the stub starts torn off (uncontrolled). See used."
        },
        {
          "name": "onUse",
          "type": "function",
          "required": false,
          "description": "Called once the stub has dropped away. With it, the ticket shows a key to use it."
        },
        {
          "name": "useLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"Use ticket\"",
          "description": "The key's word."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4",
          "required": false,
          "defaultValue": "3",
          "description": "The heading's level."
        }
      ],
      "examples": [
        {
          "title": "An event",
          "description": "The details, and a stub to tear at the door.",
          "code": "<Ticket\n  title=\"Night Session\"\n  subtitle=\"Warehouse 9, Leeds\"\n  details={[\n    { label: \"Date\", value: \"Sat 3 Oct\" },\n    { label: \"Doors\", value: \"19:30\" },\n    { label: \"Entry\", value: \"B\" },\n  ]}\n  code=\"NS-3010-0412\"\n  stubLabel=\"Admit one\"\n  onUse={checkIn}\n/>"
        },
        {
          "title": "Already used",
          "description": "The stub's gone; the ticket says so.",
          "code": "<Ticket title=\"Coffee on us\" code=\"FREE-7731\" defaultUsed />"
        }
      ],
      "antiPatterns": [
        "Never make the barcode the only copy of the code: people read codes out and type them in.",
        "Never offer to use a ticket that can't be used yet: disable it, or leave out onUse and say when it opens."
      ],
      "usage": {
        "imports": "import { Ticket } from \"@/echo/ticket\";",
        "code": "<Ticket title=\"Night Session\" code=\"NS-3010-0412\" />"
      },
      "composition": "Ticket\n  the main part: the heading, the subtitle, the details (a description list)\n  the perforation\n  the stub: its label, a barcode, the code\n  HardwareButton (use it, with onUse)",
      "a11y": [
        "A `section` named by its heading; the details are a description list, each label read with its value.",
        "The barcode is a picture and is hidden; the code is printed beneath it as text.",
        "Using it is a button, and a used ticket says \"Used\" in text where the stub was.",
        "The ink is the same as Receipt's, held to AA contrast on the paper under every theme.",
        "Keyboard: Tab reaches the button that uses it; Enter or Space uses the ticket."
      ],
      "motion": {
        "tokens": [
          "flood",
          "pop"
        ],
        "reducedMotion": "The stub fades where it is instead of dropping away."
      },
      "sound": [
        "A tap as the stub tears, inside a SoundProvider."
      ],
      "feel": "A cinema ticket: the stub tears off along its holes when you go in.",
      "api": {
        "Ticket": {
          "props": [
            "code",
            "defaultUsed",
            "details",
            "headingLevel",
            "onUse",
            "stubLabel",
            "subtitle",
            "title",
            "useLabel",
            "used"
          ],
          "required": [
            "code",
            "title"
          ]
        }
      },
      "weight": {
        "js": 1731,
        "withDependencies": 31838,
        "css": 3155
      }
    },
    {
      "name": "echo-now-playing",
      "title": "NowPlaying",
      "version": "0.3.0",
      "description": "A small player: a screen with where it's from, what's playing and the time in lit segments, a strip to scrub along, and the transport keys under it, play the lit one. For music, podcasts, voice messages and recordings. Every part is an Echo part (SegmentDisplay, Slider, HardwareButtons in a ButtonGroup); you own the audio, this is the device you control it with.",
      "summary": "A small player: a screen, a strip to scrub, the transport keys.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-segment-display",
        "@echo/echo-slider",
        "@echo/echo-hardware-button",
        "@echo/echo-toolbar",
        "@echo/echo-scope"
      ],
      "install": "npx echo-ui-cli add now-playing",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "description": "What's playing: the track, the episode, the message."
        },
        {
          "name": "artist",
          "type": "string",
          "required": false,
          "description": "Who it's by, or what it's part of."
        },
        {
          "name": "source",
          "type": "string",
          "required": false,
          "description": "Where it's coming from, engraved over the rest: \"Fresh FM\", \"Voice memo\"."
        },
        {
          "name": "artwork",
          "type": "unknown",
          "required": false,
          "description": "Artwork beside the title, square."
        },
        {
          "name": "playing",
          "type": "boolean",
          "required": true,
          "description": "Playing now."
        },
        {
          "name": "onPlayingChange",
          "type": "function",
          "required": true,
          "description": "Called with the new state when play or pause is pressed."
        },
        {
          "name": "position",
          "type": "number",
          "required": true,
          "description": "Where it is, in seconds."
        },
        {
          "name": "duration",
          "type": "number",
          "required": false,
          "description": "How long it is, in seconds. Leave out for a live stream. An <audio>'s own duration works as it is: Infinity is live, and NaN (before its metadata loads) reads --:-- with the strip disabled."
        },
        {
          "name": "onSeek",
          "type": "function",
          "required": false,
          "description": "Called with a new position when someone scrubs. Without it, the strip only shows."
        },
        {
          "name": "onPrevious",
          "type": "function",
          "required": false,
          "description": "Shows the previous key, and calls this when it's pressed."
        },
        {
          "name": "onNext",
          "type": "function",
          "required": false,
          "description": "Shows the next key, and calls this when it's pressed."
        },
        {
          "name": "screen",
          "type": "\"emissive\" | \"reflective\"",
          "required": false,
          "defaultValue": "\"emissive\"",
          "description": "The screen's glass: emissive (dark, glowing) or reflective (paper-coloured, printed in ink)."
        }
      ],
      "examples": [
        {
          "title": "A track",
          "description": "Scrub, skip, play and pause, all driven from your player's state.",
          "code": "<NowPlaying\n  title=\"Night Drive\"\n  artist=\"Low Tide\"\n  source=\"Library\"\n  playing={playing}\n  onPlayingChange={setPlaying}\n  position={position}\n  duration={214}\n  onSeek={seek}\n  onPrevious={previous}\n  onNext={next}\n/>"
        },
        {
          "title": "Live",
          "description": "No length, so no strip: the screen says it's live.",
          "code": "<NowPlaying title=\"Breakfast Show\" source=\"Fresh FM 88.5\" playing={playing} onPlayingChange={setPlaying} position={elapsed} />"
        }
      ],
      "antiPatterns": [
        "Never let the screen and the audio disagree: position and playing come from your player; this component holds no audio state of its own.",
        "Never put more than three keys under it: previous, play, next. Everything else belongs in a menu."
      ],
      "usage": {
        "imports": "import { NowPlaying } from \"@/echo/now-playing\";",
        "code": "<NowPlaying title=\"Night Drive\" playing={false} onPlayingChange={setPlaying} position={0} duration={214} />"
      },
      "composition": "NowPlaying\n  the screen: the source, the title and artist, SegmentDisplay (elapsed and length), Slider (position)\n  ButtonGroup: HardwareButton (previous), HardwareButton (play or pause), HardwareButton (next)",
      "a11y": [
        "A region named \"Now playing\" and the title.",
        "Play and pause is one button whose name says what it will do; previous and next appear only when you handle them.",
        "The position is a Slider named \"Position\", with every slider key; without onSeek it's shown but can't be moved.",
        "The times are said once each, as \"Elapsed\" and \"Length\".",
        "Keyboard: Tab moves through the buttons and the position; Enter or Space presses a button, and the position takes the Slider's keys (arrows, Page Up and Page Down, Home and End)."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "Keys press and the strip moves without travel, as Echo's always do."
      },
      "sound": [
        "Each key's press, and the strip's tap at each step, inside a SoundProvider."
      ],
      "feel": "A pocket radio: a lit screen, a strip under your thumb, three keys.",
      "api": {
        "NowPlaying": {
          "props": [
            "artist",
            "artwork",
            "duration",
            "onNext",
            "onPlayingChange",
            "onPrevious",
            "onSeek",
            "playing",
            "position",
            "screen",
            "source",
            "title"
          ],
          "required": [
            "onPlayingChange",
            "playing",
            "position",
            "title"
          ]
        }
      },
      "weight": {
        "js": 4858,
        "withDependencies": 52977,
        "css": 4064
      }
    },
    {
      "name": "echo-lcd-chip",
      "title": "LcdChip",
      "version": "0.3.0",
      "description": "An inline recessed LCD readout: a glowing accent value next to a machine label, sunk into a dark well. The chip form of StatTile — use it inline in a row of controls; use StatTile when the readout is a dashboard cell.",
      "summary": "An inline recessed LCD readout — a glowing accent value beside a machine label, sunk into a dark well. The chip form of StatTile.",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-roll"
      ],
      "install": "npx echo-ui-cli add lcd-chip",
      "props": [
        {
          "name": "value",
          "type": "string",
          "required": true,
          "description": "The readout — digits, a duration, a count."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "Machine label beside the value; also the accessible name."
        },
        {
          "name": "glow",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Emissive accent value. false renders plain cream text."
        },
        {
          "name": "roll",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "A new value rolls to its digits, each stepping through the ones in between, instead of flickering over. For a string or number value."
        }
      ],
      "examples": [
        {
          "title": "Readout",
          "description": "Glowing value with a machine label.",
          "code": "<LcdChip value=\"07:42\" label=\"Uptime\" />"
        },
        {
          "title": "Unlit",
          "description": "Cream text — for a value that is present but not live.",
          "code": "<LcdChip value=\"--\" label=\"Signal\" glow={false} />"
        },
        {
          "title": "In a well",
          "description": "Chips read as one instrument cluster inside a screen Panel.",
          "code": "<Panel variant=\"screen\"><LcdChip value=\"128\" label=\"BPM\" /><LcdChip value=\"A♯\" label=\"Key\" /></Panel>"
        },
        {
          "title": "Rolling digits",
          "description": "A new value steps through the digits in between, the way a counter does.",
          "code": "<LcdChip value={String(queue)} label=\"Queue\" roll />"
        }
      ],
      "antiPatterns": [
        "Never put an LcdChip on a raised surface without a well — an emissive readout floating on plastic breaks the material law; nest it in a Panel variant=\"screen\" or the chassis.",
        "Never animate the value with a smooth ease — screens step. Use motion.glitch or swap the text outright; glass moves smooth, pixels move steppy.",
        "Never pass a long sentence as value — the LCD face is tabular digits and short codes, not prose."
      ],
      "usage": {
        "imports": "import { LcdChip } from \"@/echo/lcd-chip\";",
        "code": "<LcdChip value=\"07:42\" label=\"Uptime\" />"
      },
      "composition": "LcdChip",
      "a11y": [
        "`role=\"group\"` named by `label`, so the value is read with what it is.",
        "It renders a `span`, so it can sit inside a button, a label or a line of text. Inside a button, name the button with the reading too: a group's name replaces its text in the button's.",
        "The screen face is tabular: digits keep their width as the value changes.",
        "With `roll`, the digits in between are drawn but hidden from assistive technology; the value is said once, as it is."
      ],
      "feel": "A small emissive screen set into the panel, reading one value. It powers on in steps, and a new value ticks over with a short flicker — or, with roll, steps through the digits in between.",
      "api": {
        "LcdChip": {
          "props": [
            "glow",
            "roll"
          ],
          "html": true,
          "required": [
            "value"
          ]
        }
      },
      "weight": {
        "js": 978,
        "withDependencies": 1248,
        "css": 847
      }
    },
    {
      "name": "echo-segment-display",
      "title": "SegmentDisplay",
      "version": "0.3.0",
      "description": "A seven-segment readout drawn the way a real one looks: the segments a reading doesn't light stay faintly visible under the glass, and zeros that only pad a reading out print dim instead of vanishing. For the one number a screen is about: a frequency, a clock, an amount, a counter.",
      "summary": "A seven-segment readout whose dark segments stay faintly visible, with dim padding zeros.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-roll",
        "@echo/echo-scope"
      ],
      "install": "npx echo-ui-cli add segment-display",
      "props": [
        {
          "name": "value",
          "type": "string | number",
          "required": true,
          "description": "The reading: digits, a minus, a dot, a colon, and the letters a segment face can draw."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What it reads: the accessible name, and engraved over a screen display."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "description": "Engraved after the digits: \"MHz\", \"kg\"."
        },
        {
          "name": "digits",
          "type": "number",
          "required": false,
          "defaultValue": "0",
          "description": "At least this many digit cells; a shorter reading is padded on the left, and a minus stays in front of padding zeros (-05)."
        },
        {
          "name": "pad",
          "type": "\"0\" | \" \"",
          "required": false,
          "defaultValue": "\"0\"",
          "description": "What fills the padding: dim zeros, the way a counter shows them, or blanks (\" \")."
        },
        {
          "name": "ghost",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Draw the segments a reading doesn't light, faintly, as a real display shows them."
        },
        {
          "name": "glow",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Lit in the accent; false lights it cream, for a value that isn't live."
        },
        {
          "name": "roll",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "A new reading rolls to its digits, each stepping through the ones in between."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Digit height: 24, 40 or 64px."
        },
        {
          "name": "variant",
          "type": "\"screen\" | \"flush\"",
          "required": false,
          "defaultValue": "\"screen\"",
          "description": "screen sits in its own dark well; flush is the digits alone, for a screen you already have."
        },
        {
          "name": "screen",
          "type": "\"emissive\" | \"reflective\"",
          "required": false,
          "defaultValue": "\"emissive\"",
          "description": "emissive: a dark screen that glows. reflective: the paper-coloured glass of a pocket calculator, printed in ink."
        }
      ],
      "examples": [
        {
          "title": "Frequency",
          "description": "Four digits wide, so 88.5 shows a dim leading zero.",
          "code": "<SegmentDisplay label=\"Frequency\" value=\"88.5\" digits={4} unit=\"MHz\" size=\"lg\" />"
        },
        {
          "title": "Clock",
          "description": "A colon is its own narrow cell.",
          "code": "<SegmentDisplay label=\"Time left\" value=\"04:59\" />"
        },
        {
          "title": "Counting",
          "description": "Each new reading rolls through the digits in between.",
          "code": "<SegmentDisplay label=\"Queue\" value={String(queue)} digits={3} roll />"
        },
        {
          "title": "Inside your own screen",
          "description": "flush drops the well, for a readout that shares a screen with other parts.",
          "code": "<Card variant=\"screen\" title=\"Room\">\n  <SegmentDisplay variant=\"flush\" label=\"Temperature\" value=\"21.5\" unit=\"°C\" />\n</Card>"
        },
        {
          "title": "Reflective glass",
          "description": "The paper glass of a pocket calculator: printed in ink, nothing glowing.",
          "code": "<SegmentDisplay label=\"Total\" value=\"1284.50\" screen=\"reflective\" />"
        }
      ],
      "antiPatterns": [
        "Never use a SegmentDisplay for words: a seven-segment face draws digits and a few letters; anything else is a blank cell.",
        "Never pad with digits to make a number look bigger: padding is for a face whose width is fixed (a four-digit frequency, a six-digit counter).",
        "Never light every display on a screen: glow the one being watched, and set the rest to glow={false}."
      ],
      "usage": {
        "imports": "import { SegmentDisplay } from \"@/echo/segment-display\";",
        "code": "<SegmentDisplay label=\"Frequency\" value=\"88.5\" digits={4} unit=\"MHz\" />"
      },
      "composition": "SegmentDisplay",
      "a11y": [
        "`role=\"group\"` named by `label`, and the reading said once, as it is, with its unit: \"88.5 MHz\", never the padded \"088.5\".",
        "The digit face is drawing, hidden from assistive technology; so are the dark segments and the padding.",
        "In forced colors the lit segments draw in the system text colour, padding in its grey, and the dark segments go."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "With roll, a new reading appears at once instead of stepping through the digits in between."
      },
      "feel": "A lit panel meter: the dark segments are always there under the glass, and only the reading glows.",
      "api": {
        "SegmentDisplay": {
          "props": [
            "digits",
            "ghost",
            "glow",
            "label",
            "pad",
            "roll",
            "screen",
            "size",
            "unit",
            "value",
            "variant"
          ],
          "required": [
            "value"
          ]
        }
      },
      "weight": {
        "js": 2005,
        "withDependencies": 2114,
        "css": 1184
      }
    },
    {
      "name": "echo-date-tile",
      "title": "DateTile",
      "version": "0.3.0",
      "description": "The page of a desk calendar: the weekday and month engraved small on a raised tile, the day huge on the screen set into it. Change the date and the day turns over to the new one. sm sits in a list row (an event, a booking, an invoice due), md on a card, lg on its own. One date for assistive tech, said in full.",
      "summary": "A desk calendar's page: weekday and month engraved, the day lit on a screen.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-segment-display",
        "@echo/echo-panel",
        "@echo/echo-scope"
      ],
      "install": "npx echo-ui-cli add date-tile",
      "props": [
        {
          "name": "date",
          "type": "string",
          "required": true,
          "description": "The day, as an ISO date: \"2026-09-25\"."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "sm for a list row, md for a card, lg as a widget."
        },
        {
          "name": "roll",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "A new date turns over to its day instead of swapping it."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "defaultValue": "\"en\"",
          "description": "The language the weekday and month are engraved in."
        },
        {
          "name": "screen",
          "type": "\"emissive\" | \"reflective\"",
          "required": false,
          "defaultValue": "\"emissive\"",
          "description": "The tile's screen: emissive (dark, glowing) or reflective (paper-coloured, printed in ink)."
        }
      ],
      "examples": [
        {
          "title": "On a card",
          "description": "The day at a glance.",
          "code": "<DateTile date=\"2026-09-25\" />"
        },
        {
          "title": "In a list row",
          "description": "Small, as an Item's media.",
          "code": "<Item title=\"Design review\" description=\"14:00, Room 3\" media={<DateTile date=\"2026-09-29\" size=\"sm\" />} />"
        },
        {
          "title": "Turning over",
          "description": "A new date rolls to its day.",
          "code": "<DateTile date={day} size=\"lg\" roll />"
        }
      ],
      "antiPatterns": [
        "Never pass a Date built from the local clock: pass the ISO date you mean, or the day can differ between the server and the browser.",
        "Never use a DateTile for a date and time; it's a day. Put the time beside it."
      ],
      "usage": {
        "imports": "import { DateTile } from \"@/echo/date-tile\";",
        "code": "<DateTile date=\"2026-09-25\" />"
      },
      "composition": "DateTile\n  the weekday and month, engraved\n  SegmentDisplay (the day)",
      "a11y": [
        "A `time` element with the ISO date as `dateTime`, read as one full date (\"Friday, September 25, 2026\").",
        "The engraved weekday and month and the lit day are drawing, hidden from assistive technology.",
        "Worked in UTC from the ISO date, so the day is the same on the server and in every browser, whatever their time zones."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "With roll, the new day appears at once."
      },
      "feel": "A tear-off desk calendar: the day is the big thing, and it turns over when the page does.",
      "api": {
        "DateTile": {
          "props": [
            "date",
            "locale",
            "roll",
            "screen",
            "size"
          ],
          "required": [
            "date"
          ]
        }
      },
      "weight": {
        "js": 2346,
        "withDependencies": 2455,
        "css": 1709
      }
    },
    {
      "name": "echo-annunciator",
      "title": "Annunciator",
      "version": "0.3.0",
      "description": "The row of fixed marks printed on a device's screen glass (REC, SYNC, MUTE, a battery), each dark until what it names is happening, then lit. Every mark is always there, so the eye learns where each sits and reads the row at a glance: a status bar, a media player, a recorder. Screen readers hear which are on, in words.",
      "summary": "Fixed marks on a screen's glass that light when what they name is happening.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-scope"
      ],
      "install": "npx echo-ui-cli add annunciator",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What the row reports on: its accessible name (\"Status\")."
        },
        {
          "name": "marks",
          "type": "object[]",
          "required": true,
          "description": "Every mark the screen has, in order: its value, what it means in words, and what's printed (a short word or a glyph)."
        },
        {
          "name": "active",
          "type": "string[]",
          "required": true,
          "description": "The ones lit now."
        },
        {
          "name": "live",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Announce changes as they happen. For a row that's watched, not glanced at."
        },
        {
          "name": "screen",
          "type": "\"emissive\" | \"reflective\"",
          "required": false,
          "defaultValue": "\"emissive\"",
          "description": "The glass: emissive (dark, the lit word glowing) or reflective (paper-coloured, the lit word printed in ink)."
        }
      ],
      "examples": [
        {
          "title": "A recorder's status",
          "description": "Four marks, two lit.",
          "code": "<Annunciator\n  label=\"Status\"\n  marks={[\n    { value: \"rec\", label: \"Recording\", mark: \"REC\" },\n    { value: \"sync\", label: \"Syncing\", mark: \"SYNC\" },\n    { value: \"mute\", label: \"Muted\", mark: \"MUTE\" },\n    { value: \"off\", label: \"Offline\", mark: \"OFFLINE\" },\n  ]}\n  active={[\"rec\", \"sync\"]}\n/>"
        },
        {
          "title": "Watched",
          "description": "Say each change as it happens.",
          "code": "<Annunciator label=\"Connection\" marks={marks} active={active} live />"
        }
      ],
      "antiPatterns": [
        "Never use an Annunciator for something people act on: it reports; a control that changes it goes beside it.",
        "Never add and remove marks as states come and go: every mark stays, so its place means something.",
        "Never write sentences on the glass: a word or a glyph; the label says the rest."
      ],
      "usage": {
        "imports": "import { Annunciator } from \"@/echo/annunciator\";",
        "code": "<Annunciator label=\"Status\" marks={[{ value: \"rec\", label: \"Recording\", mark: \"REC\" }]} active={[\"rec\"]} />"
      },
      "composition": "Annunciator\n  the glass: a mark for each state, lit or dark",
      "a11y": [
        "A group named by `label` that says which marks are on, in words (\"Recording, Syncing\"), or that nothing is.",
        "The printed marks are drawing, hidden from assistive technology; each mark's `label` is what's said.",
        "With `live`, changes are announced politely as they happen.",
        "On and off differ by being lit, and in forced colors by the system text colour against grey, never by hue."
      ],
      "feel": "The little printed words on a stereo's display that light up when they're true.",
      "api": {
        "Annunciator": {
          "props": [
            "active",
            "label",
            "live",
            "marks",
            "screen"
          ],
          "required": [
            "active",
            "marks"
          ]
        }
      },
      "weight": {
        "js": 360,
        "withDependencies": 360,
        "css": 1032
      }
    },
    {
      "name": "echo-led-key",
      "title": "LedKey",
      "version": "0.3.0",
      "description": "A molded pill key with an indicator LED that breathes while lit. The latching sibling of HardwareButton: use it when the key's own state is part of the answer (armed, monitoring, recording), not for a command that fires and forgets.",
      "summary": "A molded pill key with an indicator LED that breathes while lit. The latching sibling of HardwareButton — for a key whose own state is part of the answer.",
      "group": "Buttons",
      "type": "registry:component",
      "dependencies": [
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add led-key",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "onClick",
          "type": "function",
          "required": false
        },
        {
          "name": "type",
          "type": "string",
          "required": false,
          "description": "\"button\" (default), \"submit\" or \"reset\"."
        },
        {
          "name": "form",
          "type": "string",
          "required": false,
          "description": "The id of the form this button submits, when it sits outside it."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Submitted with the form when this button is the one pressed."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "description": "The browser's own hover tooltip. Never the accessible name."
        },
        {
          "name": "onPointerDown",
          "type": "function",
          "required": false,
          "description": "Runs before Echo's own tactile handler, so the sound lands with the finger."
        },
        {
          "name": "onPointerUp",
          "type": "function",
          "required": false
        },
        {
          "name": "children",
          "type": "string",
          "required": true,
          "description": "The key's legend — machine-cased in the skin."
        },
        {
          "name": "lit",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Light the LED and breathe it. False is dead plastic."
        },
        {
          "name": "litLabel",
          "type": "string",
          "required": false,
          "description": "What the lit LED means, announced as visually hidden text. For a key that toggles its own state, use aria-pressed instead."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale: sm 32px, md 44px, lg 60px, the same as a HardwareButton or Field of that size, so they line up in a row. Defaults to the nearest SizeProvider's size."
        }
      ],
      "examples": [
        {
          "title": "Latching key",
          "description": "A key that toggles its own state says so with aria-pressed — the LED is then the visual half of the same fact.",
          "code": "<LedKey lit={armed} aria-pressed={armed} onClick={() => setArmed(!armed)}>Arm</LedKey>"
        },
        {
          "title": "Reporting something else",
          "description": "When the LED reports what the device is doing rather than the key's own state, litLabel is what carries it to a screen reader.",
          "code": "<LedKey lit={recording} litLabel=\"Recording\">Rec</LedKey>"
        },
        {
          "title": "Idle",
          "description": "Unlit is dead plastic — the same two-state LED as StatusDot.",
          "code": "<LedKey>Snooze +5</LedKey>"
        },
        {
          "title": "A row of keys",
          "description": "Keys read as one transport when they share a well row.",
          "code": "<div style={{ display: \"flex\", gap: 12 }}><LedKey lit litLabel=\"Recording\">Rec</LedKey><LedKey>Play</LedKey></div>"
        }
      ],
      "antiPatterns": [
        "Never let the LED be the only carrier of state — pass litLabel (or aria-pressed when the key toggles itself) so the lit key announces what it means; a colour is not a message.",
        "Never pass both aria-pressed and litLabel — one says the key is on, the other says the device is; using both leaves a screen reader with two answers to one question.",
        "Never use LedKey for a fire-and-forget command (Save, Send, Delete). An LED promises a state that persists after the press; HardwareButton is the cap for actions.",
        "Never render more than one lit key to mean one exclusive choice — that is a Segmented or a RadioGroup, which say so structurally."
      ],
      "usage": {
        "imports": "import { LedKey } from \"@/echo/led-key\";",
        "code": "<LedKey lit={armed} aria-pressed={armed} onClick={() => setArmed(!armed)}>\n  Arm\n</LedKey>"
      },
      "composition": "LedKey",
      "a11y": [
        "A native `<button>`: Enter and Space press it, with the same travel and sound as a pointer.",
        "For an on/off key, pass `aria-pressed` alongside `lit` so the state is announced.",
        "`litLabel` adds visually hidden text for what the lit LED means (“Recording”) — the LED alone is only colour."
      ],
      "motion": {
        "tokens": [
          "lift",
          "light"
        ],
        "reducedMotion": "The key does not travel, and the lit LED stops breathing and holds steady."
      },
      "sound": [
        "`press` as the key lands on its seam, `release` as it tops out on the way home, brighter the longer it was held."
      ],
      "feel": "A molded key with an indicator LED that breathes while it is lit.",
      "api": {
        "LedKey": {
          "props": [
            "autoFocus",
            "children",
            "disabled",
            "form",
            "id",
            "key",
            "lit",
            "litLabel",
            "name",
            "onBlur",
            "onClick",
            "onFocus",
            "onKeyDown",
            "onPointerDown",
            "onPointerUp",
            "readOnly",
            "ref",
            "required",
            "size",
            "title",
            "type",
            "value"
          ],
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 525,
        "withDependencies": 30730,
        "css": 1116
      }
    },
    {
      "name": "echo-engraved-plate",
      "title": "EngravedPlate",
      "version": "0.3.0",
      "description": "A paper-coloured metal plate with text cut into it and rivets at the edges — the one non-emissive readout in the library. Use it for a fact fixed to the device (a serial, a balance, a rating); use LcdChip for a value that updates while you watch.",
      "summary": "A paper-coloured metal plate with text cut into it and rivets at the edges — the one non-emissive readout. For a fact fixed to the device, where LcdChip is for a live value.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add engraved-plate",
      "props": [
        {
          "name": "children",
          "type": "string",
          "required": true,
          "description": "The engraving. Short — a plate is stamped, not written on."
        },
        {
          "name": "tilt",
          "type": "number",
          "required": false,
          "defaultValue": "0",
          "description": "Rotation in degrees, as if screwed on slightly off-true."
        },
        {
          "name": "rivets",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Rivets at the plate's short edges."
        }
      ],
      "examples": [
        {
          "title": "Plate",
          "description": "Debossed machine text on paper-coloured metal.",
          "code": "<EngravedPlate>Owed 4</EngravedPlate>"
        },
        {
          "title": "Screwed on off-true",
          "description": "A few degrees of tilt reads as a real part, not a label.",
          "code": "<EngravedPlate tilt={-5}>Model 002</EngravedPlate>"
        },
        {
          "title": "Flush",
          "description": "Without rivets the plate reads as inlaid rather than bolted.",
          "code": "<EngravedPlate rivets={false}>Serial 4417</EngravedPlate>"
        }
      ],
      "antiPatterns": [
        "Never animate the text or swap it on a timer — a plate is stamped once. A value that changes belongs on an LcdChip or a StatTile.",
        "Never put a sentence on it. The engraving is a few machine-cased words; longer copy on paper-coloured metal reads as a sticky note.",
        "Never override the text colour with your own hex — the engraving is var(--echo-accent-pressed) precisely so it stays legible on paper after a reseed."
      ],
      "usage": {
        "imports": "import { EngravedPlate } from \"@/echo/engraved-plate\";",
        "code": "<EngravedPlate>Model 002</EngravedPlate>"
      },
      "composition": "EngravedPlate",
      "a11y": [
        "Plain text; the rivets are `aria-hidden`.",
        "Engraving lowers contrast on purpose. Use it for names and labels that are also said elsewhere, not for the only copy of something someone needs to read."
      ],
      "feel": "A riveted metal nameplate with the words cut into it.",
      "api": {
        "EngravedPlate": {
          "props": [
            "rivets",
            "tilt"
          ],
          "html": true,
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 303,
        "withDependencies": 303,
        "css": 631
      }
    },
    {
      "name": "echo-textures",
      "title": "Textures",
      "version": "0.3.0",
      "description": "The grain law as composable overlays: Dither (ordered Bayer), Noise (film grain), Halftone, Scanlines, plus GrainField (the brand surface) and Textured (the default stack). Nothing in Echo is ever a flat fill — and a dither or grain pass is the cure for the banding that large soft gradients show on the dark chassis. All tiles are generated as inline SVG data-URIs, so there are no asset files to copy. Every overlay is aria-hidden and pointer-events: none.",
      "summary": "The grain law as composable overlays: Dither, Noise, Halftone, Scanlines, plus GrainField and Textured. Inline SVG tiles, no asset files; decorative and pointer-events-free.",
      "group": "Texture",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add textures",
      "props": [
        {
          "name": "dither",
          "type": "boolean",
          "required": false,
          "description": "Textured only: include the dither layer. Default true."
        },
        {
          "name": "noise",
          "type": "boolean",
          "required": false,
          "description": "Textured only: include the noise layer. Default true."
        },
        {
          "name": "opacity",
          "type": "number",
          "required": false,
          "description": "Layer opacity; each texture ships its usual weight."
        },
        {
          "name": "blend",
          "type": "string",
          "required": false,
          "description": "CSS mix-blend-mode against the surface below (overlay, soft-light, multiply)."
        },
        {
          "name": "fixed",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Pin to the viewport instead of the positioned ancestor."
        },
        {
          "name": "tone",
          "type": "\"dark\" | \"light\"",
          "required": false,
          "description": "Dither only — dark ink deepens the surface, light ink lifts it."
        },
        {
          "name": "scale",
          "type": "number",
          "required": false,
          "description": "Dither only — tile scale multiplier; 1 = a 16px tile."
        },
        {
          "name": "grain",
          "type": "\"fine\" | \"coarse\"",
          "required": false,
          "description": "Noise only — fine for surfaces, coarse for fields."
        },
        {
          "name": "size",
          "type": "number",
          "required": false,
          "description": "Noise only — tile size in px; larger reads softer."
        },
        {
          "name": "cell",
          "type": "number",
          "required": false,
          "description": "Halftone only — dot pitch in px."
        },
        {
          "name": "gap",
          "type": "number",
          "required": false,
          "description": "Scanlines only — line pitch in px."
        }
      ],
      "examples": [
        {
          "title": "De-band a gradient",
          "description": "The overlay fills the nearest positioned ancestor, so give it one.",
          "code": "<div style={{ position: \"relative\" }}><Hero /><Dither opacity={0.18} /></div>"
        },
        {
          "title": "Product whisper",
          "description": "A panel gets grain, not a full treatment.",
          "code": "<Textured dither={false}><Panel>Settings</Panel></Textured>"
        },
        {
          "title": "Brand field",
          "description": "The marketing surface; blooms in whatever the seed accent is.",
          "code": "<GrainField tone=\"accent\" style={{ minHeight: 320 }}><h1>Echo UI</h1></GrainField>"
        },
        {
          "title": "Screen register",
          "description": "Scanlines over a screen Panel, never over plastic.",
          "code": "<Panel variant=\"screen\"><Readout /><Scanlines gap={3} /></Panel>"
        }
      ],
      "antiPatterns": [
        "Never stack every texture at once — one structural texture (dither OR halftone) plus grain is the ceiling; more reads as a filter, not a material.",
        "Never put Scanlines or Halftone on a raised plastic surface — those are screen and print registers; plastic gets Noise.",
        "Never raise Noise above ~0.08 on a product surface; the loud grain (0.3+) belongs to GrainField and marketing only.",
        "Never give an overlay a positioned ancestor it does not have — an absolutely-filling texture with no relative parent escapes to the page. Textured and GrainField provide their own; bare overlays do not.",
        "Never animate a texture's opacity to signal state — texture is material, not feedback. Use StatusDot or Progress."
      ],
      "usage": {
        "imports": "import { Panel } from \"@/echo/panel\";\nimport { Textured } from \"@/echo/textures\";",
        "code": "<Textured>\n  <Panel>Settings</Panel>\n</Textured>"
      },
      "composition": "Textured\nDither\nNoise\nHalftone\nScanlines\nGrainField",
      "a11y": [
        "Every overlay is decorative: `aria-hidden` and `pointer-events: none`, so it never reaches the accessibility tree or catches a click.",
        "Texture sits over content at low opacity. Check that text under it still meets contrast — the theme's guarantee is for the roles, not for what you lay on top of them."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "Nothing moves: every texture is static."
      },
      "feel": "Print and screen grain — dither, noise, halftone, scanlines — laid over a surface so it reads as a made object.",
      "api": {
        "Dither": {
          "props": [
            "blend",
            "className",
            "fixed",
            "opacity",
            "scale",
            "tone"
          ]
        },
        "Noise": {
          "props": [
            "blend",
            "className",
            "fixed",
            "grain",
            "opacity",
            "size"
          ]
        },
        "Halftone": {
          "props": [
            "blend",
            "cell",
            "className",
            "fixed",
            "opacity"
          ]
        },
        "Scanlines": {
          "props": [
            "blend",
            "className",
            "fixed",
            "gap",
            "opacity"
          ]
        },
        "GrainField": {
          "props": [
            "children",
            "className",
            "style",
            "tone"
          ]
        },
        "Textured": {
          "props": [
            "children",
            "className",
            "dither",
            "noise",
            "style"
          ]
        }
      },
      "weight": {
        "js": 1348,
        "withDependencies": 1348,
        "css": 599
      }
    },
    {
      "name": "echo-shader-field",
      "title": "ShaderField",
      "version": "0.3.0",
      "description": "The live brand field: a WebGL domain-warped fbm gradient sampled from the accent ramp, blooming toward the cursor, with animated grain and TPDF debanding. Reads the accent ramp from the CSS it sits in (or from a pinned theme mirror), so it relights with the seed like every other surface. Reduced motion draws one static frame; no WebGL falls back to the wrapper's CSS gradient; the GL context is released on unmount.",
      "summary": "The live brand field — a WebGL domain-warped fbm gradient sampled from the accent ramp, blooming toward the cursor. Reduced motion draws one static frame; no WebGL falls back to a CSS gradient.",
      "group": "Texture",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add shader-field",
      "props": [
        {
          "name": "theme",
          "type": "unknown",
          "required": false,
          "description": "EchoTheme mirror (createEchoTheme().values) that pins the field colours. Omit it and the field follows the --echo-accent-* variables in scope, re-reading them when the page is reseeded."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "Accessible name. Omit and the field is decorative (aria-hidden) — right for a background."
        }
      ],
      "examples": [
        {
          "title": "Hero background",
          "description": "Decorative by default — give the wrapper a size.",
          "code": "<div style={{ height: 420 }}><ShaderField /></div>"
        },
        {
          "title": "Themed",
          "description": "Pass a seeded theme mirror and the field follows the accent.",
          "code": "const theme = createEchoTheme({ accent: \"#F2674E\" }).values;\n<ShaderField theme={theme} />"
        }
      ],
      "antiPatterns": [
        "Never put readable body text directly on the field — it moves and its contrast is not guaranteed. Put text on a Panel over it, or use GrainField, which carries an edge-darkening pass for exactly this.",
        "Never mount more than one or two ShaderFields on a page — each holds a live WebGL context and a rAF loop; browsers cap contexts, and a grid of them will drop frames on integrated GPUs.",
        "Never give it a `label` unless it genuinely carries meaning; a named background is noise in the a11y tree.",
        "Never reach for it as a page background behind an app — it is the marketing field. Product surfaces are the chassis plus a whisper of Noise."
      ],
      "usage": {
        "imports": "import { ShaderField } from \"@/echo/shader-field\";",
        "code": "<div style={{ height: 420 }}>\n  <ShaderField />\n</div>"
      },
      "composition": "ShaderField",
      "a11y": [
        "Decorative by default (`aria-hidden`). Pass `label` only when the field carries meaning, and it becomes `role=\"img\"` with that name.",
        "Without WebGL it draws a CSS gradient instead, so the space it occupies never goes blank."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "It draws one still frame and stops: no animation loop, no cursor tracking."
      },
      "feel": "A slow, glowing field in the accent colour that leans toward the cursor — ambient, never busy.",
      "api": {
        "ShaderField": {
          "props": [
            "className",
            "label",
            "style",
            "theme"
          ]
        }
      },
      "weight": {
        "js": 3016,
        "withDependencies": 11562,
        "css": 168,
        "gpu": true
      }
    },
    {
      "name": "echo-radio-group",
      "title": "RadioGroup",
      "version": "0.3.0",
      "description": "One choice from a small set: each option is a recessed hole beside its label, and the chosen one lights a pip inside its hole. Base UI owns roving focus, arrow-key selection, and form semantics; Echo owns the material. Compound API: RadioGroup with RadioGroup.Item children.",
      "summary": "One choice from a small set, as molded selector caps with a pip that lights accent when chosen. Base UI owns roving focus and arrow-key selection.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add radio-group",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "Identifies the field when a form is submitted."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Controlled selected value."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "description": "Uncontrolled initial value."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Fired with the newly selected value."
        },
        {
          "name": "orientation",
          "type": "\"horizontal\" | \"vertical\"",
          "required": false,
          "defaultValue": "\"horizontal\"",
          "description": "Lay the options out in a row or a stack."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Names the group, since its options only name themselves. This or aria-labelledby is required: leaving both out is a type error."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Id of the visible heading or label that names the group."
        }
      ],
      "examples": [
        {
          "title": "Pick a mode",
          "description": "The group needs an accessible name; each item names itself.",
          "code": "<RadioGroup defaultValue=\"meter\" aria-label=\"Panel mode\">\n  <RadioGroup.Item value=\"meter\">Meter</RadioGroup.Item>\n  <RadioGroup.Item value=\"log\">Log</RadioGroup.Item>\n</RadioGroup>"
        },
        {
          "title": "Stacked",
          "description": "Vertical when the labels are long or the list is more than three.",
          "code": "<RadioGroup orientation=\"vertical\" defaultValue=\"a\" aria-label=\"Output\">…</RadioGroup>"
        }
      ],
      "antiPatterns": [
        "Never use a RadioGroup for two mutually exclusive states that are really on/off — that is a Switch. Radios are for three or more genuine alternatives.",
        "Never leave the group unnamed: pass aria-label or aria-labelledby to the group itself. Naming only the items tells a screen-reader user what the options are but never what the question was.",
        "Never wrap RadioGroup.Item in a <label> — the item already renders the role=radio button and takes its accessible name from its own children; a wrapping label associates with nothing and adds a second hit target that does not select.",
        "Never use it where only one option can ever be valid — a radio group that cannot be changed is a readout, so use an LcdChip or StatTile."
      ],
      "usage": {
        "imports": "import { RadioGroup } from \"@/echo/radio-group\";",
        "code": "<RadioGroup defaultValue=\"meter\" aria-label=\"Panel mode\">\n  <RadioGroup.Item value=\"meter\">Meter</RadioGroup.Item>\n  <RadioGroup.Item value=\"log\">Log</RadioGroup.Item>\n</RadioGroup>"
      },
      "composition": "RadioGroup\n  RadioGroup.Item",
      "a11y": [
        "Base UI RadioGroup: a `radiogroup` of `radio` items, each labelled by its own text.",
        "Tab enters the group at the checked item; arrow keys move the selection.",
        "Name the group with `aria-label` or `aria-labelledby`."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "The pip lights without a transition."
      },
      "sound": [
        "`select` when the choice changes."
      ],
      "feel": "A row of pips, one lit — like the mode lamps on a front panel. Pressing one deepens its hole on the same stroke as the library's keys, and the lit pip sinks with it.",
      "api": {
        "RadioGroup": {
          "props": [
            "autoFocus",
            "children",
            "defaultValue",
            "disabled",
            "id",
            "key",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "orientation",
            "readOnly",
            "ref",
            "required",
            "value"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          }
        },
        "RadioGroup.Item": {
          "props": [
            "autoFocus",
            "children",
            "disabled",
            "id",
            "key",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "readOnly",
            "ref",
            "required",
            "value"
          ],
          "required": [
            "value"
          ]
        }
      },
      "weight": {
        "js": 613,
        "withDependencies": 42527,
        "css": 1118
      }
    },
    {
      "name": "echo-otp-field",
      "title": "OTP field",
      "version": "0.3.0",
      "description": "The one-time code a sign-in asks for, as a row of lit digit windows — the Field's recessed well cut into six screens, in the LCD voice. Built on Base UI's OTP Field: a pasted code fills the row, arrow keys and Backspace walk it, and the phone can offer the code from an SMS. Composes var(--echo-*).",
      "summary": "The one-time code a sign-in asks for, as a row of lit digit windows. Built on Base UI's OTP Field: paste fills the row, arrow keys and Backspace walk it, and autocomplete=\"one-time-code\" lets a phone offer the code.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add otp-field",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "Identifies the code when a form is submitted."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "description": "The greyed hint shown while the field is empty. Never a substitute for a label."
        },
        {
          "name": "autoComplete",
          "type": "string",
          "required": false,
          "description": "What the value is, for autofill and assistive tech — \"email\", \"one-time-code\". WCAG 1.3.5."
        },
        {
          "name": "inputMode",
          "type": "string",
          "required": false,
          "description": "Which on-screen keyboard a phone offers: \"numeric\", \"email\", \"tel\", …"
        },
        {
          "name": "maxLength",
          "type": "number",
          "required": false
        },
        {
          "name": "minLength",
          "type": "number",
          "required": false
        },
        {
          "name": "pattern",
          "type": "string",
          "required": false,
          "description": "A regular expression the value must match for the field to be valid."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "Visible eyebrow label; also the group's accessible name. An OTP field needs label, aria-label or aria-labelledby: leaving all three out is a type error."
        },
        {
          "name": "length",
          "type": "number",
          "required": false,
          "defaultValue": "6",
          "description": "How many digit windows."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Controlled value — the whole code, not one digit."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "description": "Uncontrolled initial code."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Fired with the code so far."
        },
        {
          "name": "onComplete",
          "type": "function",
          "required": false,
          "description": "Fired when the last window is filled. Where a sign-in screen submits."
        },
        {
          "name": "mask",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Hide the characters. For a secret, not for a one-time code."
        },
        {
          "name": "description",
          "type": "unknown",
          "required": false,
          "description": "Help text under the row, linked to the group."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "description": "What's wrong, in words. While set, the group is invalid and the message shows with a square pip."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted; a visible label wins."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false,
          "description": "Used only when `label` is omitted."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale: sm 32px, md 44px, lg 60px, the same as a HardwareButton or Field of that size, so they line up in a row. Defaults to the nearest SizeProvider's size."
        }
      ],
      "examples": [
        {
          "title": "Six digits",
          "description": "The default.",
          "code": "<OtpField label=\"Verification code\" onComplete={submit} />"
        },
        {
          "title": "Four digits",
          "description": "Any length.",
          "code": "<OtpField label=\"PIN\" length={4} onComplete={unlock} />"
        },
        {
          "title": "Wrong code",
          "description": "`error` marks the group invalid and says what to do.",
          "code": "<OtpField label=\"Verification code\" value={code} onValueChange={setCode} error={wrong ? \"That code has expired. Ask for a new one.\" : undefined} />"
        }
      ],
      "antiPatterns": [
        "Don't use it for a password or anything long: it's for a short code, one character per window.",
        "Don't block pasting — it is how most people enter a code they were just sent."
      ],
      "usage": {
        "imports": "import { OtpField } from \"@/echo/otp-field\";",
        "code": "<OtpField label=\"Verification code\" onComplete={submit} />"
      },
      "composition": "OtpField",
      "a11y": [
        "One labelled group, not six unlabelled boxes: the visible `label` names the whole row, and each window says which digit it is.",
        "`autocomplete=\"one-time-code\"` is on by construction, so a phone offers the code from an SMS and password managers fill it (WCAG 3.3.8).",
        "Pasting the whole code fills the row — nobody has to retype it digit by digit.",
        "`description` and `error` are linked to the group with `aria-describedby`; `error` marks it invalid. The error is words plus a square pip, never colour alone.",
        "Keyboard: typing a digit moves to the next window; Backspace and the arrow keys walk the row, and a pasted code fills it."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap"
        ],
        "reducedMotion": "The windows don't sink, take strikes or ripple; only the focus edge fades."
      },
      "feel": "Six lit windows. The one you're on sinks into the panel, so the press travels along the row as you type, each digit lands as a small strike, and the finished code runs a ripple down the row.",
      "api": {
        "OtpField": {
          "props": [
            "autoComplete",
            "autoFocus",
            "defaultValue",
            "description",
            "disabled",
            "error",
            "id",
            "inputMode",
            "key",
            "label",
            "length",
            "mask",
            "maxLength",
            "minLength",
            "name",
            "onBlur",
            "onComplete",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "pattern",
            "placeholder",
            "readOnly",
            "ref",
            "required",
            "size",
            "value"
          ],
          "name": {
            "anyOf": [
              "label",
              "aria-label",
              "aria-labelledby"
            ]
          }
        }
      },
      "weight": {
        "js": 909,
        "withDependencies": 44272,
        "css": 1138
      }
    },
    {
      "name": "echo-number-field",
      "title": "NumberField",
      "version": "0.3.0",
      "description": "A machined readout between two molded step caps — and the label above it is a scrub handle you drag left and right to dial the value. Base UI owns typing, arrow keys, alt/shift step sizes, clamping, and form semantics. Scrubbing is what makes this worth wrapping instead of using a plain number input.",
      "summary": "A machined readout between two molded step caps, with the label doubling as a drag-to-scrub handle. Base UI owns typing, arrow keys, step sizes, and clamping.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-roll",
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add number-field",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "description": "The greyed hint shown while the field is empty. Never a substitute for a label."
        },
        {
          "name": "autoComplete",
          "type": "string",
          "required": false,
          "description": "What the value is, for autofill and assistive tech — \"email\", \"one-time-code\". WCAG 1.3.5."
        },
        {
          "name": "inputMode",
          "type": "string",
          "required": false,
          "description": "Which on-screen keyboard a phone offers: \"numeric\", \"email\", \"tel\", …"
        },
        {
          "name": "maxLength",
          "type": "number",
          "required": false
        },
        {
          "name": "minLength",
          "type": "number",
          "required": false
        },
        {
          "name": "pattern",
          "type": "string",
          "required": false,
          "description": "A regular expression the value must match for the field to be valid."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "Visible label, the scrub handle, and the input's accessible name."
        },
        {
          "name": "value",
          "type": "number",
          "required": false,
          "description": "Controlled value."
        },
        {
          "name": "defaultValue",
          "type": "number",
          "required": false,
          "description": "Uncontrolled initial value."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with the new value — which is null while the field is empty mid-edit, so do not assume a number."
        },
        {
          "name": "min",
          "type": "number",
          "required": false
        },
        {
          "name": "max",
          "type": "number",
          "required": false
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "defaultValue": "1",
          "description": "Step for the caps, arrow keys, and scrubbing."
        },
        {
          "name": "largeStep",
          "type": "number",
          "required": false,
          "defaultValue": "10",
          "description": "Step while holding shift."
        },
        {
          "name": "smallStep",
          "type": "number",
          "required": false,
          "defaultValue": "0.1",
          "description": "Step while holding alt."
        },
        {
          "name": "decrementLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"Decrease\"",
          "description": "Accessible name for the − cap."
        },
        {
          "name": "incrementLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"Increase\"",
          "description": "Accessible name for the + cap."
        },
        {
          "name": "roll",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "A step rolls the digits, each stepping through the ones in between, instead of sliding the whole number in. Typing never rolls."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale: sm 32px, md 44px, lg 60px, the same as a HardwareButton or Field of that size, so they line up in a row. Defaults to the nearest SizeProvider's size."
        }
      ],
      "examples": [
        {
          "title": "Bounded setting",
          "description": "Label names the value; min/max clamp every interaction path.",
          "code": "<NumberField label=\"Tempo\" defaultValue={120} min={40} max={240} />"
        },
        {
          "title": "Fine control",
          "description": "Alt steps by smallStep, shift by largeStep — no extra props needed.",
          "code": "<NumberField label=\"Gain\" defaultValue={0} min={-24} max={24} step={1} smallStep={0.1} />"
        },
        {
          "title": "Controlled",
          "description": "value can be null while the input is mid-edit or empty.",
          "code": "<NumberField label=\"Count\" value={count} onValueChange={(v) => setCount(v ?? 0)} />"
        },
        {
          "title": "Rolling digits",
          "description": "Each step rolls the digits through the ones in between, like an odometer.",
          "code": "<NumberField label=\"Floor\" defaultValue={18} min={0} max={99} roll />"
        }
      ],
      "antiPatterns": [
        "Never treat the value as always-numeric in a controlled setup — Base UI reports null while the input is empty or mid-edit. Coalesce it; do not render NaN into the readout.",
        "Never use this for a bounded value the user should feel out rather than name — that is a Slider. NumberField is for values worth typing exactly.",
        "Never remove the label: it is the input's accessible name AND the scrub handle. Without it the field is both unnamed and undraggable.",
        "Never hand the step caps to a screen reader unnamed. They ship as − and +, which read as punctuation; the decrementLabel and incrementLabel props exist so they announce as actions."
      ],
      "usage": {
        "imports": "import { NumberField } from \"@/echo/number-field\";",
        "code": "<NumberField label=\"Tempo\" defaultValue={120} min={40} max={240} />"
      },
      "composition": "NumberField",
      "a11y": [
        "`label` names the input. The − and + steppers have their own labels.",
        "Arrow Up and Down change the value by `step`; with Shift by `largeStep`, with Alt by `smallStep`.",
        "Scrubbing by dragging the label is a pointer extra — everything it does is also possible from the keyboard.",
        "With `roll`, the digits in between are drawn over the input and hidden from assistive technology; the input itself always holds, and says, the real value."
      ],
      "motion": {
        "tokens": [
          "lift"
        ],
        "reducedMotion": "The value changes without sliding or rolling, the readout doesn't sink, and the steppers press without travelling."
      },
      "sound": [
        "`tap` once per whole step, however the value changed."
      ],
      "feel": "A readout with two molded steppers; drag the label sideways to scrub it like a knob. The readout sinks while you type into it, and each digit lands as a small strike.",
      "api": {
        "NumberField": {
          "props": [
            "autoComplete",
            "autoFocus",
            "decrementLabel",
            "defaultValue",
            "disabled",
            "id",
            "incrementLabel",
            "inputMode",
            "key",
            "label",
            "largeStep",
            "max",
            "maxLength",
            "min",
            "minLength",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "pattern",
            "placeholder",
            "readOnly",
            "ref",
            "required",
            "roll",
            "size",
            "smallStep",
            "step",
            "value"
          ]
        }
      },
      "weight": {
        "js": 1829,
        "withDependencies": 46634,
        "css": 1336
      }
    },
    {
      "name": "echo-avatar",
      "title": "Avatar",
      "version": "0.3.0",
      "description": "A person or device as a molded disc — the image seated in a well, or initials engraved into the plastic when there is no image or the image fails to load. The failure case is the whole point: a bare <img> with a broken URL leaves a hole, this falls back.",
      "summary": "A person or device as a molded disc — image in a well, or initials engraved into the plastic when there is no image or it fails to load.",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add avatar",
      "props": [
        {
          "name": "src",
          "type": "string",
          "required": false,
          "description": "Image URL. Omit to render initials."
        },
        {
          "name": "name",
          "type": "string",
          "required": true,
          "description": "Who or what this is; also the accessible name."
        },
        {
          "name": "size",
          "type": "\"xs\" | \"sm\" | \"md\" | \"lg\" | \"xl\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "xs inline with text (a mention, a byline), sm in lists and table rows, md the default, lg a card header, xl a profile header."
        },
        {
          "name": "initials",
          "type": "string",
          "required": false,
          "description": "Override the derived initials, e.g. a device code."
        },
        {
          "name": "status",
          "type": "\"live\" | \"idle\"",
          "required": false,
          "description": "Presence LED in the rim: live is lit, idle is an empty socket. Appended to the accessible name."
        },
        {
          "name": "statusLabel",
          "type": "string",
          "required": false,
          "description": "Override the words announced for status."
        }
      ],
      "examples": [
        {
          "title": "With an image",
          "description": "name still required — it is the accessible name and the fallback source.",
          "code": "<Avatar src=\"/ada.jpg\" name=\"Ada Lovelace\" />"
        },
        {
          "title": "Initials only",
          "description": "Derived from the first and last words.",
          "code": "<Avatar name=\"Ada Lovelace\" size=\"lg\" />"
        },
        {
          "title": "Presence",
          "description": "A lit LED in the rim. Fill-vs-hollow carries the state, so it never rests on colour.",
          "code": "<Avatar name=\"Ada Lovelace\" status=\"live\" />"
        },
        {
          "title": "Device",
          "description": "Override initials when the name is not a person.",
          "code": "<Avatar name=\"Echo One, studio unit\" initials=\"E1\" />"
        }
      ],
      "antiPatterns": [
        "Never omit name to make it decorative — an avatar identifies someone, and an unnamed one is a hole in the a11y tree. If it is truly decorative, do not render an avatar.",
        "Never put the name in an adjacent <img alt> as well; the inner image is alt=\"\" on purpose so the wrapper's label is announced once, not twice.",
        "Never use status for anything but presence. A lit LED on a person means they are there — not that they are selected, an admin, or new.",
        "Never stack avatars into an overlapping row without giving the group its own label and count — a pile of individually-named discs reads as noise."
      ],
      "usage": {
        "imports": "import { Avatar } from \"@/echo/avatar\";",
        "code": "<Avatar src=\"/ada.jpg\" name=\"Ada Lovelace\" />"
      },
      "composition": "Avatar",
      "a11y": [
        "`role=\"img\"` named by `name` — with the status added (“Ada Lovelace, online”) when `status` is set.",
        "The image and initials inside are `aria-hidden`, so the name is read once.",
        "Presence is shown filled or hollow, not only by colour."
      ],
      "feel": "A round inset portrait, or initials on molded plastic, with an optional presence LED that lights and goes dark over a moment.",
      "api": {
        "Avatar": {
          "props": [
            "initials",
            "status",
            "statusLabel"
          ],
          "html": true,
          "required": [
            "name"
          ]
        }
      },
      "weight": {
        "js": 525,
        "withDependencies": 525,
        "css": 788
      }
    },
    {
      "name": "echo-tag",
      "title": "Tag",
      "version": "0.3.0",
      "description": "A label the user applied — a category, a person, a filter — optionally removable. Distinct from Badge, which reports state the system knows. The material says which is which: a Badge is printed onto the chassis, a Tag is a small plate that can be peeled off.",
      "summary": "A label the user applied, optionally removable. Badge reports what the system knows; Tag is a small plate that can be peeled off.",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add tag",
      "props": [
        {
          "name": "children",
          "type": "string",
          "required": true,
          "description": "The label text."
        },
        {
          "name": "onRemove",
          "type": "function",
          "required": false,
          "description": "Render a remove control. Omit for a static tag."
        },
        {
          "name": "removeLabel",
          "type": "string",
          "required": false,
          "description": "Accessible name for the remove control. Defaults to \"Remove\" plus the tag's text (\"Remove Loop\"); pass one when the content isn't text."
        }
      ],
      "examples": [
        {
          "title": "Static",
          "description": "A plain applied label.",
          "code": "<Tag>Ambient</Tag>"
        },
        {
          "title": "Removable",
          "description": "Name the control per tag so a row of them is navigable.",
          "code": "<Tag onRemove={() => drop(\"Ambient\")}>Ambient</Tag>"
        }
      ],
      "antiPatterns": [
        "Never use a Tag for system state — status, counts, and severity are Badge. A tag that the user cannot have applied is a badge wearing the wrong material.",
        "Never give a tag with non-text content (an icon, an avatar) a remove control without `removeLabel`. It falls back to \"Remove\", and five of those are indistinguishable in a screen reader's element list.",
        "Never make the whole tag clickable for removal — a body that deletes on click has no safe place to press, and the tag stops being selectable text."
      ],
      "usage": {
        "imports": "import { Tag } from \"@/echo/tag\";",
        "code": "<Tag onRemove={() => drop(\"Ambient\")}>\n  Ambient\n</Tag>"
      },
      "composition": "Tag",
      "a11y": [
        "With `onRemove`, the × is a real button named after the tag (“Remove Ambient”), so a row of tags doesn't read as a row of identical “Remove” buttons. Pass `removeLabel` when the tag's content isn't text.",
        "After a removal, move focus somewhere sensible — the next tag, or the input that adds tags.",
        "Keyboard: Tab reaches the ×; Enter or Space removes the tag."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-tick"
        ],
        "reducedMotion": "The remove key no longer moves when pressed."
      },
      "feel": "A small applied label; the removable kind has its own tiny key.",
      "api": {
        "Tag": {
          "props": [
            "onRemove",
            "removeLabel"
          ],
          "html": true,
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 488,
        "withDependencies": 30669,
        "css": 841
      }
    },
    {
      "name": "echo-count-badge",
      "title": "CountBadge",
      "version": "0.3.0",
      "description": "A number worth noticing — the lit sibling of Badge. Badge names a state; CountBadge counts a thing. It disappears at zero unless told otherwise, caps its face at max while still announcing the true count, and stays round at one digit.",
      "summary": "A number worth noticing — the lit sibling of Badge. Disappears at zero, caps its face at max while still announcing the true count.",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add count-badge",
      "props": [
        {
          "name": "count",
          "type": "number",
          "required": true,
          "description": "The number itself."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What is counted, e.g. \"unread messages\". The name becomes \"3 unread messages\"."
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "defaultValue": "99",
          "description": "Cap the display; above it renders as `${max}+`."
        },
        {
          "name": "showZero",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Render at zero instead of returning null."
        },
        {
          "name": "variant",
          "type": "\"accent\" | \"neutral\"",
          "required": false,
          "defaultValue": "\"accent\"",
          "description": "A colour choice, not a status — which is why it is a variant and not a tone."
        }
      ],
      "examples": [
        {
          "title": "Unread count",
          "description": "Announces as \"7 unread messages\".",
          "code": "<CountBadge count={7} label=\"unread messages\" />"
        },
        {
          "title": "Capped",
          "description": "Reads \"127 unread messages\" while showing \"99+\".",
          "code": "<CountBadge count={127} label=\"unread messages\" />"
        },
        {
          "title": "On a control",
          "description": "Anchor it yourself; the badge does not position itself.",
          "code": "<span style={{ position: \"relative\" }}>\n  <HardwareButton iconOnly aria-label=\"Inbox\" icon={<Inbox />} />\n  <CountBadge count={3} label=\"unread messages\" />\n</span>"
        }
      ],
      "antiPatterns": [
        "Never pass a bare number as the label. \"3\" on its own is meaningless in a screen reader's element list — the label is what the number counts, not the number again.",
        "Never use it for a state with no quantity — an on/off or severity marker is a Badge or a StatusDot. A count of one is still a count; a count of \"error\" is not.",
        "Never render it at zero by default. A badge that says 0 draws the eye to nothing happening; that is what showZero is for, and it is rarely the right call.",
        "Never raise max to show a huge exact number in the face — a five-digit badge breaks the pill and the round-at-one-digit geometry. Cap it and let the accessible name carry the truth."
      ],
      "usage": {
        "imports": "import { CountBadge } from \"@/echo/count-badge\";",
        "code": "<CountBadge count={7} label=\"unread messages\" />"
      },
      "composition": "CountBadge",
      "a11y": [
        "`role=\"status\"`, named by the real count and `label` (“127 unread messages”) even when the face shows “99+”.",
        "Being a status region, a change is announced politely."
      ],
      "feel": "A small counter pinned to a corner, capped so it never outgrows its spot.",
      "api": {
        "CountBadge": {
          "props": [
            "count",
            "showZero",
            "variant"
          ],
          "html": true,
          "required": [
            "count"
          ]
        }
      },
      "weight": {
        "js": 330,
        "withDependencies": 330,
        "css": 662
      }
    },
    {
      "name": "echo-skeleton",
      "title": "Skeleton",
      "version": "0.3.0",
      "description": "The shape of content that has not arrived: a recess in the chassis with a slow sheen crossing it. Every skeleton is aria-hidden by default — the wait is announced once, by the region that owns it, via the label prop. Reduced motion holds the sheen still rather than looping forever.",
      "summary": "The shape of content that has not arrived: a recess with a slow sheen. aria-hidden by default; one label announces the wait for the whole region.",
      "group": "Feedback",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add skeleton",
      "props": [
        {
          "name": "variant",
          "type": "\"text\" | \"block\" | \"circle\"",
          "required": false,
          "defaultValue": "\"text\"",
          "description": "text (the default) is lines of text, the last one short; block is a rectangle, for an image or a card; circle is a round stand-in, for an avatar."
        },
        {
          "name": "width",
          "type": "string | number",
          "required": false,
          "description": "CSS width; numbers are px."
        },
        {
          "name": "height",
          "type": "string | number",
          "required": false,
          "description": "CSS height; numbers are px."
        },
        {
          "name": "lines",
          "type": "number",
          "required": false,
          "defaultValue": "1",
          "description": "Text lines; the last renders short."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "Announce the wait. Pass ONCE per loading region, on the outermost skeleton."
        }
      ],
      "examples": [
        {
          "title": "A paragraph",
          "description": "The last line renders short, the way real text ends.",
          "code": "<Skeleton lines={3} label=\"Loading article\" />"
        },
        {
          "title": "A media block",
          "description": "Match the real element's box so nothing jumps on arrival.",
          "code": "<Skeleton variant=\"block\" height={180} />"
        },
        {
          "title": "An avatar row",
          "description": "One label on the region; the rest stay silent.",
          "code": "<div role=\"status\" aria-busy aria-label=\"Loading people\">\n  <Skeleton variant=\"circle\" height={36} />\n  <Skeleton width={140} />\n</div>"
        }
      ],
      "antiPatterns": [
        "Never label every skeleton. A dozen boxes each announcing \"Loading\" is unusable; one busy region owns the wait and the rest are decoration.",
        "Never let a skeleton outlive its request. A skeleton that never resolves reads as a broken layout, not as pending — show an EmptyState or a Banner when the fetch fails.",
        "Never draw a skeleton in a shape the real content will not take. The entire value is that nothing moves when the data lands; a wrong-sized placeholder is worse than a spinner.",
        "Never use a skeleton for an action's progress — that is Loader or Progress. Skeletons stand in for CONTENT, not for work."
      ],
      "usage": {
        "imports": "import { Skeleton } from \"@/echo/skeleton\";",
        "code": "<Skeleton lines={3} label=\"Loading article\" />"
      },
      "composition": "Skeleton",
      "a11y": [
        "With `label`, it is `role=\"status\"` with `aria-busy`, and announces what is loading.",
        "Without a label its shapes are `aria-hidden`: use that when something else already says the region is loading."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "The sheen stops; the shapes hold at a steady tone."
      },
      "feel": "The shape of what is coming, with a slow sheen passing over it.",
      "api": {
        "Skeleton": {
          "props": [
            "lines",
            "variant"
          ],
          "html": true
        }
      },
      "weight": {
        "js": 412,
        "withDependencies": 412,
        "css": 577
      }
    },
    {
      "name": "echo-banner",
      "title": "Banner",
      "version": "0.3.0",
      "description": "A persistent inline message strip. Toast is transient and reports what just happened; Banner stays and describes a condition still true. Built as a piece of the device: a molded strip, a status LED in a recessed well, and an LCD tag that names the tone. Severity is carried by the LED's shape and the tag's words rather than by red/amber/green — Echo has one accent, and inventing hues the seed does not own would break on a reseed.",
      "summary": "A persistent inline message strip for a condition that is still true. Severity by luminance and a tone pip, not by hues the seed does not own.",
      "group": "Feedback",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add banner",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "description": "The condition, stated plainly."
        },
        {
          "name": "children",
          "type": "string",
          "required": false,
          "description": "Supporting detail."
        },
        {
          "name": "tone",
          "type": "\"info\" | \"active\" | \"warn\" | \"danger\"",
          "required": false,
          "defaultValue": "\"info\"",
          "description": "Echo's one status vocabulary: info, active (happening now), warn, danger."
        },
        {
          "name": "icon",
          "type": "string",
          "required": false,
          "description": "Leading icon. Decorative — the title carries meaning."
        },
        {
          "name": "tag",
          "type": "string | false",
          "required": false,
          "description": "The LCD tag on the right. Defaults to the tone in words; false hides it."
        },
        {
          "name": "onDismiss",
          "type": "function",
          "required": false,
          "description": "Render a dismiss control."
        },
        {
          "name": "dismissLabel",
          "type": "string",
          "required": false,
          "description": "Name of the dismiss button. Defaults to \"Dismiss\" plus the title."
        },
        {
          "name": "live",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Announce when the banner appears dynamically. danger becomes an alert."
        }
      ],
      "examples": [
        {
          "title": "Standing condition",
          "description": "Rendered with the page — ordinary content, no live region.",
          "code": "<Banner title=\"Offline mode\" tone=\"warn\">Changes sync when the device reconnects.</Banner>"
        },
        {
          "title": "Appeared in response",
          "description": "live makes danger an alert and everything else a polite status.",
          "code": "<Banner live tone=\"danger\" title=\"Upload failed\" onDismiss={retry}>The file exceeds the size limit.</Banner>"
        }
      ],
      "antiPatterns": [
        "Never set live on a banner that renders with the page. Announcing it on load interrupts the reader for something they were going to reach anyway; live is for banners that APPEAR.",
        "Never use a Banner for something that just happened and is already over — that is a Toast. A banner describes a condition that is still true.",
        "Never rely on tone alone to convey severity. The strip reads as \"more urgent\" by luminance, which is not a message; say the severity in the title.",
        "Never stack more than one or two banners. Three standing conditions at once means the screen has a structural problem that a fourth banner will not fix."
      ],
      "usage": {
        "imports": "import { Banner } from \"@/echo/banner\";",
        "code": "<Banner title=\"Offline mode\" tone=\"warn\">\n  Changes sync when the device reconnects.\n</Banner>"
      },
      "composition": "Banner",
      "a11y": [
        "Silent by default. `live` makes it a live region: `role=\"alert\"` for `tone=\"danger\"`, `role=\"status\"` for the rest.",
        "Only set `live` on a banner that appears in response to something; one that is on the page from the start should not announce.",
        "The tone is spelled out in the LCD tag (Info, Live, Warn, Error), not carried by colour alone.",
        "With `onDismiss`, a close button appears, named “Dismiss” plus the title (“Dismiss Offline mode”) unless you pass `dismissLabel`.",
        "Keyboard: Tab reaches the dismiss button; Enter or Space dismisses the banner."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-tick"
        ],
        "reducedMotion": "The dismiss button's hover change is instant."
      },
      "feel": "A molded strip with an LED in a recessed well and an LCD tag that names the tone.",
      "api": {
        "Banner": {
          "props": [
            "dismissLabel",
            "icon",
            "live",
            "onDismiss",
            "tag",
            "tone"
          ],
          "html": true,
          "required": [
            "title"
          ]
        }
      },
      "weight": {
        "js": 557,
        "withDependencies": 557,
        "css": 1430
      }
    },
    {
      "name": "echo-empty-state",
      "title": "EmptyState",
      "version": "0.3.0",
      "description": "Nothing here, and what to do about it. Rendered as a quiet recess: the screen is on and working, there is simply nothing in it yet. The action slot is part of the API on purpose — an empty state without a way forward is a dead end.",
      "summary": "Nothing here, and what to do about it — a quiet recess with the way forward built into the API.",
      "group": "Feedback",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add empty-state",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "description": "What is not here, stated plainly."
        },
        {
          "name": "description",
          "type": "string",
          "required": false,
          "description": "Why, or what to expect."
        },
        {
          "name": "icon",
          "type": "string",
          "required": false,
          "description": "Decorative mark above the title: a small line icon, drawn quiet."
        },
        {
          "name": "illustration",
          "type": "unknown",
          "required": false,
          "description": "A picture above the title at its own size, in full colour: a Creature, an illustration. Takes the icon's place."
        },
        {
          "name": "action",
          "type": "string",
          "required": false,
          "description": "The way forward — usually one HardwareButton."
        }
      ],
      "examples": [
        {
          "title": "First run",
          "description": "Name the thing and offer the one action that fixes it.",
          "code": "<EmptyState\n  title=\"No recordings yet\"\n  description=\"Anything you capture on this device shows up here.\"\n  action={<HardwareButton variant=\"primary\">Start recording</HardwareButton>}\n/>"
        },
        {
          "title": "Filtered to nothing",
          "description": "The way forward is undoing the filter, not creating data.",
          "code": "<EmptyState title=\"No matches\" description=\"No takes match this tag.\" action={<HardwareButton>Clear filters</HardwareButton>} />"
        },
        {
          "title": "With a creature",
          "description": "An illustration keeps its own size and colour; here the creature naps because there's nothing to do.",
          "code": "<EmptyState illustration={<Creature mood=\"sleep\" />} title=\"No messages yet\" description=\"When someone writes to you, it shows up here.\" />"
        }
      ],
      "antiPatterns": [
        "Never ship an empty state with no action. If there is genuinely nothing the user can do, say why in the description — an unexplained blank reads as a bug.",
        "Never use it for a failure. A request that errored is a Banner with a retry; an empty state means the query worked and the answer was nothing.",
        "Never write the title as an apology (\"Oops, nothing here!\"). Say what is not there; the user is not owed a mood.",
        "Never use it while data is still loading — that is Skeleton. Showing \"nothing here\" before the fetch resolves tells the user something false."
      ],
      "usage": {
        "imports": "import { EmptyState } from \"@/echo/empty-state\";\nimport { HardwareButton } from \"@/echo/hardware-button\";",
        "code": "<EmptyState\n  title=\"No recordings yet\"\n  description=\"Anything you capture on this device shows up here.\"\n  action={<HardwareButton variant=\"primary\">Start recording</HardwareButton>}\n/>"
      },
      "composition": "EmptyState",
      "a11y": [
        "The title is a paragraph, not a heading. If the empty region should be reachable by heading navigation, put it under one of your own.",
        "`icon` is decorative and `aria-hidden`; the title and description carry the meaning.",
        "When the state appears after an action (a search with no results), announce it — for example with a Toast — since focus stays where it was."
      ],
      "feel": "A dark, quiet screen that says what will appear here, and how to make it appear.",
      "api": {
        "EmptyState": {
          "props": [
            "description",
            "icon",
            "illustration"
          ],
          "html": true,
          "required": [
            "title"
          ]
        }
      },
      "weight": {
        "js": 284,
        "withDependencies": 284,
        "css": 581
      }
    },
    {
      "name": "echo-creature",
      "title": "Creature",
      "version": "0.3.0",
      "description": "A small companion that lives in the interface and only reports what's happening: idle, busy, done, an error, asleep. As a vector it breathes, drifts, blinks and watches the pointer, and a new mood carries on from wherever it is; as dots it lives on a screen and moves in steps. It's Echo by default; bring your own character as a few paths (a body, eye whites, pupils) or rows of text, and Echo supplies the materials so it relights with the theme.",
      "summary": "A companion that reports what's happening: Echo, or your own, as a vector or in dots.",
      "group": "Creature",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core",
        "gsap"
      ],
      "registryDependencies": [
        "@echo/echo-scope"
      ],
      "install": "npx echo-ui-cli add creature",
      "props": [
        {
          "name": "mood",
          "type": "\"idle\" | \"busy\" | \"done\" | \"error\" | \"sleep\"",
          "required": false,
          "defaultValue": "\"idle\"",
          "description": "What it's doing: idle watches, busy works, done hops, error is puzzled, sleep naps."
        },
        {
          "name": "variant",
          "type": "\"vector\" | \"dots\"",
          "required": false,
          "defaultValue": "\"vector\"",
          "description": "vector on the page; dots on a dot-matrix screen of its own."
        },
        {
          "name": "character",
          "type": "object",
          "required": false,
          "description": "Who it is: path data for a body, the whites of its eyes and its pupils, and optionally rows of dots. Default: Echo."
        },
        {
          "name": "size",
          "type": "number",
          "required": false,
          "description": "Height in px. Default 96 for vector, 120 for dots."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "An accessible name, only when no text nearby says what it shows."
        },
        {
          "name": "accent",
          "type": "string",
          "required": false,
          "description": "A colour of its own, as a hex: a family of creatures, one each."
        }
      ],
      "examples": [
        {
          "title": "An empty inbox",
          "description": "Asleep in the empty state; the words say what's going on.",
          "code": "<EmptyState\n  illustration={<Creature mood=\"sleep\" />}\n  title=\"No messages yet\"\n  description=\"When someone writes to you, it shows up here.\"\n/>"
        },
        {
          "title": "On a screen",
          "description": "The same creature in dots, busy while something syncs.",
          "code": "<Creature variant=\"dots\" mood=\"busy\" />"
        },
        {
          "title": "Your own character",
          "description": "A body path and two eyes; the dots are drawn from them.",
          "code": "const blob: CreatureCharacter = {\n  viewBox: \"0 0 100 100\",\n  body: \"M50 8C77 8 92 30 92 55S77 92 50 92 8 80 8 55 23 8 50 8z\",\n  eyes: [\"M34 40h10v16H34z\", \"M56 40h10v16H56z\"],\n  pupils: [\"M38 46h5v8h-5z\", \"M60 46h5v8h-5z\"],\n  origin: [50, 80],\n  eyeLine: 48,\n};\n\nreturn <Creature character={blob} mood=\"done\" />;"
        }
      ],
      "antiPatterns": [
        "Never let the creature be the only sign of a state: an error needs its words, and the creature only looks puzzled beside them.",
        "Never put a creature on every screen: it's for the moments that are about nothing else (empty, waiting, done).",
        "Never colour your character's paths: Echo lights them from the theme, and a hardcoded colour breaks when the theme changes."
      ],
      "usage": {
        "imports": "import { Creature } from \"@/echo/creature\";",
        "code": "<Creature mood=\"idle\" />"
      },
      "composition": "Creature\n  vector: the body, its eyes and pupils on a living rig\n  dots: a screen well with the dot matrix",
      "a11y": [
        "Hidden from assistive technology by default: the text beside it says what's happening, and it only shows it.",
        "Give it `label` only when no nearby text says what it shows; it's then an image with that name.",
        "Under reduced motion it holds one pose per mood: no drift, no breath, no following the pointer, no hopping.",
        "Nothing it does flashes; the dots change a few times a second at most.",
        "In forced colors it's drawn as an outline in the system colours."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "One still pose per mood."
      },
      "feel": "A companion, not a mascot: small movements on slow springs, eyes that follow you, a hop when something works.",
      "api": {
        "Creature": {
          "props": [
            "accent",
            "character",
            "label",
            "mood",
            "size",
            "variant"
          ]
        }
      },
      "weight": {
        "js": 5725,
        "withDependencies": 41993,
        "css": 954
      }
    },
    {
      "name": "echo-perch",
      "title": "Perch",
      "version": "0.3.0",
      "description": "A creature on the top edge of whatever you put in it: sitting there watching, or hiding behind the edge and peeking up when you come near. It reacts through its mood like every Creature, and never covers what it sits on: the space it needs above the edge is kept for it. For onboarding, an empty state's card, a success moment, a product's assistant.",
      "summary": "A creature on a card's top edge, sitting or peeking.",
      "group": "Creature",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-creature"
      ],
      "install": "npx echo-ui-cli add perch",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "What it sits on: a Card, a Panel, a Modal's body."
        },
        {
          "name": "mood",
          "type": "\"idle\" | \"busy\" | \"done\" | \"error\" | \"sleep\"",
          "required": false,
          "defaultValue": "\"idle\"",
          "description": "What it's doing."
        },
        {
          "name": "character",
          "type": "unknown",
          "required": false,
          "description": "Who it is: a Creature character. Default: Echo."
        },
        {
          "name": "behavior",
          "type": "\"sit\" | \"peek\"",
          "required": false,
          "defaultValue": "\"sit\"",
          "description": "sit on the edge; peek hides behind it and comes up when you're near."
        },
        {
          "name": "at",
          "type": "\"start\" | \"end\"",
          "required": false,
          "defaultValue": "\"end\"",
          "description": "Which end of the edge."
        },
        {
          "name": "size",
          "type": "number",
          "required": false,
          "defaultValue": "56",
          "description": "The creature's height in px."
        }
      ],
      "examples": [
        {
          "title": "On a card",
          "description": "Echo sits on the corner, watching the pointer.",
          "code": "<Perch>\n  <Card title=\"Welcome\" description=\"Three steps and you're set up.\" />\n</Perch>"
        },
        {
          "title": "Peeking",
          "description": "Out of sight until you're near the card.",
          "code": "<Perch behavior=\"peek\" at=\"start\">\n  <Card title=\"Tip\" description=\"Press ? for every shortcut.\" />\n</Perch>"
        },
        {
          "title": "Pleased",
          "description": "A hop when the work is done.",
          "code": "<Perch mood=\"done\">\n  <Card title=\"All set\" description=\"Your workspace is ready.\" />\n</Perch>"
        }
      ],
      "antiPatterns": [
        "Never perch a creature on every card: one, on the card a moment is about.",
        "Never let the creature carry a message: the card says it, the creature only looks the part."
      ],
      "usage": {
        "imports": "import { Perch } from \"@/echo/perch\";\nimport { Card } from \"@/echo/card\";",
        "code": "<Perch>\n  <Card title=\"Welcome\" />\n</Perch>"
      },
      "composition": "Perch\n  Creature, on the ledge\n  what it sits on",
      "a11y": [
        "The creature is hidden from assistive technology; what's in the Perch is read as it is.",
        "It never covers content: the room it needs above the edge is kept for it, and it takes no pointer.",
        "Peeking comes up on hover and when focus is inside, so keyboard users see it too.",
        "Under reduced motion it holds one pose, and a peeking creature appears without rising."
      ],
      "motion": {
        "tokens": [
          "springs",
          "pop"
        ],
        "reducedMotion": "One still pose; a peek appears at once."
      },
      "feel": "A small companion sitting on the edge of the card, looking at what you're doing.",
      "api": {
        "Perch": {
          "props": [
            "at",
            "behavior",
            "character",
            "children",
            "mood",
            "size"
          ],
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 5865,
        "withDependencies": 42098,
        "css": 1268
      }
    },
    {
      "name": "echo-table",
      "title": "Table",
      "version": "0.3.0",
      "description": "Rows of data in a recessed screen well. A real <table> with a real <caption>, compound parts for a table you lay out yourself, and a data mode for one you don't: given columns and rows it sorts (LED pips and aria-sort), selects (Checkbox, with a select-all that can read 'some'), filters, keeps its header at the top, windows thousands of rows, and shows loading and empty rows. Rows that move slide to their new place.",
      "summary": "Rows of data in a recessed screen well. Real table semantics, a required caption, tabular figures for numbers, and a scroll region that keyboard users can reach. Given columns and rows, it sorts, selects, filters and windows them.",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-checkbox"
      ],
      "install": "npx echo-ui-cli add table",
      "props": [
        {
          "name": "caption",
          "type": "string",
          "required": true,
          "description": "What the table is. Required; names both the table and its scroll region."
        },
        {
          "name": "hideCaption",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Hide the caption visually. It still names the table."
        },
        {
          "name": "striped",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Faint bands on alternate rows. For wide tables; the rules already separate rows."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Row height from the control scale: sm 32px for dense screens, md 44px, lg 60px. Default md with `columns`; content height without."
        },
        {
          "name": "columns",
          "type": "TableColumn[]",
          "required": false,
          "description": "Data mode: `{ key, header, value?, cell?, sortable?, align?, numeric?, rowHeader?, filterable? }` for each column. With `rows`, Table draws the head and body itself."
        },
        {
          "name": "rows",
          "type": "unknown[]",
          "required": false,
          "description": "Data mode: the rows, in any shape; columns read them."
        },
        {
          "name": "getRowId",
          "type": "function",
          "required": false,
          "description": "Data mode: a stable id for a row. Selection is kept by it."
        },
        {
          "name": "sort",
          "type": "object",
          "required": false,
          "description": "The sort, controlled: `{ column, direction }`, or null for none."
        },
        {
          "name": "defaultSort",
          "type": "object",
          "required": false,
          "description": "The sort to start with, uncontrolled."
        },
        {
          "name": "onSortChange",
          "type": "function",
          "required": false,
          "description": "Called with the new sort when a header is pressed."
        },
        {
          "name": "selectable",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "A Checkbox on every row, and one in the header that selects every row shown (and reads 'some' when some are)."
        },
        {
          "name": "selected",
          "type": "string[]",
          "required": false,
          "description": "Selected row ids, controlled."
        },
        {
          "name": "defaultSelected",
          "type": "string[]",
          "required": false,
          "description": "Selected row ids to start with, uncontrolled."
        },
        {
          "name": "onSelectedChange",
          "type": "function",
          "required": false,
          "description": "Called with every selected row id."
        },
        {
          "name": "filter",
          "type": "string",
          "required": false,
          "description": "A query: rows with a value containing it stay. Set it from your own search Field."
        },
        {
          "name": "filterRows",
          "type": "function",
          "required": false,
          "description": "Your own filter, instead of 'any value contains the query'."
        },
        {
          "name": "height",
          "type": "number | string",
          "required": false,
          "description": "The table's own height (a number is pixels). It scrolls inside, and its header stays at the top."
        },
        {
          "name": "virtual",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Draw only the rows in view, for thousands of rows. Needs `height`; each row is one line at the size's height."
        },
        {
          "name": "loading",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Rows are on their way: placeholder rows, and a busy table."
        },
        {
          "name": "empty",
          "type": "ReactNode",
          "required": false,
          "description": "What an empty table says. A filtered one says what matched nothing."
        },
        {
          "name": "rowActions",
          "type": "function",
          "required": false,
          "description": "The end of each row: `(row) => …` its actions (ghost HardwareButtons, a Menu)."
        },
        {
          "name": "onRowPress",
          "type": "function",
          "required": false,
          "description": "Makes each row a press: its label cell becomes a button, and a click anywhere else on the row presses it."
        }
      ],
      "examples": [
        {
          "title": "Sort, select and filter",
          "description": "Give it columns and rows. Pressing a sortable header sorts by it; `selectable` adds the checkboxes; `filter` comes from your own search Field.",
          "code": "const columns = [\n  { key: \"take\", header: \"Take\", rowHeader: true, sortable: true },\n  { key: \"input\", header: \"Input\", sortable: true },\n  { key: \"length\", header: \"Length\", numeric: true, sortable: true },\n];\n\n<>\n  <Field label=\"Search takes\" type=\"search\" value={query} onValueChange={setQuery} />\n  <Table\n    caption=\"Recent takes\"\n    columns={columns}\n    rows={takes}\n    getRowId={(take) => take.id}\n    selectable\n    filter={query}\n    defaultSort={{ column: \"take\", direction: \"ascending\" }}\n  />\n</>"
        },
        {
          "title": "Thousands of rows",
          "description": "With a height and `virtual`, only the rows in view are drawn. The header stays at the top.",
          "code": "<Table caption=\"Events\" columns={columns} rows={events} getRowId={(e) => e.id} height={480} virtual size=\"sm\" />"
        },
        {
          "title": "Loading and empty",
          "description": "Placeholder rows while the data is on its way; your own words when there's none.",
          "code": "<Table caption=\"Invoices\" columns={columns} rows={invoices ?? []} getRowId={(i) => i.id} loading={!invoices} empty=\"No invoices yet.\" />"
        },
        {
          "title": "Row actions, and a row that opens",
          "description": "`rowActions` ends each row; `onRowPress` makes the row a press, through a real button in its label cell.",
          "code": "<Table caption=\"Projects\" columns={columns} rows={projects} getRowId={(p) => p.id} onRowPress={(p) => open(p.id)} rowActions={(p) => <HardwareButton size=\"sm\" variant=\"ghost\" onClick={() => archive(p.id)}>Archive</HardwareButton>} />"
        },
        {
          "title": "A readout you lay out yourself",
          "description": "Numeric cells align to the end (the right, in left-to-right text) and use tabular figures.",
          "code": "<Table caption=\"Recent takes\">\n  <Table.Head>\n    <Table.Row>\n      <Table.HeaderCell>Take</Table.HeaderCell>\n      <Table.HeaderCell align=\"end\">Length</Table.HeaderCell>\n    </Table.Row>\n  </Table.Head>\n  <Table.Body>\n    <Table.Row>\n      <Table.Cell>Room tone</Table.Cell>\n      <Table.Cell numeric>02:41</Table.Cell>\n    </Table.Row>\n  </Table.Body>\n</Table>"
        },
        {
          "title": "Caption carried by the surrounding heading",
          "description": "Hide it visually; it still names the table and its scroll region.",
          "code": "<Table caption=\"Recent takes\" hideCaption>…</Table>"
        }
      ],
      "antiPatterns": [
        "Never use a table for layout. These are real table semantics, and a screen reader will announce rows and columns for something that has neither.",
        "Never drop the caption to save space: use hideCaption. An uncaptioned table is an unlabelled grid of numbers to anyone not looking at it, and its scroll region loses its name too.",
        "Never remove the tabIndex from the scroll container. An overflow region that cannot take focus is unreachable by keyboard; a mouse hides the bug completely.",
        "Never mix numeric and text cells in one column and set numeric on only some: the column stops aligning, which is the entire reason to mark numbers at all.",
        "Never sort or filter the rows yourself and in Table at once: give Table all the rows, and let `sort` and `filter` say how they show.",
        "Never set `virtual` without `height`: with nothing to scroll, there's no window, and every row is drawn anyway.",
        "Never give a row press a click handler on the row alone: `onRowPress` puts the button in the label cell, which is what the keyboard reaches."
      ],
      "usage": {
        "imports": "import { Table } from \"@/echo/table\";",
        "code": "<Table\n  caption=\"Recent takes\"\n  columns={[\n    { key: \"take\", header: \"Take\", rowHeader: true, sortable: true },\n    { key: \"length\", header: \"Length\", numeric: true, sortable: true },\n  ]}\n  rows={takes}\n  getRowId={(take) => take.id}\n  selectable\n/>"
      },
      "composition": "Table (data: columns + rows)\n\nTable (compound)\n  Table.Head\n    Table.Row\n      Table.HeaderCell\n  Table.Body\n    Table.Row\n      Table.HeaderCell\n      Table.Cell",
      "a11y": [
        "A real `<table>` with a required `caption`. `hideCaption` hides it visually but keeps it as the table's name.",
        "The table sits in a scroll region named by the caption and reachable by Tab, so a wide or tall table can be scrolled from the keyboard.",
        "A sortable column's header is a button: Enter or Space sorts by it, and again reverses it. The sorted column carries `aria-sort`, and the new sort is said aloud (\"Sorted by Length, descending\").",
        "Every row's Checkbox is named by its row (\"Select Room tone\"); the header's selects every row shown, and reads as mixed when only some are.",
        "What a filter leaves is said aloud (\"3 of 12 rows\"), and so is loading; a loading table is `aria-busy`.",
        "With `virtual`, the table carries `aria-rowcount` and each drawn row its `aria-rowindex`, so position is announced out of the whole.",
        "`onRowPress` puts a real button in the row's label cell: the keyboard presses the row there, and a click anywhere else on the row does too.",
        "Use `rowHeader` (or `scope=\"row\"` on a leading HeaderCell) so each row is announced by its label."
      ],
      "motion": {
        "tokens": [
          "travel",
          "summon"
        ],
        "reducedMotion": "Rows take their new places at once and new rows appear without fading; a pressed row doesn't dip, and loading rows don't pulse."
      },
      "sound": [
        "The select click when a header sorts, a tap when a row is pressed, and the Checkbox's own click, inside a SoundProvider."
      ],
      "feel": "A readout ruled into rows: the numbers line up, the rules stay quiet, and a sorted column's pip lights up.",
      "api": {
        "Table": {
          "props": [
            "caption",
            "columns",
            "defaultSelected",
            "defaultSort",
            "empty",
            "filter",
            "filterRows",
            "getRowId",
            "hideCaption",
            "loading",
            "onRowPress",
            "onSelectedChange",
            "onSortChange",
            "rowActions",
            "selectable",
            "sort",
            "striped",
            "virtual"
          ],
          "html": true,
          "required": [
            "caption"
          ]
        },
        "Table.Head": {
          "props": [],
          "html": true
        },
        "Table.Body": {
          "props": [],
          "html": true
        },
        "Table.Row": {
          "props": [],
          "html": true
        },
        "Table.HeaderCell": {
          "props": [
            "abbr",
            "align"
          ],
          "html": true
        },
        "Table.Cell": {
          "props": [
            "abbr",
            "align",
            "numeric",
            "valign"
          ],
          "html": true
        }
      },
      "weight": {
        "js": 4073,
        "withDependencies": 41616,
        "css": 2324
      }
    },
    {
      "name": "echo-description-list",
      "title": "DescriptionList",
      "version": "0.3.0",
      "description": "Things and their values: order details, account settings, a device's specs, a system's status at a glance. plain sets the terms as machine labels over their values, with the one separator between rows; screen puts the whole list on a dark screen with the values in the readout face, like the glance list on a phone's home screen.",
      "summary": "Things and their values, on the page or on a screen.",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-scope"
      ],
      "install": "npx echo-ui-cli add description-list",
      "props": [
        {
          "name": "items",
          "type": "object[]",
          "required": true,
          "description": "Each thing and its value, with an optional glyph before the term."
        },
        {
          "name": "variant",
          "type": "\"plain\" | \"screen\"",
          "required": false,
          "defaultValue": "\"plain\"",
          "description": "plain on the page; screen on a dark screen, values in the readout face."
        },
        {
          "name": "layout",
          "type": "\"stacked\" | \"inline\"",
          "required": false,
          "defaultValue": "\"stacked\"",
          "description": "stacked puts each value under its term; inline sets them side by side."
        },
        {
          "name": "screen",
          "type": "\"emissive\" | \"reflective\"",
          "required": false,
          "defaultValue": "\"emissive\"",
          "description": "With variant=\"screen\": emissive (dark, glowing) or reflective (paper-coloured glass, printed in ink)."
        }
      ],
      "examples": [
        {
          "title": "Order details",
          "description": "Terms over values, rows separated by the seam.",
          "code": "<DescriptionList\n  items={[\n    { term: \"Order\", detail: \"4821\" },\n    { term: \"Placed\", detail: \"25 September 2026\" },\n    { term: \"Ship to\", detail: \"12 North Street, Leeds\" },\n  ]}\n/>"
        },
        {
          "title": "At a glance",
          "description": "On a screen, side by side: the status column of a home screen widget.",
          "code": "<DescriptionList\n  variant=\"screen\"\n  layout=\"inline\"\n  items={[\n    { term: \"Battery\", detail: \"89%\" },\n    { term: \"Sunrise\", detail: \"07:28\" },\n    { term: \"Steps\", detail: \"6,954\" },\n  ]}\n/>"
        }
      ],
      "antiPatterns": [
        "Never use a DescriptionList for a list of things that aren't pairs: that's a list of Items.",
        "Never put controls in the details: it's for reading; settings that change belong in a form.",
        "Never light the values: a screen full of lit numbers is noise; light the one that matters somewhere else."
      ],
      "usage": {
        "imports": "import { DescriptionList } from \"@/echo/description-list\";",
        "code": "<DescriptionList items={[{ term: \"Plan\", detail: \"Team\" }]} />"
      },
      "composition": "DescriptionList\n  a row per item: the term (with its glyph) and its detail",
      "a11y": [
        "A real `dl`: each term and its value are read together, and a screen reader can list the terms.",
        "The glyph before a term is hidden; the term says what it is.",
        "On the screen the values keep the readout face's tabular digits, so a column of numbers doesn't shift."
      ],
      "feel": "A spec sheet stamped on the device, or its status screen.",
      "api": {
        "DescriptionList": {
          "props": [
            "items",
            "layout",
            "screen",
            "variant"
          ],
          "required": [
            "items"
          ]
        }
      },
      "weight": {
        "js": 330,
        "withDependencies": 330,
        "css": 1173
      }
    },
    {
      "name": "echo-shortcut-map",
      "title": "ShortcutMap",
      "version": "0.3.0",
      "description": "The \"Keyboard shortcuts\" sheet a desktop app opens with ?: the list of what each shortcut does, and above it the keyboard drawn in Kbd caps. Point at a shortcut and its keys light on the board; press keys on your own keyboard and the drawn ones go down with them. The list is the content; the board is a picture of it.",
      "summary": "A keyboard of Kbd caps over the list of shortcuts, lighting each one's keys.",
      "group": "Data display",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-kbd"
      ],
      "install": "npx echo-ui-cli add shortcut-map",
      "props": [
        {
          "name": "shortcuts",
          "type": "object[]",
          "required": true,
          "description": "Each shortcut: its keys in the order they're held ([\"Cmd\", \"K\"]) and what it does."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "defaultValue": "\"Keyboard shortcuts\"",
          "description": "The list's accessible name."
        },
        {
          "name": "board",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Draw the keyboard over the list."
        }
      ],
      "examples": [
        {
          "title": "What ? opens",
          "description": "The board over the list; put it in a Sheet or a Modal.",
          "code": "<ShortcutMap\n  shortcuts={[\n    { keys: [\"Cmd\", \"K\"], label: \"Search everything\" },\n    { keys: [\"C\"], label: \"New issue\" },\n    { keys: [\"?\"], label: \"Show shortcuts\" },\n  ]}\n/>"
        },
        {
          "title": "The list alone",
          "description": "Without the board, for a narrow panel.",
          "code": "<ShortcutMap board={false} shortcuts={[{ keys: [\"Cmd\", \"Enter\"], label: \"Send\" }]} />"
        }
      ],
      "antiPatterns": [
        "Never make the board the only place a shortcut is written: the list is the content.",
        "Never list shortcuts that don't work where the sheet opens: show the ones for this screen."
      ],
      "usage": {
        "imports": "import { ShortcutMap } from \"@/echo/shortcut-map\";",
        "code": "<ShortcutMap shortcuts={[{ keys: [\"Cmd\", \"K\"], label: \"Search\" }]} />"
      },
      "composition": "ShortcutMap\n  the board: rows of Kbd caps\n  the list: each shortcut's Kbd keys and what it does",
      "a11y": [
        "The list is a named list; each item reads its keys and then what they do.",
        "The drawn keyboard is hidden from assistive technology: everything on it is in the list.",
        "Lighting a shortcut's keys is a pointer extra; nothing is only on the board.",
        "Keys people write as Cmd, Command or Meta all find ⌘; Ctrl, Alt or Option, Shift and Enter find theirs.",
        "Too narrow for a keyboard (a phone, or very large text), the board goes and the list stands alone."
      ],
      "feel": "A keyboard printed on the back of the manual, lighting up as you read.",
      "api": {
        "ShortcutMap": {
          "props": [
            "board",
            "label",
            "shortcuts"
          ],
          "required": [
            "shortcuts"
          ]
        }
      },
      "weight": {
        "js": 1446,
        "withDependencies": 31590,
        "css": 1127
      }
    },
    {
      "name": "echo-widget-grid",
      "title": "WidgetGrid",
      "version": "0.3.0",
      "description": "Widgets at fixed footprints (1×1, 2×1, 2×2, 4×2) on a grid people arrange for themselves: a dashboard, a home screen, a panel of gauges. Each widget is your own content; the grid gives it a place. Arrange it and each widget gets a Move key and, if you allow it, a Remove key: drag a widget by its Move key and the others slide out of its way, or use the arrow keys and the new place is said.",
      "summary": "Widgets at fixed footprints that people arrange, by drag or by key.",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core",
        "gsap"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button"
      ],
      "install": "npx echo-ui-cli add widget-grid",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What the grid is: its accessible name (\"Home\")."
        },
        {
          "name": "items",
          "type": "object[]",
          "required": true,
          "description": "The widgets: an id, the name its keys and announcements use, the columns × rows it takes, and your content."
        },
        {
          "name": "order",
          "type": "string[]",
          "required": false,
          "description": "The order, by id (controlled)."
        },
        {
          "name": "defaultOrder",
          "type": "string[]",
          "required": false,
          "description": "The starting order, by id (uncontrolled). See order."
        },
        {
          "name": "onOrderChange",
          "type": "function",
          "required": false,
          "description": "Called with the new order, by id."
        },
        {
          "name": "editing",
          "type": "boolean",
          "required": false,
          "description": "Showing the Move and Remove keys (controlled)."
        },
        {
          "name": "defaultEditing",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Whether it starts showing the Move and Remove keys (uncontrolled). See editing."
        },
        {
          "name": "onEditingChange",
          "type": "function",
          "required": false,
          "description": "Called when editing turns on or off."
        },
        {
          "name": "onRemove",
          "type": "function",
          "required": false,
          "description": "Lets a widget be removed; called with its id."
        },
        {
          "name": "columns",
          "type": "2 | 3 | 4",
          "required": false,
          "defaultValue": "4",
          "description": "Columns on a wide screen; it narrows to 2 on a phone."
        }
      ],
      "examples": [
        {
          "title": "A home screen",
          "description": "Readouts and an instrument, arranged by whoever's looking.",
          "code": "<WidgetGrid\n  label=\"Home\"\n  items={[\n    { id: \"today\", label: \"Today\", content: <DateTile date=\"2026-09-25\" size=\"lg\" /> },\n    { id: \"latency\", label: \"Latency\", content: <StatTile value=\"42\" unit=\"ms\" label=\"Latency\" /> },\n    { id: \"volume\", label: \"Volume\", size: \"2x1\", content: <Tuner label=\"Volume\" max={11} /> },\n  ]}\n/>"
        },
        {
          "title": "Saved order",
          "description": "Keep the order people choose.",
          "code": "<WidgetGrid label=\"Dashboard\" items={widgets} order={order} onOrderChange={saveOrder} onRemove={hide} />"
        }
      ],
      "antiPatterns": [
        "Never use a WidgetGrid for content with an order that means something (a list of steps): only for things people arrange by taste.",
        "Never forget onOrderChange when the arrangement should last: without it, the order is lost on reload."
      ],
      "usage": {
        "imports": "import { WidgetGrid } from \"@/echo/widget-grid\";\nimport { StatTile } from \"@/echo/stat-tile\";",
        "code": "<WidgetGrid label=\"Home\" items={[{ id: \"a\", label: \"Latency\", content: <StatTile value=\"42\" label=\"Latency\" /> }]} />"
      },
      "composition": "WidgetGrid\n  HardwareButton (Arrange, Done)\n  the grid: a list item per widget, your content, and while arranging HardwareButton (Move) and HardwareButton (Remove)",
      "a11y": [
        "A region named by `label`; the widgets are a list, in the order they're shown.",
        "Arranging is a button that says what it does (\"Arrange Home\", \"Done arranging Home\").",
        "Each Move key is named with its widget and moves it with the arrow keys (Home and End to the ends); the new place is announced (\"Weather moved to place 3 of 6\").",
        "Dragging is a pointer extra: everything it does, the Move key's arrows do.",
        "Remove keys are named with their widget, and only appear when removing is allowed."
      ],
      "motion": {
        "tokens": [
          "travel"
        ],
        "reducedMotion": "Widgets change places at once; nothing slides."
      },
      "sound": [
        "A tap as a widget takes a new place, and the select click when one is moved by key, inside a SoundProvider."
      ],
      "feel": "Tiles you pick up and put down: the others make room, and they land with a small settle.",
      "api": {
        "WidgetGrid": {
          "props": [
            "columns",
            "defaultEditing",
            "defaultOrder",
            "editing",
            "items",
            "label",
            "onEditingChange",
            "onOrderChange",
            "onRemove",
            "order"
          ],
          "required": [
            "items"
          ]
        }
      },
      "weight": {
        "js": 2814,
        "withDependencies": 32804,
        "css": 2733
      }
    },
    {
      "name": "echo-breadcrumb",
      "title": "Breadcrumb",
      "version": "0.3.0",
      "description": "Where you are in the hierarchy, as an engraved trail. A labelled nav around an ordered list; the last item is the current page, so it is not a link and carries aria-current. Separators are hidden from assistive tech — the list structure is the real separator.",
      "summary": "Where you are in the hierarchy, as an engraved trail. Labelled nav around an ordered list; the current page is text with aria-current, not a link.",
      "group": "Navigation",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add breadcrumb",
      "props": [
        {
          "name": "items",
          "type": "object[]",
          "required": true,
          "description": "The trail, root first. The last item is the current page and needs no href."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "defaultValue": "\"Breadcrumb\"",
          "description": "Name for the nav landmark."
        },
        {
          "name": "separator",
          "type": "string",
          "required": false,
          "defaultValue": "\"/\"",
          "description": "Separator glyph. Decorative."
        }
      ],
      "examples": [
        {
          "title": "A path",
          "description": "Root first; the last entry needs no href.",
          "code": "<Breadcrumb items={[\n  { label: \"Home\", href: \"/\" },\n  { label: \"Device\", href: \"/device\" },\n  { label: \"Settings\" },\n]} />"
        },
        {
          "title": "Custom separator",
          "description": "Purely visual; it never reaches the a11y tree.",
          "code": "<Breadcrumb separator=\"›\" items={items} />"
        }
      ],
      "antiPatterns": [
        "Never link the last item to the current page. A link back to where you already are is a dead control, which is why the last entry renders as text with aria-current.",
        "Never use a breadcrumb for steps in a flow — a trail describes WHERE you are in a hierarchy, not HOW FAR you are through a sequence. That is Steps.",
        "Never truncate the middle of the trail with a bare ellipsis span. If a path is too long, collapse it into a control that can actually expand it; a dead ellipsis loses the path with no way back.",
        "Never rely on the separator to convey structure — it is aria-hidden, and it should be. The ordered list carries the meaning."
      ],
      "usage": {
        "imports": "import { Breadcrumb } from \"@/echo/breadcrumb\";",
        "code": "<Breadcrumb\n  items={[\n    { label: \"Home\", href: \"/\" },\n    { label: \"Device\", href: \"/device\" },\n    { label: \"Settings\" },\n  ]}\n/>"
      },
      "composition": "Breadcrumb",
      "a11y": [
        "A `<nav>` named “Breadcrumb” around an ordered list, so position is announced (“2 of 3”).",
        "The last item is the current page: plain text with `aria-current=\"page\"`, not a link.",
        "Separators are `aria-hidden`; the list carries the structure."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-tick"
        ],
        "reducedMotion": "Link hover changes colour without a transition."
      },
      "feel": "A trail of engraved labels back up the hierarchy.",
      "api": {
        "Breadcrumb": {
          "props": [
            "items",
            "separator"
          ],
          "html": true,
          "required": [
            "items"
          ]
        }
      },
      "weight": {
        "js": 368,
        "withDependencies": 368,
        "css": 628
      }
    },
    {
      "name": "echo-pagination",
      "title": "Pagination",
      "version": "0.3.0",
      "description": "A row of molded number caps with the current page lit, inside a labelled nav. The window keeps the first and last page always reachable, never elides a single page behind a gap, and never changes width as you page through — a control that reflows under the cursor is how you misclick. The arithmetic is exported as paginationRange if you need it elsewhere.",
      "summary": "Molded number caps with the current page lit. The window never elides a single page and never changes width as you page through.",
      "group": "Navigation",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add pagination",
      "props": [
        {
          "name": "page",
          "type": "number",
          "required": true,
          "description": "1-based current page."
        },
        {
          "name": "count",
          "type": "number",
          "required": true,
          "description": "Total number of pages."
        },
        {
          "name": "onPageChange",
          "type": "function",
          "required": true,
          "description": "Called with the requested page."
        },
        {
          "name": "siblings",
          "type": "number",
          "required": false,
          "defaultValue": "1",
          "description": "Pages shown either side of the current one."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "defaultValue": "\"Pagination\"",
          "description": "Name for the nav landmark."
        },
        {
          "name": "previousLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"Previous page\"",
          "description": "The accessible name of the previous-page key. Default \"Previous page\"."
        },
        {
          "name": "nextLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"Next page\"",
          "description": "The accessible name of the next-page key. Default \"Next page\"."
        }
      ],
      "examples": [
        {
          "title": "Controlled",
          "description": "The component owns no state; page is yours.",
          "code": "<Pagination page={page} count={20} onPageChange={setPage} />"
        },
        {
          "title": "Wider window",
          "description": "More context either side of the current page.",
          "code": "<Pagination page={page} count={120} siblings={2} onPageChange={setPage} />"
        },
        {
          "title": "Named for its list",
          "description": "Two paginations on one screen need distinguishable landmarks.",
          "code": "<Pagination label=\"Takes pagination\" page={page} count={9} onPageChange={setPage} />"
        }
      ],
      "antiPatterns": [
        "Never render pagination for a list that fits on one page — count of 1 is a control that does nothing. Guard on count > 1 at the call site.",
        "Never label the page buttons with the bare number. They carry aria-label=\"Page 3\" for a reason: a screen reader's element list full of \"1 2 3\" names nothing.",
        "Never make the ellipsis a control. It is aria-hidden here because announcing \"ellipsis\" between two numbers is noise, and a gap that expands on click is a different component.",
        "Never use it for infinite or unknown-length data. Pagination promises a last page; if you cannot name count, you want a load-more control instead."
      ],
      "usage": {
        "imports": "import { Pagination } from \"@/echo/pagination\";",
        "code": "<Pagination page={page} count={20} onPageChange={setPage} />"
      },
      "composition": "Pagination",
      "a11y": [
        "A `<nav>` named by `label`. Two paginations on one page need different labels.",
        "Each page button is named “Page 3”, not “3”; the current one carries `aria-current=\"page\"`.",
        "Previous and next are named “Previous page” and “Next page” (`previousLabel`, `nextLabel`) and are disabled at the ends. Gaps are `aria-hidden`.",
        "Keyboard: Tab moves through Previous, the pages and Next; Enter or Space goes to that page."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-pop"
        ],
        "reducedMotion": "The caps press without travelling, and the page you land on lights without the bloom."
      },
      "feel": "A row of small keys; the current page is the one pressed in.",
      "api": {
        "Pagination": {
          "props": [
            "count",
            "nextLabel",
            "onPageChange",
            "page",
            "previousLabel",
            "siblings"
          ],
          "html": true,
          "required": [
            "count",
            "onPageChange",
            "page"
          ]
        }
      },
      "weight": {
        "js": 977,
        "withDependencies": 31150,
        "css": 1127
      }
    },
    {
      "name": "echo-steps",
      "title": "Steps",
      "version": "0.3.0",
      "description": "How far through a sequence you are, as LED stations wired together: done stations stay lit but dim, the current one blooms, upcoming ones are dead plastic. An ordered list inside a labelled group, with aria-current on the current station and the done state stated in text — a colour is not a message.",
      "summary": "How far through a sequence you are, as LED stations on a wire. aria-current on the current station; the done state reaches screen readers as words, not colour.",
      "group": "Navigation",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add steps",
      "props": [
        {
          "name": "steps",
          "type": "string[]",
          "required": true,
          "description": "Station labels, in order."
        },
        {
          "name": "current",
          "type": "number",
          "required": true,
          "description": "0-based index of the current step."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "Name for the group, e.g. \"Setup\". Required."
        },
        {
          "name": "completeLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"completed\"",
          "description": "Word appended for screen readers on finished steps."
        }
      ],
      "examples": [
        {
          "title": "Setup flow",
          "description": "current is a 0-based index, so 1 means the second station.",
          "code": "<Steps label=\"Setup\" current={1} steps={[\"Connect\", \"Calibrate\", \"Done\"]} />"
        },
        {
          "title": "Finished",
          "description": "Past the end marks every station complete.",
          "code": "<Steps label=\"Setup\" current={3} steps={[\"Connect\", \"Calibrate\", \"Done\"]} />"
        }
      ],
      "antiPatterns": [
        "Never use Steps for navigation between arbitrary sections — it promises a sequence with a direction. Somewhere you can move around freely is Tabs.",
        "Never let the lit pip be the only signal that a step is done. It is why completeLabel exists: the state reaches a screen reader as words, not as a colour.",
        "Never render more than about five stations in a row — the labels collapse and the wire stops reading as progress. Long sequences want a count (\"Step 3 of 12\") instead.",
        "Never make the stations clickable without also handling the case where a later step is not yet reachable; a station that looks navigable and silently does nothing is worse than a plain indicator."
      ],
      "usage": {
        "imports": "import { Steps } from \"@/echo/steps\";",
        "code": "<Steps label=\"Setup\" current={1} steps={[\"Connect\", \"Calibrate\", \"Done\"]} />"
      },
      "composition": "Steps",
      "a11y": [
        "A group named by `label` around an ordered list; the current station has `aria-current=\"step\"`.",
        "Finished stations say so in text (`completeLabel`, “completed” by default), not only with a lit pip.",
        "Stations are not interactive; Steps shows progress, it does not navigate."
      ],
      "feel": "Stations along a lit wire — how far you are through a sequence.",
      "api": {
        "Steps": {
          "props": [
            "completeLabel",
            "current",
            "steps"
          ],
          "html": true,
          "required": [
            "current",
            "steps"
          ]
        }
      },
      "weight": {
        "js": 509,
        "withDependencies": 509,
        "css": 1273
      }
    },
    {
      "name": "echo-card",
      "title": "Card",
      "version": "0.3.0",
      "description": "A Panel with an anatomy, so every card is spaced the same way: media flush to the edges, a header (title, description, one action), a body, and a footer whose controls are sm, pinned to the bottom so a row of cards lines its actions up. A Card alone in a list item fills it, so a Grid of cards as a list is one height a row. With href or onClick the whole card is pressable: the title becomes the link or button stretched over the card, it presses a pixel into the surface and sends Panel's ripple, and its own action and footer controls stay separate targets. Closed: slots are props, spacing is size.",
      "summary": "A Panel with an anatomy: media, header, body and footer, spaced by size; the whole card can be one link or button.",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-panel",
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add card",
      "props": [
        {
          "name": "title",
          "type": "unknown",
          "required": true,
          "description": "The card's name. With href or onClick it is also the link or button."
        },
        {
          "name": "description",
          "type": "unknown",
          "required": false,
          "description": "A line under the title."
        },
        {
          "name": "action",
          "type": "unknown",
          "required": false,
          "description": "One control at the header's end: a menu, a toggle, a small button."
        },
        {
          "name": "media",
          "type": "unknown",
          "required": false,
          "description": "An image, chart or preview across the top, flush to the edges."
        },
        {
          "name": "children",
          "type": "unknown",
          "required": false,
          "description": "The body."
        },
        {
          "name": "footer",
          "type": "unknown",
          "required": false,
          "description": "Actions along the bottom, from the content's start edge; their controls default to sm. One action across the whole footer is a fullWidth key."
        },
        {
          "name": "variant",
          "type": "\"raised\" | \"screen\"",
          "required": false,
          "defaultValue": "\"raised\"",
          "description": "raised plastic, or a screen well for a readout."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Padding and gaps: sm for dense dashboards, lg for a feature."
        },
        {
          "name": "href",
          "type": "string",
          "required": false,
          "description": "The whole card goes here."
        },
        {
          "name": "onClick",
          "type": "function",
          "required": false,
          "description": "The whole card does this."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4",
          "required": false,
          "defaultValue": "3",
          "description": "The title's heading level."
        }
      ],
      "examples": [
        {
          "title": "A settings card",
          "description": "Title, description, body and footer actions.",
          "code": "<Card\n  title=\"Backups\"\n  description=\"Nightly, kept for 30 days.\"\n  footer={<HardwareButton>Restore</HardwareButton>}\n>\n  Last backup 02:14.\n</Card>"
        },
        {
          "title": "A card that goes somewhere",
          "description": "The whole card is the link; the title is its name.",
          "code": "<Card title=\"Getting started\" description=\"Install and theme in five minutes.\" href=\"/docs/installation\" />"
        },
        {
          "title": "A row of plans",
          "description": "In a list, every card is the row's height and the actions line up.",
          "code": "<Grid as=\"ul\" minItemWidth=\"16rem\">\n  {plans.map((plan: { name: string; price: string; features: string[] }) => (\n    <li key={plan.name}>\n      <Card title={plan.name} description={plan.price} footer={<HardwareButton fullWidth>Choose {plan.name}</HardwareButton>}>\n        <Stack as=\"ul\" gap={0}>{plan.features.map((f: string) => <Item key={f} as=\"li\" size=\"sm\" title={f} />)}</Stack>\n      </Card>\n    </li>\n  ))}\n</Grid>"
        },
        {
          "title": "A readout",
          "description": "The screen variant, for numbers.",
          "code": "<Card variant=\"screen\" size=\"sm\" title=\"Latency\"><StatTile value=\"42\" unit=\"ms\" label=\"p95\" /></Card>"
        }
      ],
      "antiPatterns": [
        "Never put a second link in an interactive card's body: the card is already one, and a screen reader hears the card as its title.",
        "Never make every card on a screen interactive and give them all actions too: pick one way in.",
        "Don't nest a Card in a Card; use a divider or an Item list inside."
      ],
      "usage": {
        "imports": "import { Card } from \"@/echo/card\";",
        "code": "<Card title=\"Backups\" description=\"Nightly\">{body}</Card>"
      },
      "composition": "Card → Panel material; header, body, footer (SizeProvider sm); stretched title link when interactive",
      "a11y": [
        "The title is a real heading (h3 by default; `headingLevel` changes it).",
        "An interactive card is one link or button named by its title, with one focus ring around the whole card; a screen reader doesn't hear the card twice.",
        "The action and footer controls stay separate targets with their own names, above the stretched link.",
        "The ripple is decoration, hidden from assistive tech and still under reduced motion.",
        "Keyboard: an interactive card is one Tab stop; Enter follows its link (Enter or Space presses it when it's a button), and its action and footer controls are Tab stops of their own."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-tick",
          "--echo-duration-swap"
        ],
        "reducedMotion": "An interactive card doesn't sink or ripple; it still lights its focus ring."
      },
      "sound": [
        "`tap` when an interactive card is pressed, inside a SoundProvider."
      ],
      "feel": "A molded slab you can set things on. A card that goes somewhere lifts toward the light as you reach for it and sinks a pixel as you press, sending a wave out from your finger.",
      "api": {
        "Card": {
          "props": [
            "action",
            "children",
            "description",
            "footer",
            "headingLevel",
            "href",
            "media",
            "onClick",
            "size",
            "title",
            "variant"
          ],
          "required": [
            "title"
          ]
        }
      },
      "weight": {
        "js": 2166,
        "withDependencies": 2470,
        "css": 1386
      }
    },
    {
      "name": "echo-item",
      "title": "Item",
      "version": "0.3.0",
      "description": "The row every list is made of: media to recognise it by, a title and a line under it, and what trails it. Consecutive Items are ruled by a hairline. The title and description wrap onto more lines when they need them, so a plan's features read whole; truncate keeps each to one line. href or onClick makes the whole row one target. swipeActions sit behind the row: swipe it toward the start and they slide into view, and Tab reaches each one, sliding the row open the same way.",
      "summary": "The row every list is made of: media, title, description, trailing content, and actions behind it that a swipe or Tab reveals.",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add item",
      "props": [
        {
          "name": "title",
          "type": "unknown",
          "required": true,
          "description": "The row's name. With href or onClick it is also the link or button."
        },
        {
          "name": "description",
          "type": "unknown",
          "required": false,
          "description": "A line under the title."
        },
        {
          "name": "media",
          "type": "unknown",
          "required": false,
          "description": "What you recognise it by, at the start: an Avatar, an icon, or one small control (a done Checkbox) that stays its own target."
        },
        {
          "name": "trailing",
          "type": "unknown",
          "required": false,
          "description": "What trails it: a time, a CountBadge, a Switch."
        },
        {
          "name": "href",
          "type": "string",
          "required": false,
          "description": "The whole row goes here."
        },
        {
          "name": "onClick",
          "type": "function",
          "required": false,
          "description": "The whole row does this."
        },
        {
          "name": "swipeActions",
          "type": "object[]",
          "required": false,
          "description": "Actions behind the row: a swipe toward the start reveals them, and so does Tab. Each is a real button."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "sm for dense lists."
        },
        {
          "name": "truncate",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "One line each for the title and description, ending in an ellipsis, for a list whose rhythm matters more than every word (file names). Off, the text wraps."
        },
        {
          "name": "as",
          "type": "\"div\" | \"li\"",
          "required": false,
          "defaultValue": "\"div\"",
          "description": "li inside a list (<Stack as=\"ul\">)."
        }
      ],
      "examples": [
        {
          "title": "A member list",
          "description": "In a list, as li.",
          "code": "<Stack as=\"ul\" gap={0}>\n  <Item as=\"li\" media={<Avatar name=\"Ada Lovelace\" size=\"sm\" />} title=\"Ada Lovelace\" description=\"Owner\" trailing=\"2m\" />\n  <Item as=\"li\" media={<Avatar name=\"Alan Turing\" size=\"sm\" />} title=\"Alan Turing\" description=\"Editor\" />\n</Stack>"
        },
        {
          "title": "Swipe to archive",
          "description": "A real button behind the row; Tab reaches it too.",
          "code": "<Item title=\"Weekly report\" description=\"From Ada\" swipeActions={[{ label: \"Archive\", onSelect: archive }]} />"
        }
      ],
      "antiPatterns": [
        "Never put the only way to do something in swipeActions without also offering it somewhere else a mouse user will see.",
        "Never colour a destructive swipe action red: say it in the label, and confirm it (ConfirmButton, AlertDialog)."
      ],
      "usage": {
        "imports": "import { Item } from \"@/echo/item\";\nimport { Switch } from \"@/echo/switch\";",
        "code": "<Item title=\"Backups\" description=\"Nightly\" trailing={<Switch aria-label=\"Backups\" />} />"
      },
      "composition": "Item → a surface row (media, text, trailing) over a tray of actions",
      "a11y": [
        "Swipe actions are real buttons with their own names; Tab reaches them and focusing one slides the row open, so the gesture is never the only way.",
        "An interactive row is one link or button named by its title.",
        "Trailing controls stay separate targets above the row's link.",
        "Actions carry their meaning in words (\"Delete file\"), never in a colour."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The row jumps open or shut instead of sliding."
      },
      "sound": [
        "`tap` as the row snaps open or shut, and when an action is pressed, inside a SoundProvider."
      ],
      "feel": "A ruled row that slides aside under your thumb to show what's behind it, and springs back when you let it go short.",
      "api": {
        "Item": {
          "props": [
            "as",
            "description",
            "href",
            "media",
            "onClick",
            "size",
            "swipeActions",
            "title",
            "trailing",
            "truncate"
          ],
          "required": [
            "title"
          ]
        }
      },
      "weight": {
        "js": 1469,
        "withDependencies": 29500,
        "css": 1239
      }
    },
    {
      "name": "echo-layout",
      "title": "Stack & Grid",
      "version": "0.3.0",
      "description": "The two layouts a screen is made of, with gaps on the space scale: Stack is a column or a wrapping row, Grid is as many columns as fit (minItemWidth) or a fixed number. Closed: a gap is a step, not a length, so two cards laid out by two people are spaced the same way.",
      "summary": "The two layouts a screen is made of, with gaps on the space scale.",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add layout",
      "props": [
        {
          "name": "direction",
          "type": "\"column\" | \"row\"",
          "required": false,
          "defaultValue": "\"column\"",
          "description": "Stack: a column or a row."
        },
        {
          "name": "gap",
          "type": "number",
          "required": false,
          "description": "Space between items, a step on the space scale (6 is 12px, 8 is 16px). Stack defaults to 6, Grid to 8."
        },
        {
          "name": "align",
          "type": "\"start\" | \"center\" | \"end\" | \"stretch\" | \"baseline\"",
          "required": false,
          "description": "Cross-axis alignment. Stack: stretch for a column, center for a row."
        },
        {
          "name": "justify",
          "type": "\"start\" | \"center\" | \"end\" | \"between\"",
          "required": false,
          "defaultValue": "\"start\"",
          "description": "Stack: main-axis distribution."
        },
        {
          "name": "wrap",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Stack: a row wraps when it runs out of room."
        },
        {
          "name": "minItemWidth",
          "type": "string",
          "required": false,
          "description": "Grid: the narrowest a column may be (\"16rem\"); as many columns as fit, never wider than the screen. Fewer items than fit share the row: two cards never sit in three columns."
        },
        {
          "name": "columns",
          "type": "number",
          "required": false,
          "description": "Grid: a fixed number of equal columns."
        },
        {
          "name": "as",
          "type": "\"div\" | \"section\" | \"ul\" | \"ol\" | \"nav\" | \"header\" | \"footer\"",
          "required": false,
          "defaultValue": "\"div\"",
          "description": "The element; a ul or ol drops its bullets."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "A name, when `as` gives it a role worth naming (a nav, a section)."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false
        }
      ],
      "examples": [
        {
          "title": "A form column",
          "description": "Fields spaced by one step.",
          "code": "<Stack gap={8}>\n  <Field label=\"Name\" />\n  <Field label=\"Email\" />\n</Stack>"
        },
        {
          "title": "A row of actions",
          "description": "Wraps on a narrow screen instead of pushing it sideways.",
          "code": "<Stack direction=\"row\" gap={4} justify=\"end\">\n  <HardwareButton>Cancel</HardwareButton>\n  <HardwareButton variant=\"primary\">Save</HardwareButton>\n</Stack>"
        },
        {
          "title": "A card grid",
          "description": "As many columns as fit, one on a phone.",
          "code": "<Grid minItemWidth=\"16rem\" as=\"ul\">{cards}</Grid>"
        }
      ],
      "antiPatterns": [
        "Never use a Grid for a table of data: that's a Table, which a screen reader can navigate by row and column.",
        "Never reverse visual order with layout: tab order would stop matching what's seen."
      ],
      "usage": {
        "imports": "import { Stack, Grid } from \"@/echo/layout\";",
        "code": "<Stack gap={6}>{children}</Stack>"
      },
      "composition": "Stack → flex; Grid → grid",
      "a11y": [
        "No role of its own; `as` gives it one when the grouping means something (a `ul` of cards, a `nav`).",
        "Order on screen is the order in the source, so reading and tab order follow what's seen."
      ],
      "feel": "Nothing of its own: the rhythm between things, the same everywhere.",
      "api": {
        "Stack": {
          "props": [
            "align",
            "as",
            "children",
            "direction",
            "gap",
            "justify",
            "wrap"
          ]
        },
        "Grid": {
          "props": [
            "align",
            "as",
            "children",
            "columns",
            "gap",
            "minItemWidth"
          ]
        }
      },
      "weight": {
        "js": 483,
        "withDependencies": 483,
        "css": 269
      }
    },
    {
      "name": "echo-collapsible",
      "title": "Collapsible",
      "version": "0.3.0",
      "description": "One disclosure on its own: a ruled row with a title and a chevron that opens a panel. For \"show more\", advanced settings or a single FAQ row, without Accordion's group rules. Built on Base UI Collapsible; closed content stays findable with the browser's find-in-page, which opens it.",
      "summary": "One disclosure on its own: a ruled row with a chevron that opens a panel.",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add collapsible",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "description": "The row's label: what opening it shows (\"Advanced settings\")."
        },
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "What it shows when open."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false,
          "description": "Controlled open state."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Open to begin with, uncontrolled."
        },
        {
          "name": "onOpenChange",
          "type": "function",
          "required": false
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        }
      ],
      "examples": [
        {
          "title": "Advanced settings",
          "description": "Tucks away what most people never change.",
          "code": "<Collapsible title=\"Advanced settings\">\n  <NumberField label=\"Buffer\" defaultValue={256} />\n</Collapsible>"
        },
        {
          "title": "Controlled",
          "description": "Open it from elsewhere, or remember it.",
          "code": "<Collapsible title=\"Details\" open={open} onOpenChange={setOpen}>{details}</Collapsible>"
        }
      ],
      "antiPatterns": [
        "Never put the only copy of something required inside a closed Collapsible.",
        "Don't stack several to build a list: that's an Accordion, which keeps them as one group."
      ],
      "usage": {
        "imports": "import { Collapsible } from \"@/echo/collapsible\";",
        "code": "<Collapsible title=\"Advanced settings\">{content}</Collapsible>"
      },
      "composition": "Collapsible → a trigger row and a panel (Base UI Collapsible)",
      "a11y": [
        "The row is a button with `aria-expanded`, controlling the panel.",
        "Enter and Space open and close it.",
        "Closed content is `hidden=\"until-found\"`: find-in-page can match it and opens the panel."
      ],
      "motion": {
        "tokens": [
          "pop"
        ],
        "reducedMotion": "The panel opens and closes at once; the chevron turns without moving."
      },
      "sound": [
        "`tap` as it opens or closes, inside a SoundProvider."
      ],
      "feel": "A ruled row whose chevron turns over with a little weight; the panel slides open under it without jolting what's below.",
      "api": {
        "Collapsible": {
          "props": [
            "children",
            "defaultOpen",
            "disabled",
            "onOpenChange",
            "open",
            "title"
          ],
          "required": [
            "children",
            "title"
          ]
        }
      },
      "weight": {
        "js": 486,
        "withDependencies": 7995,
        "css": 863
      }
    },
    {
      "name": "echo-scroll-area",
      "title": "ScrollArea",
      "version": "0.3.0",
      "description": "A region that scrolls inside itself, with the scrollbar in the device's material: a thin groove and a molded thumb that fattens under the hand, and shade at an edge wherever the content continues. Native scrolling (wheel, touch, keyboard, find-in-page) is untouched. Built on Base UI ScrollArea.",
      "summary": "A region that scrolls inside itself, with a groove and a molded thumb, and shade where the content continues.",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add scroll-area",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "What scrolls."
        },
        {
          "name": "maxHeight",
          "type": "string",
          "required": false,
          "description": "The most it may be before it scrolls, as a CSS length (\"20rem\", \"60vh\")."
        },
        {
          "name": "orientation",
          "type": "\"vertical\" | \"horizontal\" | \"both\"",
          "required": false,
          "defaultValue": "\"vertical\"",
          "description": "Which way it scrolls: vertical (the default), horizontal or both."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Its name. A ScrollArea takes focus so a keyboard can scroll it, so it needs aria-label or aria-labelledby: leaving both out is a type error."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false
        }
      ],
      "examples": [
        {
          "title": "A long list",
          "description": "Scrolls past 20rem; the bottom edge shades while there's more.",
          "code": "<ScrollArea aria-label=\"Members\" maxHeight=\"20rem\">{members}</ScrollArea>"
        },
        {
          "title": "Sideways",
          "description": "A row of cards that scrolls horizontally.",
          "code": "<ScrollArea aria-label=\"Presets\" orientation=\"horizontal\">{presets}</ScrollArea>"
        }
      ],
      "antiPatterns": [
        "Never nest one vertical ScrollArea in another: the wheel gets stuck in whichever is under it.",
        "Never put a whole page in one: the page already scrolls."
      ],
      "usage": {
        "imports": "import { ScrollArea } from \"@/echo/scroll-area\";",
        "code": "<ScrollArea aria-label=\"Log\" maxHeight=\"16rem\">{lines}</ScrollArea>"
      },
      "composition": "ScrollArea → a named, focusable viewport and a groove with a thumb (Base UI ScrollArea)",
      "a11y": [
        "The viewport is a named region that takes focus, so arrow keys, Page Up/Down and Space scroll it.",
        "Scrolling is native: wheel, touch, keyboard and find-in-page all work.",
        "The bar is a pointer extra; nothing is reachable only by dragging it."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-swap",
          "--echo-duration-tick"
        ],
        "reducedMotion": "The bar wakes and fattens without easing."
      },
      "feel": "A groove cut into the surface with a small molded thumb riding in it; where the list goes on, the edge sinks into shade.",
      "api": {
        "ScrollArea": {
          "props": [
            "children",
            "maxHeight",
            "orientation"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          },
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 360,
        "withDependencies": 8092,
        "css": 826
      }
    },
    {
      "name": "echo-link",
      "title": "Link",
      "version": "0.3.0",
      "description": "Inline text that goes somewhere. The words stay in the text colour and the accent is the underline, because accent-coloured text on the chassis is under AA. external opens a new tab and says so; render takes your router's link so navigation stays the router's.",
      "summary": "Inline text that goes somewhere: readable words with the accent as the underline.",
      "group": "Navigation",
      "type": "registry:component",
      "dependencies": [],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add link",
      "props": [
        {
          "name": "href",
          "type": "string",
          "required": true,
          "description": "Where it goes."
        },
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "The link text: say where it goes, not \"click here\"."
        },
        {
          "name": "external",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Opens in a new tab and says so, to the eye and to a screen reader."
        },
        {
          "name": "tone",
          "type": "\"default\" | \"muted\"",
          "required": false,
          "defaultValue": "\"default\"",
          "description": "muted for a link among quiet text: dim words, the same underline."
        },
        {
          "name": "render",
          "type": "unknown",
          "required": false,
          "description": "Your router's link element, e.g. <NextLink href=\"/docs\" />; it gets the href, class and children."
        },
        {
          "name": "onClick",
          "type": "function",
          "required": false
        },
        {
          "name": "aria-current",
          "type": "\"page\" | \"step\" | \"location\" | \"true\"",
          "required": false
        }
      ],
      "examples": [
        {
          "title": "In a sentence",
          "description": "The words say where it goes.",
          "code": "<p>\n  Read the <Link href=\"/docs/theming\">theming guide</Link> first.\n</p>"
        },
        {
          "title": "External",
          "description": "An arrow for the eye, \"opens in a new tab\" for a screen reader.",
          "code": "<Link href=\"https://base-ui.com\" external>Base UI</Link>"
        }
      ],
      "antiPatterns": [
        "Never use a Link for an action that doesn't navigate: that's a button.",
        "Never colour the link text with the accent: it fails contrast on the chassis.",
        "Never write \"click here\": the link text is what a screen reader lists."
      ],
      "usage": {
        "imports": "import { Link } from \"@/echo/link\";",
        "code": "<Link href=\"/docs\">Docs</Link>"
      },
      "composition": "Link → an <a> (or your router's link) with the underline",
      "a11y": [
        "A real `<a href>`, so it's announced as a link, opens in a new tab with the browser's own gestures, and shows its URL.",
        "The words keep text contrast; the accent is only the underline, and it's an underline, so a link never depends on colour alone.",
        "`external` adds \"(opens in a new tab)\" for screen readers and `rel=\"noopener noreferrer\"`.",
        "In forced colors it takes the system link colour."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-tick"
        ],
        "reducedMotion": "The underline changes without easing."
      },
      "feel": "Readable words with the accent drawn under them; reach for it and the underline thickens toward the text.",
      "api": {
        "Link": {
          "props": [
            "children",
            "external",
            "href",
            "onClick",
            "render",
            "tone"
          ],
          "required": [
            "children",
            "href"
          ]
        }
      },
      "weight": {
        "js": 487,
        "withDependencies": 487,
        "css": 493
      }
    },
    {
      "name": "echo-toggle",
      "title": "Toggle",
      "version": "0.3.0",
      "description": "The quiet key of a toolbar: bold, italic, grid or list, a filter. On is the cap held down in its well, not a light (that's LedKey). ToggleGroup puts Toggles in one long well with arrow-key focus: multiple lets any number be on, without it at most one. For exactly one of a few, always one, use Segmented. Built on Base UI Toggle and ToggleGroup.",
      "summary": "The quiet key of a toolbar: on is the cap held down in its well. ToggleGroup puts several in one well.",
      "group": "Buttons",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size",
        "@echo/echo-toolbar"
      ],
      "install": "npx echo-ui-cli add toggle",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "children",
          "type": "unknown",
          "required": false,
          "description": "Toggle: the legend, when it has words."
        },
        {
          "name": "icon",
          "type": "unknown",
          "required": false,
          "description": "Toggle: an icon; icon-only toggles need aria-label (a type error otherwise)."
        },
        {
          "name": "pressed",
          "type": "boolean",
          "required": false,
          "description": "Toggle: on (controlled)."
        },
        {
          "name": "defaultPressed",
          "type": "boolean",
          "required": false,
          "description": "Toggle: on to begin with."
        },
        {
          "name": "onPressedChange",
          "type": "function",
          "required": false,
          "description": "Toggle: called with the new state."
        },
        {
          "name": "value",
          "type": "unknown",
          "required": false,
          "description": "Toggle: its value inside a ToggleGroup. ToggleGroup: the values that are on (string[])."
        },
        {
          "name": "defaultValue",
          "type": "string[]",
          "required": false,
          "description": "ToggleGroup: the values on to begin with."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "ToggleGroup: called with the values that are on."
        },
        {
          "name": "multiple",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "ToggleGroup: any number can be on; without it, at most one."
        },
        {
          "name": "orientation",
          "type": "\"horizontal\" | \"vertical\"",
          "required": false,
          "defaultValue": "\"horizontal\"",
          "description": "ToggleGroup: which way the arrow keys move."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale; a group sets it for every Toggle inside. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "The name: required on an icon-only Toggle and on every ToggleGroup (or aria-labelledby)."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false
        }
      ],
      "examples": [
        {
          "title": "Formatting",
          "description": "Any number on at once.",
          "code": "<ToggleGroup aria-label=\"Text style\" multiple>\n  <Toggle value=\"bold\" aria-label=\"Bold\" icon={<BoldIcon />} />\n  <Toggle value=\"italic\" aria-label=\"Italic\" icon={<ItalicIcon />} />\n</ToggleGroup>"
        },
        {
          "title": "One view at a time",
          "description": "At most one; pressing the one that's on turns it off.",
          "code": "<ToggleGroup aria-label=\"View\" defaultValue={[\"grid\"]}>\n  <Toggle value=\"grid\">Grid</Toggle>\n  <Toggle value=\"list\">List</Toggle>\n</ToggleGroup>"
        },
        {
          "title": "On its own",
          "description": "A single latching key.",
          "code": "<Toggle defaultPressed>Loop</Toggle>"
        }
      ],
      "antiPatterns": [
        "Never use a Toggle for an action that doesn't stay on: that's a HardwareButton.",
        "Never put an LED on a Toggle: a row of lit keys breaks the one-accent rule; the latched cap is the state.",
        "Don't use a single-choice ToggleGroup where one option must always be chosen: that's Segmented."
      ],
      "usage": {
        "imports": "import { Toggle, ToggleGroup } from \"@/echo/toggle\";",
        "code": "<Toggle aria-label=\"Mute\" icon={<MuteIcon />} />"
      },
      "composition": "Toggle → a cap in a well (Base UI Toggle); ToggleGroup → one well of Toggles (Base UI ToggleGroup)",
      "a11y": [
        "Each Toggle is a button with `aria-pressed`; Space and Enter press it.",
        "A ToggleGroup is one tab stop: arrow keys move between its Toggles (following reading direction), Home and End jump to the ends.",
        "On is shown twice over: the cap sits down, and its legend goes from dim to full text. In forced colors, on is a system highlight.",
        "An icon-only Toggle without aria-label is a type error; so is a ToggleGroup without a name."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-pop",
          "--echo-ease-pop"
        ],
        "reducedMotion": "The cap goes down and up without travelling."
      },
      "sound": [
        "`tap` as it latches or releases, inside a SoundProvider."
      ],
      "feel": "A latching key: press it on and the cap goes down and stays down in its well; press it again and it springs back up.",
      "api": {
        "Toggle": {
          "props": [
            "autoFocus",
            "children",
            "defaultPressed",
            "disabled",
            "icon",
            "id",
            "key",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onPressedChange",
            "pressed",
            "readOnly",
            "ref",
            "required",
            "size",
            "value"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "children"
            ]
          }
        },
        "ToggleGroup": {
          "props": [
            "children",
            "defaultValue",
            "disabled",
            "multiple",
            "onValueChange",
            "orientation",
            "size",
            "value"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          },
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 843,
        "withDependencies": 39996,
        "css": 1185
      }
    },
    {
      "name": "echo-toolbar",
      "title": "Toolbar",
      "version": "0.3.0",
      "description": "Several caps sharing one long well, like keys on one bar. ButtonGroup is the look alone: HardwareButtons that belong together, each its own tab stop. Toolbar adds a toolbar's behaviour: one tab stop for the whole bar and the arrow keys between its controls (Base UI Toolbar), sm controls by default, ToolbarGroup to keep a few in their own well, ToolbarSeparator between groups. Toggles and ToggleGroups inside join its arrow-key order by themselves.",
      "summary": "Several caps sharing one long well: ButtonGroup, and Toolbar with one tab stop and arrow keys.",
      "group": "Buttons",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add toolbar",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "Toolbar: ToolbarButtons, Toggles, ToggleGroups, ToolbarGroups and ToolbarSeparators. ButtonGroup: HardwareButtons."
        },
        {
          "name": "orientation",
          "type": "\"horizontal\" | \"vertical\"",
          "required": false,
          "defaultValue": "\"horizontal\"",
          "description": "Toolbar: which way the arrow keys move."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Height on the control scale for everything inside. Toolbar defaults to sm; ButtonGroup to the nearest SizeProvider's size."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "The name: every Toolbar and ButtonGroup needs aria-label or aria-labelledby (a type error otherwise)."
        },
        {
          "name": "aria-labelledby",
          "type": "string",
          "required": false
        }
      ],
      "examples": [
        {
          "title": "An editor toolbar",
          "description": "One tab stop; arrows move between the controls.",
          "code": "<Toolbar aria-label=\"Formatting\">\n  <ToggleGroup aria-label=\"Style\" multiple>\n    <Toggle value=\"bold\" aria-label=\"Bold\" icon={<BoldIcon />} />\n    <Toggle value=\"italic\" aria-label=\"Italic\" icon={<ItalicIcon />} />\n  </ToggleGroup>\n  <ToolbarSeparator />\n  <ToolbarButton>Undo</ToolbarButton>\n  <ToolbarButton>Redo</ToolbarButton>\n</Toolbar>"
        },
        {
          "title": "Buttons that belong together",
          "description": "One well; each still its own tab stop.",
          "code": "<ButtonGroup aria-label=\"Steps\">\n  <HardwareButton>Back</HardwareButton>\n  <HardwareButton variant=\"primary\">Next</HardwareButton>\n</ButtonGroup>"
        }
      ],
      "antiPatterns": [
        "Never put a Field or other text input in a Toolbar: arrow keys can't both edit text and move along the bar.",
        "Don't use a ButtonGroup for a choice: that's Segmented or a ToggleGroup."
      ],
      "usage": {
        "imports": "import { Toolbar, ToolbarButton, ToolbarGroup, ToolbarSeparator, ButtonGroup } from \"@/echo/toolbar\";",
        "code": "<Toolbar aria-label=\"Actions\">\n  <ToolbarButton>Save</ToolbarButton>\n</Toolbar>"
      },
      "composition": "Toolbar → Base UI Toolbar with HardwareButtons (ToolbarButton), Toggles and groups in wells; ButtonGroup → one well of HardwareButtons",
      "a11y": [
        "A Toolbar is `role=\"toolbar\"` with a name and one tab stop: arrow keys move between its controls, Home and End jump to the ends.",
        "A ButtonGroup is `role=\"group\"` with a name; each button keeps its own tab stop.",
        "ToolbarSeparator is a real separator, oriented with the bar.",
        "A toolbar that runs out of room wraps rather than hiding controls."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "Nothing of its own; each control follows its own reduced-motion rules."
      },
      "feel": "A bar of keys: the caps sit side by side on one floor and press on their own.",
      "api": {
        "Toolbar": {
          "props": [
            "children",
            "orientation",
            "size"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          },
          "required": [
            "children"
          ]
        },
        "ToolbarButton": {
          "props": [
            "autoFocus",
            "caption",
            "children",
            "disabled",
            "engraved",
            "fill",
            "form",
            "fullWidth",
            "held",
            "href",
            "icon",
            "iconOnly",
            "id",
            "impact",
            "key",
            "loading",
            "name",
            "onBlur",
            "onClick",
            "onFocus",
            "onKeyDown",
            "onPointerDown",
            "onPointerUp",
            "readOnly",
            "ref",
            "rel",
            "required",
            "size",
            "target",
            "title",
            "trailingIcon",
            "type",
            "value",
            "variant"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ],
            "when": "iconOnly"
          }
        },
        "ToolbarGroup": {
          "props": [
            "children"
          ],
          "required": [
            "children"
          ]
        },
        "ButtonGroup": {
          "props": [
            "children",
            "size"
          ],
          "name": {
            "anyOf": [
              "aria-label",
              "aria-labelledby"
            ]
          },
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 1397,
        "withDependencies": 39042,
        "css": 2625
      }
    },
    {
      "name": "echo-range-slider",
      "title": "RangeSlider",
      "version": "0.3.0",
      "description": "Two knobs on one rail for a span (a price range, a time window), with the rail lit between them. Slider's rail and knobs, with Slider's mass: the value moves at once and each knob catches up on a spring. They never cross; minGap keeps them apart. Each knob is its own slider for assistive tech, named from label.",
      "summary": "Two knobs on one rail for a span, with Slider's weight.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-slider"
      ],
      "install": "npx echo-ui-cli add range-slider",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What the span is (\"Price\"); the knobs are named \"Price, minimum\" and \"Price, maximum\"."
        },
        {
          "name": "value",
          "type": "unknown",
          "required": false,
          "description": "Controlled [low, high]."
        },
        {
          "name": "defaultValue",
          "type": "unknown",
          "required": false,
          "defaultValue": "[25,75]"
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with [low, high]."
        },
        {
          "name": "min",
          "type": "number",
          "required": false,
          "defaultValue": "0"
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "defaultValue": "100"
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "defaultValue": "1"
        },
        {
          "name": "minGap",
          "type": "number",
          "required": false,
          "defaultValue": "1",
          "description": "How many steps the knobs keep between them; they never cross."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "As Slider: md is the fader, sm for dense rows."
        },
        {
          "name": "formatValue",
          "type": "function",
          "required": false,
          "description": "How a value reads for assistive tech (\"$40\")."
        }
      ],
      "examples": [
        {
          "title": "Price",
          "description": "Knobs that read as money.",
          "code": "<RangeSlider label=\"Price\" defaultValue={[20, 80]} max={200} step={5} formatValue={(v: number) => `$${v}`} />"
        }
      ],
      "antiPatterns": [
        "Never use a RangeSlider where exact values matter more than the feel of the span: give two NumberFields."
      ],
      "usage": {
        "imports": "import { RangeSlider } from \"@/echo/range-slider\";",
        "code": "<RangeSlider label=\"Hours\" min={0} max={24} defaultValue={[9, 17]} />"
      },
      "composition": "RangeSlider → one rail with two Slider knobs (Base UI Slider with two thumbs)",
      "a11y": [
        "Two sliders, \"{label}, minimum\" and \"{label}, maximum\", each with arrow keys, Page Up/Down, Home and End.",
        "The knobs can't cross, so neither key path can make the span inverted.",
        "`formatValue` sets each knob's `aria-valuetext`, so \"$40\" is read, not \"40\"."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The knobs sit at their values without catching up."
      },
      "sound": [
        "`tap` per step, inside a SoundProvider."
      ],
      "feel": "Two molded knobs on one lit rail, each with the weight of the Slider's.",
      "api": {
        "RangeSlider": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "formatValue",
            "id",
            "key",
            "label",
            "max",
            "min",
            "minGap",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "size",
            "step",
            "value"
          ]
        }
      },
      "weight": {
        "js": 888,
        "withDependencies": 43573,
        "css": 1075
      }
    },
    {
      "name": "echo-knob",
      "title": "Knob",
      "version": "0.3.0",
      "description": "A molded rotary cap in its well with an engraved pointer and a scale that lights up to the value. Drag it up (or around), turn it with the wheel once focused, or use the keys; it clicks at every step, settles onto each detent on a spring, and gives a little past either end. A slider for assistive tech: role=\"slider\" named by label, with every slider key.",
      "summary": "A molded rotary cap with detents, a lit scale and every slider key.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add knob",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What it sets; the visible label and the accessible name."
        },
        {
          "name": "value",
          "type": "number",
          "required": false,
          "description": "Controlled value."
        },
        {
          "name": "defaultValue",
          "type": "number",
          "required": false,
          "description": "Uncontrolled starting value; defaults to min."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false
        },
        {
          "name": "min",
          "type": "number",
          "required": false,
          "defaultValue": "0"
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "defaultValue": "100"
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "defaultValue": "1"
        },
        {
          "name": "formatValue",
          "type": "function",
          "required": false,
          "description": "How the value reads, under the knob and to assistive tech (\"-6 dB\")."
        },
        {
          "name": "ticks",
          "type": "number",
          "required": false,
          "defaultValue": "11",
          "description": "Ticks engraved around it; they light up to the value."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "40, 56 or 72px across."
        }
      ],
      "examples": [
        {
          "title": "Gain",
          "description": "A reading under the knob, and to a screen reader.",
          "code": "<Knob label=\"Gain\" min={-24} max={24} defaultValue={0} formatValue={(v: number) => `${v > 0 ? \"+\" : \"\"}${v} dB`} />"
        },
        {
          "title": "A bank of knobs",
          "description": "Small knobs in a row, like a channel strip.",
          "code": "<Stack direction=\"row\" gap={6}>\n  <Knob size=\"sm\" label=\"Low\" />\n  <Knob size=\"sm\" label=\"Mid\" />\n  <Knob size=\"sm\" label=\"High\" />\n</Stack>"
        }
      ],
      "antiPatterns": [
        "Never use a Knob where a Slider will do in a form: it's an instrument, for a panel of controls people tweak by feel.",
        "Never leave out formatValue when the number has a unit: \"-6\" means nothing, \"-6 dB\" does."
      ],
      "usage": {
        "imports": "import { Knob } from \"@/echo/knob\";",
        "code": "<Knob label=\"Volume\" defaultValue={60} />"
      },
      "composition": "Knob → a scale, a well and a turning cap (a role=\"slider\" element)",
      "a11y": [
        "`role=\"slider\"` named by `label`, with `aria-valuenow`, min, max and `formatValue`'s reading as `aria-valuetext`.",
        "Arrow keys step, Page Up/Down move a tenth of the range, Home and End go to the ends.",
        "The wheel only turns it once it has focus, so scrolling a page never catches on a knob.",
        "Dragging is a pointer extra: everything it does, the keys do."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The cap turns straight to the value; no settle, no give past the ends."
      },
      "sound": [
        "A short click at every step, inside a SoundProvider."
      ],
      "feel": "A real pot: it clicks at every step and lands on each one with a small settle; turned past the end, it gives a little and springs back.",
      "api": {
        "Knob": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "formatValue",
            "id",
            "key",
            "label",
            "max",
            "min",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "size",
            "step",
            "ticks",
            "value"
          ]
        }
      },
      "weight": {
        "js": 1645,
        "withDependencies": 29862,
        "css": 1089
      }
    },
    {
      "name": "echo-tuner",
      "title": "Tuner",
      "version": "0.3.0",
      "description": "A number picked the way a radio is tuned: a scale printed on the screen slides under a fixed, lit needle, and a seven-segment reading follows it. Drag it with your hand, flick it and it coasts onto a step, and every tick that passes the needle clicks. It fits a big range into a small width, since the range scrolls instead of being squeezed. A slider for assistive tech, with every slider key.",
      "summary": "A number tuned like a radio: a scale slides under a lit needle and a segment reading follows.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-segment-display",
        "@echo/echo-scope"
      ],
      "install": "npx echo-ui-cli add tuner",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What it sets: the machine label over the reading, and the accessible name."
        },
        {
          "name": "value",
          "type": "number",
          "required": false,
          "description": "Controlled value."
        },
        {
          "name": "defaultValue",
          "type": "number",
          "required": false,
          "description": "Uncontrolled starting value; defaults to min."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with each step the reading lands on."
        },
        {
          "name": "min",
          "type": "number",
          "required": false,
          "defaultValue": "0"
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "defaultValue": "100"
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "defaultValue": "1",
          "description": "A range that isn't a whole number of steps ends on the last step inside it: 0 to 10 by 4 stops at 8."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "description": "Engraved after the reading, and said with it: \"kg\", \"MHz\"."
        },
        {
          "name": "labelEvery",
          "type": "number",
          "required": false,
          "description": "The numbered ticks, in value units. Default: the nicest number that's at least eight steps."
        },
        {
          "name": "formatValue",
          "type": "function",
          "required": false,
          "description": "How the value is said to assistive technology. Default: the reading and its unit (\"72.5 kg\")."
        },
        {
          "name": "screen",
          "type": "\"emissive\" | \"reflective\"",
          "required": false,
          "defaultValue": "\"emissive\"",
          "description": "emissive: a dark screen that glows. reflective: paper-coloured glass, printed in ink, like a pocket calculator's."
        }
      ],
      "examples": [
        {
          "title": "Weight",
          "description": "Half-kilo steps; the reading keeps its width with a dim leading zero.",
          "code": "<Tuner label=\"Weight\" unit=\"kg\" min={30} max={150} step={0.5} defaultValue={72.5} />"
        },
        {
          "title": "A radio",
          "description": "Tenths of a megahertz, numbered every whole one.",
          "code": "<Tuner label=\"Frequency\" unit=\"MHz\" min={87.5} max={108} step={0.1} defaultValue={88.5} />"
        },
        {
          "title": "Controlled",
          "description": "The reading lands in your state a step at a time as the scale moves.",
          "code": "<Tuner label=\"Tip\" unit=\"%\" min={0} max={30} value={tip} onValueChange={setTip} />"
        }
      ],
      "antiPatterns": [
        "Never use a Tuner for a choice between a few values: that's a Segmented or a RadioGroup.",
        "Never leave out unit when the number has one: \"72.5\" means nothing, \"72.5 kg\" does.",
        "Never make the step so fine that one value is hard to land on by hand; a tenth of the thing people care about is usually enough."
      ],
      "usage": {
        "imports": "import { Tuner } from \"@/echo/tuner\";",
        "code": "<Tuner label=\"Weight\" unit=\"kg\" min={30} max={150} step={0.5} defaultValue={72.5} />"
      },
      "composition": "Tuner\n  SegmentDisplay (the reading)\n  the scale (a role=\"slider\" element) and its needle",
      "a11y": [
        "`role=\"slider\"` named by `label`, with `aria-valuenow`, min, max, and the reading with its unit as `aria-valuetext` (or `formatValue`'s).",
        "Arrow keys step, Page Up/Down move one numbered tick, Home and End go to the ends.",
        "An up-and-down wheel only turns it once it has focus, so scrolling a page never catches on it; a sideways swipe turns it at any time.",
        "Dragging and flicking are pointer extras: everything they do, the keys do.",
        "The reading above the scale is drawing; the slider says the value."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The scale jumps to each value: no coasting after a flick, no travel after a key."
      },
      "sound": [
        "A short click at every step that passes the needle, inside a SoundProvider."
      ],
      "feel": "A radio dial: the scale moves with your finger, coasts when you let it go, and clicks into each step as it passes the needle.",
      "api": {
        "Tuner": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "formatValue",
            "id",
            "key",
            "label",
            "labelEvery",
            "max",
            "min",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "screen",
            "step",
            "unit",
            "value"
          ]
        }
      },
      "weight": {
        "js": 4170,
        "withDependencies": 32386,
        "css": 1842
      }
    },
    {
      "name": "echo-keypad",
      "title": "Keypad",
      "version": "0.3.0",
      "description": "A payment terminal's keys under its screen. An amount fills from the right the way a till does (1, 2, 4, 0 reads 12.40) with its unused digits dim; a PIN lights a socket per digit and never shows them; a code shows its digits in cells. Type on a real keyboard and the key on screen goes down with your finger. It replaces the phone's own keyboard, so none pops up over it.",
      "summary": "A payment terminal's keys: an amount, a PIN or a code entered on HardwareButton caps.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-segment-display",
        "@echo/echo-status-dot",
        "@echo/echo-scope"
      ],
      "install": "npx echo-ui-cli add keypad",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What is being entered: the machine label on the screen, and the pad's accessible name."
        },
        {
          "name": "mode",
          "type": "\"amount\" | \"pin\" | \"digits\"",
          "required": false,
          "defaultValue": "\"amount\"",
          "description": "amount fills from the right; pin shows a lit socket per digit, never the digits; digits shows a code."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "Controlled value: \"12.40\" for an amount, the digits for a PIN or a code. An amount is read as a number, so \"12.4\", \"12\" and String(12.4) all work."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with the new value after every key."
        },
        {
          "name": "onComplete",
          "type": "function",
          "required": false,
          "description": "Called once a PIN or a code has all its digits."
        },
        {
          "name": "maxLength",
          "type": "number",
          "required": false,
          "description": "Most digits it takes: an amount's including decimals (default 6), a PIN's length (default 4), a code's (default 12)."
        },
        {
          "name": "decimals",
          "type": "number",
          "required": false,
          "defaultValue": "2",
          "description": "An amount's decimal places."
        },
        {
          "name": "prefix",
          "type": "string",
          "required": false,
          "description": "Engraved before the reading: \"$\", \"€\"."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "description": "Engraved after the reading, and said with it."
        },
        {
          "name": "action",
          "type": "unknown",
          "required": false,
          "description": "The way forward under the keys, usually one HardwareButton."
        },
        {
          "name": "screen",
          "type": "\"emissive\" | \"reflective\"",
          "required": false,
          "defaultValue": "\"emissive\"",
          "description": "The entry screen's glass: emissive (dark, glowing) or reflective (paper-coloured, printed in ink)."
        }
      ],
      "examples": [
        {
          "title": "Send money",
          "description": "An amount, with the action under the keys.",
          "code": "<Keypad\n  label=\"Amount\"\n  prefix=\"$\"\n  value={amount}\n  onValueChange={setAmount}\n  action={<HardwareButton variant=\"primary\" size=\"lg\" fullWidth disabled={!amount}>Send</HardwareButton>}\n/>"
        },
        {
          "title": "Unlock",
          "description": "Four digits, called back once they're all in.",
          "code": "<Keypad label=\"Passcode\" mode=\"pin\" onComplete={(pin: string) => unlock(pin)} />"
        },
        {
          "title": "A code",
          "description": "Six digits in their cells; the empty ones show only their dark segments.",
          "code": "<Keypad label=\"Verification code\" mode=\"digits\" maxLength={6} />"
        }
      ],
      "antiPatterns": [
        "Never use a Keypad for free text or a form a keyboard does better: it's for digits entered by thumb (money, a PIN, a code).",
        "Never announce or show a PIN's digits: mode=\"pin\" exists so they stay hidden.",
        "Never put the submit button anywhere but action: under the keys is where a thumb goes next."
      ],
      "usage": {
        "imports": "import { Keypad } from \"@/echo/keypad\";",
        "code": "<Keypad label=\"Amount\" prefix=\"$\" defaultValue=\"12.40\" />"
      },
      "composition": "Keypad\n  the screen: SegmentDisplay (an amount or a code) or StatusDots (a PIN)\n  twelve HardwareButtons\n  action",
      "a11y": [
        "A `role=\"group\"` named by `label`; every key is a button named by its digit, and the delete key is named \"Delete\".",
        "One Tab stop: arrow keys move between keys, Home and End go to the first and last. Digits and Backspace type wherever focus is on the pad.",
        "Focus given to the pad itself (a form library's setFocus, the ref) goes on to the key you were last on.",
        "The reading is announced politely after each key. A PIN is announced as how many digits are in (\"2 of 4 digits entered\"), never the digits.",
        "`disabled` and `readOnly` stop every key; `name` submits the value with a form."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "Keys go down and up without their travel, as every HardwareButton does."
      },
      "sound": [
        "Every key clicks as it goes down and as it comes back, whether pressed or typed, inside a SoundProvider."
      ],
      "feel": "A card terminal: big keys that go down under your finger with a click, and a lit reading that fills in from the right.",
      "api": {
        "Keypad": {
          "props": [
            "action",
            "autoFocus",
            "decimals",
            "defaultValue",
            "disabled",
            "id",
            "key",
            "label",
            "maxLength",
            "mode",
            "name",
            "onBlur",
            "onComplete",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "prefix",
            "readOnly",
            "ref",
            "required",
            "screen",
            "unit",
            "value"
          ]
        }
      },
      "weight": {
        "js": 4573,
        "withDependencies": 34682,
        "css": 3512
      }
    },
    {
      "name": "echo-filter-tray",
      "title": "FilterTray",
      "version": "0.3.0",
      "description": "Chunky filter pads in one long well, with the action at its end. A pad that's on is a lit cap; the action carries the count. Apply, and the lit pads fold into the action (Morph) and it becomes one key saying how many are on, their names engraved on the tray's floor beside it; press it and they come back out. For the filter bar of a shop, a map or a feed.",
      "summary": "Filter pads in one well that fold into the action when applied.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-morph",
        "@echo/echo-size",
        "@echo/echo-scope"
      ],
      "install": "npx echo-ui-cli add filter-tray",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What the pads filter: the tray's accessible name."
        },
        {
          "name": "options",
          "type": "object[]",
          "required": true,
          "description": "The pads: a value, the name engraved under the pad (and its accessible name), its glyph, and optionally a colour of its own it lights in."
        },
        {
          "name": "value",
          "type": "string[]",
          "required": false,
          "description": "The pads that are on (controlled)."
        },
        {
          "name": "defaultValue",
          "type": "string[]",
          "required": false
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with the pads that are on."
        },
        {
          "name": "applied",
          "type": "boolean",
          "required": false,
          "description": "Folded into the action (controlled)."
        },
        {
          "name": "defaultApplied",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Whether the lit pads start folded into the action (uncontrolled). See applied."
        },
        {
          "name": "onAppliedChange",
          "type": "function",
          "required": false,
          "description": "Called when the pads fold in (true) or come back out (false). Clearing every pad unfolds it for good, and says so here."
        },
        {
          "name": "actionLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"Filter\"",
          "description": "The action's word."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"lg\"",
          "description": "Height on the control scale for every pad."
        }
      ],
      "examples": [
        {
          "title": "Places nearby",
          "description": "Four pads; the chosen ones fold into Filter.",
          "code": "<FilterTray\n  label=\"Filters\"\n  options={[\n    { value: \"open\", label: \"Open now\", icon: <ClockIcon /> },\n    { value: \"rated\", label: \"Top rated\", icon: <StarIcon /> },\n    { value: \"near\", label: \"Nearby\", icon: <PinIcon /> },\n  ]}\n  defaultValue={[\"open\"]}\n/>"
        },
        {
          "title": "A colour per pad",
          "description": "Each pad lights in its own colour, derived and contrast-checked like your accent.",
          "code": "<FilterTray\n  label=\"Filters\"\n  options={[\n    { value: \"open\", label: \"Open now\", icon: <ClockIcon />, accent: \"#DE3B74\" },\n    { value: \"rated\", label: \"Top rated\", icon: <StarIcon />, accent: \"#F2674E\" },\n    { value: \"near\", label: \"Nearby\", icon: <PinIcon />, accent: \"#3D8EF0\" },\n  ]}\n/>"
        },
        {
          "title": "Applied from your state",
          "description": "Fold it in when results come back, and hear about it when someone reopens it.",
          "code": "<FilterTray label=\"Filters\" options={options} value={on} onValueChange={setOn} applied={applied} onAppliedChange={setApplied} />"
        }
      ],
      "antiPatterns": [
        "Never give a pad a glyph without its label: the engraved name is what makes an icon a filter.",
        "Never use a FilterTray for one choice out of many: that's a Segmented, or a Select.",
        "Never put more pads in it than fit on one row at your narrowest width; a tray that wraps can't fold."
      ],
      "usage": {
        "imports": "import { FilterTray } from \"@/echo/filter-tray\";",
        "code": "<FilterTray label=\"Filters\" options={[{ value: \"open\", label: \"Open now\", icon: <ClockIcon /> }]} />"
      },
      "composition": "FilterTray\n  Morph\n    HardwareButton (a pad, one per option)\n    HardwareButton (the action)",
      "a11y": [
        "A `role=\"group\"` named by `label`. Each pad is a button named by its engraved label, with `aria-pressed` for whether it's on.",
        "The action says what it will do (\"Filter: 2 filters\"); once folded, the one key says what's on and that pressing it changes them.",
        "Folding in is announced politely: how many filters are on, and which.",
        "Escape on the folded key brings the pads back out.",
        "`name` submits every pad that's on with a form."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The pads and the one key swap in place; nothing folds."
      },
      "sound": [
        "Each pad and the action click like every HardwareButton, inside a SoundProvider."
      ],
      "feel": "A row of backlit pads on a tray: press one and it lights; apply, and the lit ones run into the button like drops.",
      "api": {
        "FilterTray": {
          "props": [
            "actionLabel",
            "applied",
            "autoFocus",
            "defaultApplied",
            "defaultValue",
            "disabled",
            "id",
            "key",
            "label",
            "name",
            "onAppliedChange",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "options",
            "readOnly",
            "ref",
            "required",
            "size",
            "value"
          ],
          "required": [
            "options"
          ]
        }
      },
      "weight": {
        "js": 5295,
        "withDependencies": 47353,
        "css": 3243
      }
    },
    {
      "name": "echo-week-strip",
      "title": "WeekStrip",
      "version": "0.3.0",
      "description": "A week you tap through, the way the top of a phone's calendar works: seven days in a Segmented, each with its weekday and date, the chosen one lit, the light travelling there as a drop. Today carries a lit rail; a day with something on it has a pip. The keys at either end go a week back or forward, keeping the weekday.",
      "summary": "A week of days to tap through, today on a lit rail, busy days with a pip.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-segmented",
        "@echo/echo-date-tile"
      ],
      "install": "npx echo-ui-cli add week-strip",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What the day is for: the group's accessible name (\"Delivery day\")."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "description": "The chosen day, as an ISO date (controlled)."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "description": "The day chosen to begin with. Default: today."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with the ISO date of the day chosen."
        },
        {
          "name": "today",
          "type": "string",
          "required": false,
          "description": "Today, which carries the lit rail."
        },
        {
          "name": "marked",
          "type": "string[]",
          "required": false,
          "description": "Days with something on them: each gets a pip."
        },
        {
          "name": "weekStartsOn",
          "type": "0 | 1",
          "required": false,
          "defaultValue": "1",
          "description": "The first day of the week: 1 Monday, 0 Sunday."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "defaultValue": "\"en\"",
          "description": "The language days are named in."
        }
      ],
      "examples": [
        {
          "title": "Pick a day",
          "description": "Today lit by its rail, days with bookings marked.",
          "code": "<WeekStrip label=\"Day\" today=\"2026-09-25\" marked={[\"2026-09-22\", \"2026-09-26\"]} />"
        },
        {
          "title": "Controlled",
          "description": "The chosen day in your state; the arrows move a week at a time.",
          "code": "<WeekStrip label=\"Delivery day\" value={day} onValueChange={setDay} today={today} />"
        }
      ],
      "antiPatterns": [
        "Never use a WeekStrip to pick a date far away: it moves a week at a time; for any date, use a date field.",
        "Never leave out today when there is one: without the rail, the strip doesn't say where now is."
      ],
      "usage": {
        "imports": "import { WeekStrip } from \"@/echo/week-strip\";",
        "code": "<WeekStrip label=\"Day\" today=\"2026-09-25\" />"
      },
      "composition": "WeekStrip\n  HardwareButton (week before)\n  Segmented (the seven days)\n  HardwareButton (week after)",
      "a11y": [
        "A group named by `label`, holding a radio group of days; each day is named with its full date, and says \"today\" and \"has events\" when it is and has.",
        "Arrow keys move between days, Home and End go to the first and last; the week keys are named with the week they go to.",
        "Worked in UTC from ISO dates, so the days are the same on the server and in every browser.",
        "`name` submits the chosen day with a form."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The light moves straight to the chosen day, as Segmented's does."
      },
      "sound": [
        "The select click as a day is chosen, and each week key's press, inside a SoundProvider."
      ],
      "feel": "The top of a calendar app: tap a day and the light rolls over to it.",
      "api": {
        "WeekStrip": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "id",
            "key",
            "label",
            "locale",
            "marked",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "today",
            "value",
            "weekStartsOn"
          ]
        }
      },
      "weight": {
        "js": 3947,
        "withDependencies": 46232,
        "css": 3382
      }
    },
    {
      "name": "echo-switch-bank",
      "title": "SwitchBank",
      "version": "0.3.0",
      "description": "Settings that come in a grid (notifications by event and channel, permissions by role and resource, a schedule by day) as a bank of Switches in a Table, one where each row meets each column. A Checkbox in each column header flips the whole column and shows a dash when it's mixed. A real table, so every Switch is read with its row and column.",
      "summary": "A table of Switches for settings that come in a grid, a Checkbox per column.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-table",
        "@echo/echo-switch",
        "@echo/echo-checkbox"
      ],
      "install": "npx echo-ui-cli add switch-bank",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What the bank sets: the table's caption (\"Notifications\")."
        },
        {
          "name": "hideLabel",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Hide the caption visually; it still names the table."
        },
        {
          "name": "columns",
          "type": "object[]",
          "required": true,
          "description": "The columns: the channels, roles or days."
        },
        {
          "name": "rows",
          "type": "object[]",
          "required": true,
          "description": "The rows: the events, resources or times."
        },
        {
          "name": "value",
          "type": "string[]",
          "required": false,
          "description": "What's on, as \"row:column\" keys (controlled)."
        },
        {
          "name": "defaultValue",
          "type": "string[]",
          "required": false
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with every \"row:column\" key that's on."
        }
      ],
      "examples": [
        {
          "title": "Notifications",
          "description": "Events by channel.",
          "code": "<SwitchBank\n  label=\"Notifications\"\n  columns={[{ value: \"email\", label: \"Email\" }, { value: \"push\", label: \"Push\" }, { value: \"sms\", label: \"SMS\" }]}\n  rows={[{ value: \"mentions\", label: \"Mentions\" }, { value: \"replies\", label: \"Replies\" }]}\n  defaultValue={[\"mentions:email\", \"mentions:push\"]}\n/>"
        },
        {
          "title": "Controlled",
          "description": "The keys that are on, in your state.",
          "code": "<SwitchBank label=\"Permissions\" columns={roles} rows={resources} value={grants} onValueChange={setGrants} />"
        }
      ],
      "antiPatterns": [
        "Never use a SwitchBank for settings that don't form a grid: a list of Switches in rows reads better.",
        "Never let it grow past what fits: more than five columns is a table people scroll sideways through; split it."
      ],
      "usage": {
        "imports": "import { SwitchBank } from \"@/echo/switch-bank\";",
        "code": "<SwitchBank label=\"Notifications\" columns={[{ value: \"email\", label: \"Email\" }]} rows={[{ value: \"mentions\", label: \"Mentions\" }]} />"
      },
      "composition": "SwitchBank\n  Table\n    a Checkbox in each column header\n    a row header for each row\n    a Switch where each row meets each column",
      "a11y": [
        "A `table` captioned by `label`, with column and row headers, so a screen reader reads each Switch with where it sits.",
        "Every Switch is also named by its column and row (\"Push, Mentions\"), for anyone moving by Tab.",
        "Each column's Checkbox is named \"All\" and the column, and is mixed when only some of the column is on.",
        "`name` submits every key that's on with a form."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "Switches and Checkboxes change without travel, as they always do."
      },
      "sound": [
        "Each Switch and Checkbox clicks as it always does, inside a SoundProvider."
      ],
      "feel": "A bank of switches on a panel: flip one, or flip a whole column at once.",
      "api": {
        "SwitchBank": {
          "props": [
            "autoFocus",
            "columns",
            "defaultValue",
            "disabled",
            "hideLabel",
            "id",
            "key",
            "label",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "rows",
            "value"
          ],
          "required": [
            "columns",
            "rows"
          ]
        }
      },
      "weight": {
        "js": 5504,
        "withDependencies": 43889,
        "css": 2770
      }
    },
    {
      "name": "echo-jog-wheel",
      "title": "JogWheel",
      "version": "0.3.0",
      "description": "A knob with no ends: turn it and it moves a value by steps, as far as you like, like a video editor's jog wheel. Turn slowly for a step per click; spin it and the steps grow with the speed, so a long way is a quick flick. Knob's own cap, well and pointer, larger, with a ring engraved on the cap to see it turn. A spin button for assistive tech: arrows step, Shift or Page Up/Down take ten.",
      "summary": "A knob with no ends: slow for a step, spin for a long way.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-knob"
      ],
      "install": "npx echo-ui-cli add jog-wheel",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What it moves: the label under it, and the accessible name."
        },
        {
          "name": "value",
          "type": "number",
          "required": false,
          "description": "Controlled value."
        },
        {
          "name": "defaultValue",
          "type": "number",
          "required": false,
          "description": "Uncontrolled starting value; defaults to min, or 0."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with every step it moves."
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "defaultValue": "1"
        },
        {
          "name": "min",
          "type": "number",
          "required": false,
          "description": "An optional end. Without one it turns forever."
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "description": "An optional end. Without one it turns forever."
        },
        {
          "name": "formatValue",
          "type": "function",
          "required": false,
          "description": "How the value reads, under the wheel and to assistive tech (\"00:01:12.08\")."
        },
        {
          "name": "size",
          "type": "\"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "96 or 128px across."
        }
      ],
      "examples": [
        {
          "title": "Scrub a clip",
          "description": "Frames, read as a timecode.",
          "code": "<JogWheel label=\"Position\" formatValue={(f: number) => timecode(f)} />"
        },
        {
          "title": "Fine adjustment",
          "description": "Tenths, between two ends.",
          "code": "<JogWheel label=\"Exposure\" step={0.1} min={-3} max={3} defaultValue={0} formatValue={(v: number) => `${v > 0 ? \"+\" : \"\"}${v.toFixed(1)} EV`} />"
        }
      ],
      "antiPatterns": [
        "Never use a JogWheel for a value with a sensible range: that's a Knob or a Slider, which show where the value sits.",
        "Never leave out formatValue for anything but a plain count: a jog wheel usually moves time or position."
      ],
      "usage": {
        "imports": "import { JogWheel } from \"@/echo/jog-wheel\";",
        "code": "<JogWheel label=\"Position\" />"
      },
      "composition": "JogWheel\n  Knob's well and cap (a role=\"spinbutton\" element), with an engraved ring and a finger dimple",
      "a11y": [
        "`role=\"spinbutton\"` named by `label`, with `aria-valuenow` and `formatValue`'s reading as `aria-valuetext`; `min` and `max` when it has ends.",
        "Arrow keys step, Shift with an arrow or Page Up/Down take ten, and Home and End go to the ends when there are ends.",
        "The wheel only turns it once it has focus, so scrolling a page never catches on it. It turns as far as you scrolled, either axis: a mouse wheel's click is a notch, a trackpad's small moves add up to one.",
        "Dragging turns it by the angle round the centre; over the hub, where a hair's move is a quarter turn, it waits for the hand to leave.",
        "Turning it by hand is a pointer extra: everything it does, the keys do."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The cap turns straight to where it's going; no settle."
      },
      "sound": [
        "A short click at every step, inside a SoundProvider."
      ],
      "feel": "A heavy wheel with a notch under your finger: slow clicks for precision, a spin to travel.",
      "api": {
        "JogWheel": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "formatValue",
            "id",
            "key",
            "label",
            "max",
            "min",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "size",
            "step",
            "value"
          ]
        }
      },
      "weight": {
        "js": 1923,
        "withDependencies": 30067,
        "css": 1252
      }
    },
    {
      "name": "echo-quick-tile",
      "title": "QuickTile",
      "version": "0.3.0",
      "description": "A quick-settings tile, as in a phone's control centre: a big molded key with a glyph, a name and a line of state. Press it to switch the thing on or off; on, it's the lit cap and its socket glows. It presses like every Echo key. A switch for assistive tech, with the state line as its description; details open from a separate key, never from holding the tile down.",
      "summary": "A quick-settings tile: a big key that lights when the thing is on.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-status-dot",
        "@echo/echo-hardware-button"
      ],
      "install": "npx echo-ui-cli add quick-tile",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What it switches: the tile's name and its accessible name."
        },
        {
          "name": "icon",
          "type": "unknown",
          "required": true,
          "description": "The glyph on the tile."
        },
        {
          "name": "status",
          "type": "string",
          "required": false,
          "description": "A line of state under the name: \"Home network\", \"Until 7:00\"."
        },
        {
          "name": "checked",
          "type": "boolean",
          "required": false,
          "description": "On (controlled)."
        },
        {
          "name": "defaultChecked",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "onCheckedChange",
          "type": "function",
          "required": false,
          "description": "Called with the new state."
        },
        {
          "name": "shape",
          "type": "\"square\" | \"wide\"",
          "required": false,
          "defaultValue": "\"square\"",
          "description": "square is a glyph over a name; wide sets them side by side, with the state line."
        },
        {
          "name": "onMore",
          "type": "function",
          "required": false,
          "description": "Shows a small key for the details, and calls this when it's pressed."
        }
      ],
      "examples": [
        {
          "title": "A control centre",
          "description": "Square tiles and a wide one with its state.",
          "code": "<Grid columns={2} gap={5}>\n  <QuickTile label=\"Wi-Fi\" icon={<WifiIcon />} status=\"Home network\" shape=\"wide\" defaultChecked />\n  <QuickTile label=\"Focus\" icon={<MoonIcon />} />\n</Grid>"
        },
        {
          "title": "With details",
          "description": "A small key for the settings behind it.",
          "code": "<QuickTile label=\"Bluetooth\" icon={<BluetoothIcon />} status=\"2 devices\" shape=\"wide\" onMore={() => open(\"bluetooth\")} />"
        }
      ],
      "antiPatterns": [
        "Never use a QuickTile in a form: it's for switching something on right now; a setting saved with a form is a Switch.",
        "Never put a long label on a square tile: a word or two; the state line says the rest."
      ],
      "usage": {
        "imports": "import { QuickTile } from \"@/echo/quick-tile\";",
        "code": "<QuickTile label=\"Wi-Fi\" icon={<WifiIcon />} />"
      },
      "composition": "QuickTile\n  the tile (a role=\"switch\" button): the glyph, a StatusDot, the name and the state line\n  HardwareButton (details, with onMore)",
      "a11y": [
        "A `role=\"switch\"` button named by `label`, with `aria-checked` for its state and the state line as its description.",
        "Details are a separate button named with the tile (\"Wi-Fi settings\"), so nothing is hidden behind a long press.",
        "On is carried by the lit cap and by the socket, and in forced colors by the system highlight colour, never by colour alone.",
        "Keyboard: Tab reaches the tile and then its Details button; Enter or Space turns the tile on or off."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The tile presses without travel."
      },
      "sound": [
        "The key's press and release, inside a SoundProvider."
      ],
      "feel": "A chunky backlit key: it lights up when the thing is on.",
      "api": {
        "QuickTile": {
          "props": [
            "autoFocus",
            "checked",
            "defaultChecked",
            "disabled",
            "icon",
            "id",
            "key",
            "label",
            "name",
            "onBlur",
            "onCheckedChange",
            "onFocus",
            "onKeyDown",
            "onMore",
            "readOnly",
            "ref",
            "required",
            "shape",
            "status"
          ],
          "required": [
            "icon"
          ]
        }
      },
      "weight": {
        "js": 1630,
        "withDependencies": 31846,
        "css": 3071
      }
    },
    {
      "name": "echo-fill-slider",
      "title": "FillSlider",
      "version": "0.3.0",
      "description": "The tall slider of a control centre: a tube in the chassis that fills with lit plastic from the bottom, like Meter's, and follows your finger anywhere on it. For brightness, volume, a level you set by eye. Base UI's slider, vertical, with every slider key; the top of the fill is the handle, so there's no knob to aim for.",
      "summary": "A tall tube that fills with lit plastic as you push it up.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add fill-slider",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What it sets: engraved under the tube and its accessible name."
        },
        {
          "name": "icon",
          "type": "unknown",
          "required": false,
          "description": "A glyph at the foot of the tube, over the fill. Decorative."
        },
        {
          "name": "value",
          "type": "number",
          "required": false,
          "description": "Controlled value."
        },
        {
          "name": "defaultValue",
          "type": "number",
          "required": false,
          "description": "Uncontrolled starting value; defaults to min."
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with each step the fill reaches."
        },
        {
          "name": "min",
          "type": "number",
          "required": false,
          "defaultValue": "0"
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "defaultValue": "100"
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "defaultValue": "1"
        },
        {
          "name": "formatValue",
          "type": "function",
          "required": false,
          "description": "How the value is said to assistive technology. Default: a percent of the range."
        }
      ],
      "examples": [
        {
          "title": "Brightness and volume",
          "description": "Two tubes side by side, as in a control centre.",
          "code": "<Stack direction=\"row\" gap={6}>\n  <FillSlider label=\"Brightness\" icon={<SunIcon />} defaultValue={70} />\n  <FillSlider label=\"Volume\" icon={<SpeakerIcon />} defaultValue={40} />\n</Stack>"
        },
        {
          "title": "In your own units",
          "description": "Said as you'd say it.",
          "code": "<FillSlider label=\"Warmth\" min={2700} max={6500} step={100} formatValue={(k: number) => `${k} kelvin`} />"
        }
      ],
      "antiPatterns": [
        "Never use a FillSlider in a form row: it's tall on purpose; beside a field, use a Slider.",
        "Never leave out the icon when there are several side by side: the glyph is how people tell them apart at a glance."
      ],
      "usage": {
        "imports": "import { FillSlider } from \"@/echo/fill-slider\";",
        "code": "<FillSlider label=\"Brightness\" defaultValue={70} />"
      },
      "composition": "FillSlider\n  the tube (Base UI slider, vertical): the fill, the glyph, the handle\n  the label",
      "a11y": [
        "A vertical `slider` named by `label`, with the value said as a percent of the range, or as `formatValue` says it.",
        "Up and Right raise it, Down and Left lower it, Page Up/Down move a tenth, Home and End go to the ends.",
        "Dragging anywhere on the tube is a pointer extra: everything it does, the keys do."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "Nothing animates: the fill follows the value directly."
      },
      "sound": [
        "A tap at each step, inside a SoundProvider."
      ],
      "feel": "A tube of light you push up and down with a thumb.",
      "api": {
        "FillSlider": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "formatValue",
            "icon",
            "id",
            "key",
            "label",
            "max",
            "min",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "step",
            "value"
          ]
        }
      },
      "weight": {
        "js": 595,
        "withDependencies": 14970,
        "css": 905
      }
    },
    {
      "name": "echo-patch-bay",
      "title": "PatchBay",
      "version": "0.3.0",
      "description": "Connecting things to things, the way a studio patches its gear: sockets down one side for what you have, sockets down the other for where it goes, and a cable between each connected pair. For mapping an import's columns to fields, routing events to channels, inputs to outputs. Each source has a Select that says where it goes, and that's the real control; the cables draw what the Selects say, and with a pointer you can drag one from socket to socket.",
      "summary": "Sockets on two sides and cables between: a Select per source is the control.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-select",
        "@echo/echo-status-dot"
      ],
      "install": "npx echo-ui-cli add patch-bay",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What's being connected: the group's accessible name (\"Import columns\")."
        },
        {
          "name": "sources",
          "type": "object[]",
          "required": true,
          "description": "What you have: the left column."
        },
        {
          "name": "targets",
          "type": "object[]",
          "required": true,
          "description": "Where it can go: the right column."
        },
        {
          "name": "sourcesLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"From\"",
          "description": "The heading over the sources."
        },
        {
          "name": "targetsLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"To\"",
          "description": "The heading over the targets."
        },
        {
          "name": "value",
          "type": "Record<string, unknown>",
          "required": false,
          "description": "Where each source goes: its target's value, or null for nowhere (controlled)."
        },
        {
          "name": "defaultValue",
          "type": "Record<string, unknown>",
          "required": false
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with where every source goes."
        }
      ],
      "examples": [
        {
          "title": "Map an import",
          "description": "The file's columns on the left, your fields on the right.",
          "code": "<PatchBay\n  label=\"Import columns\"\n  sourcesLabel=\"In your file\"\n  targetsLabel=\"Fields\"\n  sources={[{ value: \"e-mail\", label: \"E-mail\" }, { value: \"name\", label: \"Full name\" }]}\n  targets={[{ value: \"email\", label: \"Email\" }, { value: \"first\", label: \"First name\" }]}\n  defaultValue={{ \"e-mail\": \"email\" }}\n/>"
        },
        {
          "title": "Controlled",
          "description": "The mapping in your state.",
          "code": "<PatchBay label=\"Routing\" sources={events} targets={channels} value={routes} onValueChange={setRoutes} />"
        }
      ],
      "antiPatterns": [
        "Never use a PatchBay for a list with one obvious answer each: a column of Selects is plainer.",
        "Never make the cables the only way to connect: they draw the Selects; the Selects are the control."
      ],
      "usage": {
        "imports": "import { PatchBay } from \"@/echo/patch-bay\";",
        "code": "<PatchBay label=\"Import columns\" sources={[{ value: \"e-mail\", label: \"E-mail\" }]} targets={[{ value: \"email\", label: \"Email\" }]} />"
      },
      "composition": "PatchBay\n  the sources: a name, a Select (where it goes) and a socket per row\n  the targets: a socket and a name per row\n  the cables",
      "a11y": [
        "A group named by `label`. Each source has a Select named \"<source> goes to\", which is the whole control: keys, screen readers and forms use it.",
        "The target column and the cables are a picture of what the Selects say, hidden from assistive technology.",
        "Dragging a cable is a pointer extra; on a narrow screen the cables go and the Selects stay.",
        "`name` submits each connection as name[source] with a form.",
        "Keyboard: Tab reaches each source's Select, which opens with Enter, Space or the arrow keys and chooses with Enter, like any Select."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "Cables appear hanging at rest, without dropping or swaying."
      },
      "sound": [
        "The select click when a cable connects, and a tap when one comes out, inside a SoundProvider."
      ],
      "feel": "Plugging cables into a patch panel: they drop into place and sway once.",
      "api": {
        "PatchBay": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "id",
            "key",
            "label",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "readOnly",
            "ref",
            "required",
            "sources",
            "sourcesLabel",
            "targets",
            "targetsLabel",
            "value"
          ],
          "required": [
            "sources",
            "targets"
          ]
        }
      },
      "weight": {
        "js": 2775,
        "withDependencies": 78425,
        "css": 2017
      }
    },
    {
      "name": "echo-meter",
      "title": "Meter",
      "version": "0.3.0",
      "description": "A measurement within a known range (storage, battery, signal, a score), not a task's progress. A recessed tube with the primary plastic poured to the value; when the value changes the level travels there on a spring and settles with a small slosh. marks engrave levels on the tube without a second colour. Built on Base UI Meter: role=\"meter\" with the formatted value as aria-valuetext.",
      "summary": "A measurement in a known range: a tube of lit plastic that settles to its level.",
      "group": "Feedback",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add meter",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What it measures; the visible label and the accessible name."
        },
        {
          "name": "value",
          "type": "number",
          "required": true
        },
        {
          "name": "min",
          "type": "number",
          "required": false,
          "defaultValue": "0"
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "defaultValue": "100"
        },
        {
          "name": "format",
          "type": "unknown",
          "required": false,
          "description": "How the value reads: Intl.NumberFormatOptions ({ style: \"unit\", unit: \"gigabyte\" }). Default: percent of the range; `{ style: \"percent\" }` is the share of the range too, with your digits."
        },
        {
          "name": "marks",
          "type": "number[]",
          "required": false,
          "description": "Levels engraved on the tube (a limit, a target). Say what each means in words."
        },
        {
          "name": "showValue",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "The value as text beside the label."
        }
      ],
      "examples": [
        {
          "title": "Storage",
          "description": "A unit, and a limit engraved on the tube.",
          "code": "<Meter label=\"Storage\" value={42.5} max={64} marks={[56]} format={{ style: \"unit\", unit: \"gigabyte\", maximumFractionDigits: 1 }} />"
        },
        {
          "title": "Battery",
          "description": "The default reads as a percent.",
          "code": "<Meter label=\"Battery\" value={battery} />"
        }
      ],
      "antiPatterns": [
        "Never use a Meter for a task's progress: that's Progress.",
        "Never colour a Meter red when it's high: say it (\"almost full\") and, if it matters, raise a Banner.",
        "Never animate a Meter that changes many times a second: that's a Level meter."
      ],
      "usage": {
        "imports": "import { Meter } from \"@/echo/meter\";",
        "code": "<Meter label=\"Signal\" value={signal} />"
      },
      "composition": "Meter → a label, a reading and a tube (Base UI Meter)",
      "a11y": [
        "`role=\"meter\"` named by `label`, with min, max and the formatted value as `aria-valuetext`.",
        "It doesn't announce changes; announce a meaningful threshold (\"storage almost full\") separately.",
        "A mark has no colour of its own and no meaning without words: say what it is.",
        "In forced colors the fill is the system highlight inside an outlined tube."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The level jumps to the new value."
      },
      "feel": "A tube of lit plastic that settles to its level like a liquid: when the reading moves, you see it travel.",
      "api": {
        "Meter": {
          "props": [
            "format",
            "label",
            "marks",
            "max",
            "min",
            "showValue",
            "value"
          ],
          "required": [
            "value"
          ]
        }
      },
      "weight": {
        "js": 681,
        "withDependencies": 31521,
        "css": 765
      }
    },
    {
      "name": "echo-level-meter",
      "title": "LevelMeter",
      "version": "0.3.0",
      "description": "A live level (a microphone, a mix bus) as LED cells with real ballistics: it rises at once, falls back slowly, and holds its peak for a moment. Feed it a level as often as you like; it draws on animation frames without re-rendering and stops once it has settled. For assistive tech a meter whose value is rounded to tenths and updated at most once a second.",
      "summary": "A live level as LED cells with real ballistics and a held peak.",
      "group": "Objects",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add level-meter",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "What it measures; the accessible name, and the visible label unless hideLabel."
        },
        {
          "name": "level",
          "type": "number",
          "required": true,
          "description": "The level now, 0–1. Feed it as often as you like."
        },
        {
          "name": "cells",
          "type": "number",
          "required": false,
          "defaultValue": "16",
          "description": "How many LED cells the meter has. Default 16."
        },
        {
          "name": "orientation",
          "type": "\"vertical\" | \"horizontal\"",
          "required": false,
          "defaultValue": "\"vertical\"",
          "description": "vertical (the default) stands the cells in a column; horizontal lays them in a row."
        },
        {
          "name": "peakHold",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Hold the highest cell reached for a moment before it drops."
        },
        {
          "name": "hideLabel",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Keep the label for assistive tech only."
        }
      ],
      "examples": [
        {
          "title": "A microphone",
          "description": "Feed it the input level on every frame.",
          "code": "<LevelMeter label=\"Input\" level={micLevel} />"
        },
        {
          "title": "A stereo pair",
          "description": "Two meters, the second without its own label on screen.",
          "code": "<Stack direction=\"row\" gap={2}>\n  <LevelMeter label=\"Left\" level={left} />\n  <LevelMeter label=\"Right\" level={right} />\n</Stack>"
        }
      ],
      "antiPatterns": [
        "Never use a LevelMeter for a value that changes rarely: that's a Meter.",
        "Never colour the top cells red: the held peak is the warning; say \"clipping\" in words if it matters."
      ],
      "usage": {
        "imports": "import { LevelMeter } from \"@/echo/level-meter\";",
        "code": "<LevelMeter label=\"Input\" level={level} />"
      },
      "composition": "LevelMeter → a strip of LED cells (a role=\"meter\" element)",
      "a11y": [
        "`role=\"meter\"` named by `label`; its value is rounded to tenths and updated at most once a second, so a screen reader isn't flooded.",
        "It's a reading, never a control: nothing to focus.",
        "If a level crossing a threshold matters (\"clipping\"), say it in words as well."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "It shows the level as it is: no slow fall, no held peak."
      },
      "feel": "An instrument, not a chart: it jumps up with a loud sound and drifts down after it, with the last peak hanging on above.",
      "api": {
        "LevelMeter": {
          "props": [
            "cells",
            "hideLabel",
            "label",
            "level",
            "orientation",
            "peakHold"
          ],
          "required": [
            "level"
          ]
        }
      },
      "weight": {
        "js": 837,
        "withDependencies": 970,
        "css": 652
      }
    },
    {
      "name": "echo-combobox",
      "title": "Combobox",
      "version": "0.3.0",
      "description": "A Select you can type into, for a list long enough to search: people, countries, labels. Field's well holds the text; the list drops in Select's plastic with the same flowing highlight. multiple keeps each choice as a tag that buds into the well; creatable offers to add what was typed. Built on Base UI Combobox, which owns filtering, the listbox and every announcement.",
      "summary": "A Select you can type into; multiple keeps choices as tags; creatable adds what you typed.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size",
        "@echo/echo-select",
        "@echo/echo-tag"
      ],
      "install": "npx echo-ui-cli add combobox",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false
        },
        {
          "name": "autoComplete",
          "type": "string",
          "required": false,
          "description": "What the value is, for autofill and assistive tech — \"email\", \"one-time-code\". WCAG 1.3.5."
        },
        {
          "name": "inputMode",
          "type": "string",
          "required": false,
          "description": "Which on-screen keyboard a phone offers: \"numeric\", \"email\", \"tel\", …"
        },
        {
          "name": "maxLength",
          "type": "number",
          "required": false
        },
        {
          "name": "minLength",
          "type": "number",
          "required": false
        },
        {
          "name": "pattern",
          "type": "string",
          "required": false,
          "description": "A regular expression the value must match for the field to be valid."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "The visible label and the input's accessible name."
        },
        {
          "name": "options",
          "type": "object[]",
          "required": true,
          "description": "What can be chosen."
        },
        {
          "name": "multiple",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Several at once, each kept as a tag in the well."
        },
        {
          "name": "value",
          "type": "string | string[] | unknown",
          "required": false,
          "description": "Chosen value (single) or values (multiple), controlled."
        },
        {
          "name": "defaultValue",
          "type": "string | string[] | unknown",
          "required": false
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false,
          "description": "Called with the value (or null when cleared), or the values when multiple."
        },
        {
          "name": "creatable",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Offer to add what was typed when nothing matches; it's chosen at once."
        },
        {
          "name": "onCreate",
          "type": "function",
          "required": false,
          "description": "Hears the label of an option made with creatable."
        },
        {
          "name": "emptyText",
          "type": "unknown",
          "required": false,
          "description": "What the list says when nothing matches. Default \"No matches\"."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "container",
          "type": "unknown",
          "required": false,
          "description": "Where the list mounts; defaults to <body>."
        }
      ],
      "examples": [
        {
          "title": "Pick one",
          "description": "Type to narrow a long list.",
          "code": "<Combobox label=\"Country\" options={countries} placeholder=\"Start typing\" />"
        },
        {
          "title": "Several, as tags",
          "description": "Backspace or a tag's × takes one out.",
          "code": "<Combobox label=\"Assignees\" options={people} multiple defaultValue={[\"ada\"]} />"
        },
        {
          "title": "Labels you can add to",
          "description": "Nothing matches? Create it.",
          "code": "<Combobox label=\"Labels\" options={labels} multiple creatable onCreate={(label: string) => save(label)} />"
        }
      ],
      "antiPatterns": [
        "Never use a Combobox for a handful of options: that's Select, or Segmented for two to four.",
        "Never use it for free text: that's Field, or TagsInput for free-form tags."
      ],
      "usage": {
        "imports": "import { Combobox } from \"@/echo/combobox\";",
        "code": "<Combobox label=\"Country\" options={countries} />"
      },
      "composition": "Combobox → a label, Field's well (input, tags, chevron cap) and Select's popup (Base UI Combobox)",
      "a11y": [
        "The input is a combobox named by its visible label; the list is a listbox, and the active option follows the arrow keys.",
        "Enter chooses, Escape closes; typing filters and the number of matches is announced.",
        "Chosen tags are named, told \"Press Backspace or Delete to remove\", and reachable with Left Arrow from the start of the input; each × is a named button.",
        "The chevron is a named button that opens the list without typing."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-pop",
          "--echo-ease-pop"
        ],
        "reducedMotion": "Tags appear without budding; the list opens without unrolling."
      },
      "sound": [
        "`select` when a value is chosen, inside a SoundProvider."
      ],
      "feel": "Field's well that answers as you type; each thing you choose buds out of the text line as a tag.",
      "api": {
        "Combobox": {
          "props": [
            "autoComplete",
            "autoFocus",
            "container",
            "creatable",
            "defaultValue",
            "disabled",
            "emptyText",
            "id",
            "inputMode",
            "key",
            "label",
            "maxLength",
            "minLength",
            "multiple",
            "name",
            "onBlur",
            "onCreate",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "options",
            "pattern",
            "placeholder",
            "readOnly",
            "ref",
            "required",
            "size",
            "value"
          ],
          "required": [
            "options"
          ]
        }
      },
      "weight": {
        "js": 1630,
        "withDependencies": 82225,
        "css": 2438
      }
    },
    {
      "name": "echo-tags-input",
      "title": "TagsInput",
      "version": "0.3.0",
      "description": "Free-form tags typed into Field's well: addresses, keywords, labels. Enter or a comma turns the text into a tag that buds into the well; Backspace in an empty input takes the last back; a paste with commas becomes several. For a fixed list to choose from, use Combobox with multiple.",
      "summary": "Free-form tags typed into Field's well: Enter or a comma adds, Backspace removes.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-size",
        "@echo/echo-tag",
        "@echo/echo-combobox"
      ],
      "install": "npx echo-ui-cli add tags-input",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The field's name in a form submission, and the key react-hook-form registers."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false
        },
        {
          "name": "autoComplete",
          "type": "string",
          "required": false,
          "description": "What the value is, for autofill and assistive tech — \"email\", \"one-time-code\". WCAG 1.3.5."
        },
        {
          "name": "inputMode",
          "type": "string",
          "required": false,
          "description": "Which on-screen keyboard a phone offers: \"numeric\", \"email\", \"tel\", …"
        },
        {
          "name": "maxLength",
          "type": "number",
          "required": false
        },
        {
          "name": "minLength",
          "type": "number",
          "required": false
        },
        {
          "name": "pattern",
          "type": "string",
          "required": false,
          "description": "A regular expression the value must match for the field to be valid."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "The visible label and the input's accessible name."
        },
        {
          "name": "value",
          "type": "string[]",
          "required": false
        },
        {
          "name": "defaultValue",
          "type": "string[]",
          "required": false,
          "defaultValue": "[]"
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false
        },
        {
          "name": "normalize",
          "type": "function",
          "required": false,
          "description": "Turn typed text into a tag, or reject it by returning null: trim, lower-case, validate an address."
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "description": "The most tags it takes."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "defaultValue": "\"md\"",
          "description": "Height on the control scale."
        }
      ],
      "examples": [
        {
          "title": "Invite by email",
          "description": "normalize lower-cases and rejects what isn't an address.",
          "code": "<TagsInput\n  label=\"Invite\"\n  placeholder=\"name@example.com\"\n  normalize={(t: string) => (/^\\S+@\\S+\\.\\S+$/.test(t.trim()) ? t.trim().toLowerCase() : null)}\n/>"
        },
        {
          "title": "Keywords",
          "description": "Posted with a form, one value per tag.",
          "code": "<TagsInput label=\"Keywords\" name=\"keywords\" defaultValue={[\"analog\", \"tape\"]} max={8} />"
        }
      ],
      "antiPatterns": [
        "Never use it when the values come from a known list: that's Combobox with multiple.",
        "Never accept anything without normalize when the tags must be valid (addresses, IDs)."
      ],
      "usage": {
        "imports": "import { TagsInput } from \"@/echo/tags-input\";",
        "code": "<TagsInput label=\"Keywords\" />"
      },
      "composition": "TagsInput → a label, Field's well with a list of Tags and an input",
      "a11y": [
        "The input is named by the visible label and described by how to add and remove.",
        "The tags are a named list; each × is a button that says what it removes.",
        "Additions and removals are announced politely.",
        "With `name`, each tag is posted as its own form value.",
        "Keyboard: Enter or a comma adds what's typed as a tag; Backspace in the empty input takes the last tag back; Tab reaches each ×."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-pop",
          "--echo-ease-pop"
        ],
        "reducedMotion": "Tags appear without budding."
      },
      "sound": [
        "`tap` when a tag is added, inside a SoundProvider."
      ],
      "feel": "You type and commit, and each word buds out of the line into its own tag.",
      "api": {
        "TagsInput": {
          "props": [
            "autoComplete",
            "autoFocus",
            "defaultValue",
            "disabled",
            "id",
            "inputMode",
            "key",
            "label",
            "max",
            "maxLength",
            "minLength",
            "name",
            "normalize",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "pattern",
            "placeholder",
            "readOnly",
            "ref",
            "required",
            "size",
            "value"
          ]
        }
      },
      "weight": {
        "js": 1290,
        "withDependencies": 1596,
        "css": 1871
      }
    },
    {
      "name": "echo-fieldset",
      "title": "Fieldset",
      "version": "0.3.0",
      "description": "A named group of fields (a shipping address, notification settings): a legend in the section voice, an optional description, and the fields under it. disabled turns off every control inside. Built on Base UI Fieldset.",
      "summary": "A named group of fields with a legend.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add fieldset",
      "props": [
        {
          "name": "legend",
          "type": "string",
          "required": true,
          "description": "The group's name, said before each field inside."
        },
        {
          "name": "description",
          "type": "unknown",
          "required": false,
          "description": "A line under the legend."
        },
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "The fields."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Disable every control inside at once."
        }
      ],
      "examples": [
        {
          "title": "An address",
          "description": "The legend is said before each field.",
          "code": "<Fieldset legend=\"Shipping address\">\n  <Field label=\"Street\" name=\"street\" />\n  <Field label=\"City\" name=\"city\" />\n</Fieldset>"
        }
      ],
      "antiPatterns": [
        "Never use a Fieldset for a single field: its label is enough.",
        "Never put a Fieldset's legend in a heading instead: the legend is what ties the name to the fields."
      ],
      "usage": {
        "imports": "import { Fieldset } from \"@/echo/fieldset\";",
        "code": "<Fieldset legend=\"Contact\">{fields}</Fieldset>"
      },
      "composition": "Fieldset → a fieldset with a legend (Base UI Fieldset)",
      "a11y": [
        "A real `<fieldset>` with a `<legend>`: screen readers give the group's name with the first field they reach in it.",
        "`disabled` disables every control inside, the way a native fieldset does."
      ],
      "feel": "A ruled section of a form with its name at the top.",
      "api": {
        "Fieldset": {
          "props": [
            "children",
            "description",
            "disabled",
            "legend"
          ],
          "required": [
            "children",
            "legend"
          ]
        }
      },
      "weight": {
        "js": 258,
        "withDependencies": 2584,
        "css": 317
      }
    },
    {
      "name": "echo-checkbox-group",
      "title": "CheckboxGroup",
      "version": "0.3.0",
      "description": "A named set of checkboxes sharing one value, the list of what's checked. Each option is a row, the checkbox and its words, the whole row a target. parent adds a checkbox that checks or clears them all and shows a dash when only some are on. Built on Base UI CheckboxGroup with Echo's Checkbox.",
      "summary": "A named set of checkboxes sharing one value, with an optional parent.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-checkbox"
      ],
      "install": "npx echo-ui-cli add checkbox-group",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "Posted with a form, one value per checked box."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "The group's name, shown above it."
        },
        {
          "name": "options",
          "type": "object[]",
          "required": true,
          "description": "One checkbox each: its words, and a line under them."
        },
        {
          "name": "value",
          "type": "string[]",
          "required": false
        },
        {
          "name": "defaultValue",
          "type": "string[]",
          "required": false
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false
        },
        {
          "name": "parent",
          "type": "string",
          "required": false,
          "description": "A checkbox above the rest that checks or clears them all, and shows a dash when only some are on."
        }
      ],
      "examples": [
        {
          "title": "Notifications",
          "description": "With a parent that sets them all.",
          "code": "<CheckboxGroup\n  label=\"Email me about\"\n  parent=\"Everything\"\n  options={[\n    { value: \"mentions\", label: \"Mentions\" },\n    { value: \"replies\", label: \"Replies\" },\n    { value: \"digest\", label: \"Weekly digest\", description: \"Mondays, 9am\" },\n  ]}\n  defaultValue={[\"mentions\"]}\n/>"
        }
      ],
      "antiPatterns": [
        "Never use a CheckboxGroup where only one can be chosen: that's RadioGroup.",
        "Never leave a parent without saying what it covers (\"Everything\", \"All channels\")."
      ],
      "usage": {
        "imports": "import { CheckboxGroup } from \"@/echo/checkbox-group\";",
        "code": "<CheckboxGroup label=\"Show\" options={options} />"
      },
      "composition": "CheckboxGroup → a named group of Checkbox rows (Base UI CheckboxGroup)",
      "a11y": [
        "A group named by its visible label; each checkbox is named by its row's words.",
        "The whole row toggles its box; Space toggles a focused box.",
        "The parent checkbox is `mixed` (a dash) while only some are checked."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "The tick appears without being drawn."
      },
      "sound": [
        "Each checkbox's `select`, inside a SoundProvider."
      ],
      "feel": "A column of small keys that latch with a drawn tick, each with its words beside it.",
      "api": {
        "CheckboxGroup": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "id",
            "key",
            "label",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "options",
            "parent",
            "readOnly",
            "ref",
            "required",
            "value"
          ],
          "required": [
            "options"
          ]
        }
      },
      "weight": {
        "js": 1425,
        "withDependencies": 40336,
        "css": 1122
      }
    },
    {
      "name": "echo-form",
      "title": "Form",
      "version": "0.3.0",
      "description": "A form that fails well. Give it errors (field name → message) and pass the same message to each field's error. When a submit comes back with errors, a summary opens at the top: how many things need fixing, each a link that takes focus to its field, and the summary takes focus so it's heard first. A native form (noValidate: you validate in onSubmit), whose values arrive as an object.",
      "summary": "A form that fails well: a summary of what to fix, each a link to its field.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add form",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "The fields, fieldsets and the submit button."
        },
        {
          "name": "onSubmit",
          "type": "function",
          "required": false,
          "description": "Called with the form's values by field name; a name used more than once gives an array. The browser doesn't validate (noValidate): do it here."
        },
        {
          "name": "errors",
          "type": "Record<string, unknown>",
          "required": false,
          "description": "Field name → what's wrong: from your validation or your server. Pass the same message to each field's own error."
        },
        {
          "name": "showSummary",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Show the summary: true after a submit that came back with errors, false once they're fixed."
        },
        {
          "name": "summaryTitle",
          "type": "string",
          "required": false,
          "description": "The summary's heading. Default \"There's a problem\" / \"There are N problems\"."
        },
        {
          "name": "aria-label",
          "type": "string",
          "required": false,
          "description": "Names the form, so it's listed as a landmark."
        }
      ],
      "examples": [
        {
          "title": "The validation pattern",
          "description": "Validate on submit; the same errors go to the fields and the summary.",
          "code": "const [errors, setErrors] = useState({} as Record<string, string>);\nconst [tried, setTried] = useState(false);\n<Form\n  errors={errors}\n  showSummary={tried}\n  onSubmit={(values) => {\n    const next = validate(values);\n    setErrors(next);\n    setTried(Object.keys(next).length > 0);\n  }}\n>\n  <Field id=\"email\" name=\"email\" label=\"Email\" error={errors.email} />\n  <HardwareButton type=\"submit\" variant=\"primary\">Create account</HardwareButton>\n</Form>"
        }
      ],
      "antiPatterns": [
        "Never validate as someone types their first character: validate on submit, then re-check as they fix.",
        "Never show the summary without the same message on the field: people arrive at fields, not only at the summary.",
        "Never disable the submit button until the form is valid: nobody can find out why it won't go."
      ],
      "usage": {
        "imports": "import { Form } from \"@/echo/form\";",
        "code": "<Form onSubmit={save}>{fields}</Form>"
      },
      "composition": "Form → a native form, with a summary (a list of links to the invalid fields) when a submit fails",
      "a11y": [
        "The summary is an alert that takes focus when it opens, so a screen reader hears how many problems there are first.",
        "Each problem is a link; following it moves focus to that field.",
        "Every field still shows its own message and is marked invalid, so a person who skips the summary finds the problem where it is.",
        "Give the form an aria-label and it's listed as a landmark.",
        "Keyboard: each problem in the summary is a Tab stop; Enter on it moves focus to that field."
      ],
      "feel": "When it fails, it tells you once, at the top, how many things need fixing, and takes you to each one.",
      "api": {
        "Form": {
          "props": [
            "children",
            "errors",
            "onSubmit",
            "showSummary",
            "summaryTitle"
          ],
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 774,
        "withDependencies": 774,
        "css": 640
      }
    },
    {
      "name": "echo-hold-button",
      "title": "HoldButton",
      "version": "0.3.0",
      "description": "Confirm by holding: the primary plastic fills the cap while you press, drains if you let go early, and the action happens when it's full. For the moments a second tap would be too easy. Space and Enter hold it too; a single press that can't hold (assistive tech) arms it instead, and a second press confirms. ConfirmButton is the two-press alternative.",
      "summary": "Confirm by holding: the cap fills while pressed and drains if let go.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-size"
      ],
      "install": "npx echo-ui-cli add hold-button",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "What it does: \"Delete project\"."
        },
        {
          "name": "onConfirm",
          "type": "function",
          "required": true,
          "description": "Called once, when the hold completes (or the armed second press lands)."
        },
        {
          "name": "duration",
          "type": "number",
          "required": false,
          "defaultValue": "1200",
          "description": "How long to hold, ms."
        },
        {
          "name": "doneLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"Done\"",
          "description": "What it says once done, for a moment."
        },
        {
          "name": "armedLabel",
          "type": "string",
          "required": false,
          "defaultValue": "\"Press again to confirm\"",
          "description": "What it says when a single press (from assistive tech) arms it."
        },
        {
          "name": "variant",
          "type": "\"primary\" | \"neutral\"",
          "required": false,
          "defaultValue": "\"neutral\"",
          "description": "primary for the one main action, neutral (the default) for the rest, as on HardwareButton."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Height of its cap on the control scale: sm 32px, md 44px, lg 60px, as a HardwareButton of that size. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "icon",
          "type": "unknown",
          "required": false,
          "description": "A leading icon (a ReactNode)."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false"
        }
      ],
      "examples": [
        {
          "title": "End for everyone",
          "description": "Hold until the cap is full.",
          "code": "<HoldButton variant=\"primary\" onConfirm={endCall}>End for everyone</HoldButton>"
        }
      ],
      "antiPatterns": [
        "Never use a HoldButton for a routine action: holding is a cost, keep it for what's hard to undo.",
        "Never mix HoldButton and ConfirmButton in one product: pick one way to confirm."
      ],
      "usage": {
        "imports": "import { HoldButton } from \"@/echo/hold-button\";",
        "code": "<HoldButton onConfirm={remove}>Delete project</HoldButton>"
      },
      "composition": "HoldButton → a HardwareButton whose fill is the hold",
      "a11y": [
        "A real button. Space and Enter hold it like a pointer; a held key is one hold.",
        "Assistive tech that can only click arms it: it says \"Press again to confirm\" (announced), and the next press within five seconds confirms.",
        "The label says what happens, and the result (\"Done\") is announced.",
        "Nothing is ever confirmed by a single tap."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "The fill still shows the hold: it's the progress, not decoration."
      },
      "sound": [
        "The cap's own press, and a rising note when it's done, inside a SoundProvider."
      ],
      "feel": "You lean on it and it fills under your finger; let go and it drains away, as if nothing happened.",
      "api": {
        "HoldButton": {
          "props": [
            "armedLabel",
            "children",
            "disabled",
            "doneLabel",
            "duration",
            "icon",
            "onConfirm",
            "size",
            "variant"
          ],
          "required": [
            "children",
            "onConfirm"
          ]
        }
      },
      "weight": {
        "js": 1692,
        "withDependencies": 31820,
        "css": 2387
      }
    },
    {
      "name": "echo-choice-cards",
      "title": "ChoiceCards",
      "version": "0.3.0",
      "description": "Choosing between things that need more than a word (a plan, a template, an account): each option is a card and the whole card is the choice. The chosen one lights a rail along its edge and its pip; nothing else changes colour. One choice is a radio group; multiple is a group of checkboxes. Built on Base UI RadioGroup and CheckboxGroup.",
      "summary": "Options that need more than a word, as cards; the chosen one lights its edge.",
      "group": "Forms",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-panel"
      ],
      "install": "npx echo-ui-cli add choice-cards",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "The element's id, so a <label for> or an aria-describedby can reach it."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "Posted with a form."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "readOnly",
          "type": "boolean",
          "required": false
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "description": "Focus this control when it mounts. One per page, at most."
        },
        {
          "name": "onBlur",
          "type": "function",
          "required": false,
          "description": "Fired on focus loss. react-hook-form validates onTouched and onBlur with it."
        },
        {
          "name": "onFocus",
          "type": "function",
          "required": false,
          "description": "Fired when the control takes focus."
        },
        {
          "name": "onKeyDown",
          "type": "function",
          "required": false,
          "description": "Keyboard handling you own — Enter to send, Escape to clear. Runs before Echo's."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "The group's name, shown above it."
        },
        {
          "name": "options",
          "type": "object[]",
          "required": true,
          "description": "One card each: a title, a line, and anything else (a price, a preview)."
        },
        {
          "name": "multiple",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Several at once (checkboxes). Default one (radios)."
        },
        {
          "name": "value",
          "type": "string | string[]",
          "required": false
        },
        {
          "name": "defaultValue",
          "type": "string | string[]",
          "required": false
        },
        {
          "name": "onValueChange",
          "type": "function",
          "required": false
        },
        {
          "name": "minCardWidth",
          "type": "string",
          "required": false,
          "defaultValue": "\"14rem\"",
          "description": "How narrow a card may get before they wrap. Fewer cards than fit share the row."
        }
      ],
      "examples": [
        {
          "title": "Plans",
          "description": "One of three, with prices.",
          "code": "<ChoiceCards\n  label=\"Plan\"\n  defaultValue=\"team\"\n  options={[\n    { value: \"solo\", title: \"Solo\", description: \"One seat\", extra: \"$0\" },\n    { value: \"team\", title: \"Team\", description: \"Up to 10 seats\", extra: \"$12 / seat\" },\n    { value: \"studio\", title: \"Studio\", description: \"Unlimited seats\", extra: \"$240\" },\n  ]}\n/>"
        }
      ],
      "antiPatterns": [
        "Never put a link or button inside a choice card: the whole card is already the control.",
        "Never use ChoiceCards for short labels: that's RadioGroup or Segmented."
      ],
      "usage": {
        "imports": "import { ChoiceCards } from \"@/echo/choice-cards\";",
        "code": "<ChoiceCards label=\"Template\" options={templates} />"
      },
      "composition": "ChoiceCards → Panels as Base UI Radio (or Checkbox) items in a group",
      "a11y": [
        "One choice is a radio group: one tab stop, arrow keys move and choose. `multiple` is a group of checkboxes, each its own tab stop.",
        "Each card is named by all its text, title first.",
        "Chosen is shown by the rail and the mark, not colour alone; in forced colors, by a heavy system-highlight border."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-pop",
          "--echo-ease-pop"
        ],
        "reducedMotion": "The pip appears without dropping in; the card doesn't sink."
      },
      "sound": [
        "`select` on a choice, inside a SoundProvider."
      ],
      "feel": "A row of slabs you pick up by touching; the chosen one has a light running down its edge.",
      "api": {
        "ChoiceCards": {
          "props": [
            "autoFocus",
            "defaultValue",
            "disabled",
            "id",
            "key",
            "label",
            "minCardWidth",
            "multiple",
            "name",
            "onBlur",
            "onFocus",
            "onKeyDown",
            "onValueChange",
            "options",
            "readOnly",
            "ref",
            "required",
            "value"
          ],
          "required": [
            "options"
          ]
        }
      },
      "weight": {
        "js": 889,
        "withDependencies": 15319,
        "css": 1475
      }
    },
    {
      "name": "echo-side-nav",
      "title": "SideNav",
      "version": "0.3.0",
      "description": "The navigation down the side of an app: groups under machine-voice headings, items with an icon and a count, nested items that fold away (and open by themselves around the current page), and one lit rail that travels to where you are as a drop. collapsed folds it to its icons. Closed and data-driven; renderLink hands links to your router.",
      "summary": "The navigation down the side of an app, with one lit rail that travels to where you are.",
      "group": "Navigation",
      "type": "registry:component",
      "dependencies": [
        "@base-ui/react",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add side-nav",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "Names the navigation landmark (\"Main\", \"Workspace\")."
        },
        {
          "name": "groups",
          "type": "object[]",
          "required": true,
          "description": "Groups of items: each a label, an href, an icon, a badge count, or nested items that fold open."
        },
        {
          "name": "current",
          "type": "string",
          "required": false,
          "description": "The href of the page you're on: it gets aria-current and the rail."
        },
        {
          "name": "collapsed",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Folded to its icons; labels show as tips on hover or focus."
        },
        {
          "name": "onNavigate",
          "type": "function",
          "required": false,
          "description": "Called after a link is followed (close a mobile sheet here)."
        },
        {
          "name": "renderLink",
          "type": "function",
          "required": false,
          "description": "Render each link through your router: (props) => <NextLink {...props} />."
        }
      ],
      "examples": [
        {
          "title": "An app's sidebar",
          "description": "Groups, icons, counts and a nested section.",
          "code": "<SideNav\n  label=\"Main\"\n  current=\"/inbox\"\n  groups={[\n    { items: [{ label: \"Inbox\", href: \"/inbox\", badge: 3 }, { label: \"Drafts\", href: \"/drafts\" }] },\n    { label: \"Projects\", items: [{ label: \"Echo\", items: [{ label: \"Board\", href: \"/echo/board\" }, { label: \"Docs\", href: \"/echo/docs\" }] }] },\n  ]}\n/>"
        }
      ],
      "antiPatterns": [
        "Never nest more than two levels: past that it's a Tree, not navigation.",
        "Never put actions (buttons that don't navigate) in the list; they belong in a toolbar or the page."
      ],
      "usage": {
        "imports": "import { SideNav } from \"@/echo/side-nav\";",
        "code": "<SideNav label=\"Main\" groups={groups} current={pathname} />"
      },
      "composition": "SideNav → a nav landmark of grouped lists, nested groups as Collapsibles, one travelling rail",
      "a11y": [
        "A `nav` landmark named by `label`; each group is a list named by its heading.",
        "The current link has `aria-current=\"page\"`; the rail is decoration.",
        "A nested group is a button with `aria-expanded`; it opens by itself when it holds the current page.",
        "Folded, every link keeps its name; the tip shows it on hover and on keyboard focus.",
        "A badge is read as \"3 new\"."
      ],
      "motion": {
        "tokens": [
          "--echo-duration-pop",
          "--echo-ease-pop"
        ],
        "reducedMotion": "The rail jumps to the current link; folding and opening are instant."
      },
      "sound": [
        "`tap` when a link is followed, inside a SoundProvider."
      ],
      "feel": "A column of quiet links with one light down its edge; move and the light runs to where you went.",
      "api": {
        "SideNav": {
          "props": [
            "collapsed",
            "current",
            "groups",
            "label",
            "onNavigate",
            "renderLink"
          ],
          "required": [
            "groups"
          ]
        }
      },
      "weight": {
        "js": 1196,
        "withDependencies": 8653,
        "css": 1515
      }
    },
    {
      "name": "echo-tab-bar",
      "title": "TabBar",
      "version": "0.3.0",
      "description": "A phone app's bottom navigation as a dock: a molded slab floating clear of the screen's edges, a recessed track along it, and one raised cap seated under the destination you're on. Every destination has an LED socket; the current one's is lit, and its icon with it. Go somewhere else and the cap lifts, travels there on a spring and settles back in. A count on any with news, clear of the home indicator. Four or five destinations; more belong in SideNav.",
      "summary": "A phone app's bottom navigation: a floating molded dock whose cap travels to the place you're on.",
      "group": "Navigation",
      "type": "registry:component",
      "dependencies": [
        "gsap",
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add tab-bar",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "Names the navigation landmark."
        },
        {
          "name": "items",
          "type": "object[]",
          "required": true,
          "description": "Four or five destinations: a label, an href, an icon, a count."
        },
        {
          "name": "current",
          "type": "string",
          "required": false,
          "description": "The href of the page you're on."
        },
        {
          "name": "fixed",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Pinned to the bottom of the viewport, or in the flow."
        },
        {
          "name": "renderLink",
          "type": "function",
          "required": false,
          "description": "Render each link through your router."
        }
      ],
      "examples": [
        {
          "title": "Four destinations",
          "description": "The current one is lit.",
          "code": "<TabBar label=\"Main\" current=\"/today\" items={[\n  { label: \"Today\", href: \"/today\", icon: <PlayIcon /> },\n  { label: \"Library\", href: \"/library\", icon: <PlayIcon /> },\n  { label: \"Inbox\", href: \"/inbox\", icon: <PlayIcon />, badge: 2 },\n  { label: \"You\", href: \"/you\", icon: <PlayIcon /> },\n]} />"
        }
      ],
      "antiPatterns": [
        "Never put more than five destinations in a TabBar.",
        "Never use icons without labels: a row of icons is a guessing game.",
        "Never put actions in it (a compose button): that's a floating HardwareButton, not a place."
      ],
      "usage": {
        "imports": "import { TabBar } from \"@/echo/tab-bar\";",
        "code": "<TabBar label=\"Main\" items={items} current={pathname} />"
      },
      "composition": "TabBar → a nav landmark with a list of links",
      "a11y": [
        "A `nav` landmark named by `label`; the current link has `aria-current=\"page\"`.",
        "Each destination is a link at least 56px tall, labelled by its words (the icon is decoration).",
        "A count is read as \"2 new\".",
        "Pinned to the bottom, it keeps clear of the home indicator (the safe-area inset, which a phone reports once the page's viewport meta has `viewport-fit=cover`)."
      ],
      "motion": {
        "tokens": [
          "springs",
          "--echo-duration-swap"
        ],
        "reducedMotion": "The cap moves straight to the new place, with no lift or travel, and a pressed link doesn't sink."
      },
      "sound": [
        "`tap` on a destination, inside a SoundProvider."
      ],
      "feel": "A dock under the thumb: the cap you're sitting in lifts out of its track and slides to where you go.",
      "api": {
        "TabBar": {
          "props": [
            "current",
            "fixed",
            "items",
            "label",
            "renderLink"
          ],
          "required": [
            "items"
          ]
        }
      },
      "weight": {
        "js": 1388,
        "withDependencies": 30630,
        "css": 1779
      }
    },
    {
      "name": "echo-notification-center",
      "title": "NotificationCenter",
      "version": "0.3.0",
      "description": "An inbox of what happened: a bell key with the unread count, opening a panel of notifications as Items, newest first, each with an LED lit while unread. Opening one marks it read; \"Mark all read\" clears them. Where a toast goes after it's gone.",
      "summary": "A bell with the unread count, opening an inbox of notifications.",
      "group": "Feedback",
      "type": "registry:component",
      "dependencies": [],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-popover",
        "@echo/echo-count-badge",
        "@echo/echo-item"
      ],
      "install": "npx echo-ui-cli add notification-center",
      "props": [
        {
          "name": "notifications",
          "type": "object[]",
          "required": true,
          "description": "Newest first: a title, a line, when, whether it's read, where it goes."
        },
        {
          "name": "onOpen",
          "type": "function",
          "required": false,
          "description": "Called with the id when one is opened; mark it read here."
        },
        {
          "name": "onReadAll",
          "type": "function",
          "required": false,
          "description": "Called by \"Mark all read\"."
        },
        {
          "name": "emptyText",
          "type": "unknown",
          "required": false,
          "description": "What an empty inbox says. Default \"Nothing new.\""
        }
      ],
      "examples": [
        {
          "title": "In a top bar",
          "description": "The count is on the bell and in its name.",
          "code": "<NotificationCenter\n  notifications={items}\n  onOpen={(id: string) => markRead(id)}\n  onReadAll={markAllRead}\n/>"
        }
      ],
      "antiPatterns": [
        "Never make the notification center the only record of something important: link to where it lives.",
        "Never show an unread count that never goes down: opening must mark read."
      ],
      "usage": {
        "imports": "import { NotificationCenter } from \"@/echo/notification-center\";",
        "code": "<NotificationCenter notifications={items} onReadAll={markAllRead} />"
      },
      "composition": "NotificationCenter → a HardwareButton trigger with a CountBadge, a Popover with a list of Items",
      "a11y": [
        "The bell is a button named with the count (\"Notifications, 3 unread\"); the badge on it is decoration.",
        "The panel is a titled Popover; focus moves into it and Escape returns to the bell.",
        "Each notification is one link or button; unread ones say \"(unread)\"."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "The panel appears without blooming (Popover's rule)."
      },
      "feel": "A small bell with a lit number on it; open it and the unread ones glow.",
      "api": {
        "NotificationCenter": {
          "props": [
            "emptyText",
            "notifications",
            "onOpen",
            "onReadAll"
          ],
          "required": [
            "notifications"
          ]
        }
      },
      "weight": {
        "js": 3491,
        "withDependencies": 74776,
        "css": 3671
      }
    },
    {
      "name": "echo-pull-to-refresh",
      "title": "PullToRefresh",
      "version": "0.3.0",
      "description": "A scrolling list you pull down from the top to reload: the content follows your finger with a rubber band, a molded key turns as you pull and clicks at the detent, and the list holds there with the Loader's lights until onRefresh settles. A real Refresh button and one status make it work without the gesture.",
      "summary": "A list you pull down to reload: a rubber band, a key that clicks at the detent, and a real Refresh button beside the gesture.",
      "group": "Layout",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-loader"
      ],
      "install": "npx echo-ui-cli add pull-to-refresh",
      "props": [
        {
          "name": "onRefresh",
          "type": "function",
          "required": true,
          "description": "Reload. The list holds at the detent, with the Loader's lights, until the promise settles."
        },
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "The scrolling content: usually a list of Items."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "defaultValue": "\"list\"",
          "description": "What is refreshed, for the button and the status: \"tasks\" gives \"Refresh tasks\" and \"Tasks up to date\"."
        },
        {
          "name": "threshold",
          "type": "number",
          "required": false,
          "defaultValue": "64",
          "description": "How far to pull, in px, before letting go refreshes."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Turn the gesture off. The Refresh button still works."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "description": "A class on the scroller."
        }
      ],
      "examples": [
        {
          "title": "A list that reloads",
          "description": "Give it the height to scroll in; it holds at the detent until your promise settles.",
          "code": "<div style={{ height: 480 }}>\n  <PullToRefresh label=\"tasks\" onRefresh={() => refetch()}>\n    <Stack as=\"ul\" gap={0}>{items}</Stack>\n  </PullToRefresh>\n</div>"
        }
      ],
      "antiPatterns": [
        "Do not make pull-to-refresh the only way to reload. The component has the button; don't hide it with CSS.",
        "Do not put it around a page that already scrolls: it is the scroller, so give it a height (a flex child, a fixed-height app body).",
        "Do not refresh on a timer and also ask people to pull. Pick one: a pull that does nothing new teaches people it is broken."
      ],
      "usage": {
        "imports": "import { PullToRefresh } from \"@/echo/pull-to-refresh\";",
        "code": "<PullToRefresh label=\"tasks\" onRefresh={refetch}>{list}</PullToRefresh>"
      },
      "composition": "PullToRefresh (the scroll region)\n  Refresh button (shown on focus)\n  status\n  indicator key → Loader while refreshing\n  your content",
      "a11y": [
        "The pull is a gesture, so it is never the only way: the region's first tab stop is a real \"Refresh {label}\" button, visually hidden until it has keyboard focus.",
        "One status, on the page from the start, says \"Refreshing tasks…\" and then \"Tasks up to date\"; the indicator itself is decoration.",
        "Only a downward pull from the very top counts; a sideways drag (a row's swipe actions) or an upward one (a scroll) is left alone.",
        "A pull that ends over a row doesn't click the row."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "The content still follows the finger (that is the gesture), but it jumps home and to the detent instead of springing."
      },
      "sound": [
        "`select` as the pull passes the detent, `notify` when the refresh finishes, inside a SoundProvider."
      ],
      "feel": "The top of the list is on a spring: pull it and it stretches, a key rises in the gap and clicks when you've pulled far enough, and it holds there, lit, until the new rows land.",
      "api": {
        "PullToRefresh": {
          "props": [
            "children",
            "className",
            "disabled",
            "label",
            "onRefresh",
            "threshold"
          ],
          "required": [
            "children",
            "onRefresh"
          ]
        }
      },
      "weight": {
        "js": 1777,
        "withDependencies": 29972,
        "css": 1259
      }
    },
    {
      "name": "echo-console",
      "title": "Console screen",
      "version": "0.3.0",
      "description": "A device dashboard as a front panel: AppShell, six StatTile readouts, an LED level matrix in a Panel, status lights, a takes Table and Pagination. Proves density and data at scale — whether the material language still holds when nothing on the screen has room.",
      "summary": "A device dashboard as a front panel: AppShell, six StatTile readouts, an LED level matrix, a takes Table and Pagination. Proves density and data at scale.",
      "group": "Templates",
      "type": "registry:block",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-app-shell",
        "@echo/echo-panel",
        "@echo/echo-table",
        "@echo/echo-stat-tile",
        "@echo/echo-lcd-chip",
        "@echo/echo-status-dot",
        "@echo/echo-chart",
        "@echo/echo-badge",
        "@echo/echo-menu",
        "@echo/echo-segmented",
        "@echo/echo-engraved-plate",
        "@echo/echo-pagination"
      ],
      "install": "npx echo-ui-cli add console",
      "props": [
        {
          "name": "embedded",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "This screen is inside another page: the title drops to <h2> and AppShell drops its landmarks, so the page keeps one <h1> and one <main>."
        },
        {
          "name": "product",
          "type": "string",
          "required": false,
          "defaultValue": "\"ECHO ONE\"",
          "description": "Name on the rail and the plate."
        },
        {
          "name": "takes",
          "type": "object[]",
          "required": false,
          "description": "Table rows. Defaults to a demo session."
        },
        {
          "name": "levels",
          "type": "number[]",
          "required": false,
          "description": "Level history, oldest first, on a fixed 0..100 scale."
        },
        {
          "name": "channels",
          "type": "string[]",
          "required": false,
          "description": "Channels down the left rail."
        }
      ],
      "examples": [
        {
          "title": "With your own data",
          "description": "Every list is a prop; the screen owns only which channel and page are selected.",
          "code": "<ConsoleScreen product=\"STUDIO A\" takes={takes} levels={levels} channels={channels} />"
        }
      ],
      "antiPatterns": [
        "Do not embed this screen in another page without `embedded`. It owns the page's <h1>, so a preview or a marketing page that renders it ends up with two first-level headings, and a screen-reader user's outline of the page stops making sense.",
        "Do not let the level matrix rescale itself. Without a fixed `max` the ceiling is the largest value present, so a new peak silently shrinks all the history behind it — the one thing a live readout must not do.",
        "Do not report a take's state with the badge colour alone. The badge carries the word; colour alone is lost on anyone with a colour-vision deficiency, on a screen reader, and in greyscale.",
        "Do not put the session's destructive actions in the rail menu without a confirm. A menu item fires on the first click — send anything that discards a take through an AlertDialog.",
        "Do not add axes and tooltips to the Chart to make this feel more like a dashboard. It is a device readout; the moment it needs hit-testing, the screen wants a real charting library beside it, not a heavier Chart."
      ],
      "usage": {
        "imports": "import { ConsoleScreen } from \"@/echo/console\";",
        "code": "<ConsoleScreen product=\"STUDIO A\" takes={takes} levels={levels} channels={channels} />"
      },
      "composition": "ConsoleScreen\n  AppShell\n  EngravedPlate\n  StatusDot\n  Segmented\n  Panel\n  StatTile\n  LcdChip\n  Chart\n  Table\n  Badge\n  Menu\n  Pagination",
      "a11y": [
        "Built on AppShell, so it gets its landmarks and skip link.",
        "The selected channel carries `aria-current`, not only a lit rail.",
        "The readouts are named groups and the chart is an image with a spoken summary; the takes are a real table.",
        "The screen owns the page's `<h1>`. Inside another page, pass `embedded`: headings drop a level and AppShell drops its landmarks."
      ],
      "feel": "A dense front panel: six readouts, a meter, a channel list — and the material still holds when nothing has room.",
      "api": {
        "ConsoleScreen": {
          "props": [
            "channels",
            "embedded",
            "levels",
            "product",
            "takes"
          ]
        }
      },
      "weight": {
        "js": 12206,
        "withDependencies": 102867,
        "css": 6977
      }
    },
    {
      "name": "echo-chat",
      "title": "Chat screen",
      "version": "0.3.0",
      "description": "One conversation as a front panel: an Avatar and StatusDot header, a message log, a Loader typing indicator, and a Textarea composer with one lit Send cap. Proves that the material language survives running text — incoming messages get no container, yours sit in the composer's recessed well, and the only raised object is Send.",
      "summary": "One conversation as a front panel: Avatar + StatusDot header, a message log, a Loader typing indicator, and a Textarea composer with one lit Send cap. Proves the material language survives running text.",
      "group": "Templates",
      "type": "registry:block",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-avatar",
        "@echo/echo-textarea",
        "@echo/echo-hardware-button",
        "@echo/echo-loader",
        "@echo/echo-status-dot",
        "@echo/echo-kbd"
      ],
      "install": "npx echo-ui-cli add chat",
      "props": [
        {
          "name": "embedded",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "This screen is inside another page: the title drops to <h2>, so the page keeps one <h1>. Never set it on the screen that owns the page."
        },
        {
          "name": "participant",
          "type": "string",
          "required": false,
          "defaultValue": "\"Echo\"",
          "description": "Who you are talking to — the header name and the avatar."
        },
        {
          "name": "subtitle",
          "type": "string",
          "required": false,
          "defaultValue": "\"Session assistant\"",
          "description": "Under the name: a role, a channel, a device."
        },
        {
          "name": "messages",
          "type": "object[]",
          "required": false,
          "description": "The thread so far, oldest first. Defaults to a demo conversation."
        },
        {
          "name": "onSend",
          "type": "function",
          "required": false,
          "description": "Called with the sent text. Return (or resolve to) the reply to show it after the typing indicator."
        },
        {
          "name": "replies",
          "type": "string[]",
          "required": false,
          "description": "Canned replies, cycled — used only without onSend."
        }
      ],
      "examples": [
        {
          "title": "Wired to your backend",
          "description": "Resolve with the reply and it lands after the typing indicator.",
          "code": "<ChatScreen\n  participant=\"Studio A\"\n  onSend={async (text) => {\n    const res = await fetch(\"/api/chat\", { method: \"POST\", body: text });\n    return (await res.json()).reply;\n  }}\n/>"
        }
      ],
      "antiPatterns": [
        "Do not put incoming messages in raised cards. Forty molded bubbles on one screen is forty competing controls; running text sits on the ground, and only Send is raised.",
        "Do not put the typing indicator inside the message log. role=\"log\" announces every addition — an indicator in it is read out between every message.",
        "Do not scroll the page to the newest message. Scroll the thread; an embedded chat that moves the document it sits in is a bug in someone else's page.",
        "Do not send on Enter while an IME composition is active. That is the key a Japanese or Chinese typist presses to accept a word.",
        "Do not colour your messages with the accent. Accent is the lit Send cap; a thread of accent bubbles makes every message read as a button."
      ],
      "usage": {
        "imports": "import { ChatScreen } from \"@/echo/chat\";",
        "code": "<main>\n  <ChatScreen\n    participant=\"Studio A\"\n    onSend={async (text) => {\n      const res = await fetch(\"/api/chat\", { method: \"POST\", body: text });\n      return (await res.json()).reply;\n    }}\n  />\n</main>"
      },
      "composition": "ChatScreen\n  Avatar\n  StatusDot\n  Loader\n  Textarea\n  HardwareButton",
      "a11y": [
        "The thread is `role=\"log\"`, named “Conversation with …”: new messages are announced politely, in order.",
        "The typing indicator is its own status (a Loader), so the log does not announce “typing” as a message.",
        "Enter sends and Shift+Enter starts a new line; Enter during IME composition does not send.",
        "The screen owns the page's `<h1>`. Inside another page, pass `embedded` and the title drops to `<h2>`.",
        "It renders no `<main>` of its own: put it inside your page's `<main>`, or the page has no main landmark."
      ],
      "sound": [
        "From its parts: `press` / `release` on the Send cap."
      ],
      "feel": "A conversation as a front panel: your words sit in a recessed well, theirs on the ground, and Send is the only raised thing.",
      "api": {
        "ChatScreen": {
          "props": [
            "embedded",
            "messages",
            "onSend",
            "participant",
            "replies",
            "subtitle"
          ]
        }
      },
      "weight": {
        "js": 3992,
        "withDependencies": 44837,
        "css": 4283
      }
    },
    {
      "name": "echo-morph",
      "title": "Morph",
      "version": "0.3.0",
      "description": "Wrap your own HardwareButtons and render whatever state you like: when the buttons inside change, the old ones flow into the new ones as one liquid body. One becoming several divides at the waist, several becoming one folds, and the same number move and resize in place. At rest there is nothing but your buttons; the body is drawn only while the shape changes, in the cap's own material. Give a button data-morph=\"name\" and it is the same button in every state that has that name; filled caps (HardwareButton fill) are drawn as levels.",
      "summary": "Wrap your own HardwareButtons and render any state: when the buttons change, the old ones flow into the new. The base for DoneButton, ConfirmButton and ActionFan.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button"
      ],
      "install": "npx echo-ui-cli add morph",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "Your HardwareButtons, in whatever state you render. When they change, the old ones flow into the new."
        },
        {
          "name": "feel",
          "type": "\"cell\" | \"mercury\" | \"cut\"",
          "required": false,
          "description": "How it moves: cell (default) pinches evenly and each half swells round; mercury is short, quick and jiggles; cut is a clean cut, then the caps slide."
        },
        {
          "name": "align",
          "type": "\"center\" | \"start\" | \"end\"",
          "required": false,
          "description": "Which edge stays put while the buttons change."
        },
        {
          "name": "onEscape",
          "type": "function",
          "required": false,
          "description": "Called on Escape inside it, e.g. to fold back."
        },
        {
          "name": "status",
          "type": "string",
          "required": false,
          "description": "Said politely when it changes: what the new state means."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "Names the buttons as a group."
        }
      ],
      "examples": [
        {
          "title": "Done, and its way back",
          "description": "One button becomes two. The lit cap reports the state, so it can't be pressed.",
          "code": "<Morph onEscape={() => setDone(false)}>\n  {done ? (\n    <>\n      <HardwareButton variant=\"primary\" disabled>Done</HardwareButton>\n      <HardwareButton onClick={() => setDone(false)}>Undo</HardwareButton>\n    </>\n  ) : (\n    <HardwareButton onClick={() => setDone(true)}>Mark as done</HardwareButton>\n  )}\n</Morph>"
        },
        {
          "title": "A label that changes",
          "description": "The same button with a new label flows to its new width instead of jumping.",
          "code": "<Morph>\n  <HardwareButton onClick={save}>{saved ? \"Saved\" : \"Save changes\"}</HardwareButton>\n</Morph>"
        },
        {
          "title": "The same button across states",
          "description": "Named alike, the duration you pick is the cap the others run into.",
          "code": "<Morph>\n  {running ? (\n    <HardwareButton data-morph=\"30s\" fill={left / total} onClick={pause}>{clock}</HardwareButton>\n  ) : (\n    <>\n      <HardwareButton data-morph=\"10s\" onClick={() => start(10)}>10s</HardwareButton>\n      <HardwareButton data-morph=\"30s\" onClick={() => start(30)}>30s</HardwareButton>\n    </>\n  )}\n</Morph>"
        },
        {
          "title": "No liquid",
          "description": "The same change with the cut feel, for a quieter screen.",
          "code": "<Morph feel=\"cut\">\n  {asking ? (\n    <>\n      <HardwareButton onClick={no}>No</HardwareButton>\n      <HardwareButton variant=\"primary\" onClick={yes}>Yes</HardwareButton>\n    </>\n  ) : (\n    <HardwareButton onClick={ask}>Answer</HardwareButton>\n  )}\n</Morph>"
        }
      ],
      "antiPatterns": [
        "Never divide into more than about four buttons: past that it's a menu, and Menu reads better.",
        "Never make a lit cap a button that does nothing — disable it and say so, as DoneButton does.",
        "Never make both buttons of a destructive choice primary: one lit cap is the one to press on purpose.",
        "Never put anything but a row of HardwareButtons inside: the body is drawn from their caps."
      ],
      "usage": {
        "imports": "import { Morph } from \"@/echo/morph\";\nimport { HardwareButton } from \"@/echo/hardware-button\";",
        "code": "<Morph>\n  {open ? (\n    <>\n      <HardwareButton onClick={keep}>Keep</HardwareButton>\n      <HardwareButton variant=\"primary\" onClick={discard}>Discard</HardwareButton>\n    </>\n  ) : (\n    <HardwareButton onClick={() => setOpen(true)}>Close draft</HardwareButton>\n  )}\n</Morph>"
      },
      "composition": "Morph → your HardwareButtons, with echo-ui-core's MorphSurface drawn only while they change",
      "a11y": [
        "Every cap is your own HardwareButton, with its own name, state and handlers; Morph adds none.",
        "If focus was on a button that went away, it moves to the first new button that can take it, so a keyboard user lands on the next choice.",
        "Escape inside it calls `onEscape`.",
        "Pass `status` to say what the new state means; it's announced politely.",
        "Reduced motion, forced colors and a row that wraps onto more than one line swap the buttons in place."
      ],
      "motion": {
        "tokens": [
          "morphFeels.cell",
          "morphFeels.mercury",
          "morphFeels.cut"
        ],
        "reducedMotion": "The buttons swap in place: no drawn body."
      },
      "sound": [
        "Each cap's own press and release (HardwareButton).",
        "A soft pop when a neck snaps and a gulp when two caps fuse, inside a SoundProvider."
      ],
      "feel": "A button that comes apart in your hand into the buttons you need next, and flows back together.",
      "api": {
        "Morph": {
          "props": [
            "align",
            "children",
            "className",
            "feel",
            "label",
            "onEscape",
            "status"
          ],
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 3534,
        "withDependencies": 35362,
        "css": 420
      }
    },
    {
      "name": "echo-done-button",
      "title": "DoneButton",
      "version": "0.3.0",
      "description": "\"Mark as done\" divides into a lit \"Done\" and an \"Undo\". The state and its way back sit where the hand already is; Undo folds them back into the whole.",
      "summary": "\"Mark as done\" divides into a lit \"Done\" and an \"Undo\".",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-morph"
      ],
      "install": "npx echo-ui-cli add done-button",
      "props": [
        {
          "name": "done",
          "type": "boolean",
          "required": true,
          "description": "Done (divided) or not (whole)."
        },
        {
          "name": "onDoneChange",
          "type": "function",
          "required": true,
          "description": "Called with true when the whole is pressed, false when Undo is."
        },
        {
          "name": "children",
          "type": "string",
          "required": false,
          "description": "The whole's label. Defaults to \"Mark as done\"."
        },
        {
          "name": "doneLabel",
          "type": "string",
          "required": false,
          "description": "The lit half. Defaults to \"Done\"."
        },
        {
          "name": "undoLabel",
          "type": "string",
          "required": false,
          "description": "Defaults to \"Undo\"."
        },
        {
          "name": "doneStatus",
          "type": "string",
          "required": false,
          "description": "Announced when it becomes done. Defaults to \"Marked as done\"."
        },
        {
          "name": "feel",
          "type": "\"cell\" | \"mercury\" | \"cut\"",
          "required": false,
          "description": "How it moves: cell (the default) pinches evenly and each half swells round; mercury is short, quick and jiggles; cut is a clean cut, then the caps slide."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Height of its caps on the control scale: sm 32px, md 44px, lg 60px, as a HardwareButton of that size. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "align",
          "type": "\"center\" | \"start\" | \"end\"",
          "required": false,
          "description": "Which edge stays put while the buttons change: center (the default), start or end."
        }
      ],
      "examples": [
        {
          "title": "A task",
          "description": "Controlled, so the list owns the state.",
          "code": "<DoneButton done={task.done} onDoneChange={(done) => update(task.id, { done })} />"
        },
        {
          "title": "Other words",
          "description": "Any state with a way back: read, resolved, approved.",
          "code": "<DoneButton done={read} onDoneChange={setRead} doneLabel=\"Read\" doneStatus=\"Marked as read\">Mark as read</DoneButton>"
        }
      ],
      "antiPatterns": [
        "Never use it for something that can't be undone — that's ConfirmButton.",
        "Never leave `done` uncontrolled in a list; the row owns the state."
      ],
      "usage": {
        "imports": "import { DoneButton } from \"@/echo/done-button\";",
        "code": "<DoneButton done={done} onDoneChange={setDone} />"
      },
      "composition": "DoneButton → Morph → HardwareButton",
      "a11y": [
        "The lit \"Done\" cap is disabled — it reports the state, it doesn't act — so Undo is the only control while it's done, and it takes focus as the halves appear.",
        "\"Marked as done\" is announced politely (`doneStatus`).",
        "Escape, or Undo, folds it back and returns focus to the whole."
      ],
      "motion": {
        "tokens": [
          "morphFeels.cell",
          "morphFeels.mercury",
          "morphFeels.cut"
        ],
        "reducedMotion": "The buttons swap in place: no drawn body."
      },
      "sound": [
        "A soft pop as the halves part, a gulp as they fuse (inside a SoundProvider)."
      ],
      "feel": "The button becomes the answer and its own undo.",
      "api": {
        "DoneButton": {
          "props": [
            "align",
            "children",
            "className",
            "done",
            "doneLabel",
            "doneStatus",
            "feel",
            "onDoneChange",
            "size",
            "undoLabel"
          ],
          "required": [
            "done",
            "onDoneChange"
          ]
        }
      },
      "weight": {
        "js": 4671,
        "withDependencies": 38383,
        "css": 2565
      }
    },
    {
      "name": "echo-confirm-button",
      "title": "ConfirmButton",
      "version": "0.3.0",
      "description": "A destructive action that asks for its second press where the hand already is. \"Delete\" divides into \"Cancel\" and a lit \"Delete\"; Delete confirms, and the parts fold into a button saying what happened, which flows back to its first label a moment later. A lighter answer than a dialog for an action on one thing.",
      "summary": "A destructive action that asks for its second press where the hand already is.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-morph"
      ],
      "install": "npx echo-ui-cli add confirm-button",
      "props": [
        {
          "name": "children",
          "type": "string",
          "required": true,
          "description": "The whole's label, e.g. \"Delete project\"."
        },
        {
          "name": "onConfirm",
          "type": "function",
          "required": true,
          "description": "Called when the lit part is pressed."
        },
        {
          "name": "confirmLabel",
          "type": "string",
          "required": false,
          "description": "The lit part. Defaults to \"Delete\"."
        },
        {
          "name": "cancelLabel",
          "type": "string",
          "required": false,
          "description": "Defaults to \"Cancel\"."
        },
        {
          "name": "confirmedLabel",
          "type": "string",
          "required": false,
          "description": "What the whole says after confirming, e.g. \"Deleted\". Omit to go straight back."
        },
        {
          "name": "confirmedFor",
          "type": "number",
          "required": false,
          "description": "How long the confirmed label stays, ms. Defaults to 2000."
        },
        {
          "name": "icon",
          "type": "unknown",
          "required": false,
          "description": "The whole's icon. Defaults to a bin."
        },
        {
          "name": "feel",
          "type": "\"cell\" | \"mercury\" | \"cut\"",
          "required": false,
          "description": "How it moves: cell (the default) pinches evenly and each half swells round; mercury is short, quick and jiggles; cut is a clean cut, then the caps slide."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Height of its caps on the control scale: sm 32px, md 44px, lg 60px, as a HardwareButton of that size. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "align",
          "type": "\"center\" | \"start\" | \"end\"",
          "required": false,
          "description": "Which edge stays put while the buttons change: center (the default), start or end."
        }
      ],
      "examples": [
        {
          "title": "Delete, then say so",
          "description": "The whole reads \"Deleted\" for a moment, then flows back to \"Delete project\".",
          "code": "<ConfirmButton onConfirm={remove} confirmedLabel=\"Deleted\">Delete project</ConfirmButton>"
        },
        {
          "title": "Not only deleting",
          "description": "Any action worth a second press.",
          "code": "<ConfirmButton onConfirm={leave} confirmLabel=\"Leave\" confirmedLabel=\"Left\">Leave team</ConfirmButton>"
        }
      ],
      "antiPatterns": [
        "Never use it where the consequence needs explaining — that's AlertDialog, which has room for a sentence.",
        "Never use it for bulk actions on many things at once; name the count in an AlertDialog."
      ],
      "usage": {
        "imports": "import { ConfirmButton } from \"@/echo/confirm-button\";",
        "code": "<ConfirmButton onConfirm={remove} confirmedLabel=\"Deleted\">Delete project</ConfirmButton>"
      },
      "composition": "ConfirmButton → Morph → HardwareButton",
      "a11y": [
        "Cancel takes focus when the parts appear, so pressing Enter twice never confirms.",
        "\"Confirm delete\", \"Cancelled\" and the confirmed label are announced politely.",
        "Escape cancels."
      ],
      "motion": {
        "tokens": [
          "morphFeels.cell",
          "morphFeels.mercury",
          "morphFeels.cut"
        ],
        "reducedMotion": "The buttons swap in place: no drawn body."
      },
      "sound": [
        "A soft pop as the parts appear, a gulp as they fold back (inside a SoundProvider)."
      ],
      "feel": "It asks \"are you sure?\" without taking you anywhere.",
      "api": {
        "ConfirmButton": {
          "props": [
            "align",
            "cancelLabel",
            "children",
            "className",
            "confirmLabel",
            "confirmedFor",
            "confirmedLabel",
            "feel",
            "icon",
            "onConfirm",
            "size"
          ],
          "required": [
            "children",
            "onConfirm"
          ]
        }
      },
      "weight": {
        "js": 4799,
        "withDependencies": 38505,
        "css": 2550
      }
    },
    {
      "name": "echo-action-fan",
      "title": "ActionFan",
      "version": "0.3.0",
      "description": "One action opens into its choices, which bud from it one after another; the × folds them back. Choosing one does it and folds the fan. For two to four choices that belong to one verb — share, export, add.",
      "summary": "One action opens into its choices; the × folds them back.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-morph"
      ],
      "install": "npx echo-ui-cli add action-fan",
      "props": [
        {
          "name": "children",
          "type": "string",
          "required": true,
          "description": "The whole's label, e.g. \"Share\"."
        },
        {
          "name": "icon",
          "type": "unknown",
          "required": false,
          "description": "The whole's icon (a ReactNode), before its label."
        },
        {
          "name": "actions",
          "type": "object[]",
          "required": true,
          "description": "The choices, in reading order. Two to four."
        },
        {
          "name": "closeLabel",
          "type": "string",
          "required": false,
          "description": "Accessible name of the × that folds the fan. Defaults to \"Close\"."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false
        },
        {
          "name": "onOpenChange",
          "type": "function",
          "required": false
        },
        {
          "name": "feel",
          "type": "\"cell\" | \"mercury\" | \"cut\"",
          "required": false,
          "description": "How it moves: cell (the default) pinches evenly and each half swells round; mercury is short, quick and jiggles; cut is a clean cut, then the caps slide."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Height of its caps on the control scale: sm 32px, md 44px, lg 60px, as a HardwareButton of that size. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "align",
          "type": "\"center\" | \"start\" | \"end\"",
          "required": false,
          "description": "Which edge stays put while the buttons change: center (the default), start or end."
        }
      ],
      "examples": [
        {
          "title": "Share",
          "description": "Three ways to do one thing.",
          "code": "<ActionFan\n  actions={[\n    { label: \"Link\", onSelect: copyLink },\n    { label: \"Email\", onSelect: email },\n    { label: \"Embed\", onSelect: embed },\n  ]}\n>\n  Share\n</ActionFan>"
        }
      ],
      "antiPatterns": [
        "Never fan more than four choices — use a Menu.",
        "Never mix unrelated actions in one fan: every choice should finish the same sentence as the whole."
      ],
      "usage": {
        "imports": "import { ActionFan } from \"@/echo/action-fan\";",
        "code": "<ActionFan actions={[{ label: \"PNG\", onSelect: png }, { label: \"SVG\", onSelect: svg }]}>Export</ActionFan>"
      },
      "composition": "ActionFan → Morph → HardwareButton",
      "a11y": [
        "The first choice takes focus when the fan opens; Escape or × folds it and focus returns to the whole.",
        "The × is an icon-only HardwareButton named by `closeLabel`."
      ],
      "motion": {
        "tokens": [
          "morphFeels.cell",
          "morphFeels.mercury",
          "morphFeels.cut"
        ],
        "reducedMotion": "The choices appear in place: no drawn body."
      },
      "sound": [
        "A soft pop as each choice comes free (inside a SoundProvider)."
      ],
      "feel": "The choices grow out of the thing you pressed.",
      "api": {
        "ActionFan": {
          "props": [
            "actions",
            "align",
            "children",
            "className",
            "closeLabel",
            "feel",
            "icon",
            "onOpenChange",
            "open",
            "size"
          ],
          "required": [
            "actions",
            "children"
          ]
        }
      },
      "weight": {
        "js": 4644,
        "withDependencies": 38359,
        "css": 2550
      }
    },
    {
      "name": "echo-save-button",
      "title": "SaveButton",
      "version": "0.3.0",
      "description": "The button becomes its own progress, then its own answer: Save narrows into a busy cap while your save runs, widens into a lit Saved, and flows back. One HardwareButton changing shape in a Morph — no dots swapped in, no width jump.",
      "summary": "The button becomes its own progress, then its own answer: Save narrows into a busy cap while your save runs, widens into a lit Saved, and flows back.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-morph"
      ],
      "install": "npx echo-ui-cli add save-button",
      "props": [
        {
          "name": "onSave",
          "type": "function",
          "required": true,
          "description": "Runs on press. Return a promise to hold the busy state until it settles; if it rejects, the button comes back and says so."
        },
        {
          "name": "children",
          "type": "string",
          "required": false,
          "description": "The idle label. Defaults to \"Save\"."
        },
        {
          "name": "savingLabel",
          "type": "string",
          "required": false,
          "description": "Said while saving, and the busy cap's name. Defaults to \"Saving\"."
        },
        {
          "name": "savedLabel",
          "type": "string",
          "required": false,
          "description": "Defaults to \"Saved\"."
        },
        {
          "name": "errorLabel",
          "type": "string",
          "required": false,
          "description": "Said if onSave rejects. Defaults to \"Couldn't save\"."
        },
        {
          "name": "savedFor",
          "type": "number",
          "required": false,
          "description": "How long Saved stays, ms. Defaults to 1800."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "feel",
          "type": "\"cell\" | \"mercury\" | \"cut\"",
          "required": false,
          "description": "How it moves: cell (the default) pinches evenly and each half swells round; mercury is short, quick and jiggles; cut is a clean cut, then the caps slide."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Height of its caps on the control scale: sm 32px, md 44px, lg 60px, as a HardwareButton of that size. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "align",
          "type": "\"center\" | \"start\" | \"end\"",
          "required": false,
          "description": "Which edge stays put while the buttons change: center (the default), start or end."
        }
      ],
      "examples": [
        {
          "title": "Save to a server",
          "description": "The busy cap holds until the promise settles.",
          "code": "<SaveButton onSave={() => fetch(\"/api/draft\", { method: \"POST\", body: draft })} />"
        },
        {
          "title": "Other words",
          "description": "Any action with an in-between and an answer.",
          "code": "<SaveButton onSave={publish} savingLabel=\"Publishing\" savedLabel=\"Published\">Publish</SaveButton>"
        }
      ],
      "antiPatterns": [
        "Never use it for a save that happens without asking (autosave): show that with a status line, not a button that moves by itself.",
        "Never leave it busy forever: if the save can hang, time it out and reject."
      ],
      "usage": {
        "imports": "import { SaveButton } from \"@/echo/save-button\";",
        "code": "<SaveButton onSave={save} />"
      },
      "composition": "SaveButton → Morph → HardwareButton (loading while busy)",
      "a11y": [
        "Saving, Saved and a failure are announced politely.",
        "The busy cap is HardwareButton's loading state: named \"Saving\", aria-busy, and it can't be pressed twice.",
        "Focus waits on the busy cap and comes back to Save when it returns.",
        "Keyboard: Tab reaches it; Enter or Space saves, and a second press while it's saving does nothing."
      ],
      "motion": {
        "tokens": [
          "morphFeels.cell",
          "morphFeels.mercury",
          "morphFeels.cut"
        ],
        "reducedMotion": "The states swap in place: no drawn body."
      },
      "sound": [
        "The cap's own press (HardwareButton)."
      ],
      "feel": "You see it working, then you see it done, in the same place your finger is.",
      "api": {
        "SaveButton": {
          "props": [
            "align",
            "children",
            "className",
            "disabled",
            "errorLabel",
            "feel",
            "onSave",
            "savedFor",
            "savedLabel",
            "savingLabel",
            "size"
          ],
          "required": [
            "onSave"
          ]
        }
      },
      "weight": {
        "js": 4855,
        "withDependencies": 38558,
        "css": 2565
      }
    },
    {
      "name": "echo-timer",
      "title": "Timer",
      "version": "0.3.0",
      "description": "A timer that is one button. It opens into its durations; the others run into the one you pick, which fills with the primary plastic and drains as the time runs, then rings three times and folds back. Press the running cap to pause; × or Escape folds it.",
      "summary": "A timer that is one button.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-morph"
      ],
      "install": "npx echo-ui-cli add timer",
      "props": [
        {
          "name": "durations",
          "type": "object[]",
          "required": false,
          "description": "What it opens into. Defaults to 10s, 30s and 1m. Keep to three or four."
        },
        {
          "name": "onDone",
          "type": "function",
          "required": false,
          "description": "Called when the time is up."
        },
        {
          "name": "children",
          "type": "string",
          "required": false,
          "description": "The closed label. Defaults to \"Timer\"."
        },
        {
          "name": "feel",
          "type": "\"cell\" | \"mercury\" | \"cut\"",
          "required": false,
          "description": "How it moves: cell (the default) pinches evenly and each half swells round; mercury is short, quick and jiggles; cut is a clean cut, then the caps slide."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Height of its caps on the control scale: sm 32px, md 44px, lg 60px, as a HardwareButton of that size. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "align",
          "type": "\"center\" | \"start\" | \"end\"",
          "required": false,
          "description": "Which edge stays put while the buttons change: center (the default), start or end."
        }
      ],
      "examples": [
        {
          "title": "Your own durations",
          "description": "A tea timer.",
          "code": "<Timer durations={[{ label: \"3m\", ms: 180_000 }, { label: \"4m\", ms: 240_000 }, { label: \"5m\", ms: 300_000 }]} onDone={notify}>Tea</Timer>"
        }
      ],
      "antiPatterns": [
        "Never use it for a countdown the user didn't start: that's a Progress with a label.",
        "Never offer more than four durations: past that it's a picker, and NumberField reads better."
      ],
      "usage": {
        "imports": "import { Timer } from \"@/echo/timer\";",
        "code": "<Timer onDone={() => setBreak(true)} />"
      },
      "composition": "Timer → Morph → HardwareButton (the running cap uses `fill`)",
      "a11y": [
        "The durations are a named group; the first takes focus as it opens.",
        "The running cap says what pressing it does and how long is left: \"Pause timer, 0:09 left\".",
        "The duration picked, pause, resume and \"Time's up\" are announced politely.",
        "Escape folds it at any point."
      ],
      "motion": {
        "tokens": [
          "morphFeels.cell",
          "morphFeels.mercury",
          "morphFeels.cut"
        ],
        "reducedMotion": "The states swap in place; the level still drains, as information."
      },
      "sound": [
        "Each cap's own press, and the notify chime three times when the time is up (inside a SoundProvider)."
      ],
      "feel": "The time you chose is a level in the button, and you watch it drain.",
      "api": {
        "Timer": {
          "props": [
            "align",
            "children",
            "className",
            "durations",
            "feel",
            "onDone",
            "size"
          ]
        }
      },
      "weight": {
        "js": 5518,
        "withDependencies": 39255,
        "css": 2590
      }
    },
    {
      "name": "echo-stopwatch",
      "title": "Stopwatch",
      "version": "0.3.0",
      "description": "Start becomes the running time (lit) and a Lap button beside it; each lap is a drop that leaves Lap and lands in the lap count. Pause dims the time and Lap flows into Reset, which folds everything back into Start.",
      "summary": "Start becomes the running time (lit) and a Lap button beside it; each lap is a drop that leaves Lap and lands in the lap count.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-morph",
        "@echo/echo-count-badge",
        "@echo/echo-bud-off"
      ],
      "install": "npx echo-ui-cli add stopwatch",
      "props": [
        {
          "name": "onLap",
          "type": "function",
          "required": false,
          "description": "Called on every lap, with its number and the time, ms."
        },
        {
          "name": "onReset",
          "type": "function",
          "required": false,
          "description": "Called on reset, with the time it had reached, ms."
        },
        {
          "name": "feel",
          "type": "\"cell\" | \"mercury\" | \"cut\"",
          "required": false,
          "description": "How it moves: cell (the default) pinches evenly and each half swells round; mercury is short, quick and jiggles; cut is a clean cut, then the caps slide."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Height of its caps on the control scale: sm 32px, md 44px, lg 60px, as a HardwareButton of that size. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "align",
          "type": "\"center\" | \"start\" | \"end\"",
          "required": false,
          "description": "Which edge stays put while the buttons change: center (the default), start or end."
        }
      ],
      "examples": [
        {
          "title": "Keep the laps",
          "description": "The component counts them; you keep the times.",
          "code": "<Stopwatch onLap={(n, ms) => setLaps((l: number[]) => [...l, ms])} />"
        }
      ],
      "antiPatterns": [
        "Never use it to show elapsed time the user didn't start: that's a readout, not a control."
      ],
      "usage": {
        "imports": "import { Stopwatch } from \"@/echo/stopwatch\";",
        "code": "<Stopwatch />"
      },
      "composition": "Stopwatch → Morph → HardwareButton ×2, CountBadge, useBudOff",
      "a11y": [
        "The time cap says what pressing it does: \"Pause stopwatch\" / \"Resume stopwatch\".",
        "Each lap and its time, pausing and resetting are announced politely; the lap count is a status.",
        "The time is written straight into the cap, so nothing re-renders while it runs.",
        "Keyboard: Tab moves through the time cap, Lap and Reset; Enter or Space presses each."
      ],
      "motion": {
        "tokens": [
          "morphFeels.cell",
          "morphFeels.mercury",
          "morphFeels.cut"
        ],
        "reducedMotion": "The states swap in place and laps count without a drop."
      },
      "sound": [
        "Each cap's own press, and a soft gulp as a lap lands (inside a SoundProvider)."
      ],
      "feel": "Every lap visibly goes somewhere.",
      "api": {
        "Stopwatch": {
          "props": [
            "align",
            "className",
            "feel",
            "onLap",
            "onReset",
            "size"
          ]
        }
      },
      "weight": {
        "js": 6308,
        "withDependencies": 39866,
        "css": 2878
      }
    },
    {
      "name": "echo-voice-note",
      "title": "VoiceNote",
      "version": "0.3.0",
      "description": "Record a voice note in one button. The microphone opens into Stop and the take, whose bars follow your voice while Stop swells with it. Stop, and Stop is swallowed by the take, which becomes a note you can play: it fills with the primary plastic as it plays. × deletes it and everything folds back into the microphone.",
      "summary": "Record a voice note in one button.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-morph"
      ],
      "install": "npx echo-ui-cli add voice-note",
      "props": [
        {
          "name": "onRecorded",
          "type": "function",
          "required": false,
          "description": "Called when a take is kept, with the recording (a Blob, or null if the browser gave none) and its length, ms."
        },
        {
          "name": "onDelete",
          "type": "function",
          "required": false,
          "description": "Called when the note is deleted."
        },
        {
          "name": "maxDuration",
          "type": "number",
          "required": false,
          "description": "Longest take, ms. Defaults to 60000."
        },
        {
          "name": "feel",
          "type": "\"cell\" | \"mercury\" | \"cut\"",
          "required": false,
          "description": "How it moves: cell (the default) pinches evenly and each half swells round; mercury is short, quick and jiggles; cut is a clean cut, then the caps slide."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Height of its caps on the control scale: sm 32px, md 44px, lg 60px, as a HardwareButton of that size. Defaults to the nearest SizeProvider's size."
        },
        {
          "name": "align",
          "type": "\"center\" | \"start\" | \"end\"",
          "required": false,
          "description": "Which edge stays put while the buttons change: center (the default), start or end."
        }
      ],
      "examples": [
        {
          "title": "Keep the recording",
          "description": "Send the Blob wherever notes go.",
          "code": "<VoiceNote onRecorded={(audio) => audio && upload(audio)} onDelete={clear} />"
        }
      ],
      "antiPatterns": [
        "Never record without the user pressing record: the microphone is theirs."
      ],
      "usage": {
        "imports": "import { VoiceNote } from \"@/echo/voice-note\";",
        "code": "<VoiceNote onRecorded={(audio, ms) => save(audio, ms)} />"
      },
      "composition": "VoiceNote → Morph → HardwareButton (the note uses `fill`)",
      "a11y": [
        "Every control is named: \"Record a voice note\", \"Stop recording\", \"Play voice note, 0:07\", \"Delete voice note\".",
        "Recording, the note's length, deleting and a missing microphone are announced politely.",
        "Escape deletes the note.",
        "It asks for the microphone only when you press record, and says so if it isn't allowed."
      ],
      "motion": {
        "tokens": [
          "morphFeels.cell",
          "morphFeels.mercury",
          "morphFeels.cut"
        ],
        "reducedMotion": "The states swap in place and Stop doesn't swell; the bars still show the level."
      },
      "sound": [
        "Each cap's own press. The recording itself is the only other sound."
      ],
      "feel": "Your voice is visibly in the button while you speak, and the take becomes the thing you play.",
      "api": {
        "VoiceNote": {
          "props": [
            "align",
            "className",
            "feel",
            "maxDuration",
            "onDelete",
            "onRecorded",
            "size"
          ]
        }
      },
      "weight": {
        "js": 6347,
        "withDependencies": 39978,
        "css": 2694
      }
    },
    {
      "name": "echo-add-to-bag",
      "title": "AddToBag",
      "version": "0.3.0",
      "description": "Press it and a drop buds out of the button and lands in the count beside it, which swells and goes up — the eye follows the thing you added to where it went. A HardwareButton and a CountBadge, joined by useBudOff.",
      "summary": "Press it and a drop buds out of the button and lands in the count beside it, which swells and goes up — the eye follows the thing you added to where it went.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button",
        "@echo/echo-count-badge",
        "@echo/echo-bud-off"
      ],
      "install": "npx echo-ui-cli add add-to-bag",
      "props": [
        {
          "name": "count",
          "type": "number",
          "required": true,
          "description": "How many are in the bag."
        },
        {
          "name": "onAdd",
          "type": "function",
          "required": true,
          "description": "Add one. It runs at once; the badge shows it when the drop lands."
        },
        {
          "name": "children",
          "type": "string",
          "required": false,
          "description": "The button's label. Defaults to \"Add to bag\"."
        },
        {
          "name": "countLabel",
          "type": "string",
          "required": false,
          "description": "What the count is of, for the badge's name: \"3 in bag\". Defaults to \"in bag\"."
        },
        {
          "name": "icon",
          "type": "unknown",
          "required": false,
          "description": "The button's icon. Defaults to a plus."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "Height of its cap on the control scale: sm 32px, md 44px, lg 60px, as a HardwareButton of that size. Defaults to the nearest SizeProvider's size."
        }
      ],
      "examples": [
        {
          "title": "A cart",
          "description": "The count is yours; the button only adds.",
          "code": "<AddToBag count={cart.length} onAdd={() => addToCart(item)} countLabel=\"in cart\">Add to cart</AddToBag>"
        }
      ],
      "antiPatterns": [
        "Never delay the add itself until the drop lands: onAdd runs at once; only the count waits."
      ],
      "usage": {
        "imports": "import { AddToBag } from \"@/echo/add-to-bag\";",
        "code": "<AddToBag count={count} onAdd={() => setCount((n: number) => n + 1)} />"
      },
      "composition": "AddToBag → HardwareButton + CountBadge, with useBudOff between them",
      "a11y": [
        "The count is a CountBadge (role=status) that says its number with what it counts: \"3 in bag\".",
        "The drop is decoration and hidden from assistive tech; the badge is what's announced.",
        "Keyboard: Tab reaches it; Enter or Space adds one to the bag."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "No drop: the count goes up at once."
      },
      "sound": [
        "The cap's own press, and a soft gulp as the drop lands (inside a SoundProvider)."
      ],
      "feel": "What you added visibly goes into the bag.",
      "api": {
        "AddToBag": {
          "props": [
            "children",
            "className",
            "count",
            "countLabel",
            "disabled",
            "icon",
            "onAdd",
            "size"
          ],
          "required": [
            "count",
            "onAdd"
          ]
        }
      },
      "weight": {
        "js": 2462,
        "withDependencies": 32418,
        "css": 2690
      }
    },
    {
      "name": "echo-bud-off",
      "title": "useBudOff",
      "version": "0.3.0",
      "description": "A drop of the primary plastic buds out of one element, flies to another and is swallowed by it, which swells as it lands — so the eye follows cause to effect. Its front leads and its tail lags, so it draws out on its own. Returns a function, budOff(from, to, options): from is the element the drop buds out of, to the one it lands in, and the options are below. budOff returns a function that calls the drop off before it lands: it's gone and onArrive never runs, for when what it was flying to is reset first.",
      "summary": "A drop of the primary plastic buds out of one element, flies to another and is swallowed by it, which swells as it lands — so the eye follows cause to effect.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add bud-off",
      "props": [
        {
          "name": "onArrive",
          "type": "function",
          "required": false,
          "description": "When the drop lands: update what it landed in here."
        },
        {
          "name": "size",
          "type": "number",
          "required": false,
          "description": "The drop's radius, px. Defaults to 10."
        }
      ],
      "examples": [
        {
          "title": "Into a count",
          "description": "Show the new number when the drop gets there.",
          "code": "const budOff = useBudOff();\nbudOff(button, badge, { onArrive: () => setShown((n: number) => n + 1) });"
        },
        {
          "title": "Call it off",
          "description": "Reset before the drop lands, and it never arrives.",
          "code": "const cancel = budOff(button, badge, { onArrive: () => setShown((n: number) => n + 1) });\n// …the count is reset while it flies:\ncancel();"
        }
      ],
      "antiPatterns": [
        "Never make the drop the only sign something happened: update what it lands in.",
        "Never fire it on something the user didn't do: cause and effect is the point."
      ],
      "usage": {
        "imports": "import { useBudOff } from \"@/echo/bud-off\";",
        "code": "const budOff = useBudOff();\nconst send = (e: React.MouseEvent<HTMLButtonElement>) =>\n  budOff(e.currentTarget, outbox.current!, { onArrive: () => setSent((n: number) => n + 1) });"
      },
      "composition": "useBudOff → a drawn drop over the page, removed when it lands",
      "a11y": [
        "The drop is decoration (aria-hidden): whatever it means has to be said by what it lands in.",
        "It never takes focus and never blocks the pointer."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "No drop: onArrive runs at once."
      },
      "sound": [
        "A soft gulp as it lands, inside a SoundProvider."
      ],
      "feel": "Something leaves the thing you pressed and lands where it counts.",
      "weight": {
        "js": 1122,
        "withDependencies": 29178,
        "css": 278
      }
    },
    {
      "name": "echo-pull-cord",
      "title": "PullCord",
      "version": "0.3.0",
      "description": "A switch you pull. A real Echo Switch sits at the top and a cord hangs from it with a cap on its end; pull the cap and the cord stretches, clicks at the detent, and the switch changes when you let go past it. A short pull springs back. The Switch is the control — its state, name, focus, keyboard and form value — and the cord is a way to operate it by hand. Anything inside can read --echo-pull-on to light up.",
      "summary": "A switch you pull.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-switch"
      ],
      "install": "npx echo-ui-cli add pull-cord",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "The switch's name, e.g. \"Light\"."
        },
        {
          "name": "checked",
          "type": "boolean",
          "required": false
        },
        {
          "name": "defaultChecked",
          "type": "boolean",
          "required": false
        },
        {
          "name": "onCheckedChange",
          "type": "function",
          "required": false
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "description": "The switch's name in a form submission."
        },
        {
          "name": "id",
          "type": "string",
          "required": false,
          "description": "Passed to the switch: its id, for a <label for> or an aria-describedby."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false
        },
        {
          "name": "length",
          "type": "number",
          "required": false,
          "description": "How far the cord hangs, px. Defaults to 120."
        },
        {
          "name": "children",
          "type": "unknown",
          "required": false,
          "description": "What it lights, drawn behind the cord. Reads --echo-pull-on (0 off, 1 on, on a spring)."
        }
      ],
      "examples": [
        {
          "title": "A lamp",
          "description": "Whatever is inside reads --echo-pull-on.",
          "code": "<PullCord label=\"Lamp\" checked={on} onCheckedChange={setOn}>\n  <span className=\"lamp\" />\n</PullCord>"
        }
      ],
      "antiPatterns": [
        "Never use it for an ordinary setting in a form: it carries a Switch's value, but it's a feature control, for the one toggle a screen is about.",
        "Never put two side by side: which one you pulled stops being obvious."
      ],
      "usage": {
        "imports": "import { PullCord } from \"@/echo/pull-cord\";",
        "code": "<PullCord label=\"Light\" checked={on} onCheckedChange={setOn} />"
      },
      "composition": "PullCord → a Switch, and a drawn cord hanging from it with a round cap to pull",
      "a11y": [
        "The control is an Echo Switch (role=\"switch\", aria-checked) named by `label`, with the Switch's focus ring, keyboard and form value (`name`).",
        "Space or Enter flips the Switch, and the cord gives a tug; holding the key is one flip.",
        "The cord and its cap are a pointer handle, hidden from assistive tech: everything a pull does, the Switch does.",
        "In forced colors the cap and cord are system-coloured."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "A flip from the Switch doesn't tug the cord; the cord still hangs by its own weight."
      },
      "sound": [
        "A pull clicks at the detent and a lighter one as it lets go; a flip from the Switch makes the Switch's own tap (inside a SoundProvider)."
      ],
      "feel": "It stretches under your hand and gives way at a detent.",
      "api": {
        "PullCord": {
          "props": [
            "checked",
            "children",
            "className",
            "defaultChecked",
            "disabled",
            "id",
            "label",
            "length",
            "name",
            "onCheckedChange",
            "required"
          ]
        }
      },
      "weight": {
        "js": 3074,
        "withDependencies": 37433,
        "css": 1303
      }
    },
    {
      "name": "echo-roll",
      "title": "useRoll",
      "version": "0.3.0",
      "description": "A readout that rolls to a new value instead of swapping it: each digit steps through the ones in between, all together, fast when it's far and slowest on the last step. It's the drum SplitFlap turns on, and what roll does on LcdChip, StatTile and NumberField. useRoll(text, options) returns the text to draw, which is in between values while it rolls, so draw it aria-hidden and give the real value to assistive technology.",
      "summary": "A readout that rolls to a new value instead of swapping it: each digit steps through the ones in between, all together, fast when it's far and slowest on the last step.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npx echo-ui-cli add roll",
      "props": [
        {
          "name": "drum",
          "type": "string",
          "required": false,
          "description": "The characters in order, each one a step. Defaults to the digits."
        },
        {
          "name": "enabled",
          "type": "boolean",
          "required": false,
          "description": "Roll at all. false swaps the value outright. Default true."
        },
        {
          "name": "onTurn",
          "type": "function",
          "required": false,
          "description": "Called on every step, for a tick."
        }
      ],
      "examples": [
        {
          "title": "A count",
          "description": "Draw what it returns; say the real value.",
          "code": "const shown = useRoll(String(count));\n<span aria-hidden=\"true\">{shown}</span>"
        },
        {
          "title": "Letters",
          "description": "Any drum: here a departures board's.",
          "code": "const shown = useRoll(gate, { drum: CHARS });"
        }
      ],
      "antiPatterns": [
        "Never roll a value that changes many times a second: each step is 55–120ms, so it falls behind by design.",
        "Never roll text the user is typing: it's theirs, and it should appear as typed."
      ],
      "usage": {
        "imports": "import { useRoll } from \"@/echo/roll\";",
        "code": "const shown = useRoll(String(count));"
      },
      "composition": "useRoll → the text to draw, one step at a time",
      "a11y": [
        "What it returns is in between values while it rolls: draw it aria-hidden and give the real value to assistive technology, the way LcdChip, StatTile and NumberField do.",
        "It never announces anything by itself."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "It returns the value itself: no steps."
      },
      "feel": "Numbers that travel to their value, so you can see how far it moved and which way.",
      "weight": {
        "js": 749,
        "withDependencies": 859,
        "css": 0
      }
    },
    {
      "name": "echo-split-flap",
      "title": "SplitFlap",
      "version": "0.3.0",
      "description": "A readout that turns over like a departures board: each character flips through the ones between where it is and where it's going, flap by flap — fast when it's far, slowest on the last one — and each flap ticks. The flaps are the neutral cap's plastic in a recessed well.",
      "summary": "A readout that turns over like a departures board: each character flips through the ones between where it is and where it's going, flap by flap — fast when it's far, slowest on the last one — and each flap ticks.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-roll"
      ],
      "install": "npx echo-ui-cli add split-flap",
      "props": [
        {
          "name": "value",
          "type": "number | string",
          "required": true,
          "description": "What it shows. Numbers pad with zeros to `length`; text with spaces."
        },
        {
          "name": "length",
          "type": "number",
          "required": false,
          "description": "How many flaps. Defaults to the value's own length."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "description": "What the value is, for its name: \"Visitors\" reads \"Visitors 1024\"."
        },
        {
          "name": "live",
          "type": "boolean",
          "required": false,
          "description": "Announce changes politely."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "description": "How big each flap is: sm 44px tall, md (the default) 64px, lg 84px."
        }
      ],
      "examples": [
        {
          "title": "A count",
          "description": "Pads to four digits.",
          "code": "<SplitFlap value={visitors} length={4} label=\"Visitors\" live />"
        },
        {
          "title": "Words",
          "description": "Letters turn through the alphabet.",
          "code": "<SplitFlap value=\"BOARDING\" length={8} />"
        }
      ],
      "antiPatterns": [
        "Never use it for a value that changes many times a second: it can't keep up, by design.",
        "Never use it for the only copy of important text: it's slow to read on purpose."
      ],
      "usage": {
        "imports": "import { SplitFlap } from \"@/echo/split-flap\";",
        "code": "<SplitFlap value={count} length={4} />"
      },
      "composition": "SplitFlap → a well of flaps, each two halves and two turning leaves",
      "a11y": [
        "The flaps are one image named by the value (and `label`), so it's read once, not letter by letter.",
        "`live` announces changes politely, from outside the image where it can be heard."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "It jumps to the value."
      },
      "sound": [
        "A short tick per flap, inside a SoundProvider."
      ],
      "feel": "A mechanism you can hear: every flap between here and there.",
      "api": {
        "SplitFlap": {
          "props": [
            "className",
            "label",
            "length",
            "live",
            "size",
            "value"
          ],
          "required": [
            "value"
          ]
        }
      },
      "weight": {
        "js": 1182,
        "withDependencies": 1605,
        "css": 819
      }
    },
    {
      "name": "echo-light-source",
      "title": "LightSource",
      "version": "0.3.0",
      "description": "Your pointer becomes the light the caps inside it are lit by: each HardwareButton's shine turns toward it and its shadow falls away from it, the nearest most. When the pointer leaves, the light drifts back to the room's key light. It lights what you put in it and adds nothing of its own.",
      "summary": "Your pointer becomes the light the caps inside it are lit by: each HardwareButton's shine turns toward it and its shadow falls away from it, the nearest most.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button"
      ],
      "install": "npx echo-ui-cli add light-source",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "HardwareButtons (and anything else); the caps are what it lights."
        },
        {
          "name": "reach",
          "type": "number",
          "required": false,
          "description": "How far a shadow can fall, px. Defaults to 13."
        }
      ],
      "examples": [
        {
          "title": "A keypad",
          "description": "Any HardwareButtons inside.",
          "code": "<LightSource>\n  {keys.map((k: string) => (\n    <HardwareButton key={k} size=\"sm\" iconOnly aria-label={k} icon={<b>{k}</b>} />\n  ))}\n</LightSource>"
        }
      ],
      "antiPatterns": [
        "Never put it around a whole page: one lit group is a moment; everything lit is noise."
      ],
      "usage": {
        "imports": "import { LightSource } from \"@/echo/light-source\";\nimport { HardwareButton } from \"@/echo/hardware-button\";",
        "code": "<LightSource>\n  <HardwareButton>Play</HardwareButton>\n  <HardwareButton>Stop</HardwareButton>\n</LightSource>"
      },
      "composition": "LightSource → your HardwareButtons, through their light offset (--hw-light-x/-y) and a shine",
      "a11y": [
        "Pure light: it changes no names, roles or focus, and the caps keep their contrast.",
        "In forced colors the glow and shine are dropped."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The light jumps to the pointer instead of gliding."
      },
      "sound": [
        "None of its own."
      ],
      "feel": "Your hand is the light the surface is lit by.",
      "api": {
        "LightSource": {
          "props": [
            "children",
            "className",
            "reach"
          ],
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 921,
        "withDependencies": 28632,
        "css": 398
      }
    },
    {
      "name": "echo-magnetic",
      "title": "Magnetic",
      "version": "0.3.0",
      "description": "The HardwareButtons inside it feel your hand before you touch them: each leans toward the pointer and comes up a little, the nearest most, and springs home when you move away. It moves the button as a whole, never the cap, so pressing still presses.",
      "summary": "The HardwareButtons inside it feel your hand before you touch them: each leans toward the pointer and comes up a little, the nearest most, and springs home when you move away.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-hardware-button"
      ],
      "install": "npx echo-ui-cli add magnetic",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "HardwareButtons, usually icon-only ones in a row."
        },
        {
          "name": "radius",
          "type": "number",
          "required": false,
          "description": "How far away a button starts to feel the hand, px. Defaults to 130."
        },
        {
          "name": "strength",
          "type": "number",
          "required": false,
          "description": "How far it leans, as a share of the distance to the hand. Defaults to 0.4."
        }
      ],
      "examples": [
        {
          "title": "A dock",
          "description": "Icon-only caps in a row.",
          "code": "<Magnetic>\n  <HardwareButton iconOnly aria-label=\"Home\" icon={homeIcon} />\n  <HardwareButton iconOnly aria-label=\"Search\" icon={searchIcon} />\n</Magnetic>"
        }
      ],
      "antiPatterns": [
        "Never put text buttons in a dense form inside it: a target that moves is harder to hit.",
        "Never set a radius so large that buttons lean while the hand is going somewhere else."
      ],
      "usage": {
        "imports": "import { Magnetic } from \"@/echo/magnetic\";\nimport { HardwareButton } from \"@/echo/hardware-button\";",
        "code": "<Magnetic>\n  <HardwareButton iconOnly aria-label=\"Record\" icon={micIcon} />\n</Magnetic>"
      },
      "composition": "Magnetic → your HardwareButtons, moved as a whole (translate, scale)",
      "a11y": [
        "It changes nothing about the buttons but where they are: names, roles and focus are theirs.",
        "Pointer only — touch has no hover — and nothing leans under reduced motion."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "Nothing leans."
      },
      "sound": [
        "None of its own; each cap's press is its own."
      ],
      "feel": "It answers before you touch it.",
      "api": {
        "Magnetic": {
          "props": [
            "children",
            "className",
            "radius",
            "strength"
          ],
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 878,
        "withDependencies": 28599,
        "css": 219
      }
    },
    {
      "name": "echo-throw-stack",
      "title": "ThrowStack",
      "version": "0.3.0",
      "description": "A stack of cards you can throw: drag the top one and flick it, and it keeps the speed you gave it, spins from where you held it (grabbed low, it swings the other way), flies off and tucks back in underneath. A short drag springs back. The cards are Echo Panels.",
      "summary": "A stack of cards you can throw: drag the top one and flick it, and it keeps the speed you gave it, spins from where you held it (grabbed low, it swings the other way), flies off and tucks back in underneath.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-panel"
      ],
      "install": "npx echo-ui-cli add throw-stack",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": true,
          "description": "One child per card, top first."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "description": "Names the stack, e.g. \"Today\"."
        },
        {
          "name": "onChange",
          "type": "function",
          "required": false,
          "description": "Called with the index (in children) of the new top card."
        }
      ],
      "examples": [
        {
          "title": "A day",
          "description": "Any content per card.",
          "code": "<ThrowStack label=\"Today\">\n  <p>Stand-up at 9:30</p>\n  <p>Two hours, no calls</p>\n</ThrowStack>"
        }
      ],
      "antiPatterns": [
        "Never hide something the user must see in a card below the top: it's for browsing, not for required content.",
        "Never use it for more than a handful of cards: past that it's a list."
      ],
      "usage": {
        "imports": "import { ThrowStack } from \"@/echo/throw-stack\";",
        "code": "<ThrowStack label=\"Cards\">\n  {cards.map((c: { id: string; text: string }) => <p key={c.id}>{c.text}</p>)}\n</ThrowStack>"
      },
      "composition": "ThrowStack → Panel per card, stacked in one cell",
      "a11y": [
        "A named group of cards; only the top card is focusable and visible to assistive tech.",
        "← and → throw the top card; focus moves to the new top card, and \"2 of 4\" is announced."
      ],
      "motion": {
        "tokens": [
          "springs"
        ],
        "reducedMotion": "The cards cycle without flying."
      },
      "sound": [
        "A whoosh as a card is thrown, inside a SoundProvider."
      ],
      "feel": "It keeps the speed you gave it.",
      "api": {
        "ThrowStack": {
          "props": [
            "children",
            "className",
            "label",
            "onChange"
          ],
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 3202,
        "withDependencies": 31176,
        "css": 889
      }
    },
    {
      "name": "echo-ripple",
      "title": "Ripple",
      "version": "0.3.0",
      "description": "A surface of dots that answers a press like water: a wave runs out from where you pressed, and pressing longer presses deeper and sends a bigger one. The dots dip under the pointer as it passes. Put controls on it; a key press on one of them sends a wave from it too. It's Panel's pressable wave with the dots always out; to make an ordinary Panel give like this, use <Panel pressable>.",
      "summary": "A surface of dots that answers a press like water: a wave runs out from where you pressed, and pressing longer presses deeper and sends a bigger one.",
      "group": "Motion",
      "type": "registry:component",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [
        "@echo/echo-panel"
      ],
      "install": "npx echo-ui-cli add ripple",
      "props": [
        {
          "name": "children",
          "type": "unknown",
          "required": false,
          "description": "Controls or content on the surface; a key press on one sends a wave from it."
        },
        {
          "name": "spacing",
          "type": "number",
          "required": false,
          "description": "Distance between dots, px. Defaults to 16."
        },
        {
          "name": "onRipple",
          "type": "function",
          "required": false,
          "description": "Called when a wave is sent, with how hard (0–1)."
        }
      ],
      "examples": [
        {
          "title": "Behind a call to action",
          "description": "The button is the control; the surface answers it.",
          "code": "<Ripple>\n  <HardwareButton variant=\"primary\">Start</HardwareButton>\n</Ripple>"
        }
      ],
      "antiPatterns": [
        "Never put body text on it: moving dots behind words make them harder to read."
      ],
      "usage": {
        "imports": "import { Ripple } from \"@/echo/ripple\";\nimport { HardwareButton } from \"@/echo/hardware-button\";",
        "code": "<Ripple>\n  <HardwareButton>Press</HardwareButton>\n</Ripple>"
      },
      "composition": "Ripple → a canvas of dots under your content, waved by Panel's useRippleSurface",
      "a11y": [
        "The dots are decoration, hidden from assistive tech; what's on the surface keeps its own names and focus.",
        "Keyboard presses on controls on it send waves too.",
        "In forced colors the dots are dropped."
      ],
      "motion": {
        "tokens": [],
        "reducedMotion": "The dots stay still."
      },
      "sound": [
        "A soft thud when you press the surface itself, deeper for a longer press, inside a SoundProvider. A control on it makes its own sound instead."
      ],
      "feel": "The surface itself is pliable.",
      "api": {
        "Ripple": {
          "props": [
            "children",
            "className",
            "onRipple",
            "spacing"
          ]
        }
      },
      "weight": {
        "js": 1837,
        "withDependencies": 2159,
        "css": 708
      }
    },
    {
      "name": "echo-theme",
      "title": "Theme Engine",
      "description": "createEchoTheme(seed) → { css, values, report }. One OKLCH derivation turns a ~6-value seed into the full semantic-role set. Users touch only the seed; components consume only the roles.",
      "summary": "createEchoTheme(seed) — OKLCH derivation turning a ~6-value seed into the full semantic-role set, with dual { css, values } output and WCAG-AA contrast guardrails. The token foundation every Echo component depends on.",
      "group": "Foundations",
      "type": "core:module",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npm install echo-ui-core",
      "props": [
        {
          "name": "accent",
          "type": "string",
          "required": false,
          "defaultValue": "\"#8B6FE8\"",
          "description": "The one saturated colour, as a hex: the mark, the on state, the lit rail."
        },
        {
          "name": "chassis",
          "type": "string",
          "required": false,
          "defaultValue": "\"#232130\"",
          "description": "The molded plastic every surface, cap and well is derived from. A pale one makes a light device."
        },
        {
          "name": "paper",
          "type": "string",
          "required": false,
          "defaultValue": "\"#EFEBFA\"",
          "description": "The source of every text colour."
        },
        {
          "name": "lcdTint",
          "type": "string",
          "required": false,
          "description": "The screens' tint. Derived from the chassis when omitted."
        },
        {
          "name": "finish",
          "type": "number",
          "required": false,
          "defaultValue": "0.5",
          "description": "Matte (0) to glossy (1): how brightly lit edges shine and how deep shadows fall."
        },
        {
          "name": "radius",
          "type": "number",
          "required": false,
          "defaultValue": "0.5",
          "description": "Crisp (0) to soft (1): the corners of boxes, panels and fields."
        },
        {
          "name": "density",
          "type": "\"compact\" | \"default\" | \"comfortable\"",
          "required": false,
          "defaultValue": "\"default\"",
          "description": "compact, default or comfortable: spacing and control heights. A target is never under 24px."
        },
        {
          "name": "render",
          "type": "\"molded\" | \"drawn\"",
          "required": false,
          "defaultValue": "\"molded\"",
          "description": "molded or drawn: lit plastic, or the same parts drawn in the ink, outlined and hollow on a hard ink edge."
        },
        {
          "name": "pills",
          "type": "boolean",
          "required": false,
          "description": "Buttons and segments as pills (true) or following the radius (false), whatever the radius. Unset, they're pills at radius 1 only."
        },
        {
          "name": "fontMachine",
          "type": "string",
          "required": false,
          "defaultValue": "\"ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif\"",
          "description": "Instrument labels: caps, panel legends, captions. A font stack."
        },
        {
          "name": "fontSans",
          "type": "string",
          "required": false,
          "defaultValue": "\"ui-sans-serif, system-ui, -apple-system, 'Segoe UI', sans-serif\"",
          "description": "Body and UI prose. A font stack."
        },
        {
          "name": "fontMono",
          "type": "string",
          "required": false,
          "defaultValue": "\"ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace\"",
          "description": "Data, code, and digits that must be told apart. A font stack."
        },
        {
          "name": "fontLcd",
          "type": "string",
          "required": false,
          "defaultValue": "\"ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace\"",
          "description": "The screen face on LCD panels and readouts. A font stack."
        }
      ],
      "examples": [
        {
          "title": "Default theme",
          "description": "Inject the default device look at :root.",
          "code": "const { css } = createEchoTheme();\n// <style>{css}</style> or inject into document head"
        },
        {
          "title": "Retheme by seed",
          "description": "One color in, the whole device relights, with nothing of the default left in it.",
          "code": "const { css, values } = createEchoTheme({ accent: \"#3D8EF0\" });"
        },
        {
          "title": "Scoped theme",
          "description": "Scope variables to a class instead of :root.",
          "code": "createEchoTheme({ accent: \"#F2674E\" }, { selector: \".coral-device\" });"
        },
        {
          "title": "What contrast corrected",
          "description": "The seed can ask for text no one can read; the theme moves it to AA and says so, in `report`.",
          "code": "const { report } = createEchoTheme({ paper: \"#2A2A2A\", chassis: \"#26222B\" });\n// report.corrected: [\"text (→ 9.04:1)\", \"text-dim (→ 6.41:1)\"]"
        },
        {
          "title": "A light device",
          "description": "A pale chassis makes a light device: a light page, caps with their own bevel, screens of paper glass printed in ink, the accent lit at 3:1 with its text and focus ring a step deeper (accent-ink), the same primary cap with a light label (primary-cap-ink), and shadows in the body's own colour. The chassis decides; there's no mode to set.",
          "code": "const { values } = createEchoTheme({ chassis: \"#EBEBEB\", paper: \"#1D1D1D\", accent: \"#0594F9\" });\n// values.polarity: \"light\""
        },
        {
          "title": "Pill keys, crisp boxes",
          "description": "`radius` sets the corners of boxes, panels and fields; `pills` makes buttons and segments pills whatever they are, the way a spec sheet draws pill keys on square-cornered tables. Unset, pills come with radius 1, as they always have.",
          "code": "const { css } = createEchoTheme({ radius: 0.3, pills: true });\n// --echo-cap-round: 1, and the panels keep their crisp corners"
        },
        {
          "title": "A drawn device",
          "description": "`render: \"drawn\"` draws the same parts in the ink instead of molding and lighting them, the way a spec sheet draws a key: every part outlined at one weight (`--echo-line`, `--echo-line-width`), hollow on the body, raised parts standing on a hard ink edge they travel down when pressed. Nothing glows, so what a molded device lights is solid ink; the primary key is the paper key that stands highest, and the accent stays the seed's own colour as the fill for what's on. Every contrast guarantee holds. Molded is the default, and a molded device renders exactly as it did.",
          "code": "const { css } = createEchoTheme({ chassis: \"#FBFBFB\", paper: \"#050505\", accent: \"#D3F44F\", render: \"drawn\" });\n// --echo-drawn: 1; --echo-line-width: 1.5px; --echo-primary-cap-top: #d3f44f"
        },
        {
          "title": "What's hard to read, past AA",
          "description": "The WCAG 2 ratio overrates bright colour on a dark ground. `report.readability` measures every text role on every ground in APCA, a dark device's cap against its body, and what changes for people with colour blindness: a `fail` is hard to read for anyone, a `note` is under comfortable. Lit text on a dark device is already lifted to read.",
          "code": "const { report } = createEchoTheme({ accent: \"#0A33E3\", chassis: \"#030529\" });\n// report.readability: [{ kind: \"text\", level: \"note\", message: \"dim text (#…) reads at APCA Lc 5… on the cap; small text reads well from 60.\" }, …]"
        },
        {
          "title": "When the accent moves",
          "description": "On a molded device every accent is held to one lightness, so a lit cap reads the same whatever the seed, and an acid yellow comes out olive. `report.accent` says what it became. A drawn device keeps the seed's colour as its fill, so nothing is reported.",
          "code": "const { report } = createEchoTheme({ accent: \"#F1FF01\" });\n// report.accent: { from: \"#F1FF01\", to: \"#929b00\" }"
        }
      ],
      "antiPatterns": [
        "Never hardcode hex in component CSS — consume var(--echo-*) so themes propagate.",
        "Never write a box-shadow by hand — take a recipe, var(--echo-shadow-well) and the rest, so it follows the key light and the finish.",
        "Do not read the seed inside components; components see only Layer 3 roles.",
        "Do not bypass createEchoTheme to hand-write role values — you lose the WCAG-AA contrast guardrail."
      ],
      "usage": {
        "imports": "import { createEchoTheme } from \"echo-ui-core/theme\";",
        "code": "const { css } = createEchoTheme({ accent: \"#3D8EF0\" });\nreturn <style>{css}</style>;"
      },
      "feel": "Pick one colour and the whole device relights around it — plastic, glow and screen stay the same material in a new hue.",
      "weight": {
        "js": 9748,
        "withDependencies": 9748,
        "css": 0
      }
    },
    {
      "name": "echo-sound",
      "title": "Sound Engine",
      "description": "SoundProvider + SynthBackend. Sound tokens are serializable synth recipes (dry, short, hardware) rendered by a zero-dep WebAudio synth. Lazy AudioContext, persisted mute, per-play jitter, blur guard, reduced-motion coupling. On a phone, the tokens that mark a choice, a completion, a warning or an error also buzz.",
      "summary": "SoundProvider + zero-dep WebAudio SynthBackend. Sound tokens are serializable synth recipes; lazy AudioContext, persisted mute, per-play jitter, blur guard, and reduced-motion coupling.",
      "group": "Foundations",
      "type": "core:module",
      "dependencies": [
        "echo-ui-core"
      ],
      "registryDependencies": [],
      "install": "npm install echo-ui-core",
      "props": [
        {
          "name": "backend",
          "type": "SoundBackend",
          "required": false,
          "description": "A SoundBackend to render recipes with. Defaults to the zero-dependency WebAudio SynthBackend; swap it to route sound elsewhere or silence it in tests."
        },
        {
          "name": "masterGain",
          "type": "number",
          "required": false,
          "defaultValue": "0.5",
          "description": "Conservative master gain, scales every recipe."
        },
        {
          "name": "muteOnReducedMotion",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Silence sound when the OS requests reduced motion."
        },
        {
          "name": "defaultMuted",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Muted until the person turns sound on, for anyone who hasn't chosen yet; a stored choice always wins. Pass false where sound is the point from the first press."
        },
        {
          "name": "haptics",
          "type": "boolean",
          "required": false,
          "defaultValue": "true",
          "description": "Buzz a phone for the sounds that carry a haptic (a choice changing, a completion, a warning, an error), whether or not sound is on. A device that can't vibrate ignores it; iPhones can't, from a web page."
        }
      ],
      "examples": [
        {
          "title": "Wrap the app",
          "description": "Provide sound once near the root. It starts muted until the person turns sound on; the AudioContext is created lazily on first pointerdown, and closed when the provider unmounts.",
          "code": "<SoundProvider>\n  <App />\n</SoundProvider>"
        },
        {
          "title": "Play a token",
          "description": "useSound().play resolves a token name to its recipe and jitters it.",
          "code": "const { play } = useSound();\n<button onPointerDown={() => play(\"press\")} onPointerUp={() => play(\"release\")} />"
        },
        {
          "title": "A haptic on its own",
          "description": "play buzzes for the tokens that carry a haptic, even while sound is off; buzz plays one with no sound. Android browsers vibrate; iPhones can't, from a web page.",
          "code": "const { play, buzz } = useSound();\nplay(\"select\"); // a tick in the hand, and the sound when it's on\nbuzz(\"success\");"
        },
        {
          "title": "Mute toggle",
          "description": "Sound starts muted, so offer this somewhere people can find it. Their choice is persisted to localStorage and wins over defaultMuted.",
          "code": "const { muted, setMuted } = useSound();\n<button onClick={() => setMuted(!muted)}>{muted ? \"unmute\" : \"mute\"}</button>"
        }
      ],
      "antiPatterns": [
        "Never create an AudioContext at module scope — autoplay policy blocks it and it breaks SSR.",
        "Do not add reverb or long tails; recipes stay dry and short (a device, not an instrument).",
        "Do not ship audio-file assets as the default sound path — synth recipes are the default backend."
      ],
      "usage": {
        "imports": "import { SoundProvider } from \"echo-ui-core/sound\";",
        "code": "<SoundProvider>\n  <App />\n</SoundProvider>"
      },
      "sound": [
        "Nine tokens: `tap`, `press`, `release`, `select`, `transition`, `notify`, `caution`, `error`, `celebrate`.",
        "Every play varies pitch by up to 3% and volume by up to 10%.",
        "Silent until the person turns sound on (it starts muted; `defaultMuted={false}` starts with it), until the first pointer press, when muted, in a hidden or unfocused tab, and — by default — under reduced motion.",
        "Haptics, on a phone that can vibrate: `select` a tick; `notify` and `celebrate` a success; `caution` a warning; `error` an error. A tap or a press never buzzes. They don't wait for sound to be on, stay still in a hidden tab, and `haptics={false}` turns them off."
      ],
      "feel": "Dry, short clicks and chirps from a device — never notes from an instrument.",
      "api": {
        "SoundProvider": {
          "props": [
            "backend",
            "children",
            "defaultMuted",
            "haptics",
            "masterGain",
            "muteOnReducedMotion"
          ],
          "required": [
            "children"
          ]
        }
      },
      "weight": {
        "js": 2106,
        "withDependencies": 2106,
        "css": 0
      }
    },
    {
      "name": "echo-motion",
      "title": "Motion Tokens",
      "description": "Named motion tokens carrying duration, GSAP ease, and an explicit reduced-motion collapse, plus four springs (track, smooth, snappy, bouncy) for parts the hand can redirect mid-flight. The rulebook is one line — glass and light move SMOOTHLY (summon, flood, settle, bloom); pixel things move SNAPPY or STEPPY (press, pop, glitch, boot). The physics tokens (lift, follow, travel, squash, rebound, light, island) time a part that has weight. Pure data with no window or GSAP access at module scope, so it is safe to import anywhere.",
      "summary": "GSAP motion tokens (duration + ease as serializable data), each with an optional reduced-motion collapse variant. Glass moves smooth; pixels move steppy.",
      "group": "Foundations",
      "type": "core:module",
      "dependencies": [
        "echo-ui-core",
        "gsap"
      ],
      "registryDependencies": [],
      "install": "npm install echo-ui-core",
      "props": [
        {
          "name": "duration",
          "type": "number",
          "required": false,
          "description": "Duration in ms. Omitted for scrub-driven tokens."
        },
        {
          "name": "ease",
          "type": "string",
          "required": true,
          "description": "GSAP ease string."
        },
        {
          "name": "loop",
          "type": "boolean",
          "required": false,
          "description": "Looping idle animation."
        },
        {
          "name": "reduced",
          "type": "\"none\" | object",
          "required": false,
          "description": "How the token collapses under prefers-reduced-motion. Defaults to \"none\"."
        }
      ],
      "examples": [
        {
          "title": "Drive a tween",
          "description": "Tokens are data; you pass them to GSAP yourself.",
          "code": "const t = motion.summon;\ngsap.to(el, { opacity: 1, duration: t.duration / 1000, ease: t.ease });"
        },
        {
          "title": "Through useTactile",
          "description": "The usual path — the token names the gesture, the hook resolves it.",
          "code": "const { ref, handlers } = useTactile({ press: { motion: \"press\" }, release: { motion: \"pop\" } });"
        },
        {
          "title": "Honour the collapse",
          "description": "Every token declares what it becomes under reduced motion; never ignore it.",
          "code": "const t = motion.summon;\nconst vars = reduced && t.reduced !== \"none\" ? { opacity: 1 } : { opacity: 1, scale: 1 };"
        }
      ],
      "antiPatterns": [
        "Never put a soft material ease on a pixel element — an LCD or dot-matrix surface takes glitch (steps), never summon or settle. This is the single most legible motion mistake in the system.",
        "Never invent a duration inline. If a gesture needs timing the tokens do not cover, add a token and document it; scattered magic numbers are how a motion system dies.",
        "Never animate a token's transform under prefers-reduced-motion — read `reduced` and collapse to the fade (or to nothing) as the token instructs.",
        "Never use `drain` with a duration — it is scrub-driven, timed by the clock that owns it, which is why it ships no duration at all."
      ],
      "usage": {
        "imports": "import gsap from \"gsap\";\nimport { motion } from \"echo-ui-core/motion\";",
        "code": "const t = motion.summon;\ngsap.to(panel, { opacity: 1, duration: t.duration / 1000, ease: t.ease });"
      },
      "motion": {
        "tokens": [
          "press",
          "pop",
          "summon",
          "breath",
          "settle",
          "glitch",
          "flood",
          "drain",
          "lift",
          "follow",
          "travel",
          "squash",
          "rebound",
          "light",
          "boot",
          "bloom",
          "island"
        ],
        "reducedMotion": "Each token declares its own collapse: `summon`, `flood`, `bloom` and `island` fade in place, `boot` fades quickly; every other token stops moving. Springs are skipped: the part jumps to where it is going."
      },
      "feel": "Glass moves smooth, pixels move steppy: panels ease into place, screen readouts tick over in steps.",
      "weight": {
        "js": 433,
        "withDependencies": 433,
        "css": 0
      }
    },
    {
      "name": "use-tactile",
      "title": "useTactile",
      "description": "Binds motion (GSAP), sound and haptics per interaction phase. Returns { ref, handlers } to spread onto an element. Pointer and keyboard feel identical; motion collapses under prefers-reduced-motion. The same entry exports the physics Echo's own parts move on: createSpring for anything the hand can redirect, useCapPhysics for a cap that presses like Echo's keys, and the liquid cap and morph engine under Morph.",
      "summary": "The fusion primitive: binds motion (GSAP) + sound per interaction phase, with pointer/keyboard parity and prefers-reduced-motion collapse. Accepts token names or raw GSAP vars / sound recipes as an escape hatch. The same entry holds the physics the components are built on: springs, cap presses, the typing well, the liquid cap, and the morph engine (MorphSurface, morphFeels) that divides one cap into several.",
      "group": "Foundations",
      "type": "core:module",
      "dependencies": [
        "echo-ui-core",
        "gsap"
      ],
      "registryDependencies": [],
      "install": "npm install echo-ui-core",
      "props": [
        {
          "name": "press",
          "type": "object",
          "required": false,
          "description": "As it goes down: on pointer down, or Space or Enter."
        },
        {
          "name": "release",
          "type": "object",
          "required": false,
          "description": "As it comes back up: on letting go, or when the pointer leaves or focus moves away mid-press."
        },
        {
          "name": "hover",
          "type": "object",
          "required": false,
          "description": "When a pointer comes over it."
        },
        {
          "name": "leave",
          "type": "object",
          "required": false,
          "description": "When the pointer leaves it."
        },
        {
          "name": "focus",
          "type": "object",
          "required": false,
          "description": "When it takes focus."
        },
        {
          "name": "blur",
          "type": "object",
          "required": false,
          "description": "When focus leaves it."
        },
        {
          "name": "activate",
          "type": "object",
          "required": false,
          "description": "When it's activated: a click, which a keyboard's Enter or Space also makes."
        }
      ],
      "examples": [
        {
          "title": "Tactile button",
          "description": "The canonical press/release pairing.",
          "code": "const { ref, handlers } = useTactile({\n  press:   { motion: \"press\", sound: \"press\" },\n  release: { motion: \"pop\",   sound: \"release\" },\n});\nreturn <button ref={ref} {...handlers}>Record</button>;"
        },
        {
          "title": "A buzz with the choice",
          "description": "On a phone, a phase can buzz: select's tick comes with its sound; name one for a phase that has no sound.",
          "code": "useTactile({\n  activate: { sound: \"select\" },\n  release:  { haptic: \"tick\" },\n});"
        },
        {
          "title": "Escape hatch",
          "description": "Pass raw GSAP vars / recipe objects instead of token names for full control.",
          "code": "useTactile({ press: { motion: { rotation: -3, duration: 0.1 } } });"
        },
        {
          "title": "A spring you can redirect",
          "description": "createSpring carries its velocity: sent somewhere else mid-flight, it bends toward the new target instead of starting again from rest. The presets are `springs` in echo-ui-core/motion.",
          "code": "const x = createSpring(0, springs.snappy, (v) => knob.style.setProperty(\"--x\", `${v}px`));\nx.to(120);\n// Sent back mid-flight, it keeps its momentum.\nx.to(-40);\n// Let go of a flick: start from the pointer's speed.\nx.to(0, { velocity: 900 });"
        },
        {
          "title": "A cap that presses like Echo's keys",
          "description": "useCapPhysics: it lifts and leans toward the pointer, clicks as it lands on its seam, loads while held and snaps home. HardwareButton is built on it.",
          "code": "const { ref, handlers } = useCapPhysics<HTMLButtonElement>({ tilt: 4, sound: true });\nreturn <button ref={ref} {...handlers}>Arm</button>;"
        },
        {
          "title": "A spring under reduced motion",
          "description": "createSpring doesn't read the preference; jump with set instead of travelling with to.",
          "code": "const reduced = useReducedMotion();\nif (reduced) x.set(120);\nelse x.to(120);"
        }
      ],
      "antiPatterns": [
        "Do not re-implement keyboard handling on top of useTactile — Space/Enter parity is built in.",
        "Do not put a smooth material ease on pixel/LCD elements — use motion.glitch (steppy).",
        "Do not gate sound on reduced-motion here; the SoundProvider already couples them.",
        "Never hand-roll a spring, or a requestAnimationFrame ease, for a part the hand can move or redirect: a tween restarts from rest when retargeted, createSpring keeps its momentum.",
        "Never call a spring's `to` under reduced motion: createSpring doesn't read the preference, so `set` the value there (useReducedMotion says when)."
      ],
      "usage": {
        "imports": "import { useTactile } from \"echo-ui-core/tactile\";",
        "code": "const { ref, handlers } = useTactile<HTMLButtonElement>({\n  press: { motion: \"press\", sound: \"press\" },\n  release: { motion: \"pop\", sound: \"release\" },\n});\nreturn <button ref={ref} {...handlers}>Record</button>;"
      },
      "a11y": [
        "Keyboard: Space and Enter press and release the element the same way a pointer does; a held key doesn't repeat the press.",
        "It adds feel, not semantics: spread its handlers onto a real button (or give the element a role and a name)."
      ],
      "motion": {
        "tokens": [
          "press",
          "pop"
        ],
        "reducedMotion": "Motion follows each token's own collapse; sound follows the SoundProvider, which mutes under reduced motion by default."
      },
      "sound": [
        "Whatever each phase names — typically `press` on the way down and `release` on the way up. Space and Enter play the same as a pointer."
      ],
      "feel": "One hook makes a custom control press, spring back and click. For a molded cap that should press like Echo's own keys, with the detent and the clicks on the mechanism, use useCapPhysics.",
      "api": {
        "MorphSurface": {
          "props": [
            "body",
            "className",
            "feel",
            "labelClassName",
            "labelStyle",
            "labels",
            "onMorph",
            "radius",
            "style",
            "tones",
            "well",
            "width"
          ],
          "required": [
            "body",
            "feel",
            "labels",
            "onMorph",
            "tones",
            "width"
          ]
        }
      },
      "weight": {
        "js": 11161,
        "withDependencies": 38408,
        "css": 0
      }
    },
    {
      "name": "use-gsap-presence",
      "title": "useGsapPresence",
      "description": "The GSAP exit bridge, implemented once and reused by every Base UI wrapper (Modal, Popover, Tooltip). Base UI can auto-detect a CSS transition before unmounting, but not a JS animation — so on close this hook asks Base UI to keep the node mounted, runs the GSAP exit, and calls unmount() itself when the tween finishes. It is Base-UI-agnostic: it only needs an actions object exposing unmount().",
      "summary": "The GSAP-exit bridge (implemented once, reused by every Base UI wrapper): controls open state + actionsRef so the exit animation completes before Base UI unmounts. Runs the enter on mount.",
      "group": "Foundations",
      "type": "core:module",
      "dependencies": [
        "echo-ui-core",
        "gsap"
      ],
      "registryDependencies": [],
      "install": "npm install echo-ui-core",
      "props": [
        {
          "name": "enter",
          "type": "string",
          "required": false,
          "defaultValue": "\"bloom\"",
          "description": "Motion token for the enter timing: it grows out of what opened it."
        },
        {
          "name": "exit",
          "type": "string",
          "required": false,
          "defaultValue": "\"summon\"",
          "description": "Motion token for the exit timing."
        },
        {
          "name": "animate",
          "type": "object",
          "required": false,
          "description": "Take over the motion: enter(el, { trigger }) when the popup mounts, exit(el, done, { trigger }) when it closes — call done and it unmounts. Skipped under reduced motion for the plain fade."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false,
          "defaultValue": "false",
          "description": "Initial open state."
        },
        {
          "name": "onOpened",
          "type": "function",
          "required": false,
          "description": "Called once the enter animation has started."
        }
      ],
      "examples": [
        {
          "title": "Wire a Base UI Root",
          "description": "Spread the three controlled props; attach popupRef to the animated popup. The hook types its actions as the one method it calls (`unmount`), so narrow them to the Root's own Actions type at the call site.",
          "code": "const p = useGsapPresence();\n<Dialog.Root\n  open={p.open}\n  onOpenChange={p.onOpenChange}\n  actionsRef={p.actionsRef as React.RefObject<Dialog.Root.Actions | null>}\n>\n  <Dialog.Popup ref={p.popupRef}>…</Dialog.Popup>\n</Dialog.Root>"
        },
        {
          "title": "Different enter and exit",
          "description": "Tokens name the timing at both ends.",
          "code": "const p = useGsapPresence({ enter: \"summon\", exit: \"glitch\" });"
        }
      ],
      "antiPatterns": [
        "Never rely on actionsRef alone to hold the element mounted — it does not. The exit only survives because onOpenChange calls details.preventUnmountOnClose(); drop that call and Base UI removes the node mid-tween.",
        "Never also put a CSS transition on the popup's transform or opacity. GSAP owns those properties here, and Base UI's transition auto-detection will race the tween. Transition the backdrop instead.",
        "Never use this with Base UI Select — its ChangeEventDetails has no preventUnmountOnClose, so there is nothing to hold the node. Select animates through [data-starting-style] / [data-ending-style] CSS instead.",
        "Never assert on role=\"dialog\" to test the exit — Base UI drops the role immediately on close while the node is still animating. Target the popup element's class."
      ],
      "usage": {
        "imports": "import type { RefObject } from \"react\";\nimport { Dialog } from \"@base-ui/react/dialog\";\nimport { useGsapPresence } from \"echo-ui-core/presence\";",
        "code": "const p = useGsapPresence();\nreturn (\n  <Dialog.Root\n    open={p.open}\n    onOpenChange={p.onOpenChange}\n    actionsRef={p.actionsRef as RefObject<Dialog.Root.Actions | null>}\n  >\n    <Dialog.Portal>\n      <Dialog.Popup ref={p.popupRef}>…</Dialog.Popup>\n    </Dialog.Portal>\n  </Dialog.Root>\n);"
      },
      "motion": {
        "tokens": [
          "bloom",
          "summon"
        ],
        "reducedMotion": "Enter and exit follow the tokens' collapse — `bloom` and `summon` become a fade — and the node still unmounts when it ends."
      },
      "feel": "A popup leaves the way it arrived, instead of vanishing mid-animation.",
      "weight": {
        "js": 1896,
        "withDependencies": 29325,
        "css": 0
      }
    }
  ],
  "tokens": {
    "colorRoles": [
      "--echo-surface",
      "--echo-cap",
      "--echo-cap-highlight",
      "--echo-cap-shadow",
      "--echo-well",
      "--echo-accent",
      "--echo-accent-highlight",
      "--echo-accent-pressed",
      "--echo-accent-glow",
      "--echo-accent-ink",
      "--echo-primary-cap-top",
      "--echo-primary-cap-mid",
      "--echo-primary-cap-base",
      "--echo-primary-cap-ink",
      "--echo-pour-top",
      "--echo-pour-mid",
      "--echo-pour-base",
      "--echo-accent-fill-top",
      "--echo-accent-fill-base",
      "--echo-lcd",
      "--echo-lcd-glow",
      "--echo-text",
      "--echo-text-dim",
      "--echo-seam"
    ],
    "sound": [
      "tap",
      "press",
      "release",
      "select",
      "transition",
      "notify",
      "caution",
      "error",
      "celebrate"
    ],
    "motion": [
      "press",
      "pop",
      "summon",
      "breath",
      "settle",
      "glitch",
      "flood",
      "drain",
      "lift",
      "follow",
      "travel",
      "squash",
      "rebound",
      "light",
      "boot",
      "bloom",
      "island"
    ],
    "springs": [
      "track",
      "smooth",
      "snappy",
      "bouncy"
    ],
    "morphFeels": [
      "cell",
      "mercury",
      "cut"
    ],
    "durations": [
      "--echo-duration-glitch",
      "--echo-duration-press",
      "--echo-duration-tick",
      "--echo-duration-swap",
      "--echo-duration-glide",
      "--echo-duration-sweep",
      "--echo-duration-pop",
      "--echo-duration-flood"
    ],
    "eases": [
      "--echo-ease-glitch",
      "--echo-ease-press",
      "--echo-ease-tick",
      "--echo-ease-swap",
      "--echo-ease-glide",
      "--echo-ease-sweep",
      "--echo-ease-pop",
      "--echo-ease-flood"
    ],
    "shadows": [
      {
        "token": "--echo-shadow-well",
        "kind": "recessed",
        "use": "Set into the body: a field, a tray, the bed a cap sits in."
      },
      {
        "token": "--echo-shadow-well-screen",
        "kind": "recessed",
        "use": "The glass of a screen or a readout."
      },
      {
        "token": "--echo-shadow-well-deep",
        "kind": "recessed",
        "use": "A deep bezel: a large screen, a keypad's window."
      },
      {
        "token": "--echo-shadow-groove",
        "kind": "recessed",
        "use": "A track cut into the body, for progress and levels."
      },
      {
        "token": "--echo-shadow-edge",
        "kind": "raised",
        "use": "Light catching the top edge of a small raised part."
      },
      {
        "token": "--echo-shadow-hairline",
        "kind": "raised",
        "use": "The lit lip under a divider."
      },
      {
        "token": "--echo-shadow-chip",
        "kind": "raised",
        "use": "A small raised part: a badge, a count."
      },
      {
        "token": "--echo-shadow-key",
        "kind": "raised",
        "use": "A low key standing on its seam."
      },
      {
        "token": "--echo-shadow-cap",
        "kind": "raised",
        "use": "A molded cap at rest."
      },
      {
        "token": "--echo-shadow-cap-lit",
        "kind": "raised",
        "use": "The lit cap, the one place the hardware turns accent."
      },
      {
        "token": "--echo-shadow-panel",
        "kind": "raised",
        "use": "A panel resting on the body."
      },
      {
        "token": "--echo-shadow-lifted",
        "kind": "raised",
        "use": "A panel lifted off the body, the way a card rises under the pointer."
      },
      {
        "token": "--echo-shadow-chassis",
        "kind": "raised",
        "use": "The device's own body."
      },
      {
        "token": "--echo-shadow-tooltip",
        "kind": "floating",
        "use": "A tooltip, the lowest thing that floats."
      },
      {
        "token": "--echo-shadow-popover",
        "kind": "floating",
        "use": "A popover or a small popup."
      },
      {
        "token": "--echo-shadow-toast",
        "kind": "floating",
        "use": "An announcement floating over the page."
      },
      {
        "token": "--echo-shadow-modal",
        "kind": "floating",
        "use": "A dialog, the highest thing that floats."
      },
      {
        "token": "--echo-shadow-ring",
        "kind": "focus",
        "use": "Keyboard focus around a raised part: a gap in the body's colour, then the accent's ink (the glow, on a dark device)."
      },
      {
        "token": "--echo-shadow-ring-inset",
        "kind": "focus",
        "use": "Keyboard focus inside a well, where a ring outside would sit on the rim."
      },
      {
        "token": "--echo-shadow-glow",
        "kind": "glow",
        "use": "The glow of something lit: the current tab, the page you're on."
      }
    ]
  },
  "htmlAttributes": [
    "about",
    "accept",
    "acceptCharset",
    "accessKey",
    "action",
    "allowFullScreen",
    "allowTransparency",
    "alt",
    "as",
    "async",
    "autoCapitalize",
    "autoComplete",
    "autoCorrect",
    "autoFocus",
    "autoPlay",
    "autoSave",
    "capture",
    "cellPadding",
    "cellSpacing",
    "challenge",
    "charSet",
    "checked",
    "children",
    "cite",
    "classID",
    "className",
    "colSpan",
    "color",
    "cols",
    "content",
    "contentEditable",
    "contextMenu",
    "controls",
    "coords",
    "crossOrigin",
    "dangerouslySetInnerHTML",
    "data",
    "datatype",
    "dateTime",
    "default",
    "defaultChecked",
    "defaultValue",
    "defer",
    "dir",
    "disabled",
    "download",
    "draggable",
    "encType",
    "enterKeyHint",
    "exportparts",
    "form",
    "formAction",
    "formEncType",
    "formMethod",
    "formNoValidate",
    "formTarget",
    "frameBorder",
    "headers",
    "height",
    "hidden",
    "high",
    "href",
    "hrefLang",
    "htmlFor",
    "httpEquiv",
    "id",
    "inert",
    "inlist",
    "inputMode",
    "integrity",
    "is",
    "itemID",
    "itemProp",
    "itemRef",
    "itemScope",
    "itemType",
    "keyParams",
    "keyType",
    "kind",
    "label",
    "lang",
    "list",
    "loop",
    "low",
    "manifest",
    "marginHeight",
    "marginWidth",
    "max",
    "maxLength",
    "media",
    "mediaGroup",
    "method",
    "min",
    "minLength",
    "multiple",
    "muted",
    "name",
    "noValidate",
    "nonce",
    "onAbort",
    "onAbortCapture",
    "onAnimationEnd",
    "onAnimationEndCapture",
    "onAnimationIteration",
    "onAnimationIterationCapture",
    "onAnimationStart",
    "onAnimationStartCapture",
    "onAuxClick",
    "onAuxClickCapture",
    "onBeforeInput",
    "onBeforeInputCapture",
    "onBeforeToggle",
    "onBlur",
    "onBlurCapture",
    "onCanPlay",
    "onCanPlayCapture",
    "onCanPlayThrough",
    "onCanPlayThroughCapture",
    "onChange",
    "onChangeCapture",
    "onClick",
    "onClickCapture",
    "onCompositionEnd",
    "onCompositionEndCapture",
    "onCompositionStart",
    "onCompositionStartCapture",
    "onCompositionUpdate",
    "onCompositionUpdateCapture",
    "onContextMenu",
    "onContextMenuCapture",
    "onCopy",
    "onCopyCapture",
    "onCut",
    "onCutCapture",
    "onDoubleClick",
    "onDoubleClickCapture",
    "onDrag",
    "onDragCapture",
    "onDragEnd",
    "onDragEndCapture",
    "onDragEnter",
    "onDragEnterCapture",
    "onDragExit",
    "onDragExitCapture",
    "onDragLeave",
    "onDragLeaveCapture",
    "onDragOver",
    "onDragOverCapture",
    "onDragStart",
    "onDragStartCapture",
    "onDrop",
    "onDropCapture",
    "onDurationChange",
    "onDurationChangeCapture",
    "onEmptied",
    "onEmptiedCapture",
    "onEncrypted",
    "onEncryptedCapture",
    "onEnded",
    "onEndedCapture",
    "onError",
    "onErrorCapture",
    "onFocus",
    "onFocusCapture",
    "onGotPointerCapture",
    "onGotPointerCaptureCapture",
    "onInput",
    "onInputCapture",
    "onInvalid",
    "onInvalidCapture",
    "onKeyDown",
    "onKeyDownCapture",
    "onKeyPress",
    "onKeyPressCapture",
    "onKeyUp",
    "onKeyUpCapture",
    "onLoad",
    "onLoadCapture",
    "onLoadStart",
    "onLoadStartCapture",
    "onLoadedData",
    "onLoadedDataCapture",
    "onLoadedMetadata",
    "onLoadedMetadataCapture",
    "onLostPointerCapture",
    "onLostPointerCaptureCapture",
    "onMouseDown",
    "onMouseDownCapture",
    "onMouseEnter",
    "onMouseLeave",
    "onMouseMove",
    "onMouseMoveCapture",
    "onMouseOut",
    "onMouseOutCapture",
    "onMouseOver",
    "onMouseOverCapture",
    "onMouseUp",
    "onMouseUpCapture",
    "onPaste",
    "onPasteCapture",
    "onPause",
    "onPauseCapture",
    "onPlay",
    "onPlayCapture",
    "onPlaying",
    "onPlayingCapture",
    "onPointerCancel",
    "onPointerCancelCapture",
    "onPointerDown",
    "onPointerDownCapture",
    "onPointerEnter",
    "onPointerLeave",
    "onPointerMove",
    "onPointerMoveCapture",
    "onPointerOut",
    "onPointerOutCapture",
    "onPointerOver",
    "onPointerOverCapture",
    "onPointerUp",
    "onPointerUpCapture",
    "onProgress",
    "onProgressCapture",
    "onRateChange",
    "onRateChangeCapture",
    "onReset",
    "onResetCapture",
    "onScroll",
    "onScrollCapture",
    "onScrollEnd",
    "onScrollEndCapture",
    "onSeeked",
    "onSeekedCapture",
    "onSeeking",
    "onSeekingCapture",
    "onSelect",
    "onSelectCapture",
    "onStalled",
    "onStalledCapture",
    "onSubmit",
    "onSubmitCapture",
    "onSuspend",
    "onSuspendCapture",
    "onTimeUpdate",
    "onTimeUpdateCapture",
    "onToggle",
    "onTouchCancel",
    "onTouchCancelCapture",
    "onTouchEnd",
    "onTouchEndCapture",
    "onTouchMove",
    "onTouchMoveCapture",
    "onTouchStart",
    "onTouchStartCapture",
    "onTransitionCancel",
    "onTransitionCancelCapture",
    "onTransitionEnd",
    "onTransitionEndCapture",
    "onTransitionRun",
    "onTransitionRunCapture",
    "onTransitionStart",
    "onTransitionStartCapture",
    "onVolumeChange",
    "onVolumeChangeCapture",
    "onWaiting",
    "onWaitingCapture",
    "onWheel",
    "onWheelCapture",
    "open",
    "optimum",
    "part",
    "pattern",
    "placeholder",
    "playsInline",
    "popover",
    "popoverTarget",
    "popoverTargetAction",
    "poster",
    "prefix",
    "preload",
    "property",
    "radioGroup",
    "readOnly",
    "rel",
    "required",
    "resource",
    "results",
    "rev",
    "reversed",
    "role",
    "rowSpan",
    "rows",
    "sandbox",
    "scope",
    "scoped",
    "scrolling",
    "seamless",
    "security",
    "selected",
    "shape",
    "size",
    "sizes",
    "slot",
    "span",
    "spellCheck",
    "src",
    "srcDoc",
    "srcLang",
    "srcSet",
    "start",
    "step",
    "style",
    "summary",
    "suppressContentEditableWarning",
    "suppressHydrationWarning",
    "tabIndex",
    "target",
    "title",
    "translate",
    "type",
    "typeof",
    "unselectable",
    "useMap",
    "value",
    "vocab",
    "width",
    "wmode",
    "wrap"
  ],
  "materials": [
    "echo-button-cap",
    "echo-button-cap--active",
    "echo-button-well",
    "echo-chassis",
    "echo-screen-well"
  ]
}
