{
  "package": "ionbase-ui",
  "version": "0.138.1",
  "generated": "by scripts/build-meta.mjs — do not edit; intent lives in meta/*.json",
  "hooks": [
    "useAgentRun",
    "useLocale",
    "useTableColumns",
    "useTableSelection",
    "useTableSort",
    "useToast"
  ],
  "helpers": [
    "agentRunFrom",
    "agentRunHasEnded",
    "agentRunLog",
    "agentRunReducer",
    "chartAxisProps",
    "chartGridProps",
    "chartSeriesClass",
    "chartTooltipProps",
    "initialAgentRunState",
    "replayAgentRun"
  ],
  "components": {
    "Accordion": {
      "name": "Accordion",
      "source": "src/components/Accordion.tsx",
      "propsType": "AccordionProps",
      "description": "Accordion — sections that collapse, with the heading structure intact.\n\nWHY THE TRIGGER IS A BUTTON INSIDE A HEADING\n\nBoth parts are load-bearing and the obvious implementations drop one of them.\nThe heading is how a screen-reader user navigates a long page — pressing `h`\nmoves between sections, and an accordion built from `div`s removes every one\nof those stops. The button is what makes the section operable by keyboard and\nannounced as expandable; a heading with a click handler is neither.\n\nSo the shape is `<h3><button aria-expanded aria-controls>`, which is the only\narrangement that keeps document structure AND operability.\n\n`headingLevel` is a prop with no safe default beyond 3, because the right\nlevel depends on the document around it and nothing here can see that. Two\naccordions at the wrong level produce a page whose outline is nonsense while\nlooking perfectly fine.\n\nTHE PANEL IS NOT UNMOUNTED\n\nCollapsed sections keep their DOM and are hidden with `hidden`. Unmounting\nwould lose form state in a collapsed section — the classic multi-step-form\nbug where answers vanish when a section is folded away — and would break\nin-page search, which cannot find text that is not there.",
      "import": "import { Accordion } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Sections that collapse, with the document's heading structure intact. Holds the open set and the heading level.",
      "useWhen": [
        "a page has several long sections and most users need one of them",
        "optional or advanced settings should be present but not in the way",
        "an FAQ or a reference list where scanning titles is the primary action"
      ],
      "useInstead": [
        {
          "when": "the sections are alternatives and only one is ever relevant",
          "use": "Tabs",
          "why": "tabs say \"pick one\"; an accordion says \"all of these exist, open what you need\""
        },
        {
          "when": "hiding the content would hide something the user must act on",
          "use": "render it directly",
          "why": "a required field inside a collapsed section is a form that cannot be completed without exploration"
        }
      ],
      "slots": {
        "children": {
          "accepts": "AccordionItem"
        }
      },
      "a11y": {
        "requires": [
          "`headingLevel` that fits the surrounding document — the default of 3 is a guess this component cannot verify"
        ],
        "notes": [
          "each section's trigger is a button inside a heading, which is the only arrangement that keeps both document structure and operability",
          "collapsed panels are hidden rather than unmounted, so form state survives and in-page search still finds the text"
        ]
      },
      "antiPatterns": [
        {
          "dont": "allowsMultiple on a long FAQ",
          "do": "leave it off, so opening one section closes the last",
          "why": "with every section openable at once the page has no maximum height, and a user who opens six is scrolling a wall of text looking for the one they wanted. Reach for it when sections are settings or reference material a user may need to compare side by side"
        },
        {
          "dont": "headingLevel left at 3 inside a section already under an h4",
          "do": "headingLevel={5}",
          "why": "the page looks correct and its outline is nonsense — a heading-level jump is invisible to sighted users and disorienting to everyone navigating by headings"
        },
        {
          "dont": "an accordion holding a single section",
          "do": "a heading and the content",
          "why": "one collapsible section is a control that exists to hide the only thing on screen"
        }
      ],
      "stylesheet": "src/styles/accordion.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-disabled",
        "--icon-secondary",
        "--icon-size-md",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-xs",
        "--spacing-12",
        "--spacing-16",
        "--spacing-4",
        "--text-default",
        "--text-disabled",
        "--text-primary",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height"
      ],
      "props": {
        "allowsMultiple": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Let more than one section be open at once."
        },
        "defaultExpandedKeys": {
          "type": "string[] | undefined",
          "required": false,
          "origin": "own",
          "description": "Uncontrolled: which sections start open."
        },
        "expandedKeys": {
          "type": "string[] | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled: which sections are open. Pass `onExpandedChange` with it."
        },
        "onExpandedChange": {
          "type": "((keys: string[]) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "headingLevel": {
          "type": "AccordionHeadingLevel | undefined",
          "required": false,
          "origin": "own",
          "description": "The level each section's heading renders at. Must fit the surrounding\ndocument — a panel inside an `h2` section wants `3`."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "AccordionItem": {
      "name": "AccordionItem",
      "source": "src/components/Accordion.tsx",
      "propsType": "AccordionItemProps",
      "import": "import { AccordionItem } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One collapsible section: an always-visible title that toggles a region beneath it.",
      "useWhen": [
        "inside an Accordion, once per section"
      ],
      "slots": {
        "title": {
          "accepts": "text",
          "note": "always visible, so it must say what is inside — \"Advanced\" tells the user nothing about whether to open it"
        },
        "children": {
          "accepts": "any content"
        }
      },
      "a11y": {
        "requires": [
          "a stable `id`, which is the section's identity in the open set — an index breaks the moment the list reorders"
        ],
        "notes": [
          "the trigger carries aria-expanded and aria-controls; the panel is a region labelled by the trigger",
          "the panel keeps its DOM when collapsed, so a half-filled form inside one does not lose its answers"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a required form field inside a collapsed section",
          "do": "keep required fields outside, or expand the section when validation fails",
          "why": "a user cannot fix an error they cannot see, and the browser cannot focus a hidden invalid field"
        }
      ],
      "stylesheet": "src/styles/accordion.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-disabled",
        "--icon-secondary",
        "--icon-size-md",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-xs",
        "--spacing-12",
        "--spacing-16",
        "--spacing-4",
        "--text-default",
        "--text-disabled",
        "--text-primary",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height"
      ],
      "props": {
        "id": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "Stable identity for the open set. Required."
        },
        "title": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The always-visible label."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 275,
        "other": 0
      }
    },
    "AgentActivity": {
      "name": "AgentActivity",
      "source": "src/components/AgentActivity.tsx",
      "propsType": "AgentActivityProps",
      "description": "AgentActivity — what the agent is doing, in plain language.\n\nAn ordered list, because the steps happened in an order and a screen reader\nshould say \"3 of 7\". Not a log viewer: this is the account a person reads to\ndecide whether to let the run continue, so the text belongs in their\nlanguage — \"Searched the invoice archive\", not `searchIndex(q, {limit:50})`.\n\nSTATUS IS NEVER CARRIED BY THE ICON ALONE. Every step renders its status as\ntext as well as a glyph. The glyphs differ in shape rather than only in\ncolour, so the list survives greyscale, colour blindness and forced-colours\nmode — WCAG 1.4.1, which a row of coloured dots fails outright.\n\nONE POLITE ANNOUNCEMENT PER STEP, not one per render. The active step's text\nis announced when it changes, so a user who is not watching still knows where\nthe run has got to. Watching the DOM instead would re-announce on every\nunrelated update, which is the failure mode that makes people turn logs off.",
      "import": "import { AgentActivity } from 'ionbase-ui';",
      "status": "stable",
      "summary": "An ordered, plain-language log of what the agent is doing. Status is text and shape, never colour alone.",
      "useWhen": [
        "a run takes long enough that the user needs to know where it has got to",
        "a person will decide whether to let it continue — which means they need to read what it did"
      ],
      "useInstead": [
        {
          "when": "the output is prose rather than steps",
          "use": "StreamingText"
        },
        {
          "when": "you need a decision, not a report",
          "use": "ApprovalGate"
        },
        {
          "when": "it is one finished outcome",
          "use": "Alert"
        }
      ],
      "composition": {
        "order": [
          "AgentActivity",
          "AgentActivityStep"
        ],
        "note": "An <ol>, so a screen reader can say \"3 of 7\" and the order is structural rather than typed into each row.",
        "example": "<AgentActivity><AgentActivityStep status=\"done\">Searched the invoice archive</AgentActivityStep><AgentActivityStep status=\"active\">Reading 12 matches</AgentActivityStep></AgentActivity>"
      },
      "a11y": {
        "role": "list",
        "guarantees": [
          "every step renders its status as visually hidden text as well as a glyph, and the glyphs differ in shape rather than only in colour — a row of coloured dots fails WCAG 1.4.1 outright",
          "the active step is announced once, when it changes, in a polite live region",
          "the active step's spinner stops under `prefers-reduced-motion`"
        ],
        "requires": [
          "step text in the user's language — \"Searched the invoice archive\", not a function signature",
          "`announceActive={false}` when several logs are on screen, or they narrate over each other"
        ],
        "notes": [
          "The active step is read out of the children rather than taken as a prop. Two sources for one fact is how they come to disagree."
        ]
      },
      "antiPatterns": [
        {
          "dont": "raw tool calls or JSON as the step label",
          "why": "this is the account a person reads to decide whether to let the run continue"
        },
        {
          "dont": "more than one step with `status=\"active\"`",
          "why": "the announcement names one thing, and two claims about what is happening now cannot both be true"
        },
        {
          "dont": "several announcing logs on one screen",
          "do": "`announceActive={false}` on all but one",
          "why": "they interrupt each other and the user turns the whole thing off"
        }
      ],
      "stylesheet": "src/styles/agent-activity.css",
      "tokens": [
        "--font-family-sans",
        "--icon-error",
        "--icon-primary",
        "--icon-size-sm",
        "--icon-success",
        "--icon-tertiary",
        "--ion-duration-cycle",
        "--ion-ease-linear",
        "--spacing-2",
        "--spacing-8",
        "--text-default",
        "--text-error",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "announceActive": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Announce the step that just became active, once, in a polite live region.\n\nOn by default because the whole point of an activity log is knowing what\nthe agent is doing without watching it. Turn it off when several logs are\non screen at once, or they narrate over each other."
        }
      },
      "propCounts": {
        "own": 2,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "AgentActivityStep": {
      "name": "AgentActivityStep",
      "source": "src/components/AgentActivity.tsx",
      "propsType": "AgentActivityStepProps",
      "import": "import { AgentActivityStep } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One step in an activity log: what happened, in the user's language, with a status carried by shape and text.",
      "useWhen": [
        "a child of AgentActivity — it is not usable elsewhere"
      ],
      "useInstead": [
        {
          "when": "it is a row in a dropdown",
          "use": "MenuItem"
        },
        {
          "when": "it is a data row",
          "use": "TableRow"
        }
      ],
      "variants": {
        "status": {
          "pending": {
            "use": "the default — queued, not started"
          },
          "active": {
            "use": "happening now. Only one step should carry it"
          },
          "done": {
            "use": "finished successfully"
          },
          "failed": {
            "use": "did not finish. Say why in `detail`"
          },
          "skipped": {
            "use": "deliberately not run — a branch not taken, a cached result"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "text",
          "note": "the step in plain language"
        },
        "detail": {
          "accepts": "text, ToolCall, ApprovalGate",
          "note": "the result, a count, a tool name — whatever makes the step checkable. Block content is valid here: a ToolCall as evidence under the step, or the ApprovalGate the step is waiting on"
        }
      },
      "a11y": {
        "guarantees": [
          "the status name is rendered as visually hidden text, so it is never carried by the glyph's colour alone",
          "each status has a differently shaped glyph, which survives greyscale and forced-colours mode"
        ]
      },
      "antiPatterns": [
        {
          "dont": "`failed` with no `detail`",
          "why": "a failure the reader cannot diagnose is just a red mark"
        },
        {
          "dont": "using it outside AgentActivity",
          "why": "it renders an <li>, which is only valid inside a list"
        }
      ],
      "stylesheet": "src/styles/agent-activity.css",
      "tokens": [
        "--font-family-sans",
        "--icon-error",
        "--icon-primary",
        "--icon-size-sm",
        "--icon-success",
        "--icon-tertiary",
        "--ion-duration-cycle",
        "--ion-ease-linear",
        "--spacing-2",
        "--spacing-8",
        "--text-default",
        "--text-error",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "What the agent did, in the user's language. Not a function name."
        },
        "status": {
          "type": "AgentActivityStatus | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "pending",
            "active",
            "done",
            "failed",
            "skipped"
          ]
        },
        "detail": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The result, a tool name, a count — whatever makes the step checkable."
        },
        "statusLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The status in words, read after the step's label — \"Not started\", \"In\nprogress\", \"Done\", \"Failed\", \"Skipped\" in English. Pass the translation\nfor `status`."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 278,
        "other": 0
      }
    },
    "AgentStop": {
      "name": "AgentStop",
      "source": "src/components/AgentStop.tsx",
      "propsType": "AgentStopProps",
      "description": "AgentStop — the always-visible way to end a run.\n\nNOT OPTIONAL, AND NOT BEHIND A MENU. A user who cannot stop an agent is\nwatching it, not supervising it. Human oversight of an automated process is\na compliance surface in regulated contexts, not a UX preference, and the\ncontrol that provides it has to be present and reachable at the moment it is\nwanted — not two clicks into an overflow menu.\n\nWHY IT IS NOT JUST `<Button variant=\"destructive\">`\n\nThree things it owns that a Button does not:\n\n  It keeps its place while stopping. A control that vanishes the instant it\n  is pressed leaves the user unsure whether the press registered, and the\n  run is usually still going. It stays, relabels, and disables — the layout\n  does not move.\n\n  It announces. The label change is only announced to a screen reader if the\n  button happens to hold focus, which it usually does not when a run was\n  started elsewhere. A polite live region says \"Stopping\" once.\n\n  It is not destructive-red. Stopping is a normal, expected, reversible-in-\n  spirit action — you can run it again. Colouring it like `delete` teaches\n  hesitation about the one control that must never be hesitated over.\n\nWHAT IT DOES NOT DO. It does not stop anything. It reports intent; the\ncaller aborts the request, closes the stream and settles the state. A stop\nbutton that resolves optimistically while tokens keep arriving is worse than\nnone, because it lies about a guarantee the user is relying on.",
      "import": "import { AgentStop } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The always-visible way to end a running agent. Not optional, and not behind a menu.",
      "useWhen": [
        "anything runs long enough that a person might want it to stop — a generation, a tool chain, a batch job",
        "the run was started by the user and is still going"
      ],
      "useInstead": [
        {
          "when": "the action is instant and there is nothing to interrupt",
          "use": "Button"
        },
        {
          "when": "you are asking permission before something happens rather than ending something already happening",
          "use": "ApprovalGate"
        },
        {
          "when": "the thing to undo has already finished",
          "use": "Toast",
          "why": "an undo affordance belongs with the confirmation of what was done"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "inside a toolbar or beside an inline activity row"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "a full-width run surface where stopping is the primary available action"
          }
        }
      },
      "a11y": {
        "role": "button",
        "guarantees": [
          "the control keeps its place while stopping — it relabels and disables rather than disappearing, so the layout does not move under the pointer",
          "the transition to stopping is announced once through a polite live region, because the label change alone is only heard if the button happens to hold focus",
          "`stopOnEscape` ignores Escape raised inside a dialog, so closing a modal never cancels a background run"
        ],
        "requires": [
          "keeping it rendered and visible for the whole run — a stop control that scrolls away or hides in an overflow menu is the failure this component exists to prevent"
        ],
        "notes": [
          "It does not stop anything. It reports intent; aborting the request and settling the state is the caller's. A stop that resolves optimistically while output keeps arriving lies about the one guarantee the user is relying on."
        ]
      },
      "antiPatterns": [
        {
          "dont": "putting the stop control in an overflow menu",
          "why": "a user who cannot reach it is watching the agent, not supervising it"
        },
        {
          "dont": "disabling it while the run is going",
          "why": "the only correct disabled state is `isStopping` — after the request, never before"
        },
        {
          "dont": "unmounting it the moment it is pressed",
          "do": "pass `isStopping`",
          "why": "the run is usually still going, and a control that vanishes leaves the user unsure the press registered"
        },
        {
          "dont": "styling it as destructive",
          "why": "stopping is normal and repeatable; red teaches hesitation about the one control that must never be hesitated over"
        },
        {
          "dont": "`stopOnEscape` on a page with modals over the run",
          "why": "Escape already means dismiss to every overlay in this system"
        }
      ],
      "stylesheet": "src/styles/agent-stop.css",
      "tokens": [
        "--border-default",
        "--border-error",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-size-md",
        "--icon-size-sm",
        "--icon-size-xs",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-md",
        "--spacing-12",
        "--spacing-16",
        "--spacing-20",
        "--spacing-32",
        "--spacing-40",
        "--spacing-48",
        "--spacing-8",
        "--surface-default",
        "--surface-error-subtle",
        "--surface-error-subtle-hover",
        "--surface-muted",
        "--text-default",
        "--text-error",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-md",
        "--type-body-md-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "onStop": {
          "type": "() => void",
          "required": true,
          "origin": "own",
          "description": "Called when the user asks the run to stop."
        },
        "isStopping": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "The request is in flight. The control stays visible and keeps its place;\nit does not disappear while the thing it cancels is still running."
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Visible label."
        },
        "stoppingLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Visible label once `isStopping` is set."
        },
        "size": {
          "type": "AgentStopSize | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "stopOnEscape": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Bind Escape, at the document, to stop the run.\n\nOff by default and deliberately so: Escape already means \"dismiss the\nthing in front of me\" to every modal, popover and menu in this system, and\na document-level handler would cancel a background run when the user meant\nto close a dialog. Turn it on only where the run IS the foreground task."
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 286,
        "other": 0
      }
    },
    "AILabel": {
      "name": "AILabel",
      "source": "src/components/AILabel.tsx",
      "propsType": "AILabelProps",
      "description": "AILabel — marks content as generated by AI, and opens what that means.\n\nIt is a Toggletip whose button is the mark: opened on purpose, it stays\nopen, it is part of the page, and it never traps focus. The explanation is\na required prop for the same reason ConfidenceIndicator's `basis` is — a\nmark with nothing behind it is a claim the system cannot back.\n\nIt labels; it does not detect. Whether content is generated is the\ncaller's knowledge, and so is whether a person has edited it since.",
      "import": "import { AILabel } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Marks content as generated by AI, and opens what that means — what was generated, from what, and what to check. The explanation is required.",
      "useWhen": [
        "content a model wrote is shown as part of the product — a summary, a drafted reply, a suggested field value, a generated description",
        "a person has edited generated content, and it should still say where it came from"
      ],
      "useInstead": [
        {
          "when": "the content is an answer to a question someone asked, with sources",
          "use": "Citation",
          "why": "in the AssistantAnswer pattern the answer already says it is generated — its Citations and ConfidenceIndicator carry what to trust; add an AILabel only where the answer leaves the conversation and is shown elsewhere"
        },
        {
          "when": "the reader needs to know how far to trust it",
          "use": "ConfidenceIndicator",
          "why": "AILabel says who made it, not how good it is; pair them when both matter, and never let the mark stand in for a trust level"
        },
        {
          "when": "an agent is doing something now",
          "use": "AgentActivity",
          "why": "that is a run in progress; AILabel marks finished content"
        },
        {
          "when": "the explanation has nothing to do with AI",
          "use": "Toggletip",
          "why": "AILabel is a Toggletip whose button is the AI mark; for any other explanation use the ⓘ"
        }
      ],
      "composition": {
        "order": [
          "AILabel",
          "Link"
        ],
        "note": "Place it right after the heading or label of the generated content, not inside the content. Name it for the content — `aria-label=\"About this summary\"` — and the button reads \"AI: About this summary\". The children say what was generated, from what — the model, the inputs — and what to check before relying on it; at most one Link, to how it is made. Set `isEdited` once a person has changed the content; clear it only if the content is generated again.",
        "example": "<h3>Summary <AILabel aria-label=\"About this summary\">Written by atlas-m from the last 20 runs. Check the failure counts against the Runs tab before sharing. <Link href=\"/docs/summaries\">How summaries are made</Link></AILabel></h3>"
      },
      "variants": {
        "size": {
          "sm": {
            "use": "beside body-sm text — table cells, field labels, card titles; drawn 20px, a 24px target"
          },
          "md": {
            "use": "beside body text and headings — the default"
          }
        },
        "placement": {
          "top": {
            "use": "the default — above the mark, clear of the content below"
          },
          "bottom": {
            "use": "near the top of the viewport; it also flips here on its own"
          },
          "left": {
            "use": "beside a label at the end of a row"
          },
          "right": {
            "use": "beside a label at the start of a row"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "text, Link",
          "note": "the explanation, under a heading — \"Generated by AI\", or \"Generated by AI, edited by a person\" when edited; read out on open"
        },
        "labels": {
          "accepts": "text",
          "note": "mark, edited, title and editedTitle, for translation; the button's name follows the translated mark"
        }
      },
      "a11y": {
        "role": "button (a disclosure) that opens a status region",
        "guarantees": [
          "the button's name starts with the visible mark — \"AI: About this summary\", or \"AI, Edited: …\" — so what is seen is what is heard (WCAG 2.5.3)",
          "the explanation behaves as Toggletip's: opened by a press, not hover; read out on open while focus stays on the mark; Tab reaches a link inside; Escape closes it and returns focus",
          "it is not modal: it never traps focus or hides the page",
          "a 24px target at both sizes (WCAG 2.5.8)",
          "the mark uses the primary colour role, not a status colour: it says who made the content, not that something is wrong or new"
        ],
        "requires": [
          "`aria-label` naming the content it marks — \"About this summary\", not \"AI info\"",
          "children that say what was generated and from what"
        ],
        "notes": [
          "It labels; it does not detect. Whether content is generated, and whether a person has edited it since, is the caller's knowledge — pass it, never guess it."
        ]
      },
      "antiPatterns": [
        {
          "dont": "an explanation that says only \"This was generated by AI\"",
          "do": "the model, what it read, and what to check",
          "why": "the mark already says that; an explanation with nothing behind it is a claim the system cannot back"
        },
        {
          "dont": "leaving the mark unchanged after a person edits the content",
          "do": "`isEdited`",
          "why": "the reader is told the model wrote words a person chose, and blames or trusts the wrong author"
        },
        {
          "dont": "one AILabel for a whole page of mixed content",
          "why": "the reader cannot tell which parts were generated; mark each generated piece where it starts"
        },
        {
          "dont": "a mark on every row of a list that is all generated",
          "do": "one AILabel on the list's heading",
          "why": "twenty identical marks are noise, and twenty tab stops in the way of the rows"
        },
        {
          "dont": "using the mark as a badge for an AI feature — a button, a menu item, a nav link",
          "why": "it marks content a model produced; on a feature it says nothing about any content, and teaches readers to ignore it"
        }
      ],
      "stylesheet": null,
      "tokens": [],
      "props": {
        "aria-label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "What the label is for — \"About this summary\", \"About this reply\". The\nbutton is named with the mark first, so it reads \"AI: About this\nsummary\", and what is seen is what is heard (WCAG 2.5.3)."
        },
        "children": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The explanation, and required: what was generated, from what, and what\nto check before relying on it. A label that says only \"this is AI\"\ntells the reader nothing they can act on."
        },
        "isEdited": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "A person has changed the content since it was generated. The mark then\nsays so, and so does the explanation: the content is no longer only\nthe model's, and it is no longer only the person's either."
        },
        "placement": {
          "type": "ToggletipPlacement | undefined",
          "required": false,
          "origin": "own",
          "description": "Where the explanation sits. Flips when there is no room.",
          "values": [
            "top",
            "bottom",
            "left",
            "right"
          ]
        },
        "size": {
          "type": "ToggletipSize | undefined",
          "required": false,
          "origin": "own",
          "description": "`sm` beside body-sm text and in a table; `md` beside body text.",
          "values": [
            "sm",
            "md"
          ]
        },
        "labels": {
          "type": "AILabelLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "Every string the label renders, for translation. English by default."
        },
        "isOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "defaultOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "onOpenChange": {
          "type": "((isOpen: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 10,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Alert": {
      "name": "Alert",
      "source": "src/components/Alert.tsx",
      "propsType": "AlertProps",
      "description": "Alert — Figma `Alert` (812:1902).\n\n`Intent` x `Emphasis` x `Layout`, with the parts as props rather than\nvariants. Intents match `Badge` exactly, so a status mapped to one can be\npassed to the other.\n\nROLE IS CHOSEN BY INTENT, NOT PASSED IN. `error` and `warning` render\n`role=\"alert\"`, which interrupts a screen reader; the rest render\n`role=\"status\"`, which waits for a pause. Getting this backwards is the\ncommon failure — a page of `role=\"alert\"` status messages talks over the\nuser, and an error announced as a status is missed.\n\nThe icon is decorative: it repeats what the intent's colour and copy already\nsay, so it is `aria-hidden` and the live region carries the text alone.",
      "import": "import { Alert } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.13.1",
      "summary": "A persistent, in-page status message. Intent picks the ARIA role for you.",
      "useWhen": [
        "the message belongs to the page and should stay until the condition clears",
        "form-level validation summaries, permission notices, degraded-state banners"
      ],
      "useInstead": [
        {
          "when": "the message is transient and self-dismissing",
          "use": "Toast",
          "why": "an Alert stays in the layout; a Toast does not"
        },
        {
          "when": "it labels a value inline rather than telling the user something",
          "use": "Badge",
          "why": "Badge shares Alert's intent vocabulary exactly, so a status mapped to one passes to the other"
        }
      ],
      "variants": {
        "intent": {
          "error": {
            "use": "something failed and the user must act",
            "aria": "role=alert — interrupts the screen reader"
          },
          "warning": {
            "use": "something may fail or has degraded",
            "aria": "role=alert — interrupts the screen reader"
          },
          "success": {
            "use": "an operation completed",
            "aria": "role=status"
          },
          "information": {
            "use": "neutral context the user did not ask for",
            "aria": "role=status"
          },
          "primary": {
            "use": "product announcements and promotions",
            "aria": "role=status"
          },
          "neutral": {
            "use": "low-emphasis context",
            "aria": "role=status"
          }
        },
        "emphasis": {
          "subtle": {
            "use": "the default — tinted surface, readable in long pages"
          },
          "solid": {
            "use": "reserve for a single high-stakes banner; it dominates the layout"
          }
        },
        "layout": {
          "inline": {
            "use": "inside a form or card, in the flow of content"
          },
          "banner": {
            "use": "full-bleed at the top of a page or region"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "the role follows the intent — error and warning are role=alert, everything else role=status",
          "the icon is aria-hidden, so the live region announces the text once and not the glyph"
        ],
        "requires": [
          "`dismissLabel` when `onDismiss` is set and the surrounding copy does not name the action"
        ],
        "notes": [
          "role=alert interrupts whatever the screen reader is saying; role=status waits for a pause. This is why the role is not a prop."
        ]
      },
      "antiPatterns": [
        {
          "dont": "using intent=\"error\" for a message that is merely informational",
          "why": "error renders role=alert, which interrupts the screen reader. A page of interrupting status messages talks over the user."
        },
        {
          "dont": "using intent=\"information\" for a genuine failure",
          "why": "role=status waits for a pause, so a real error can be missed entirely"
        },
        {
          "dont": "rendering an Alert conditionally for a transient confirmation",
          "do": "useToast",
          "why": "an Alert that appears and disappears shifts the layout under the user"
        }
      ],
      "stylesheet": "src/styles/alert.css",
      "tokens": [
        "--border-default",
        "--border-error-strong",
        "--border-error-subtle",
        "--border-information-strong",
        "--border-information-subtle",
        "--border-inverse",
        "--border-primary-strong",
        "--border-primary-subtle",
        "--border-success-strong",
        "--border-success-subtle",
        "--border-warning-strong",
        "--border-warning-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-error",
        "--icon-information",
        "--icon-inverse",
        "--icon-on-color",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-md",
        "--icon-success",
        "--icon-warning",
        "--ion-alert-border",
        "--ion-alert-icon",
        "--ion-alert-surface",
        "--ion-alert-text",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-md",
        "--radius-sm",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-32",
        "--spacing-4",
        "--spacing-8",
        "--surface-error",
        "--surface-error-subtle",
        "--surface-hover",
        "--surface-information",
        "--surface-information-subtle",
        "--surface-inverse",
        "--surface-muted",
        "--surface-primary",
        "--surface-primary-subtle",
        "--surface-success",
        "--surface-success-subtle",
        "--surface-warning",
        "--surface-warning-subtle",
        "--text-error",
        "--text-information",
        "--text-inverse",
        "--text-link",
        "--text-on-color",
        "--text-secondary",
        "--text-success",
        "--text-warning",
        "--type-body",
        "--type-body-line-height"
      ],
      "props": {
        "intent": {
          "type": "AlertIntent | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Intent` variant. Same vocabulary as `Badge`.",
          "values": [
            "neutral",
            "primary",
            "success",
            "warning",
            "error",
            "information"
          ],
          "default": "information"
        },
        "emphasis": {
          "type": "AlertEmphasis | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Emphasis` variant.",
          "values": [
            "subtle",
            "solid"
          ],
          "default": "subtle"
        },
        "layout": {
          "type": "AlertLayout | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Layout` variant.",
          "values": [
            "inline",
            "banner"
          ],
          "default": "inline"
        },
        "title": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Optional heading above the message."
        },
        "hideIcon": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Hide the leading icon. Shown by default."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replace the intent's glyph."
        },
        "actions": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Action buttons, rendered under the message."
        },
        "onDismiss": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Called when the dismiss button is pressed. Omit for no dismiss button."
        },
        "dismissLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible name for the dismiss button.",
          "default": "Dismiss"
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The message."
        }
      },
      "propCounts": {
        "own": 10,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "ApprovalGate": {
      "name": "ApprovalGate",
      "source": "src/components/ApprovalGate.tsx",
      "propsType": "ApprovalGateProps",
      "description": "ApprovalGate — a proposed action, held until a person decides.\n\nThe human-in-the-loop control. An agent proposes; this is where a person\napproves, rejects, or amends before anything happens. In regulated contexts\ndemonstrable human oversight is a compliance surface rather than a nicety,\nwhich is why this is a first-class component and not a Modal with two\nbuttons in it.\n\nIT ENFORCES NOTHING, AND SAYING SO IS THE POINT\n\nThis renders a decision. It does not gate execution — the caller does, by not\nacting until `onApprove` fires. A component that *looked* like it enforced a\npolicy would be the worst possible thing to ship here: teams would rely on a\nguarantee that lives entirely in their own call site. `risk` changes emphasis\nand nothing else, for the same reason.\n\nNOT A MODAL, DELIBERATELY\n\nA modal steals focus and hides the page. But the page is the evidence: the\nuser needs the plan, the diff, the tool call and the context in front of them\nwhile deciding. This sits inline, keeps everything visible, and never\ntraps focus — an approval a user was rushed through is not oversight.\n\nNEITHER BUTTON IS FOCUSED ON MOUNT. Autofocusing approve turns a decision\ninto an Enter keypress on a page the user has not read. Focus is moved to the\nregion's heading instead, so a screen-reader user lands on what is being\nasked rather than on the answer.\n\nTHE RESOLVED STATES ARE NOT DECORATION. `approved`, `rejected` and `expired`\nreplace the actions with what happened, so the record of the decision stays\non the page. `expired` exists because an approval request that nobody answers\nis the common real outcome, and a gate that sits pending for ever is\nindistinguishable from one that is broken.",
      "import": "import { ApprovalGate } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A proposed action held until a person approves, rejects or amends it. It renders the decision; it does not enforce it.",
      "useWhen": [
        "an agent proposes something with consequences — spending, sending, deleting, deploying, changing permissions",
        "a regulated or audited process needs demonstrable human oversight at a specific point",
        "the proposal is worth showing in full, in context, before it runs"
      ],
      "useInstead": [
        {
          "when": "the action is already happening and needs stopping",
          "use": "AgentStop"
        },
        {
          "when": "a person initiated the action themselves and only needs to confirm it",
          "use": "Modal",
          "why": "confirming your own request is a dialog; reviewing something proposed for you is a decision"
        },
        {
          "when": "you are reporting what happened rather than asking",
          "use": "Alert"
        }
      ],
      "variants": {
        "risk": {
          "low": {
            "use": "reversible and cheap — a draft, a local edit. Neutral surface"
          },
          "medium": {
            "use": "the default. Real but recoverable consequences"
          },
          "high": {
            "use": "spending, sending externally, deleting, granting access. Warning surface, deliberately not error"
          }
        },
        "status": {
          "pending": {
            "use": "the default, and the only state with actions"
          },
          "approved": {
            "use": "resolved — the actions are replaced by the record"
          },
          "rejected": {
            "use": "resolved, declined"
          },
          "expired": {
            "use": "nobody answered. The common real outcome, and the reason a gate cannot sit pending for ever"
          }
        }
      },
      "slots": {
        "title": {
          "accepts": "text",
          "note": "required, and the region's accessible name. Name the action and its object — \"Delete 14 projects\", never \"Confirm action\""
        },
        "children": {
          "accepts": "any",
          "note": "the evidence: the plan, the diff, the tool call. This is what the person is deciding about"
        },
        "resolution": {
          "accepts": "text",
          "note": "shown in place of the actions once resolved; defaults to the status"
        }
      },
      "a11y": {
        "role": "region, named by its title",
        "guarantees": [
          "neither button is focused on mount — autofocusing approve turns a decision into an Enter keypress on an unread page",
          "the title is programmatically focusable, so the caller can move focus to the question rather than to an answer",
          "reject precedes approve in the DOM, so a keyboard reaches the safe answer first",
          "resolution is announced once through a polite live region",
          "focus is never trapped: an approval a user was rushed through is not oversight"
        ],
        "requires": [
          "not acting until `onApprove` fires — this component gates nothing on its own",
          "a `title` that names the action and its object"
        ],
        "notes": [
          "`risk` changes emphasis and nothing else. It does not add confirmation, does not disable anything, and must never be read as a policy the component enforces."
        ]
      },
      "antiPatterns": [
        {
          "dont": "treating the gate as the enforcement point",
          "why": "it renders a decision. If your code acts before `onApprove`, there is no gate — only a picture of one"
        },
        {
          "dont": "putting it in a Modal",
          "why": "the page is the evidence; hiding it to ask about it removes what the decision needs"
        },
        {
          "dont": "autofocusing the approve button",
          "why": "Enter on an unread page is not oversight, and it is exactly what an audit will look for"
        },
        {
          "dont": "an approve-only gate with no reject",
          "why": "a decision with one answer is a notification wearing a button"
        },
        {
          "dont": "`onEdit` when the proposal cannot actually be amended",
          "why": "an edit button that discards the edit is worse than no edit button"
        },
        {
          "dont": "leaving gates pending indefinitely",
          "do": "the `expired` status",
          "why": "a gate nobody answered is indistinguishable from one that is broken"
        }
      ],
      "stylesheet": "src/styles/approval-gate.css",
      "tokens": [
        "--border-default",
        "--border-disabled",
        "--border-focus",
        "--border-information",
        "--border-strong",
        "--border-success",
        "--border-warning-strong",
        "--border-width-default",
        "--border-width-thick",
        "--border-width-thicker",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-semibold",
        "--icon-information",
        "--icon-secondary",
        "--icon-size-md",
        "--icon-success",
        "--icon-tertiary",
        "--icon-warning",
        "--ion-approval-accent",
        "--ion-approval-border",
        "--ion-approval-icon",
        "--ion-approval-surface",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-md",
        "--radius-sm",
        "--radius-xs",
        "--spacing-12",
        "--spacing-16",
        "--spacing-32",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-hover",
        "--surface-information-subtle",
        "--surface-muted",
        "--surface-primary",
        "--surface-primary-hover",
        "--surface-warning-subtle",
        "--text-default",
        "--text-disabled",
        "--text-information",
        "--text-on-color",
        "--text-secondary",
        "--text-success",
        "--text-warning",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "title": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "What is being approved, stated as the action and its object — \"Delete 14\nprojects\", not \"Confirm action\". Required: it is the region's accessible\nname, and a decision the user cannot name is not a decision."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Why it stopped here, and what happens on approval."
        },
        "risk": {
          "type": "ApprovalGateRisk | undefined",
          "required": false,
          "origin": "own",
          "description": "How much is at stake. Drives emphasis only — it does NOT change what the\ncomponent enforces, because the component enforces nothing.",
          "values": [
            "low",
            "medium",
            "high"
          ]
        },
        "status": {
          "type": "ApprovalGateStatus | undefined",
          "required": false,
          "origin": "own",
          "description": "Where the decision has got to. `pending` is the only state with buttons.",
          "values": [
            "pending",
            "approved",
            "rejected",
            "expired"
          ]
        },
        "onApprove": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "onReject": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "onEdit": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Offer \"edit\" as a third way out. Omit it when the proposal cannot be\namended — an approve/reject pair the user cannot influence is honest;\nan edit button that discards their edit is not."
        },
        "approveLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "rejectLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "editLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "statusLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "What happened, in words — \"Approved\", \"Rejected\", \"Expired without a\ndecision\" in English. Announced when `status` changes, and shown in place\nof the actions when there is no `resolution`. Pass the translation for\n`status`."
        },
        "isSubmitting": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "A decision is in flight. Both actions disable; neither disappears."
        },
        "resolution": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Shown in place of the actions once the decision is made."
        }
      },
      "propCounts": {
        "own": 13,
        "aria": 0,
        "dom": 275,
        "other": 0
      }
    },
    "AppSwitcher": {
      "name": "AppSwitcher",
      "source": "src/components/AppSwitcher.tsx",
      "propsType": "AppSwitcherProps",
      "description": "AppSwitcher — the grid of products in a suite's header.\n\nLINKS, NOT A MENU\n\nEach product is a place with an address: it opens in this tab, in a new\none with a modifier or a middle click, and its address can be copied. A\n`menu` of `menuitem`s would give up all three for arrow keys. So the panel\nis a Popover — a dialog, focus kept inside, Escape and an outside click\nclose it and focus goes back to the button — holding a list of links,\nreached with Tab.\n\nWHERE YOU ARE\n\nThe product you are in is marked `aria-current=\"true\"` — not \"page\": it is\na product, not this page — and drawn with a border as well as a tint, so\nit is not told by colour alone. It stays a link, to its home.\n\nA press on a link closes the panel. Opening a product in the same document\n— a hash route, a client router — would otherwise leave it open over the\npage it went to.",
      "import": "import { AppSwitcher } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The nine-dot button in a suite's header and the grid of products it opens — links, each to a product's home, with the one you are in marked.",
      "useWhen": [
        "a suite of separate products shares a header, and people move between them",
        "the products have their own addresses: each opens at its home, in this tab or a new one"
      ],
      "useInstead": [
        {
          "when": "sections of one product",
          "use": "Sidebar",
          "why": "sections are navigation within a product, always in view; a switcher hides them behind a button for the sake of a move people make every minute"
        },
        {
          "when": "choosing a workspace or an account inside the same product",
          "use": "MenuTrigger",
          "why": "that changes what the product shows, not which product it is — a menu of choices, not a set of places"
        },
        {
          "when": "jumping to anything by name, products included",
          "use": "CommandPalette",
          "why": "search reaches every page by typing; the switcher is for recognising a product's mark"
        }
      ],
      "composition": {
        "order": [
          "AppSwitcher"
        ],
        "note": "Goes in Header's `end`, before the account. `apps` is the suite, in the order people look for them; `currentApp` names this product's `id`. `label` names the button, its tooltip and the panel. `footer` takes a Link to every app or to managing access.",
        "example": "<Header brand={…} end={<><AppSwitcher currentApp=\"ops\" apps={[{ id: 'ops', name: 'Ops', href: '/' }, { id: 'docs', name: 'Docs', href: 'https://docs.example' }]} /><Avatar … /></>} />"
      },
      "slots": {
        "footer": {
          "accepts": "Link",
          "note": "one link under the grid, centred"
        }
      },
      "a11y": {
        "requires": [
          "a `label` in the product's language when it is not English — it is the button's only name, the icon being nine dots"
        ],
        "guarantees": [
          "the button is named by `label`, \"Apps\" by default, with a tooltip showing it; `aria-expanded` says whether the panel is open",
          "the panel is a dialog named by `label`: focus moves into it and stays there, and Escape or a click outside closes it and gives focus back to the button",
          "each product is a link, reached with Tab, so it opens in a new tab with a modifier and its address can be copied",
          "the product you are in is `aria-current=\"true\"` and drawn with a border as well as a tint",
          "a product's `description` is its link's accessible description",
          "a press on a product closes the panel, so a same-document route does not leave it open over the page it went to",
          "in forced colours the current product's border is Highlight"
        ],
        "notes": [
          "Not a `menu`: menu items would give up opening in a new tab and copying the address for arrow keys.",
          "`icon` sits centred on a 40px tinted square, at its own size — a 24px icon suits. A product without one shows its initial there."
        ]
      },
      "antiPatterns": [
        {
          "dont": "an AppSwitcher listing the sections of one product",
          "do": "<Sidebar> with a NavItem each",
          "why": "sections are moved between constantly and belong in view; a switcher is for the few moves between products"
        },
        {
          "dont": "a Menu of products, each a MenuItem with an onAction that sets `location`",
          "do": "<AppSwitcher apps={…} />",
          "why": "menu items are not links: no new tab, no copied address, no status-bar URL"
        },
        {
          "dont": "leaving out `currentApp`",
          "do": "currentApp=\"ops\"",
          "why": "without it nothing says which product you are in, and the grid reads as a list of elsewheres"
        }
      ],
      "stylesheet": "src/styles/app-switcher.css",
      "tokens": [
        "--border-primary-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-weight-medium",
        "--font-weight-semibold",
        "--icon-primary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-md",
        "--ring-focus",
        "--spacing-12",
        "--spacing-4",
        "--spacing-40",
        "--spacing-8",
        "--surface-hover",
        "--surface-primary-subtle",
        "--surface-selected",
        "--surface-selected-hover",
        "--text-default",
        "--text-primary",
        "--text-secondary",
        "--type-body-md",
        "--type-body-md-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "apps": {
          "type": "AppSwitcherApp[]",
          "required": true,
          "origin": "own",
          "description": "The products in the suite, in the order people look for them."
        },
        "currentApp": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The `id` of the product this header belongs to."
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the button — which is an icon only — its tooltip, and the panel.\nDefault \"Apps\"."
        },
        "footer": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Under the grid: a link to every app, or to manage access."
        },
        "isOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "onOpenChange": {
          "type": "((isOpen: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 7,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Avatar": {
      "name": "Avatar",
      "source": "src/components/Avatar.tsx",
      "propsType": "AvatarProps",
      "description": "Avatar picks its content the way Figma's `Type` variant does, but by\nprecedence rather than by a prop: image, then initials, then icon. A caller\npassing `src` and `initials` gets the image with the initials as its alt\nfallback, which is what you want when the image 404s.\n\nA failed `src` falls through to initials (then icon) rather than leaving the\nbrowser's broken-image glyph — that is why initials exist alongside `src`.\n\nIt is a `<span>`, not a `<div>`, so it can sit inline beside text without the\ncaller fighting a block element.\n\nTHE MEDIA IS A CHILD, AND IT HAS TO BE\n\nThe box that clips the image is `.ion-avatar__media`, not the root. The root\ncannot clip: a corner indicator sits outside a circle's clip path and would\nbe sliced in half by the `overflow: hidden` that crops the photo. Figma\ndraws exactly this — an unclipped frame holding a clipped `Media` child and\nthe indicators as its siblings.",
      "import": "import { Avatar } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A person or entity marker. Content is chosen by precedence — image, then initials, then icon — not by a type prop.",
      "useWhen": [
        "identifying who owns, authored or is assigned to something",
        "a table cell, menu row or header needs a compact identity marker beside a name"
      ],
      "useInstead": [
        {
          "when": "the thing being marked is a status, count or category rather than a person",
          "use": "Badge"
        },
        {
          "when": "you only need a decorative graphic with no identity behind it",
          "use": "Icon"
        }
      ],
      "variants": {
        "size": {
          "mini": {
            "use": "24px — inline beside body text, dense table rows"
          },
          "sm": {
            "use": "32px — menu rows, compact lists"
          },
          "md": {
            "use": "the default, 40px"
          },
          "lg": {
            "use": "48px — profile headers and detail pages"
          }
        },
        "shape": {
          "circle": {
            "use": "the default — people"
          },
          "square": {
            "use": "organisations, projects, workspaces and other non-human entities"
          }
        },
        "topIndicator": {
          "neutral": {
            "use": "no state, or a state this system has no colour for"
          },
          "primary": {
            "use": "attention rather than health — a request, an invite, a mention"
          },
          "success": {
            "use": "online, available, verified, complete"
          },
          "warning": {
            "use": "away, idle, needs attention but is not broken"
          },
          "error": {
            "use": "offline, blocked, failed"
          },
          "information": {
            "use": "a neutral fact about the person — a role or a badge, not a state"
          }
        },
        "bottomIndicator": {
          "neutral": {
            "use": "no state, or a state this system has no colour for"
          },
          "primary": {
            "use": "attention rather than health — a request, an invite, a mention"
          },
          "success": {
            "use": "online, available, verified, complete"
          },
          "warning": {
            "use": "away, idle, needs attention but is not broken"
          },
          "error": {
            "use": "offline, blocked, failed"
          },
          "information": {
            "use": "a neutral fact about the person — a role or a badge, not a state"
          }
        }
      },
      "slots": {
        "icon": {
          "accepts": "Icon",
          "note": "the last fallback — rendered only when there is neither `src` nor `initials`"
        },
        "topIndicatorIcon": {
          "accepts": "Icon",
          "note": "replaces the top indicator's default check glyph. The bottom indicator takes no glyph — Figma draws it bare, and at Mini it is 8px, which nothing legible fits inside."
        }
      },
      "a11y": {
        "guarantees": [
          "`alt` falls back to `initials`, so the avatar is never announced as an unlabelled image",
          "a `src` that fails to load falls through to initials, then icon — never the browser's broken-image glyph",
          "indicator labels are announced wherever they land: folded into the avatar's own `aria-label` when there is no image (the root is `role=\"img\"` and therefore a leaf), and carried as hidden text inside each indicator when there is"
        ],
        "requires": [
          "`alt` describing the person, not the picture — \"Ada Lovelace\", not \"profile photo\"",
          "`topIndicatorLabel` / `bottomIndicatorLabel` on any indicator that carries meaning. An indicator with no label is decorative, which is the right answer only when the same fact is already stated in text beside it."
        ]
      },
      "antiPatterns": [
        {
          "dont": "passing `src` without `initials`",
          "why": "a 404 leaves the fallback empty; initials are what make the failure invisible"
        },
        {
          "dont": "using Avatar as a button",
          "do": "put the Avatar inside a Button or Link",
          "why": "Avatar renders a <span> with no role and takes no focus"
        },
        {
          "dont": "using an indicator's colour as the only carrier of a status",
          "do": "pass `topIndicatorLabel` / `bottomIndicatorLabel`, or state the status in text nearby",
          "why": "WCAG 1.4.1. A green dot and a red dot are the same dot to a screen reader, and to a good share of sighted users too"
        },
        {
          "dont": "reaching for `ring` to mean selected or focused",
          "why": "it is a static brand ring from the design, not a focus indicator. Focus belongs on whatever interactive element wraps the avatar, and that element draws its own"
        }
      ],
      "stylesheet": "src/styles/avatar.css",
      "tokens": [
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-size-2xs",
        "--icon-size-lg",
        "--icon-size-md",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-avatar-border",
        "--ion-avatar-font-size",
        "--ion-avatar-font-weight",
        "--ion-avatar-group-overlap",
        "--ion-avatar-icon",
        "--ion-avatar-indicator-icon",
        "--ion-avatar-indicator-ring",
        "--ion-avatar-indicator-size",
        "--ion-avatar-line-height",
        "--ion-avatar-radius",
        "--ion-avatar-ring-width",
        "--ion-avatar-size",
        "--ion-avatar-surface",
        "--radius-full",
        "--radius-md",
        "--radius-sm",
        "--radius-xs",
        "--ring-offset",
        "--spacing-12",
        "--spacing-14",
        "--spacing-16",
        "--spacing-24",
        "--spacing-32",
        "--spacing-40",
        "--spacing-48",
        "--spacing-8",
        "--surface-error",
        "--surface-information",
        "--surface-inverse",
        "--surface-muted",
        "--surface-placeholder",
        "--surface-primary",
        "--surface-success",
        "--surface-warning",
        "--text-inverse",
        "--text-on-color",
        "--text-tertiary",
        "--type-body",
        "--type-body-lg",
        "--type-body-lg-line-height",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "size": {
          "type": "AvatarSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant: Mini 24, Small 32, Medium 40, Large 48.",
          "values": [
            "sm",
            "md",
            "lg",
            "mini"
          ]
        },
        "shape": {
          "type": "AvatarShape | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Shape` variant.",
          "values": [
            "circle",
            "square"
          ]
        },
        "src": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Image source. Figma's `Type=Image`."
        },
        "alt": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Describes the person, not the picture. Falls back to `initials` so the\navatar is never announced as an unlabelled image."
        },
        "initials": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's `Type=Character`. Rendered when there is no `src`, or when `src`\nfails to load."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `Type=Icon`. Rendered when there is neither `src` nor `initials`."
        },
        "ring": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's `Show Ring` — a 1px/2px brand ring drawn inside the avatar's edge."
        },
        "topIndicator": {
          "type": "AvatarIndicatorIntent | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's `Show Top Indicator`, as the intent of the `Status Indicator`\ninstance it holds. Top-right, and it carries a glyph: this is the corner\nthat says something was verified or requested, not who is online.",
          "values": [
            "neutral",
            "primary",
            "success",
            "warning",
            "error",
            "information"
          ]
        },
        "topIndicatorIcon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the top indicator's default check glyph."
        },
        "topIndicatorLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the top indicator for assistive tech. Without it the mark is decorative."
        },
        "bottomIndicator": {
          "type": "AvatarIndicatorIntent | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's `Show Bottom Indicator`. Bottom-right and glyphless — the presence\ndot.",
          "values": [
            "neutral",
            "primary",
            "success",
            "warning",
            "error",
            "information"
          ]
        },
        "bottomIndicatorLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the bottom indicator for assistive tech. Colour alone is not a status."
        }
      },
      "propCounts": {
        "own": 12,
        "aria": 0,
        "dom": 278,
        "other": 0
      }
    },
    "AvatarGradient": {
      "name": "AvatarGradient",
      "source": "src/components/AvatarGradient.tsx",
      "propsType": "AvatarGradientProps",
      "description": "AvatarGradient is Avatar's initials-only sibling: a tinted, bevelled disc for\npeople who have no photo.\n\nIT IS A SEPARATE COMPONENT BECAUSE FIGMA DRAWS A SEPARATE COMPONENT, AND THE\nAXES ARE THE ARGUMENT\n\nIt has `Color`, which Avatar does not. It has no `Shape`, because a gradient\nthis directional only reads on a circle. It has no `Type`, because there is\nno image and no icon to fall back to — the initials are the whole content.\nFolding it into Avatar would mean a `color` prop that does nothing on three\nof Avatar's four states and a `shape` prop that this one has to reject.\n\nThere is no `icon` and no `src` fallback here on purpose. If you have a\nphoto, or nothing at all to put in the circle, that is `Avatar`.",
      "import": "import { AvatarGradient } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Avatar's initials-only sibling: a tinted, bevelled disc in one of seven colours, for people with no photo.",
      "useWhen": [
        "someone has no profile image and the fallback should still look deliberate rather than empty",
        "a list of people reads better when each one is a different colour — the colour is recognisable long before the letters are legible"
      ],
      "useInstead": [
        {
          "when": "there is a photo, or there may be one later",
          "use": "Avatar",
          "why": "Avatar falls back from image to initials to icon on its own; this component has no image to fall back from"
        },
        {
          "when": "the mark needs a square shape, an icon, a ring or a status indicator",
          "use": "Avatar",
          "why": "Figma draws none of those on this component, and a gradient this directional only reads on a circle"
        },
        {
          "when": "the thing being marked is a status, count or category rather than a person",
          "use": "Badge"
        }
      ],
      "variants": {
        "size": {
          "mini": {
            "use": "24px — inline beside body text, dense table rows"
          },
          "sm": {
            "use": "32px — menu rows, compact lists"
          },
          "md": {
            "use": "the default, 40px"
          },
          "lg": {
            "use": "48px — profile headers and detail pages"
          }
        },
        "color": {
          "slate": {
            "use": "the default. The one to reach for when nothing about the person suggests a colour"
          },
          "blue": {
            "use": "one of seven, picked to tell people apart"
          },
          "violet": {
            "use": "one of seven, picked to tell people apart"
          },
          "pink": {
            "use": "one of seven, picked to tell people apart"
          },
          "orange": {
            "use": "one of seven, picked to tell people apart"
          },
          "green": {
            "use": "as the rest. Not a status — a green disc here does not mean online, and Avatar's `bottomIndicator` is what does"
          },
          "red": {
            "use": "as the rest. Not an error — this is identity, not state"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "`alt` falls back to `initials`, so the disc is never announced as an unlabelled image",
          "all seven colours clear WCAG AA for the initials against the palest end of their own gradient, which is the worst case the text sits on"
        ],
        "requires": [
          "`alt` naming the person — \"Ada Lovelace\", not \"AB\". Two letters are not a name, and that is all a screen reader would otherwise get"
        ],
        "notes": [
          "Colour is decoration here, never information. Nothing about which of the seven a person gets should be recoverable, because a screen reader cannot recover it.",
          "The first version of this component was a saturated disc with white initials and failed AA on four of its colours. It was fixed in Figma by inverting it — pale disc, coloured initials — rather than in code. Do not reintroduce the saturated version."
        ]
      },
      "antiPatterns": [
        {
          "dont": "picking the colour from a status — green for online, red for failing",
          "do": "use Avatar's `bottomIndicator`, or say it in text",
          "why": "the colour is identity, not state. It is also invisible to assistive tech, so a status expressed only this way is not expressed at all"
        },
        {
          "dont": "hashing a user id to a colour and treating it as stable",
          "do": "store the chosen colour with the person, the way you would store their avatar",
          "why": "the palette has eight entries and any hash of it will collide; worse, it silently reassigns everyone the day the palette or the hash changes"
        },
        {
          "dont": "passing more than two letters",
          "why": "Figma draws two. Three fit at 48 and clip at 24, so it looks correct in the story and wrong in the table"
        }
      ],
      "stylesheet": "src/styles/avatar-gradient.css",
      "tokens": [
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--ion-avatar-gradient-bevel",
        "--ion-avatar-gradient-font-size",
        "--ion-avatar-gradient-font-weight",
        "--ion-avatar-gradient-from",
        "--ion-avatar-gradient-line-height",
        "--ion-avatar-gradient-mid",
        "--ion-avatar-gradient-size",
        "--ion-avatar-gradient-text",
        "--ion-avatar-gradient-to",
        "--radius-full",
        "--spacing-24",
        "--spacing-32",
        "--spacing-40",
        "--spacing-48",
        "--surface-palette-1",
        "--surface-palette-1-subtle",
        "--surface-palette-1-tint",
        "--surface-palette-2",
        "--surface-palette-2-subtle",
        "--surface-palette-2-tint",
        "--surface-palette-3",
        "--surface-palette-3-subtle",
        "--surface-palette-3-tint",
        "--surface-palette-4",
        "--surface-palette-4-subtle",
        "--surface-palette-4-tint",
        "--surface-palette-5",
        "--surface-palette-5-subtle",
        "--surface-palette-5-tint",
        "--surface-palette-6",
        "--surface-palette-6-subtle",
        "--surface-palette-6-tint",
        "--surface-palette-7",
        "--surface-palette-7-subtle",
        "--surface-palette-7-tint",
        "--surface-sheen",
        "--surface-sheen-subtle",
        "--text-palette-1",
        "--text-palette-2",
        "--text-palette-3",
        "--text-palette-4",
        "--text-palette-5",
        "--text-palette-6",
        "--text-palette-7",
        "--type-body",
        "--type-body-lg",
        "--type-body-lg-line-height",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "size": {
          "type": "AvatarSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant: Mini 24, Small 32, Medium 40, Large 48.",
          "values": [
            "sm",
            "md",
            "lg",
            "mini"
          ]
        },
        "color": {
          "type": "AvatarGradientColor | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Color` variant.",
          "values": [
            "slate",
            "blue",
            "violet",
            "pink",
            "orange",
            "green",
            "red"
          ]
        },
        "initials": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The two letters. Figma's `Initials`."
        },
        "alt": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Describes the person, not the picture — \"Ada Lovelace\", not \"avatar\".\nWithout it the initials are announced as the letters they are, which is\nnot a name."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "AvatarGroup": {
      "name": "AvatarGroup",
      "source": "src/components/Avatar.tsx",
      "propsType": "AvatarGroupProps",
      "description": "AvatarGroup overlaps its children by a quarter of the avatar size, matching\nFigma's -6 / -8 / -10 / -12 gaps at Mini / Small / Medium / Large.\n\nGroup defaults fill in `size` / `shape` only when a child Avatar has not set\nthem itself — an explicit child prop wins. Non-Avatar children are left\nalone so `size`/`shape` are never pushed onto arbitrary DOM nodes.\n\nChildren render in source order because that is Figma's stacking: each avatar\npaints over the one before it, so the `+N` overflow ends up on top. Later\nsiblings paint later, so this needs no z-index.",
      "import": "import { AvatarGroup } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Overlapping Avatars with a `+N` overflow, for showing several people in one row's width.",
      "useWhen": [
        "several people share one thing — assignees, participants, collaborators",
        "the exact list matters less than the fact that there are several"
      ],
      "useInstead": [
        {
          "when": "each person needs their own name, role or action visible",
          "use": "Table",
          "why": "an overlapping stack hides everything but the picture"
        }
      ],
      "variants": {
        "size": {
          "mini": {
            "use": "24px — inline beside body text, dense table rows"
          },
          "sm": {
            "use": "32px — menu rows, compact lists"
          },
          "md": {
            "use": "the default, 40px"
          },
          "lg": {
            "use": "48px — profile headers and detail pages"
          }
        },
        "shape": {
          "circle": {
            "use": "the default — people"
          },
          "square": {
            "use": "organisations, projects, workspaces"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "Avatar",
          "note": "group `size`/`shape` fill in only where a child has not set its own; a child's explicit prop wins. Non-Avatar children are left untouched."
        }
      },
      "a11y": {
        "requires": [
          "`alt` on every child Avatar — the overflow avatar is the only one the group names for you"
        ],
        "notes": [
          "Children paint in source order, so the `+N` overflow lands on top without any z-index."
        ]
      },
      "antiPatterns": [
        {
          "dont": "omitting `max` on an unbounded list",
          "why": "twenty overlapping avatars is not a summary; it is a smear"
        }
      ],
      "stylesheet": "src/styles/avatar.css",
      "tokens": [
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-size-2xs",
        "--icon-size-lg",
        "--icon-size-md",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-avatar-border",
        "--ion-avatar-font-size",
        "--ion-avatar-font-weight",
        "--ion-avatar-group-overlap",
        "--ion-avatar-icon",
        "--ion-avatar-indicator-icon",
        "--ion-avatar-indicator-ring",
        "--ion-avatar-indicator-size",
        "--ion-avatar-line-height",
        "--ion-avatar-radius",
        "--ion-avatar-ring-width",
        "--ion-avatar-size",
        "--ion-avatar-surface",
        "--radius-full",
        "--radius-md",
        "--radius-sm",
        "--radius-xs",
        "--ring-offset",
        "--spacing-12",
        "--spacing-14",
        "--spacing-16",
        "--spacing-24",
        "--spacing-32",
        "--spacing-40",
        "--spacing-48",
        "--spacing-8",
        "--surface-error",
        "--surface-information",
        "--surface-inverse",
        "--surface-muted",
        "--surface-placeholder",
        "--surface-primary",
        "--surface-success",
        "--surface-warning",
        "--text-inverse",
        "--text-on-color",
        "--text-tertiary",
        "--type-body",
        "--type-body-lg",
        "--type-body-lg-line-height",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "size": {
          "type": "AvatarSize | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md",
            "lg",
            "mini"
          ]
        },
        "max": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Cap on avatars shown; the remainder becomes a `+N` overflow avatar."
        },
        "shape": {
          "type": "AvatarShape | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "circle",
            "square"
          ]
        },
        "overflowLabel": {
          "type": "((count: number) => string) | undefined",
          "required": false,
          "origin": "own",
          "description": "The overflow avatar's name — \"4 more\" in English. Receives the count as a\nnumber so a translation can choose its plural form."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 5,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Badge": {
      "name": "Badge",
      "source": "src/components/Badge.tsx",
      "propsType": "BadgeProps",
      "description": "Badge is presentational — no role, no interaction. React Aria has nothing to\noffer here, so this is a plain span rather than a hook wrapper for its own\nsake.\n\nIt carries no `status` or `alert` role on purpose: a badge is a label on\nsomething else, and announcing it as a live region would interrupt screen\nreader users on every render. Wrap it yourself if the value genuinely changes\nand matters.",
      "import": "import { Badge } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A small, non-interactive label that classifies the thing next to it. Presentational — no role, no live region.",
      "useWhen": [
        "labelling a record's status, type, count or category",
        "the label belongs to something else on the page and is read as part of it"
      ],
      "useInstead": [
        {
          "when": "the message is a standalone statement the user must read",
          "use": "Alert",
          "why": "Badge carries no role and is not announced on its own"
        },
        {
          "when": "the label should be clickable — a filter chip, a removable tag",
          "use": "Button",
          "why": "Badge takes no focus and fires nothing"
        }
      ],
      "variants": {
        "intent": {
          "neutral": {
            "use": "the default — a category with no judgement attached"
          },
          "primary": {
            "use": "brand emphasis: Beta, New, Pro"
          },
          "success": {
            "use": "a terminal good state: Active, Paid, Passed"
          },
          "warning": {
            "use": "needs attention but is not broken: Pending, Expiring"
          },
          "error": {
            "use": "a failed or blocked state: Failed, Overdue, Rejected"
          },
          "information": {
            "use": "a neutral fact worth marking: Draft, Scheduled"
          }
        },
        "size": {
          "sm": {
            "use": "the default. 20px tall on caption type — a marker beside a row, a cell or a title"
          },
          "md": {
            "use": "24px on body-sm. Use when the badge sits in body copy and a caption-sized label would read as a footnote"
          },
          "lg": {
            "use": "32px. A page-level status the eye should reach before the prose, not a marker in a list"
          }
        },
        "shape": {
          "pill": {
            "use": "the default. Fully round at every size"
          },
          "rounded": {
            "use": "a squarer corner that steps with the size — 4/6/8. Use when the badge sits in a grid of rectangular things and a pill reads as the odd one out"
          }
        }
      },
      "slots": {
        "icon": {
          "accepts": "Icon",
          "note": "mutually exclusive with `dot` — `dot` wins and `icon` is dropped when both are set"
        }
      },
      "a11y": {
        "guarantees": [
          "the dot marker is `aria-hidden`, so the badge announces its text only",
          "no `status` or `alert` role, so a re-render never interrupts a screen reader"
        ],
        "notes": [
          "If the value genuinely changes and the change matters, wrap the Badge in your own live region — the component will not do it for you."
        ]
      },
      "antiPatterns": [
        {
          "dont": "relying on intent colour alone to carry the meaning",
          "why": "colour is not a label (WCAG 1.4.1); the text has to say Failed, not just be red"
        },
        {
          "dont": "putting a Button or Link inside a Badge",
          "why": "it renders a plain <span>; interactive content there has no affordance and no focus style"
        }
      ],
      "stylesheet": "src/styles/badge.css",
      "tokens": [
        "--border-default",
        "--border-error-subtle",
        "--border-information-subtle",
        "--border-primary-subtle",
        "--border-success-subtle",
        "--border-warning-subtle",
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-error",
        "--icon-information",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-2xs",
        "--icon-size-md",
        "--icon-size-sm",
        "--icon-success",
        "--icon-warning",
        "--radius-full",
        "--radius-md",
        "--radius-sm",
        "--radius-xs",
        "--spacing-2",
        "--spacing-20",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-6",
        "--surface-error-subtle",
        "--surface-information-subtle",
        "--surface-page",
        "--surface-primary-subtle",
        "--surface-success-subtle",
        "--surface-warning-subtle",
        "--text-error",
        "--text-information",
        "--text-link",
        "--text-secondary",
        "--text-success",
        "--text-warning",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "intent": {
          "type": "BadgeIntent | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Intent` variant.",
          "values": [
            "neutral",
            "primary",
            "success",
            "warning",
            "error",
            "information"
          ]
        },
        "size": {
          "type": "BadgeSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's `Size` variant. Heights are 20 / 24 / 32, pinned rather than\nderived from padding. `sm` is the default and is the size this component\nshipped with before the ramp existed.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "shape": {
          "type": "BadgeShape | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's `Shape` variant. `rounded` steps its corner with the size so the\nradius stays proportional; `pill` is fully round at every size.",
          "values": [
            "pill",
            "rounded"
          ]
        },
        "dot": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Show the leading dot marker. Figma's `Show Dot` boolean.\nThe dot inherits the intent's foreground colour, so it needs no prop."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Leading icon. Figma's `Show Icon` + `Icon` swap. Mutually exclusive with `dot`."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Banner": {
      "name": "Banner",
      "source": "src/components/Banner.tsx",
      "propsType": "BannerProps",
      "description": "Banner — a notice about the whole product, across the top of it:\nmaintenance tonight, a trial ending, the workspace in read-only mode.\n\nAN ALERT THAT BELONGS TO THE PAGE, NOT TO A PART OF IT. It is Alert with\n`layout=\"banner\"` — the same intents, roles, icons, colours and one-line\nlayout — and adds what a notice about the whole page needs. Put it in\nthe app shell, above the Header and outside <main>, so it persists across\nroutes: a live region is announced when it is inserted, and a banner that\nremounts with every page would be announced on every page.\n\nDISMISSED MEANS DISMISSED. With `dismissKey` the dismissal is remembered in\nthis browser, so a user who has read the maintenance notice is not shown it\non every page and every visit. A banner that must stay — the workspace is\nread-only, deletion is scheduled — has no key and no `onDismiss`, and no\ndismiss button.\n\nFOCUS GOES FORWARD. Dismissing removes the button that had focus; focus\nmoves to the next thing after the banner rather than falling back to the\ntop of the document.",
      "import": "import { Banner } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A notice about the whole product, across the top of it — maintenance tonight, a trial ending, the workspace read-only or scheduled for deletion. Alert's banner layout on one line, in the app shell; a dismissal is remembered with `dismissKey`.",
      "useWhen": [
        "the notice is about the product or the account, not about what is on this page — maintenance, an outage, a plan limit, read-only mode, a pending deletion",
        "it must be seen on every page until it is dealt with or dismissed"
      ],
      "useInstead": [
        {
          "when": "the notice is about one part of the page — a form, a panel, a table",
          "use": "Alert",
          "why": "an Alert sits with what it is about; a banner about a form reads as a problem with the whole product"
        },
        {
          "when": "a result the user just caused — saved, sent, deleted",
          "use": "Toast",
          "why": "it comes and goes by itself; a banner stays until dealt with"
        }
      ],
      "composition": {
        "order": [
          "Banner",
          "Header"
        ],
        "note": "In the app shell, above the Header and outside <main>, so it persists across routes and is announced once. Several stack, the most severe first. `actions` holds one Link or small Button, beside the message.",
        "example": "<Banner intent=\"information\" title=\"Maintenance on Sunday\" dismissKey=\"maintenance-2026-10-04\" actions={<Link href=\"/status\">Status page</Link>}>From 02:00 to 03:00 UTC.</Banner>"
      },
      "variants": {
        "intent": {
          "information": {
            "use": "the default — maintenance, a new feature, a plan change"
          },
          "warning": {
            "use": "something the user should act on — a trial ending, a deletion scheduled, read-only mode"
          },
          "error": {
            "use": "the product is not working — an outage, a failed payment that has stopped runs"
          },
          "success": {
            "use": "rarely — a state that has just been restored, shown once"
          },
          "neutral": {
            "use": "a notice with no judgement attached"
          },
          "primary": {
            "use": "rarely — a brand announcement"
          }
        },
        "emphasis": {
          "subtle": {
            "use": "the default"
          },
          "solid": {
            "use": "one banner that must not be missed, such as an outage"
          }
        }
      },
      "a11y": {
        "requires": [
          "a message that says what is happening and what it means for the user, in words",
          "`dismissLabel` translated"
        ],
        "guarantees": [
          "the role follows the intent, as Alert's does: `alert` for warning and error, `status` for the rest — announced when it is inserted, not on every page, because it lives in the shell",
          "with `dismissKey`, dismissed stays dismissed in this browser — on every page and the next visit — and a remembered dismissal is never painted",
          "without `dismissKey` or `onDismiss` there is no dismiss button: a banner that must stay cannot be dismissed",
          "dismissing moves focus to the next focusable element after the banner, not to the top of the document",
          "blocked storage — a private window — leaves it showing and dismissible for the page",
          "title, message and action share one line where there is room, and wrap on a phone without running past the edge; it is not sticky"
        ],
        "notes": [
          "change the key when the notice changes — `maintenance-2026-10-04`, not `maintenance` — or a user who dismissed last month's will never see this month's",
          "more than two banners at once is a sign some of them belong elsewhere"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Banner inside each page's content",
          "do": "one place in the app shell",
          "why": "remounted on every route, a live region is announced on every route, and it moves with each page's layout"
        },
        {
          "dont": "<Banner dismissKey=\"deletion\"> for a scheduled deletion",
          "do": "no dismissKey and no onDismiss",
          "why": "a notice the user must not forget cannot be one they can make go away"
        },
        {
          "dont": "a sticky banner",
          "do": "a banner that scrolls away with the page",
          "why": "stuck to the top of a phone, it takes a block of every screen for as long as it is there"
        }
      ],
      "stylesheet": null,
      "tokens": [],
      "props": {
        "dismissKey": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Makes the banner dismissible and remembers the dismissal in this\nbrowser, under this key, so it does not come back on the next page or\nthe next visit. Change the key when the notice changes —\n`maintenance-2026-10-04`, not `maintenance`."
        },
        "onDismiss": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Makes it dismissible, and is told when it is dismissed. Without\n`dismissKey`, the dismissal lasts only until the banner is mounted again."
        },
        "title": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Optional heading above the message."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The message."
        },
        "intent": {
          "type": "AlertIntent | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Intent` variant. Same vocabulary as `Badge`.",
          "values": [
            "neutral",
            "primary",
            "success",
            "warning",
            "error",
            "information"
          ]
        },
        "emphasis": {
          "type": "AlertEmphasis | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Emphasis` variant.",
          "values": [
            "subtle",
            "solid"
          ]
        },
        "hideIcon": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Hide the leading icon. Shown by default."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replace the intent's glyph."
        },
        "actions": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Action buttons, rendered under the message."
        },
        "dismissLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible name for the dismiss button."
        }
      },
      "propCounts": {
        "own": 10,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "Breadcrumb": {
      "name": "Breadcrumb",
      "source": "src/components/Breadcrumb.tsx",
      "propsType": "BreadcrumbProps",
      "description": "Breadcrumb — where this page sits, and how to get back up.\n\nWHY AN ORDERED LIST INSIDE A NAMED LANDMARK\n\nThe trail is a sequence, and `ol` is what says so: a screen reader announces\n\"list, 4 items\" and the position within it, which is the entire content of a\nbreadcrumb. A row of `div`s with slashes conveys none of that, and the\nslashes themselves get read out as punctuation.\n\nThe separator here is drawn by CSS `::before` on each item after the first,\nso it is decoration that never enters the accessibility tree. Putting a \"/\"\nin the markup is the usual version of this component and the usual defect.\n\nTHE LAST CRUMB IS NOT A LINK\n\n`isCurrent` renders text with `aria-current=\"page\"`. A link to the page you\nare already on is a dead control: it announces as a link, invites a click,\nand does nothing. This is the most common breadcrumb bug and the reason the\nprop exists rather than being inferred from position — a trail whose last\ncrumb IS a link to somewhere else is legitimate, and inferring would break it.",
      "import": "import { Breadcrumb } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Where this page sits in the hierarchy, and how to get back up. An ordered list inside a named landmark.",
      "useWhen": [
        "a page sits more than one level deep and the parent is not otherwise reachable",
        "a record view needs its collection one click away",
        "the hierarchy itself is information the user needs — which project, which environment"
      ],
      "useInstead": [
        {
          "when": "the destinations are siblings rather than ancestors",
          "use": "Tabs",
          "why": "a breadcrumb describes depth; tabs describe alternatives at the same depth"
        },
        {
          "when": "the trail is the primary navigation for the whole app",
          "use": "NavItem inside the app shell",
          "why": "a breadcrumb supplements navigation, it does not replace it — it appears after a user has already gone somewhere"
        }
      ],
      "slots": {
        "children": {
          "accepts": "BreadcrumbItem",
          "note": "ancestor first, current page last. Anything else and the trail reads backwards"
        }
      },
      "a11y": {
        "requires": [
          "`label` when a page has more than one nav landmark — it defaults to \"Breadcrumb\", which is what screen-reader users listen for"
        ],
        "notes": [
          "the separator is drawn by CSS ::before and never enters the accessibility tree; a \"/\" in the markup is announced as punctuation between every crumb",
          "the list is an `ol`, so the position and length are announced — which is the entire content of a breadcrumb"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<div>Home / Projects / Invoice 12</div>",
          "do": "<Breadcrumb><BreadcrumbItem href=\"/\">Home</BreadcrumbItem>…</Breadcrumb>",
          "why": "a row of divs conveys neither sequence nor position, and the slashes are read aloud"
        },
        {
          "dont": "a full trail on a top-level page",
          "do": "omit it",
          "why": "a breadcrumb with one crumb is furniture — it takes vertical space to say nothing"
        }
      ],
      "stylesheet": "src/styles/breadcrumb.css",
      "tokens": [
        "--border-focus",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-xs",
        "--spacing-8",
        "--text-default",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the landmark. A page with more than one `nav` needs them told apart,\nand \"Breadcrumb\" is what screen-reader users are listening for."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "`BreadcrumbItem` elements, ancestor first."
        }
      },
      "propCounts": {
        "own": 2,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "BreadcrumbItem": {
      "name": "BreadcrumbItem",
      "source": "src/components/Breadcrumb.tsx",
      "propsType": "BreadcrumbItemProps",
      "import": "import { BreadcrumbItem } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One crumb. A link to an ancestor, or the current page rendered as text.",
      "useWhen": [
        "inside a Breadcrumb, once per level"
      ],
      "useInstead": [
        {
          "when": "outside a breadcrumb trail",
          "use": "Link",
          "why": "the crumb's density, colour and truncation are tuned for a trail and look wrong on their own"
        }
      ],
      "slots": {
        "children": {
          "accepts": "text",
          "note": "the name of the level, not its path"
        }
      },
      "a11y": {
        "requires": [
          "`isCurrent` on the last crumb, which renders it as text with aria-current=\"page\" rather than as a link"
        ],
        "notes": [
          "isCurrent is a prop rather than inferred from position: a trail whose last crumb legitimately links elsewhere exists, and inferring would break it"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<BreadcrumbItem href=\"/invoices/12\">Invoice 12</BreadcrumbItem> for the page you are on",
          "do": "<BreadcrumbItem isCurrent>Invoice 12</BreadcrumbItem>",
          "why": "a link to the current page is a dead control — it announces as a link, invites a click and does nothing"
        }
      ],
      "stylesheet": "src/styles/breadcrumb.css",
      "tokens": [
        "--border-focus",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-xs",
        "--spacing-8",
        "--text-default",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "href": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Omit on the current page — the last crumb is not a link."
        },
        "isCurrent": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "The page the user is on. Marked `aria-current=\"page\"` and rendered as text\nrather than a link."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 3,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Button": {
      "name": "Button",
      "source": "src/components/Button.tsx",
      "propsType": "ButtonProps",
      "import": "import { Button } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Triggers an action in place. Never navigates.",
      "useWhen": [
        "the interaction performs an action — submit, save, delete, open a dialog",
        "the result stays on the current page"
      ],
      "useInstead": [
        {
          "when": "it navigates to a URL or changes route",
          "use": "Link",
          "why": "Link renders an <a>. Middle-click, cmd-click, copy-link-address and 'open in new tab' all work on an anchor and none of them work on a button."
        },
        {
          "when": "it toggles a persistent boolean setting",
          "use": "Toggle",
          "why": "a toggle announces its on/off state; a button announces only its name"
        }
      ],
      "variants": {
        "variant": {
          "primary-brand": {
            "use": "the single most important action in a view",
            "limit": "one per view or dialog"
          },
          "primary-neutral": {
            "use": "a primary action that should not carry brand colour"
          },
          "primary-soft": {
            "use": "a primary action on a dense or already-colourful surface"
          },
          "secondary": {
            "use": "supporting actions shown alongside a primary one"
          },
          "tertiary": {
            "use": "low-emphasis actions — Cancel, Back, inline row actions"
          },
          "destructive": {
            "use": "irreversible actions",
            "requires": "a Modal confirmation before the action runs"
          },
          "success": {
            "use": "confirms the end of a flow. Rarely the right answer — prefer primary-brand."
          }
        },
        "size": {
          "sm": {
            "use": "table rows, toolbars, dense layouts"
          },
          "md": {
            "use": "the default; forms and page-level actions"
          },
          "lg": {
            "use": "prominent standalone actions"
          },
          "xl": {
            "use": "marketing and empty-state calls to action"
          }
        }
      },
      "slots": {
        "startIcon": {
          "accepts": "Icon",
          "note": "omit Icon's `label` — the Button's own text already names it, and a labelled icon is announced twice"
        },
        "endIcon": {
          "accepts": "Icon",
          "note": "same as startIcon"
        }
      },
      "a11y": {
        "role": "button",
        "guarantees": [
          "the focus ring appears for keyboard focus and not for a mouse click",
          "hover does not latch on touch devices"
        ],
        "requires": [
          "an accessible name — `children`, or `aria-label` when the button is icon-only"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Button onPress={() => router.push('/settings')}>Settings</Button>",
          "do": "<Link href=\"/settings\">Settings</Link>",
          "why": "a button that navigates breaks middle-click, cmd-click and copy-link, and is announced as a button rather than a link"
        },
        {
          "dont": "two variant=\"primary-brand\" buttons in the same dialog",
          "do": "one primary-brand, the rest secondary or tertiary",
          "why": "two primaries is the same as none — nothing indicates the expected action"
        },
        {
          "dont": "<Button variant=\"destructive\" onPress={deleteAccount}>",
          "do": "open a Modal first and put the destructive Button in its footer",
          "why": "irreversible actions need a confirmation step"
        },
        {
          "dont": "<Button><Icon as={Trash} /></Button>",
          "do": "<Button aria-label=\"Delete\"><Icon as={Trash} /></Button>",
          "why": "an icon-only button with no aria-label is announced as just 'button'"
        },
        {
          "dont": "<Button onClick={...}>",
          "do": "<Button onPress={...}>",
          "why": "onPress is React Aria's unified pointer/keyboard/touch handler. onClick works but skips the touch and keyboard normalisation the rest of the system relies on."
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled",
          "note": "still type-checks, so this will not fail loudly"
        }
      ],
      "stylesheet": "src/styles/button.css",
      "tokens": [
        "--border-disabled",
        "--border-error-strong",
        "--border-inverse",
        "--border-primary",
        "--border-primary-strong",
        "--border-primary-subtle",
        "--border-strong",
        "--border-stronger",
        "--border-success-strong",
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-size-lg",
        "--icon-size-md",
        "--icon-size-xs",
        "--ion-button-elevation",
        "--ion-button-font-size",
        "--ion-button-gap",
        "--ion-button-icon-size",
        "--ion-button-inset-flush",
        "--ion-button-inset-lifted",
        "--ion-button-line-height",
        "--ion-button-padding-x",
        "--ion-button-pressed",
        "--ion-button-raised",
        "--ion-button-ring",
        "--ion-button-shadow",
        "--ion-button-size",
        "--ion-duration-base",
        "--ion-duration-fast",
        "--ion-ease-out",
        "--ion-shadow-focus-md",
        "--ion-shadow-inset-flush-lg",
        "--ion-shadow-inset-flush-sm",
        "--ion-shadow-inset-flush-xs",
        "--ion-shadow-inset-lifted-lg",
        "--ion-shadow-inset-lifted-sm",
        "--ion-shadow-inset-lifted-xs",
        "--ion-shadow-none",
        "--ion-shadow-raised-lifted-lg",
        "--ion-shadow-raised-lifted-sm",
        "--ion-shadow-raised-lifted-xs",
        "--ion-shadow-shadow-md",
        "--ion-shadow-shadow-sm",
        "--ion-shadow-shadow-xs",
        "--radius-md",
        "--spacing-12",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-32",
        "--spacing-40",
        "--spacing-48",
        "--spacing-56",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-error",
        "--surface-error-hover",
        "--surface-error-pressed",
        "--surface-inverse",
        "--surface-inverse-subtle",
        "--surface-muted",
        "--surface-page",
        "--surface-primary",
        "--surface-primary-hover",
        "--surface-primary-pressed",
        "--surface-primary-subtle",
        "--surface-primary-subtle-hover",
        "--surface-primary-tint",
        "--surface-success",
        "--surface-success-hover",
        "--surface-success-pressed",
        "--text-disabled",
        "--text-inverse",
        "--text-on-color",
        "--text-primary",
        "--text-secondary",
        "--type-body",
        "--type-body-lg",
        "--type-body-lg-line-height",
        "--type-body-line-height",
        "--type-body-md",
        "--type-body-md-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "variant": {
          "type": "\"success\" | \"primary-brand\" | \"primary-neutral\" | \"primary-soft\" | \"secondary\" | \"tertiary\" | \"destructive\" | undefined",
          "required": false,
          "origin": "own",
          "description": "The visual style variant of the button.",
          "values": [
            "success",
            "primary-brand",
            "primary-neutral",
            "primary-soft",
            "secondary",
            "tertiary",
            "destructive"
          ],
          "default": "primary-brand"
        },
        "size": {
          "type": "\"sm\" | \"md\" | \"lg\" | \"xl\" | undefined",
          "required": false,
          "origin": "own",
          "description": "The size of the button.",
          "values": [
            "sm",
            "md",
            "lg",
            "xl"
          ],
          "default": "md"
        },
        "startIcon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Optional icon to render before the label."
        },
        "endIcon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Optional icon to render after the label."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Additional CSS class names."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Children element to render inside the button."
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the button is disabled."
        },
        "onPress": {
          "type": "((e: PressEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the press is released over the target."
        },
        "onPressStart": {
          "type": "((e: PressEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a press interaction starts."
        },
        "onPressEnd": {
          "type": "((e: PressEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a press interaction ends, either\nover the target or when the pointer leaves the target."
        },
        "onPressChange": {
          "type": "((isPressed: boolean) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the press state changes."
        },
        "onPressUp": {
          "type": "((e: PressEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a press is released over the target, regardless of\nwhether it started on the target or not."
        },
        "onClick": {
          "type": "((e: MouseEvent<FocusableElement>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "**Not recommended – use `onPress` instead.** `onClick` is an alias for `onPress`\nprovided for compatibility with other libraries. `onPress` provides\nadditional event details for non-mouse interactions."
        },
        "autoFocus": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the element should receive focus on render."
        },
        "onFocus": {
          "type": "((e: FocusEvent<Element, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element receives focus."
        },
        "onBlur": {
          "type": "((e: FocusEvent<Element, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element loses focus."
        },
        "onFocusChange": {
          "type": "((isFocused: boolean) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element's focus status changes."
        },
        "onKeyDown": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is pressed."
        },
        "onKeyUp": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is released."
        },
        "href": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "A URL to link to if elementType=\"a\"."
        },
        "target": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The target window for the link."
        },
        "rel": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The relationship between the linked resource and the current page. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel)."
        },
        "elementType": {
          "type": "JSXElementConstructor<any> | \"button\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "The HTML element or React element used to render the button, e.g. 'div', 'a', or `RouterLink`.",
          "tags": {
            "default": "'button'"
          }
        },
        "aria-disabled": {
          "type": "boolean | \"true\" | \"false\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates whether the element is disabled to users of assistive technology."
        },
        "aria-expanded": {
          "type": "boolean | \"true\" | \"false\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates whether the element, or another grouping element it controls, is currently expanded\nor collapsed."
        },
        "aria-haspopup": {
          "type": "boolean | \"true\" | \"false\" | \"menu\" | \"listbox\" | \"tree\" | \"grid\" | \"dialog\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates the availability and type of interactive popup element, such as menu or dialog, that\ncan be triggered by an element."
        },
        "aria-controls": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) whose contents or presence are controlled by the current\nelement."
        },
        "aria-pressed": {
          "type": "boolean | \"true\" | \"false\" | \"mixed\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates the current \"pressed\" state of toggle buttons."
        },
        "aria-current": {
          "type": "boolean | \"true\" | \"false\" | \"page\" | \"step\" | \"location\" | \"date\" | \"time\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates whether this element represents the current item within a container or set of related\nelements."
        },
        "type": {
          "type": "\"button\" | \"submit\" | \"reset\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "The behavior of the button when used in an HTML form.",
          "values": [
            "button",
            "submit",
            "reset"
          ],
          "tags": {
            "default": "'button'"
          }
        },
        "preventFocusOnPress": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to prevent focus from moving to the button when pressing it.\n\nCaution, this can make the button inaccessible and should only be used when alternative\nkeyboard interaction is provided, such as ComboBox's MenuTrigger or a NumberField's\nincrement/decrement control."
        },
        "form": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The `<form>` element to associate the button with.\nThe value of this attribute must be the id of a `<form>` in the same document.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/button#form)."
        },
        "formAction": {
          "type": "string | ((formData: FormData) => void | Promise<void>) | undefined",
          "required": false,
          "origin": "aria",
          "description": "The URL that processes the information submitted by the button.\nOverrides the action attribute of the button's form owner."
        },
        "formEncType": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates how to encode the form data that is submitted."
        },
        "formMethod": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates the HTTP method used to submit the form."
        },
        "formNoValidate": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates that the form is not to be validated when it is submitted."
        },
        "formTarget": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Overrides the target attribute of the button's form owner."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Submitted as a pair with the button's value as part of the form data."
        },
        "value": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The value associated with the button's name when it's submitted with the form data."
        },
        "excludeFromTabOrder": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to exclude the element from the sequential tab order. If true,\nthe element will not be focusable via the keyboard by tabbing. This should\nbe avoided except in rare scenarios where an alternative means of accessing\nthe element or its functionality via the keyboard is available."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The element's unique identifier. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id)."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        }
      },
      "propCounts": {
        "own": 7,
        "aria": 39,
        "dom": 0,
        "other": 0
      }
    },
    "ButtonGroup": {
      "name": "ButtonGroup",
      "source": "src/components/ButtonGroup.tsx",
      "propsType": "ButtonGroupProps",
      "description": "ButtonGroup — a row of actions: a dialog's Cancel and Confirm, a form's\nBack, Save and Next, the actions at the top of a record.\n\nWHAT IT OWNS, SO NO CALLER HAS TO\n\n  - Spacing: 8px between actions, everywhere. Before this, dialogs used\n    12, drawers and page headers 8, and forms whatever their author chose.\n  - Order: children are drawn in the order they are read. The primary\n    goes last, so it lands at the end of the row in either direction —\n    and at the bottom, nearest the thumb, when `stack` stacks them. There\n    is no `row-reverse`: an order that differs on screen and in the Tab\n    sequence fails WCAG 2.4.3.\n  - Overflow, when asked: `overflow=\"menu\"` measures the row and moves\n    the Buttons that do not fit into a MenuTrigger, first Button first. A\n    menu item presses the real Button, which stays in the page hidden —\n    so its `onPress`, its `onClick` and a `type=\"submit\"` all behave as\n    if it had been clicked.\n\nNOT A TOOLBAR. Every action here is its own tab stop, which is what a\nkeyboard user expects of two or three buttons. A row of many controls on\none thing — formatting, a table's batch actions — is a Toolbar: one tab\nstop, arrow keys inside.",
      "import": "import { ButtonGroup } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A row of actions — a dialog's Cancel and Confirm, a form's Back, Save and Next, a record's header actions. Owns the gap, the order and what happens when the row runs out of room.",
      "useWhen": [
        "two or more actions sit together: a dialog or drawer footer, the end of a form, a page or card header",
        "one action is primary and the rest are alternatives to it",
        "the row may be narrower than its actions — a phone, a side panel — and has to wrap, stack or overflow rather than spill"
      ],
      "useInstead": [
        {
          "when": "many controls act on one thing — a table's batch actions, an editor's formatting",
          "use": "Toolbar",
          "why": "a toolbar is one tab stop with arrow keys inside; a ButtonGroup is a tab stop per action, which is right for two or three and tiring for ten"
        },
        {
          "when": "the buttons are one choice among options, one of them selected",
          "use": "SegmentedControl",
          "why": "that is a radio group with a value; a ButtonGroup holds independent actions"
        },
        {
          "when": "a single action",
          "use": "Button",
          "why": "a group of one adds nothing"
        }
      ],
      "composition": {
        "order": [
          "ButtonGroup",
          "Button"
        ],
        "note": "Children in reading order, least important first, the primary last. It is drawn in that order: the primary lands at the end of the row in either direction, and last in the stack on a phone.",
        "example": "<ButtonGroup stack start={<Button variant=\"tertiary\">Back</Button>}><Button variant=\"secondary\">Save and exit</Button><Button type=\"submit\">Next</Button></ButtonGroup>"
      },
      "variants": {
        "align": {
          "end": {
            "use": "the default — dialog, drawer and form footers"
          },
          "start": {
            "use": "actions under a left-aligned block of text"
          },
          "center": {
            "use": "a centred dialog or empty state"
          }
        },
        "overflow": {
          "wrap": {
            "use": "the default — actions that do not fit move to a second line, all still shown"
          },
          "menu": {
            "use": "a header row that must stay one line: the Buttons that do not fit move into More actions, first Button first; the primary and anything that is not a Button stay"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "Button, SplitButton, Link, AgentStop, MenuTrigger",
          "note": "the actions, primary last. With `overflow=\"menu\"` only Buttons move, and each needs a text label or `aria-label` — it is what the menu item says"
        },
        "start": {
          "accepts": "Button, text",
          "note": "pinned to the start edge, apart from the rest — Back in a wizard, a count in a save bar. It never collapses"
        }
      },
      "a11y": {
        "requires": [
          "the primary last among the children — the order is the reading, Tab and drawn order, and nothing reorders it",
          "with `overflow=\"menu\"`, a text label or `aria-label` on every Button that may move, and `moreLabel` translated"
        ],
        "guarantees": [
          "every action is its own tab stop, in the order it is read; no `row-reverse`, so the drawn order never contradicts the Tab order (WCAG 2.4.3)",
          "8px between actions, and between `start` and the rest",
          "the order mirrors in a right-to-left locale: the primary is at the start of the line there",
          "`stack` below 32rem of viewport: one action per line, full width, in reading order, the primary last and nearest the thumb",
          "`overflow=\"menu\"` keeps the row on one line and inside its box. The Buttons that do not fit move into a MenuTrigger named \"More actions\" at the row's start; hidden ones are out of the Tab order and the accessibility tree",
          "a menu item presses the real Button, so its `onPress`, its `onClick` and `type=\"submit\"` behave as if it were clicked; a disabled Button is a disabled menu item",
          "with room again, every action comes back and More goes — also in a parent that sizes to its content, such as PageHeader's actions"
        ],
        "notes": [
          "a menu item cannot show a Button's variant: a destructive action that must look destructive should not be allowed to overflow — make it the primary, or put it in `start`",
          "Enter in a form submits the first submit button in the DOM order; with the primary last, give the others `type=\"button\"` (IonBase's Button is `type=\"button\"` unless told otherwise)"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<div style={{ display: 'flex', gap: 12, justifyContent: 'flex-end' }}>",
          "do": "<ButtonGroup>",
          "why": "hand-built rows each pick their own gap and none decides what happens on a phone — the demo had 8, 12 and a spacer span"
        },
        {
          "dont": "flex-direction: row-reverse, to put the primary on the right while it comes first in the markup",
          "do": "the primary last among the children",
          "why": "Tab then walks the row backwards from how it is drawn, and a screen reader reads the primary before the options it is chosen over"
        },
        {
          "dont": "<ButtonGroup overflow=\"menu\"> around icon-only Buttons with no `aria-label`",
          "do": "an `aria-label` on each, which becomes its menu item's text",
          "why": "a collapsed icon-only Button has nothing to be called in the menu"
        },
        {
          "dont": "a ButtonGroup of ten actions on one record",
          "do": "<Toolbar>, or a MenuTrigger for the rare ones",
          "why": "ten tab stops in a row is ten presses to get past it"
        }
      ],
      "stylesheet": "src/styles/button-group.css",
      "tokens": [
        "--spacing-8"
      ],
      "props": {
        "children": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The actions, least important first and the primary last — the order\nthey are read and tabbed in, and the order they are drawn. Nothing is\nreordered to put the primary on the right."
        },
        "start": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Actions pinned to the start edge, apart from the rest: Back in a\nwizard, a destructive \"Delete\" in an edit dialog. They never collapse."
        },
        "align": {
          "type": "ButtonGroupAlign | undefined",
          "required": false,
          "origin": "own",
          "description": "Where the actions sit. Default `end`, the dialog and form convention.",
          "values": [
            "start",
            "center",
            "end"
          ]
        },
        "overflow": {
          "type": "ButtonGroupOverflow | undefined",
          "required": false,
          "origin": "own",
          "description": "`wrap` (default): actions that do not fit move to a second line.\n`menu`: Buttons that do not fit move into a \"More actions\" menu at the\nstart of the row, the first Button first. The last child — the primary —\nnever moves, and neither does anything that is not a Button; the\nButtons after it still can.",
          "values": [
            "menu",
            "wrap"
          ]
        },
        "stack": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Below 32rem of viewport, every action takes the full width, one per\nline, still in reading order — so the primary is last, nearest the\nthumb. For dialog and form footers."
        },
        "moreLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The overflow trigger's name. Default \"More actions\" — pass the translation."
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Calendar": {
      "name": "Calendar",
      "source": "src/components/Calendar.tsx",
      "propsType": "CalendarProps",
      "description": "Calendar — one month in the page, for a day chosen by looking at the month.\n\nDATEPICKER FIRST\n\nA date someone knows — a birthday, an invoice date — is typed faster than\nit is found, and a field takes one row where this takes a month. This is\nfor a day that is chosen by where it falls: a day to resume on, a delivery\nday, and for a place where a popover would be a second layer — a Modal, a\nSidePanel.\n\nNAMED WITH THE MONTH\n\n`label` is the calendar's name, joined by react-aria to the visible month —\n\"Resume on October 2026\" — so moving to another month says where you are.\nEvery day's name is its full date, in the reader's language; today and the\nselected day are said as such.",
      "import": "import { Calendar } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One month in the page for a day chosen by where it falls — a day to resume on, a delivery day — taking and returning a YYYY-MM-DD string.",
      "useWhen": [
        "the day is chosen by looking at the month — which weekday, how far off — not recalled and typed",
        "the calendar would otherwise be a second layer: inside a Modal or a SidePanel, where a DatePicker's popover opens over a dialog",
        "the page has room for a month, and the day is the main question on it"
      ],
      "useInstead": [
        {
          "when": "a date the person knows — a birthday, an invoice date — or a form with other fields",
          "use": "DatePicker",
          "why": "typing a known date is faster than finding it, and a field takes one row where this takes a month"
        },
        {
          "when": "a start and an end",
          "use": "DateRangePicker",
          "why": "this holds one day; a range needs the band between its ends and a check that the end is not before the start"
        },
        {
          "when": "a moment, with a time of day",
          "use": "DateTimePicker",
          "why": "this is fixed to days; a moment's bound such as \"not before now\" needs the time too"
        },
        {
          "when": "one of a few days the product defines — four delivery slots",
          "use": "RadioGroup",
          "why": "a choice from a short list, not a date; a month of which four days work reads as a month of refusals"
        }
      ],
      "composition": {
        "order": [
          "Calendar"
        ],
        "note": "`label` names it, joined to the visible month. `value` is YYYY-MM-DD, or null for none yet; `onChange` gets YYYY-MM-DD and never null. Bound it with `minValue` and `maxValue`, and refuse single days with `isDateUnavailable`. With nothing selected it opens on today, or on `minValue` when today is before it, or on `defaultFocusedValue`.",
        "example": "<Calendar label=\"Resume on\" value={day} onChange={setDay} minValue={tomorrow} maxValue={inNinetyDays} description=\"At the start of the day, UTC.\" />"
      },
      "a11y": {
        "requires": [
          "a `label`: it is the calendar's name, and the only thing that says what the day is for"
        ],
        "guarantees": [
          "named by `label` and then the visible month — \"Resume on October 2026\" — so paging months says where you are",
          "arrow keys move by day and week, Page Up and Page Down by month, Home and End to the week's ends; Enter or Space picks the focused day",
          "every day is named by its full date in the reader's language, with today and the selected day said as such; a newly picked day is announced",
          "days before `minValue`, after `maxValue` or refused by `isDateUnavailable` cannot be picked, and the month arrows stop at the bounds",
          "a value it refuses marks it invalid on its own — `errorMessage`, when given, replaces the description — and the frame turns to the error border",
          "`description` or the error is the calendar's accessible description",
          "disabled, it keeps showing the chosen day, on the disabled surface, and no day can be picked or moved to"
        ],
        "notes": [
          "No clearing: a day is changed by picking another. Offer a separate control when \"no day\" is an answer.",
          "Always one month; for two months side by side, the range is DateRangePicker's."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Calendar for a date of birth or any date the person knows",
          "do": "<DatePicker label=\"Date of birth\" />",
          "why": "paging back decades through months is the worst way to enter a date someone can type in a second"
        },
        {
          "dont": "a DatePicker inside a Modal whose only question is the day",
          "do": "<Calendar label=\"Resume on\" /> in the Modal",
          "why": "the picker's calendar opens a popover over the dialog; the day is the dialog's whole content, so show the month"
        },
        {
          "dont": "a Calendar without bounds for a day that has them",
          "do": "minValue and maxValue",
          "why": "an unbounded month lets someone pick yesterday and learn only on save that it was never possible"
        }
      ],
      "stylesheet": null,
      "tokens": [],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "Names the calendar. It has no field and no trigger, so this is the only\nthing that says what the day is for — \"Resume on\", \"Deliver on\"."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text under the month."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text while invalid — `isInvalid`, or its own bounds."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Shows the day and lets it be read and moved through, not changed."
        },
        "value": {
          "type": "string | null | undefined",
          "required": false,
          "origin": "own",
          "description": "The selected day as `YYYY-MM-DD`; `null` is none yet."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The initial day as `YYYY-MM-DD`, for an uncontrolled calendar."
        },
        "onChange": {
          "type": "((value: IsoDate) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires with `YYYY-MM-DD`. Never with `null`: a day in a grid is changed\nby picking another, not cleared."
        },
        "minValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Earliest selectable day, `YYYY-MM-DD`. Earlier months cannot be reached."
        },
        "maxValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Latest selectable day, `YYYY-MM-DD`."
        },
        "isDateUnavailable": {
          "type": "((date: IsoDate) => boolean) | undefined",
          "required": false,
          "origin": "own",
          "description": "Single days refused inside the bounds — weekends, holidays, booked days."
        },
        "defaultFocusedValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The month to open on, and the day focus lands on, while nothing is\nselected. Defaults to today — or `minValue`, when today is before it."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Posts the value under this name, for an uncontrolled form."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 16,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Card": {
      "name": "Card",
      "source": "src/components/Card.tsx",
      "propsType": "CardProps",
      "description": "Card — a bordered surface that groups one part of a page.\n\nPromoted from the demo app, where the same surface was hand-written as\n`.demo-panel` in nine screens: a settings group, a chart panel, a runs log,\nthe danger zone. For a feature row with media use FullCard; for one\nheadline figure use StatTile.\n\nTHE TITLE DECIDES THE ELEMENT\n\nA titled card is a `<section aria-labelledby>` — a named region, so a\nscreen reader user can jump between cards the way a sighted one scans\nthem. An untitled card is a `<div>`: a `<section>` with no name is not a\nlandmark at all, and pretending otherwise only adds noise.\n\nNo `'use client'`: `useId` resolves on the server, and nothing here is\ninteractive. Interactive parts arrive through `action` and `children`.",
      "import": "import { Card } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.77.0",
      "summary": "A bordered surface that groups one part of a page, with an optional title row. A titled card is a named region; an untitled one is a plain surface.",
      "useWhen": [
        "a page is several distinct groups — settings sections, dashboard panels, a log beside a chart",
        "a group needs a title, a one-line description or a single action like \"All runs\"",
        "a loading placeholder needs the same frame as the content it stands in for"
      ],
      "useInstead": [
        {
          "when": "it is a feature row with a headline, text and a media panel",
          "use": "FullCard"
        },
        {
          "when": "it is one headline figure and how it moved",
          "use": "StatTile"
        },
        {
          "when": "the content is a list of settings, each with a label and a control",
          "use": "SettingRow"
        },
        {
          "when": "the message needs the user's attention now rather than a place on the page",
          "use": "Alert"
        }
      ],
      "composition": {
        "note": "The title row is built from props — `title`, `description`, `action` — so every card lays it out the same way. The body is `children`, spaced 12 apart by the card's grid.",
        "example": "<Card title=\"Recent runs\" action={<Link variant=\"standalone\" href=\"/runs\">All runs</Link>}><Table aria-label=\"Recent runs\">…</Table></Card>"
      },
      "variants": {
        "intent": {
          "default": {
            "use": "every card that is not a danger zone"
          },
          "danger": {
            "use": "a card of irreversible actions — delete the workspace, transfer ownership. The rule turns red; the title must still say what is at stake"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "a titled card renders a <section> named by its heading, so it is a landmark a screen reader can jump to",
          "an untitled card renders a <div>, never an unnamed <section>",
          "the danger intent is a border colour only, so the meaning stays in the words"
        ],
        "requires": [
          "the right `headingLevel` for where the card sits in the page outline",
          "`isRegion={false}` when the card's only content is a landmark named by the same words, such as a Table labelled by the card's title"
        ],
        "notes": [
          "`headingLevel` takes 2-6 and defaults to 2. It is a number rather than a variant because it is an outline decision, not a visual one: the title looks the same at every level.",
          "`description` renders only under a `title`. An untitled card has no title row to put it in."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a heading passed in `children` instead of `title`",
          "why": "the card does not become a named region, and the title row loses its layout for `description` and `action`"
        },
        {
          "dont": "several buttons in `action`",
          "why": "the title row wraps and the card reads as a toolbar; `action` is for one control — put the rest in the body"
        },
        {
          "dont": "relying on `intent=\"danger\"` alone to warn",
          "why": "a red border is not read aloud and is easy to miss; the title and the button labels must say what cannot be undone"
        },
        {
          "dont": "cards nested inside cards",
          "why": "border inside border reads as a mistake; group the inner content with a heading or a Divider instead"
        }
      ],
      "stylesheet": "src/styles/card.css",
      "tokens": [
        "--border-error-subtle",
        "--border-subtle",
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-semibold",
        "--radius-lg",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-8",
        "--surface-default",
        "--text-default",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-h6",
        "--type-h6-line-height"
      ],
      "props": {
        "title": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The card's heading. With a title the card is a `<section>` named by it;\nwithout one it is a plain surface — a `<div>`, for skeletons and for\ncontent that already has its own heading."
        },
        "headingLevel": {
          "type": "CardHeadingLevel | undefined",
          "required": false,
          "origin": "own",
          "description": "Heading element for the title. Defaults to `h2`: most cards are the\nfirst level of section under the page's `h1`. The outline is the page's\ndecision, so it is a prop, not a constant."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "One line under the title — what the card holds or when changes apply."
        },
        "action": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "One control at the end of the title row: a standalone Link (\"All runs\")\nor a small Button. More than one belongs in the body."
        },
        "intent": {
          "type": "CardIntent | undefined",
          "required": false,
          "origin": "own",
          "description": "`danger` for a card of irreversible actions — the danger zone.",
          "values": [
            "default",
            "danger"
          ]
        },
        "isRegion": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether a titled card is a landmark. Defaults to `true`. Set `false`\nwhen the card's only content is itself a landmark named by the same\nwords — a Table's scroll region — or the page gets two regions with one\nname. The heading still renders; only the `<section>` becomes a `<div>`."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 7,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "ChartLegend": {
      "name": "ChartLegend",
      "source": "src/components/Chart.tsx",
      "propsType": "ChartLegendProps",
      "description": "ChartLegend — which colour is which series.\n\nColour alone never identifies a series (SC 1.4.1), so any chart with more\nthan one series needs this, or direct labels on the lines. The swatch reads\nthe same `--chart-<n>` as the series itself, through `chartSeriesClass`, so\nthe two cannot drift apart.",
      "import": "import { ChartLegend } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.74.0",
      "summary": "Which colour is which series, for a chart drawn with visx. The swatch reads the same token as the series.",
      "useWhen": [
        "a chart has more than one series",
        "a single-series chart sits beside others and its colour needs naming"
      ],
      "useInstead": [
        {
          "when": "the lines are few and there is room to label each at its end",
          "use": "direct labels in the SVG, with the series class on the text"
        },
        {
          "when": "the scale is a magnitude ramp (a heatmap), not categories",
          "use": "a Less-to-More row of .ion-chart__heat--0…5 cells"
        }
      ],
      "composition": {
        "note": "Items are listed in the order the series are drawn, with the same series numbers the chart uses. Place it above or below the plot inside the same .ion-chart figure.",
        "example": "<ChartLegend shape=\"line\" items={[{ label: 'Production', series: 1 }, { label: 'Staging', series: 2 }]} />"
      },
      "variants": {
        "shape": {
          "square": {
            "use": "the default — bars, areas and heatmap-like marks"
          },
          "line": {
            "use": "line charts, so the key looks like the mark it names"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "a list, so a screen reader announces how many series there are",
          "swatches are aria-hidden; every series is named in text"
        ],
        "requires": [
          "series numbers matching the chart — the legend cannot see the chart"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a multi-series chart with no legend and no direct labels",
          "why": "colour becomes the only way to tell series apart, which fails SC 1.4.1 and fails anyone who cannot tell blue from purple"
        },
        {
          "dont": "listing series in a different order from the chart",
          "why": "the reader matches legend to chart by position as much as by colour"
        }
      ],
      "stylesheet": "src/styles/chart.css",
      "tokens": [
        "--border-default",
        "--border-strong",
        "--border-stronger",
        "--border-subtle",
        "--border-width-default",
        "--chart-1",
        "--chart-2",
        "--chart-3",
        "--chart-4",
        "--chart-5",
        "--chart-6",
        "--chart-7",
        "--chart-8",
        "--chart-sequential-1",
        "--chart-sequential-2",
        "--chart-sequential-3",
        "--chart-sequential-4",
        "--chart-sequential-5",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-semibold",
        "--ion-chart-series",
        "--ion-shadow-shadow-lg",
        "--radius-2xs",
        "--radius-full",
        "--radius-md",
        "--spacing-12",
        "--spacing-16",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-raised",
        "--surface-sunken",
        "--text-default",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "items": {
          "type": "ChartLegendItem[]",
          "required": true,
          "origin": "own",
          "description": "One per series, in the order the series are drawn."
        },
        "shape": {
          "type": "ChartLegendShape | undefined",
          "required": false,
          "origin": "own",
          "description": "`square` for bars and areas, `line` for line charts.",
          "values": [
            "square",
            "line"
          ]
        }
      },
      "propCounts": {
        "own": 2,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "ChartTooltip": {
      "name": "ChartTooltip",
      "source": "src/components/Chart.tsx",
      "propsType": "ChartTooltipProps",
      "description": "ChartTooltip — the panel that shows the values under the pointer.\n\nPut it inside visx's Tooltip with `chartTooltipProps`, so visx places it and\nthis draws it: raised, rimmed and shadowed like Popover and Toast.\n\nIT IS NOT THE ACCESSIBLE WAY TO THE DATA. It appears on hover, so a keyboard\nor screen-reader user never sees it. Every chart still needs a text summary\nand the values in a table (visually hidden is fine) — the tooltip is a\nconvenience for pointer users, never the only route to a number.",
      "import": "import { ChartTooltip } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.74.0",
      "summary": "The panel showing the values under the pointer on a visx chart. visx positions it; this draws it.",
      "useWhen": [
        "a chart drawn with visx shows exact values on hover",
        "several series share an x value and the reader compares them at a point"
      ],
      "useInstead": [
        {
          "when": "it explains a control rather than a data point",
          "use": "Tooltip"
        },
        {
          "when": "it holds interactive content",
          "use": "Popover"
        },
        {
          "when": "the reader needs every value, or needs them without a pointer",
          "use": "Table"
        }
      ],
      "composition": {
        "note": "Render it inside visx's Tooltip, TooltipWithBounds or TooltipInPortal with {...chartTooltipProps}, which turns off visx's own box so this one is the only panel. Axes take {...chartAxisProps} and gridlines {...chartGridProps}; a series takes className={chartSeriesClass(n)} plus ion-chart__line, __area, __bar or __point.",
        "example": "<TooltipWithBounds top={top} left={left} {...chartTooltipProps}><ChartTooltip title=\"12 Sep\" rows={[{ label: 'Success rate', value: '96.2%', series: 1 }]} /></TooltipWithBounds>"
      },
      "a11y": {
        "guarantees": [
          "rows are a <dl>, so each value is announced with its label",
          "the series swatch is aria-hidden; the label carries the name"
        ],
        "requires": [
          "a text summary and the values in a table (visually hidden is fine) — the tooltip appears on hover, so keyboard and screen-reader users never reach it",
          "`value` already formatted — the tooltip cannot know the unit or precision"
        ]
      },
      "antiPatterns": [
        {
          "dont": "making the tooltip the only way to read a value",
          "why": "it exists only under a mouse pointer; without a table the data is invisible to keyboard and screen-reader users"
        },
        {
          "dont": "leaving visx's default tooltip styles on",
          "why": "it draws a second white box with a hardcoded shadow around this one, and that box does not theme"
        },
        {
          "dont": "a swatch colour that does not match the series",
          "why": "pass the same `series` number the line or bar uses — the swatch reads the same token only if it is told the same index"
        }
      ],
      "stylesheet": "src/styles/chart.css",
      "tokens": [
        "--border-default",
        "--border-strong",
        "--border-stronger",
        "--border-subtle",
        "--border-width-default",
        "--chart-1",
        "--chart-2",
        "--chart-3",
        "--chart-4",
        "--chart-5",
        "--chart-6",
        "--chart-7",
        "--chart-8",
        "--chart-sequential-1",
        "--chart-sequential-2",
        "--chart-sequential-3",
        "--chart-sequential-4",
        "--chart-sequential-5",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-semibold",
        "--ion-chart-series",
        "--ion-shadow-shadow-lg",
        "--radius-2xs",
        "--radius-full",
        "--radius-md",
        "--spacing-12",
        "--spacing-16",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-raised",
        "--surface-sunken",
        "--text-default",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "title": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "What the pointer is over — usually the x value, e.g. a date."
        },
        "rows": {
          "type": "ChartTooltipRow[] | undefined",
          "required": false,
          "origin": "own",
          "description": "One row per series at that point."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Free content after the rows, for anything that is not label/value."
        }
      },
      "propCounts": {
        "own": 3,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "ChatMessage": {
      "name": "ChatMessage",
      "source": "src/components/ChatMessage.tsx",
      "propsType": "ChatMessageProps",
      "description": "ChatMessage — one turn in a transcript: who, when, what, and what can be\ndone with it.\n\nAN ARTICLE, NAMED BY ITS HEADER. Each message is an `<article>` labelled by\nits author and time, so a screen reader moves message to message and hears\n\"Ada Reyes 10:42\" before each. The avatar is decoration; the name says who.\n\nNOT A LIVE REGION. A thread that announced every message would read a\nstreaming answer out token by token — the reason StreamingText is not one\neither. Say when an answer is ready once, from the thread, the way the\nAssistantAnswer pattern does.\n\nIt holds a message; it does not send, stream or store one.",
      "import": "import { ChatMessage } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One turn in a transcript — who, when, what, and what can be done with it. An article named by its author and time; never a live region.",
      "useWhen": [
        "a conversation with an assistant, shown as a thread of turns",
        "a transcript people read back — a support chat, a review thread between a person and an agent"
      ],
      "useInstead": [
        {
          "when": "the entries are changes to a record — who changed what, when",
          "use": "Timeline",
          "why": "that is history, read as an ordered list of events; a transcript is turns people and an assistant took"
        },
        {
          "when": "an agent is working through steps now",
          "use": "AgentActivity",
          "why": "a run has a live status per step; a message holds what was said"
        },
        {
          "when": "a system message tells the reader something went wrong or changed",
          "use": "Alert",
          "why": "an alert has an intent and a title; a message from a \"System\" author reads as a turn nobody took"
        }
      ],
      "composition": {
        "order": [
          "ChatMessage",
          "StreamingText",
          "Citation",
          "CitationList",
          "Alert"
        ],
        "note": "One ChatMessage per turn, in the order they were taken, oldest first. `from=\"person\"` for what a person typed, `from=\"assistant\"` for the reply. The assistant's content is the AssistantAnswer pattern: a StreamingText with Citations, then a CitationList and an Alert when degraded. `actions` follow the content — Copy, Ask again — as Buttons. Say when an answer is ready once, from a status region outside the messages, never from each message.",
        "example": "<ChatMessage from=\"person\" author=\"Ada Reyes\" avatar={<AvatarGradient size=\"sm\" initials=\"AR\" alt=\"Ada Reyes\" />} timestamp={askedAt}>What is our refund limit?</ChatMessage>\n<ChatMessage from=\"assistant\" author=\"Ionbase assistant\" avatar={<LogoMark size=\"sm\" label=\"Ionbase assistant\" />} timestamp={answeredAt} actions={<CopyButton value={text} label=\"Copy answer\" />}><StreamingText isStreaming={false}>…</StreamingText></ChatMessage>"
      },
      "variants": {
        "from": {
          "person": {
            "use": "what a person wrote — on the trailing side, in a muted bubble at most 80% across"
          },
          "assistant": {
            "use": "the assistant's reply — spans the column, since answers carry lists, sources and tables"
          }
        }
      },
      "slots": {
        "author": {
          "accepts": "text",
          "note": "the author's name, shown and read — the message's name starts with it"
        },
        "avatar": {
          "accepts": "Avatar, AvatarGradient, LogoMark",
          "note": "decoration, hidden from assistive tech; the name says who"
        },
        "children": {
          "accepts": "text, StreamingText, Citation, CitationList, Alert, Link",
          "note": "the message"
        },
        "actions": {
          "accepts": "Button, CopyButton",
          "note": "after the content, in reading order"
        }
      },
      "a11y": {
        "role": "article",
        "guarantees": [
          "each message is an `<article>` named by its header — the author, then the time — so a screen reader moves message to message and hears who and when first",
          "the time is a `<time>` with the exact instant as `dateTime`, whatever its label says",
          "the avatar is hidden from assistive tech",
          "it is not a live region: a thread never reads a streaming answer out as it arrives",
          "the actions come after the content in the DOM, so Tab reaches a source in the answer before Copy",
          "logical properties throughout, so right to left mirrors it"
        ],
        "requires": [
          "`author` on every message, a person's included — \"You\" at the least",
          "a status region outside the messages that says once when an answer is ready or incomplete"
        ],
        "notes": [
          "It renders on a server. It holds a message; it does not send, stream or store one."
        ]
      },
      "antiPatterns": [
        {
          "dont": "`role=\"log\"` or `aria-live` on the thread",
          "why": "a streaming answer is announced token by token, re-read with every chunk; announce completion once instead"
        },
        {
          "dont": "a person's message in the primary tint",
          "why": "the primary tint is AILabel's mark for generated content; a person's words must never read as generated"
        },
        {
          "dont": "a message with only an avatar to say who wrote it",
          "do": "`author` with the name",
          "why": "the avatar is hidden from assistive tech, so the message has no author for anyone who cannot see it"
        },
        {
          "dont": "a \"System\" message for an error or a notice",
          "do": "Alert",
          "why": "it reads as a turn in the conversation, and it has no intent to say whether anything is wrong"
        }
      ],
      "stylesheet": "src/styles/chat-message.css",
      "tokens": [
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-semibold",
        "--radius-lg",
        "--spacing-12",
        "--spacing-4",
        "--spacing-8",
        "--surface-muted",
        "--text-default",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "from": {
          "type": "ChatMessageFrom",
          "required": true,
          "origin": "own",
          "description": "Who wrote it. `person` is set apart — a tinted bubble on the trailing\nside — so a long thread reads as turns; `assistant` spans the column,\nsince its answers carry lists, sources and tables.",
          "values": [
            "person",
            "assistant"
          ]
        },
        "author": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The author's name, shown and read: \"Ada Reyes\", \"Ionbase assistant\"."
        },
        "avatar": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "A picture for the author — Avatar, AvatarGradient, LogoMark. Hidden from\nassistive tech: the name beside it says who."
        },
        "timestamp": {
          "type": "string | Date | undefined",
          "required": false,
          "origin": "own",
          "description": "When it was sent. Rendered in a `<time>` with the exact instant."
        },
        "timestampLabel": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The text shown for the time — \"10:42\", \"2 min ago\" — in place of the\ndefault date and time."
        },
        "locale": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Locale for the default time label. Pass it when rendering on a server."
        },
        "timeZone": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Time zone for the default time label."
        },
        "children": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The message: text, StreamingText, Citations, a CitationList, an Alert."
        },
        "actions": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "What can be done with it — Copy, Retry, feedback. Buttons, after the\ncontent, in reading order."
        }
      },
      "propCounts": {
        "own": 9,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Checkbox": {
      "name": "Checkbox",
      "source": "src/components/Checkbox.tsx",
      "propsType": "CheckboxProps",
      "description": "Checkbox wraps a native `<input type=\"checkbox\">` and hides it.\n\nThe native input is kept rather than replaced by a div with `role=checkbox`\nbecause it brings form association, the indeterminate property, label\nclicking, and correct announcement — none of which are free to reimplement,\nand all of which are easy to get subtly wrong.\n\n`indeterminate` is the reason for the effect below: HTML has no\n`indeterminate` attribute, only a DOM property, so React cannot set it\ndeclaratively and it must be written after every render.",
      "import": "import { Checkbox } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A native checkbox with the system's styling, supporting an indeterminate state.",
      "useWhen": [
        "a single on/off choice inside a form that is submitted",
        "selecting rows in a Table"
      ],
      "useInstead": [
        {
          "when": "the change takes effect immediately rather than on submit",
          "use": "Toggle",
          "why": "a switch reads as a setting taking effect now; a checkbox reads as a value being collected"
        },
        {
          "when": "several options answer one question",
          "use": "CheckboxGroup",
          "why": "it announces the question with each option, carries the group's error, and can require at least one"
        },
        {
          "when": "the options are mutually exclusive",
          "use": "RadioGroup"
        },
        {
          "when": "there are many options and space is tight",
          "use": "Select"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms and table rows"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "touch targets and prominent single choices"
          }
        },
        "intent": {
          "brand": {
            "use": "the default"
          },
          "neutral": {
            "use": "where the checked state is a fact rather than an approval — bulk-selection columns"
          },
          "danger": {
            "use": "consenting to something destructive or irreversible"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "text",
          "note": "the visible label; omit for a bare box and supply `aria-label` instead"
        }
      },
      "a11y": {
        "role": "checkbox",
        "guarantees": [
          "a real <input type=\"checkbox\">, so form association, label clicking, and correct announcement come from the platform",
          "`isIndeterminate` is written to the DOM property after every render — HTML has no such attribute, so React cannot set it declaratively"
        ],
        "requires": [
          "`children`, or `aria-label` when the box has no visible label — a row-selection checkbox always needs one"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a bare checkbox with no `children` and no `aria-label`",
          "why": "every row announces an unnamed checkbox"
        },
        {
          "dont": "`isIndeterminate` on a leaf checkbox",
          "why": "indeterminate means \"some of my children are checked\"; on a leaf it is a state the user can never reach"
        },
        {
          "dont": "a div with `role=\"checkbox\"`",
          "why": "form association, the indeterminate property and label clicking are not free to reimplement"
        },
        {
          "dont": "<Checkbox isSelected={on} onChange={(isSelected) => setOn(isSelected)} />",
          "do": "<Checkbox isSelected={on} onSelectionChange={setOn} />",
          "why": "both selection shapes work, but they pair: `isSelected` goes with `onSelectionChange`, which receives the boolean, and `checked` goes with `onChange`, which receives the change event. Passing the Aria selection prop with the DOM handler name is the one combination that does not type — `onChange` is still the event handler. There is no `CheckboxChange` type; a generated eval invented one."
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        }
      ],
      "stylesheet": "src/styles/checkbox.css",
      "tokens": [
        "--border-disabled",
        "--border-error-strong",
        "--border-focus",
        "--border-inverse",
        "--border-primary-strong",
        "--border-stronger",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--icon-disabled",
        "--icon-on-color",
        "--ion-checkbox-border",
        "--ion-checkbox-fill",
        "--ion-checkbox-font-size",
        "--ion-checkbox-gap",
        "--ion-checkbox-line-height",
        "--ion-checkbox-mark",
        "--ion-checkbox-radius",
        "--ion-checkbox-raised",
        "--ion-checkbox-size",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-raised-flush-lg",
        "--ion-shadow-raised-flush-sm",
        "--ion-shadow-raised-flush-xs",
        "--radius-sm",
        "--radius-xs",
        "--spacing-12",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-error",
        "--surface-inverse",
        "--surface-primary",
        "--text-disabled",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "size": {
          "type": "CheckboxSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "intent": {
          "type": "CheckboxIntent | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Color` variant: Brand, Neutral, Danger.",
          "values": [
            "neutral",
            "danger",
            "brand"
          ]
        },
        "isIndeterminate": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's `Indeterminate` state. Not an HTML attribute — `indeterminate` is a\nDOM property only, so it has to be assigned after render.",
          "default": false
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether the checkbox is disabled."
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `Show Label` + `Label`. Omit for a bare box."
        },
        "isSelected": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "React Aria's name for `checked`. Wins when both are passed."
        },
        "onSelectionChange": {
          "type": "((isSelected: boolean) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Receives the new selection state rather than the change event."
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 304,
        "other": 0
      }
    },
    "CheckboxGroup": {
      "name": "CheckboxGroup",
      "source": "src/components/Checkbox.tsx",
      "propsType": "CheckboxGroupProps",
      "description": "A set of checkboxes that answers one question — \"notify me when…\", \"which\nregions\". Owns the selected values, the group's label, help text and error,\nand \"select at least one\".\n\nA row of loose Checkboxes can do none of that accessibly: the question is\nnot announced with the options, an error has nothing to attach to, and\n\"at least one\" has no native expression at all. See the `required` note in\nCheckbox for how that last one is done.",
      "import": "import { CheckboxGroup } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A set of checkboxes answering one question. Owns the selected values, the group's label, help and error, and \"select at least one\".",
      "useWhen": [
        "several options may be picked and they answer one question — \"notify me when\", \"regions\", \"columns to show\"",
        "the group needs a rule of its own — at least one, or an error that belongs to the set rather than to an option"
      ],
      "useInstead": [
        {
          "when": "only one option may be picked",
          "use": "RadioGroup"
        },
        {
          "when": "the options are many, or space is tight",
          "use": "MultiSelect",
          "why": "past about seven options a list of boxes is longer than the form around it; MultiSelect filters them and shows the chosen ones as tags"
        },
        {
          "when": "there is one on/off choice",
          "use": "Checkbox",
          "why": "a group of one announces a question and then its only answer"
        },
        {
          "when": "each option takes effect immediately",
          "use": "Toggle"
        },
        {
          "when": "each option needs a sentence to be chosen well, and several can be",
          "use": "SelectableTile",
          "why": "the same group with tiles for its options: each a real checkbox the size of a card, its sentence read after its name"
        }
      ],
      "composition": {
        "order": [
          "CheckboxGroup",
          "Checkbox"
        ],
        "note": "Give every Checkbox a `value`; the group's `onChange` receives the array of ticked values. Selection lives on the group — do not also set `isSelected` on the boxes.",
        "example": "<CheckboxGroup label=\"Notify the team when\" isRequired value={notifyOn} onChange={setNotifyOn} isInvalid={!!error} errorMessage={error}><Checkbox value=\"failed\">A run fails</Checkbox><Checkbox value=\"approval\">A run needs approval</Checkbox></CheckboxGroup>"
      },
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms and filter panels"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "touch targets and prominent choices"
          }
        },
        "intent": {
          "brand": {
            "use": "the default"
          },
          "neutral": {
            "use": "facts rather than approvals — the columns shown, the filters applied"
          },
          "danger": {
            "use": "a set of destructive consents"
          }
        },
        "orientation": {
          "vertical": {
            "use": "the default, and always when a label runs past a few words"
          },
          "horizontal": {
            "use": "two to four one-word options — weekdays, regions. Wraps rather than overflows"
          }
        }
      },
      "slots": {
        "label": {
          "accepts": "text",
          "note": "renders as the <legend> — the question the options answer"
        },
        "description": {
          "accepts": "text",
          "note": "help beneath the options; replaced by `errorMessage` while `isInvalid` is set"
        },
        "errorMessage": {
          "accepts": "text",
          "note": "shown only while `isInvalid` is set"
        },
        "children": {
          "accepts": "Checkbox"
        }
      },
      "a11y": {
        "role": "group, from <fieldset> and <legend>",
        "guarantees": [
          "the legend is announced as the group's name when focus enters any box",
          "the description or error is on every box's `aria-describedby` as well as the fieldset's, so it is read on the box that takes focus",
          "`isInvalid` sets `aria-invalid` on every box",
          "`isRequired` sets native `required` on every box while none is ticked and on none once one is: the browser blocks the submit in its own language, and each box announces \"required\" exactly while the rule is unmet",
          "`isDisabled` cascades to every Checkbox that has not set its own"
        ],
        "requires": [
          "`label`, or `aria-label` when the question is already visible elsewhere",
          "a `value` on every Checkbox — the group tracks the selection by it"
        ]
      },
      "antiPatterns": [
        {
          "dont": "loose Checkboxes under a text label",
          "why": "the question is not announced with the options, an error has nothing to attach to, and \"at least one\" cannot be expressed"
        },
        {
          "dont": "`isSelected` or `checked` on a Checkbox inside a group",
          "why": "the group owns the selection; the box's own value is ignored"
        },
        {
          "dont": "`isRequired` on every Checkbox in the group",
          "why": "that means every box must be ticked; `isRequired` on the group means at least one"
        }
      ],
      "stylesheet": "src/styles/checkbox.css",
      "tokens": [
        "--border-disabled",
        "--border-error-strong",
        "--border-focus",
        "--border-inverse",
        "--border-primary-strong",
        "--border-stronger",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--icon-disabled",
        "--icon-on-color",
        "--ion-checkbox-border",
        "--ion-checkbox-fill",
        "--ion-checkbox-font-size",
        "--ion-checkbox-gap",
        "--ion-checkbox-line-height",
        "--ion-checkbox-mark",
        "--ion-checkbox-radius",
        "--ion-checkbox-raised",
        "--ion-checkbox-size",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-raised-flush-lg",
        "--ion-shadow-raised-flush-sm",
        "--ion-shadow-raised-flush-xs",
        "--radius-sm",
        "--radius-xs",
        "--spacing-12",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-error",
        "--surface-inverse",
        "--surface-primary",
        "--text-disabled",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The question the options answer. Renders as the `<legend>`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Help text beneath the options. Replaced by `errorMessage` while invalid."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Shown in the description's place while `isInvalid` is set."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Marks every box invalid and shows `errorMessage`."
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "At least one option must be selected — enforced by native validation."
        },
        "value": {
          "type": "readonly string[] | undefined",
          "required": false,
          "origin": "own",
          "description": "The selected values (controlled)."
        },
        "defaultValue": {
          "type": "readonly string[] | undefined",
          "required": false,
          "origin": "own",
          "description": "The initially selected values (uncontrolled)."
        },
        "onChange": {
          "type": "((value: string[]) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Receives the whole new selection, in the order the options were ticked."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Shared input name, so a form submits every ticked value under it."
        },
        "size": {
          "type": "CheckboxSize | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "intent": {
          "type": "CheckboxIntent | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "neutral",
            "danger",
            "brand"
          ]
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether every checkbox in the group is disabled."
        },
        "orientation": {
          "type": "FieldsetOrientation | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "vertical",
            "horizontal"
          ]
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Checkboxes, each with a `value`."
        }
      },
      "propCounts": {
        "own": 14,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "Citation": {
      "name": "Citation",
      "source": "src/components/Citation.tsx",
      "propsType": "CitationProps",
      "description": "Citation — the inline marker.\n\nTHE MARKER IS NOT THE NAME. Rendered naively, a superscript \"1\" announces as\n\"link, 1\", which tells a screen-reader user nothing about whether to follow\nit. The visible marker stays a number; the accessible name is\n\"Source 1: <source>\". The number is for the sighted reader's eye and the\nsentence is for everyone.\n\nNOT A TOOLTIP. A tooltip cannot be reached on touch and closes on the way to\nit; attribution has to survive both. It is a real link, or a real\nnon-interactive marker when there is nowhere to go.\n\nWITHOUT `href` IT IS NOT A LINK. A citation to a phone call or an internal\ndocument has no address, and rendering a dead anchor for it puts an\nunfollowable link in the page's link list. It becomes a plain marked-up\nreference instead.",
      "import": "import { Citation } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The inline source marker. The visible number is for the eye; the accessible name is the sentence.",
      "useWhen": [
        "a claim in generated text is drawn from a specific source the reader can check",
        "attribution has to survive being read aloud, on touch, and in a links list"
      ],
      "useInstead": [
        {
          "when": "the source needs a full row with a passage",
          "use": "CitationListItem"
        },
        {
          "when": "it is an ordinary link in prose",
          "use": "Link"
        },
        {
          "when": "you were about to put the source in a hover-only hint",
          "use": "Popover",
          "why": "a tooltip cannot be reached on touch and closes on the way to it"
        }
      ],
      "a11y": {
        "role": "link when `href` is set, note otherwise",
        "guarantees": [
          "the accessible name is \"Source <index>: <source>\", never the bare number — a superscript 1 announces as \"link, 1\", which tells a screen-reader user nothing about whether to follow it",
          "without `href` it renders a marker rather than a dead anchor, so an unfollowable link never enters the page's link list"
        ],
        "requires": [
          "`source` naming what is being cited",
          "an `index` matching the footer row, if there is one"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a bare superscript number with no accessible name",
          "why": "\"link, 1\" is not attribution"
        },
        {
          "dont": "an empty `href` for a source with no address",
          "do": "omit `href`",
          "why": "a dead anchor is announced as a link and goes nowhere"
        },
        {
          "dont": "putting the source only in a tooltip",
          "why": "undiscoverable on touch, and gone before it can be read"
        }
      ],
      "stylesheet": "src/styles/citation.css",
      "tokens": [
        "--border-focus",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-semibold",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-xs",
        "--spacing-16",
        "--spacing-2",
        "--spacing-4",
        "--spacing-8",
        "--surface-primary-subtle",
        "--surface-primary-subtle-hover",
        "--text-link",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "index": {
          "type": "string | number",
          "required": true,
          "origin": "own",
          "description": "The marker shown inline — usually a number matching the footer list."
        },
        "source": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "What is being cited. Required: it is the link's accessible name, and a\ncitation a reader cannot identify without following it is not attribution."
        },
        "href": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Where it goes. Omit for a source with no address — a document, a call."
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The accessible name — \"Source 1: <source>\" in English."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 284,
        "other": 0
      }
    },
    "CitationList": {
      "name": "CitationList",
      "source": "src/components/Citation.tsx",
      "propsType": "CitationListProps",
      "description": "The footer list. An `<ol>` so the numbering is structural rather than typed\ninto each row, and so a screen reader can say how many sources there are\nbefore the reader commits to hearing them.",
      "import": "import { CitationList } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The footer list of sources, as an ordered list so the count and order are structural.",
      "useWhen": [
        "several claims cite sources and the reader should be able to see them all at once"
      ],
      "useInstead": [
        {
          "when": "there is one source and it belongs in the sentence",
          "use": "Link"
        },
        {
          "when": "it is tabular data with more than a source per row",
          "use": "Table"
        }
      ],
      "composition": {
        "order": [
          "CitationList",
          "CitationListItem"
        ],
        "example": "<CitationList><CitationListItem index={1} source=\"Q3 filing\" href=\"/q3\">Revenue rose 4%.</CitationListItem></CitationList>"
      },
      "slots": {
        "label": {
          "accepts": "text",
          "note": "rendered as plain text, not a heading element — it should not enter the document outline as a section"
        },
        "children": {
          "accepts": "CitationListItem"
        }
      },
      "a11y": {
        "guarantees": [
          "an <ol>, so a screen reader can say how many sources there are before the reader commits to hearing them",
          "list markers are hidden in CSS while the semantics stay, because the visible number must match the inline Citation's exactly"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a <div> stack of sources",
          "why": "the count and the ordering are the useful part, and only a list carries them"
        },
        {
          "dont": "numbers that disagree with the inline markers",
          "why": "the marker is a promise about where to look"
        }
      ],
      "stylesheet": "src/styles/citation.css",
      "tokens": [
        "--border-focus",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-semibold",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-xs",
        "--spacing-16",
        "--spacing-2",
        "--spacing-4",
        "--spacing-8",
        "--surface-primary-subtle",
        "--surface-primary-subtle-hover",
        "--text-link",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Heading above the list. Rendered as plain text, not a heading element."
        }
      },
      "propCounts": {
        "own": 2,
        "aria": 0,
        "dom": 280,
        "other": 0
      }
    },
    "CitationListItem": {
      "name": "CitationListItem",
      "source": "src/components/Citation.tsx",
      "propsType": "CitationListItemProps",
      "import": "import { CitationListItem } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One source row: its number, what it is, and the passage it supports.",
      "useWhen": [
        "a child of CitationList"
      ],
      "useInstead": [
        {
          "when": "it is the inline marker in the prose",
          "use": "Citation"
        }
      ],
      "slots": {
        "children": {
          "accepts": "text",
          "note": "the quoted or summarised passage this source supports"
        }
      },
      "a11y": {
        "guarantees": [
          "the visible number is `aria-hidden`; the row is named by its source text, which is what a reader needs to hear",
          "without `href` the source renders as text rather than a dead link"
        ],
        "requires": [
          "`source` naming what is being cited"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a bare URL as the `source`",
          "why": "a screen reader spells it out character by character, and it names nothing"
        }
      ],
      "stylesheet": "src/styles/citation.css",
      "tokens": [
        "--border-focus",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-semibold",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-xs",
        "--spacing-16",
        "--spacing-2",
        "--spacing-4",
        "--spacing-8",
        "--surface-primary-subtle",
        "--surface-primary-subtle-hover",
        "--text-link",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "index": {
          "type": "string | number",
          "required": true,
          "origin": "own"
        },
        "source": {
          "type": "string",
          "required": true,
          "origin": "own"
        },
        "href": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The quoted or summarised passage this citation supports."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 278,
        "other": 0
      }
    },
    "Coachmark": {
      "name": "Coachmark",
      "source": "src/components/Coachmark.tsx",
      "propsType": "CoachmarkProps",
      "description": "Coachmark — a callout pointing at one thing on the page, to say what it is\nor what changed. Anchored to its target, which is ringed while it shows.\nSeveral in a row are a Tour.\n\nONLY WHEN ASKED\n\nIt takes focus when it appears, so it must appear because someone asked:\na \"Take the tour\" button, a \"What's new?\" link. Opened on page load, it\nwould pull a keyboard or screen-reader user out of whatever they came to\ndo. To tell people something changed without being asked, use a Banner.\n\nNOT MODAL\n\nThe page behind stays usable and is not hidden from assistive tech: this\npoints at the page, and the page is the point. Focus is not trapped; it\ngoes back where it was when the coachmark closes, and Escape closes it.",
      "import": "import { Coachmark } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A callout pointing at one thing on the page, to say what it is or what changed — anchored to its target by id or ref, which is ringed while it shows. Several in a row are a Tour.",
      "useWhen": [
        "someone asked what a control does, or what changed — a \"What's new?\" link beside a feature that moved",
        "the explanation is about one place on the page and reads best beside it",
        "it is one of several: then it is a Tour, which sequences them"
      ],
      "useInstead": [
        {
          "when": "telling people something changed without their asking",
          "use": "Banner",
          "why": "a coachmark takes focus; opened on page load, it pulls a keyboard or screen-reader user out of what they came to do"
        },
        {
          "when": "several places in order — a first look round a page",
          "use": "Tour",
          "why": "Tour gives the count, Back and Next, and one focus hand-back at the end; a row of separate coachmarks gives none of them"
        },
        {
          "when": "a short hint on a control, on hover or focus",
          "use": "Tooltip",
          "why": "a tooltip holds no controls and never takes focus; a coachmark is for what needs reading"
        },
        {
          "when": "more detail the reader opens themselves from a control",
          "use": "Toggletip",
          "why": "a toggletip is the control's own explanation; a coachmark points at the page from outside it"
        }
      ],
      "composition": {
        "order": [
          "Coachmark"
        ],
        "note": "`target` is an element id, or a ref. Render it when a person asks — `isOpen` from a button's press — with `onClose`, which gives it a close button and Escape. `footer` holds its actions and `progress` a count, which is how Tour builds its steps.",
        "example": "<Coachmark target=\"export-runs\" title=\"Export is here now\" isOpen={open} onClose={() => setOpen(false)}>Runs export as CSV from this button, filtered as the table is.</Coachmark>"
      },
      "slots": {
        "children": {
          "accepts": "text",
          "note": "what to know about the target; linked as the dialog's description, so keep it to a sentence or two"
        },
        "footer": {
          "accepts": "Button",
          "note": "actions, end-aligned; one primary at most"
        }
      },
      "a11y": {
        "requires": [
          "a `title` — it names the dialog",
          "open it from a person's action, never on page load: it takes focus"
        ],
        "guarantees": [
          "a dialog named by `title` and described by the body, focused when it appears so it is read out",
          "not modal: the rest of the page is neither hidden from assistive tech nor inert, and focus is not trapped",
          "Escape and the close button call `onClose`; focus goes back to where it was when it opened",
          "its target is scrolled into view and ringed in border/primary-strong while it shows, and the ring goes when it closes",
          "`placement` is logical: `start` is the right in a right-to-left page; React Aria flips it when there is no room, and the arrow follows the side it resolved to",
          "a target that is not on the page shows nothing, rather than a callout pointing at nothing",
          "a target drawn in the same commit as the coachmark — a section one press reveals and points at — is looked for again once it is on the page, and the coachmark still appears focused",
          "in forced colours the target's ring is Highlight and the panel keeps a CanvasText edge"
        ],
        "notes": [
          "No `onClose`, no close button, and Escape does nothing: give one unless something else closes it.",
          "The close button is named by `closeLabel`, \"Close\" by default."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Coachmark with `isOpen` true on page load",
          "do": "a \"What's new?\" button that opens it, or a Banner",
          "why": "it takes focus when it appears; unasked, it moves a keyboard user away from what they came to do"
        },
        {
          "dont": "three Coachmarks opened one after another from your own state",
          "do": "<Tour steps={…} />",
          "why": "the count, Back, Next, the skipping of missing targets and the single focus hand-back are Tour's"
        },
        {
          "dont": "a coachmark holding a form",
          "do": "a SidePanel or a Modal",
          "why": "a coachmark points at the page; a task needs its own surface"
        }
      ],
      "stylesheet": "src/styles/coachmark.css",
      "tokens": [
        "--border-primary-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-secondary",
        "--icon-size-md",
        "--ion-coachmark-width",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-shadow-lg",
        "--radius-md",
        "--radius-sm",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-32",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-hover",
        "--surface-raised",
        "--text-default",
        "--text-secondary",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "target": {
          "type": "string | React.RefObject<HTMLElement | null>",
          "required": true,
          "origin": "own",
          "description": "What the coachmark points at: an element's `id`, or a ref to it. Ids let\na tour be written as data, with no refs threaded through the page."
        },
        "title": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "Names the coachmark — it is a dialog, and this is its label."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "What to know about the target. Linked as the dialog's description."
        },
        "placement": {
          "type": "CoachmarkPlacement | undefined",
          "required": false,
          "origin": "own",
          "description": "Which side of the target it sits on. Logical: `start` is the left in a\nleft-to-right page and the right in a right-to-left one. React Aria\nflips it when there is no room.",
          "values": [
            "top",
            "bottom",
            "start",
            "end"
          ]
        },
        "isOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Shown or not. Defaults to shown: render it when a person asks for it."
        },
        "onClose": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "The close button and Escape. Without it there is no close button."
        },
        "footer": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Actions under the body, end-aligned: a tour's Back and Next."
        },
        "progress": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Where this is in a sequence — \"2 of 4\" — at the start of the footer."
        },
        "closeLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The close button's name. Default \"Close\"."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 10,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "CodeSnippet": {
      "name": "CodeSnippet",
      "source": "src/components/CodeSnippet.tsx",
      "propsType": "CodeSnippetProps",
      "description": "CodeSnippet — code shown as code: a command to run, a config to paste, a\nname in a sentence.\n\nTHE BLOCK IS A REGION YOU CAN SCROLL FROM THE KEYBOARD. Code does not wrap\n— a wrapped command reads as two — so `single` and `multi` scroll sideways,\nand a scroll area that cannot be focused cannot be scrolled without a\nmouse. Each is a named, focusable region; the arrow keys scroll it.\nChromium has made a scroll area focusable by itself since 130, so a test\nthere passes without `tabIndex`; Safari does not, and axe's\n`scrollable-region-focusable` is what catches it.\n\nTHE WHOLE TEXT IS ALWAYS THERE. A long `multi` block is cut to `maxLines`\nby height, not by dropping lines, so find-in-page still finds them, and the\ncopy button copies all of it whether it is expanded or not.\n\nCOPYING IS CopyButton's. It confirms on the button and announces; the code\nitself is on screen, so a copy that fails leaves it there to select.",
      "import": "import { CodeSnippet } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Code shown as code: a name in a sentence, a command to run, a block to paste. Commands and blocks scroll rather than wrap, are named regions a keyboard can scroll, and carry a CopyButton that copies all of it.",
      "useWhen": [
        "the user will run, paste or type what is shown — a CLI command, an API request, a config file",
        "a sentence names something the user types exactly — a flag, a file name, a command — and it should read as code"
      ],
      "useInstead": [
        {
          "when": "the value is an ID or key shown among other facts, not code",
          "use": "CopyButton",
          "why": "an ID beside its label in a DescriptionList reads as a fact; a snippet's block around it reads as something to run"
        },
        {
          "when": "a keyboard shortcut",
          "use": "Kbd",
          "why": "keys are pressed, not typed; Kbd draws each key and never prints a plus between them"
        },
        {
          "when": "an agent's tool input or output",
          "use": "ToolCall",
          "why": "it shows the payload with the call it belongs to, its status and duration, collapsed until asked for"
        }
      ],
      "composition": {
        "order": [
          "CodeSnippet"
        ],
        "note": "`children` is the code as a string, shown and copied exactly as given. `type` picks inline, single or multi; `label` names a single or multi block, and `copyLabel` its copy button when there is more than one on a page.",
        "example": "<CodeSnippet label=\"Start command\" copyLabel=\"Copy start command\">iops runs start --agent invoice-reconciler</CodeSnippet>"
      },
      "variants": {
        "type": {
          "single": {
            "use": "the default — one command, scrolling sideways if it is long"
          },
          "multi": {
            "use": "a block — a request, a config; cut to `maxLines` with Show more"
          },
          "inline": {
            "use": "in a sentence; no copy button"
          }
        }
      },
      "a11y": {
        "requires": [
          "`label` on each single or multi snippet when a page has more than one — \"Start command\"; the default is \"Code\"",
          "`copyLabel` naming what is copied when a page has more than one — \"Copy start command\"; the default is \"Copy code\"",
          "`label`, `copyLabel`, `copiedLabel`, `showMoreLabel` and `showLessLabel` translated"
        ],
        "guarantees": [
          "single and multi are focusable regions named by `label`, so the arrow keys scroll what is off screen",
          "code never wraps: single stays one line, 32px tall, and scrolls sideways; multi scrolls sideways line by line",
          "a multi block longer than `maxLines` is cut by height, not by dropping lines: every line is in the page and found by find-in-page",
          "Show more is a Button with `aria-expanded` and `aria-controls` pointing at the region; it becomes Show less",
          "the copy button is a CopyButton: it copies the whole text, cut or not, confirms on the button and announces",
          "a trailing newline is not counted as a line",
          "inline is a <code> in the sentence, with no region and no button"
        ],
        "notes": [
          "never put a secret in a snippet unless the user must copy it now; redact it everywhere else, as ToolCall's payloads are",
          "`language` only sets `language-<name>` on the <code>; IonBase does not highlight, and a highlighter added on top must keep the text readable against `surface/muted`"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<pre style={{ whiteSpace: 'pre-wrap' }}>{command}</pre>",
          "do": "<CodeSnippet>{command}</CodeSnippet>",
          "why": "a wrapped command reads as two, and a hand-built <pre> that scrolls is not focusable, so a keyboard cannot reach what is off screen"
        },
        {
          "dont": "a multi block cut to its first lines with the rest dropped",
          "do": "`maxLines`, which cuts by height",
          "why": "dropped lines are missed by find-in-page, by a screen reader and by a copy that copies what is shown"
        },
        {
          "dont": "<CodeSnippet type=\"inline\"> for a command the user will copy",
          "do": "type=\"single\"",
          "why": "inline has no copy button, and a command selected by hand from a sentence picks up the full stop"
        }
      ],
      "stylesheet": "src/styles/code-snippet.css",
      "tokens": [
        "--border-default",
        "--border-focus",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-mono",
        "--ion-code-snippet-lines",
        "--radius-md",
        "--radius-xs",
        "--spacing-12",
        "--spacing-4",
        "--spacing-6",
        "--surface-muted",
        "--text-default",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "children": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The code, as text. It is shown and copied exactly as given."
        },
        "type": {
          "type": "CodeSnippetType | undefined",
          "required": false,
          "origin": "own",
          "description": "`inline` sits in a sentence and has no copy button. `single` is one line\nthat scrolls sideways — a command. `multi` is a block — a config file, a\nrequest.",
          "values": [
            "inline",
            "single",
            "multi"
          ]
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the block for a screen reader — \"Install command\". `single` and\n`multi` are focusable regions, and a region needs a name. Default \"Code\";\nwith more than one snippet on a page, name each."
        },
        "copyLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The copy button's name. Default \"Copy code\"; with more than one snippet\non a page, say which — \"Copy install command\"."
        },
        "copiedLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Default \"Copied\"."
        },
        "failedLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Read and shown when the clipboard refused the copy — CopyButton's `failedLabel`."
        },
        "hideCopyButton": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Leaves out the copy button — for code that is read, not run."
        },
        "maxLines": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "`multi` only: lines shown before \"Show more\". Default 12."
        },
        "showMoreLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Default \"Show more\"."
        },
        "showLessLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Default \"Show less\"."
        },
        "language": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Set as `language-<name>` on the `<code>`, the class highlighters look\nfor. IonBase does not highlight."
        },
        "onCopy": {
          "type": "((text: string) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "onCopyError": {
          "type": "((error: unknown, text: string) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 14,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "ColorPicker": {
      "name": "ColorPicker",
      "source": "src/components/ColorPicker.tsx",
      "propsType": "ColorPickerProps",
      "description": "ColorPicker — a colour chosen by a person: a brand accent, a label's\ncolour, a chart series. A hex field with a swatch button; the button opens\na saturation and brightness area, a hue strip and any preset swatches.\n\nTHE FIELD FIRST\n\nTyping `#0B5FFF` is the fastest way to a known colour and the only one that\nneeds no pointer, so the field is always there and the popover is the\nsecond way in. The two cannot disagree: they are one value.\n\nTHE HUE IS KEPT\n\nHex has no hue for a grey or for black. Dragged to the bottom of the area,\n`#000000` would come back as hue 0 and the strip would jump to red. The\npicker holds its own HSB colour and emits hex from it, taking a new value\nfrom the caller only when its hex differs — so a round trip through the\ncaller's state loses nothing.\n\nWHAT IT DOES NOT DO\n\n  - No alpha. A transparent brand colour renders differently on every\n    ground; ask for a solid one.\n  - No contrast check: the picker cannot know what goes on the colour.\n    When text will sit on it, check the pair and say so with `isInvalid`.",
      "import": "import { ColorPicker } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A colour chosen by a person — a brand accent, a label, a chart series — as a hex field with a swatch button that opens a saturation and brightness area, a hue strip and any preset swatches, taking and returning `#RRGGBB`.",
      "useWhen": [
        "the colour is the user's to choose: a workspace's brand accent, a label's or a tag's colour, a series in a chart they own",
        "any colour should be possible, not only a list — with the likely ones offered as `swatches`",
        "the value is stored and shown back as a colour, not as a name"
      ],
      "useInstead": [
        {
          "when": "the choice is among a few named colours and nothing else is allowed",
          "use": "RadioGroup",
          "why": "an open picker invites a colour the product then has to refuse; radios name each choice and allow no other"
        },
        {
          "when": "the colour marks a status — failed, running, healthy",
          "use": "StatusIndicator",
          "why": "status colours are the system's, each with a word and a shape beside it; a picked colour carries neither, and means nothing to the next reader"
        },
        {
          "when": "the choice is a theme — light, dark, the system's",
          "use": "SegmentedControl",
          "why": "a theme is a named mode with every colour decided; a picker would ask for one colour and leave the rest to chance"
        }
      ],
      "composition": {
        "order": [
          "ColorPicker"
        ],
        "note": "One field. `value` is `#RRGGBB`; `onChange` fires on every step of a drag, `onChangeEnd` once a change is finished — save or recompute there. Offer the likely colours as `swatches`, each with a `label` when it has a name people use. When text will sit on the colour, check the pair yourself and pass `isInvalid` with an `errorMessage` that gives the ratio.",
        "example": "<ColorPicker label=\"Brand colour\" description=\"The Approve button in approval emails.\" value={brand} onChange={setBrand} swatches={[{ value: '#0B5FFF', label: 'Northwind blue' }]} />"
      },
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms, a colour in a table row"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "prominent single-field forms"
          }
        }
      },
      "slots": {
        "description": {
          "accepts": "text",
          "note": "where the colour is used, and any rule it must meet; replaced by `errorMessage` while the field is invalid"
        },
        "labels": {
          "accepts": "text",
          "note": "the swatch button's name and the presets' group name"
        }
      },
      "a11y": {
        "requires": [
          "`label`, or an `aria-label` when no visible label is rendered",
          "a `label` on every swatch that has a name people use — \"Northwind blue\" says more than \"vibrant blue\""
        ],
        "guarantees": [
          "the hex is a text field named by the label, typed and committed on Enter or blur; shorthand such as `f00` is accepted as typing and expanded, and anything that is not a colour goes back to the last one",
          "the swatch button is named by `labels.picker`, not by the field's label, and opens a modal dialog named by the field's label, with focus in the area",
          "the area is a two-dimensional slider: the arrow keys move saturation and brightness, Page Up and Down take bigger steps, and each step is announced with the colour's name",
          "the hue strip is a slider named Hue, in the reader's language; in a right-to-left locale it runs from the right and the arrows follow it",
          "presets are one radio group named by `labels.swatches`: the arrow keys move and pick, the chosen one is ringed as well as coloured, and a colour off every preset leaves none checked",
          "a preset with no `label` is named by its colour in the reader's language",
          "Tab moves from the area to the hue strip to the presets and stays in the dialog; Escape or a click outside closes it and focus returns to the button",
          "the hue is kept at a grey or black: dragged to black and back up, a blue comes back blue",
          "a caller that does not take the change keeps its value, and the field shows it",
          "in forced colours the chip, the swatches and the thumbs keep their colours; their edges and the chosen ring take the system's",
          "a name, `rgb()` or shorthand passed as a prop throws with the prop's name, rather than rendering an empty field",
          "`name` posts the hex with the form"
        ],
        "notes": [
          "No alpha: a translucent colour looks different on every ground it lands on. Ask for a solid one.",
          "No contrast check: the picker cannot know what goes on the colour. When text sits on it, measure the pair and say so with `isInvalid`.",
          "The colour names are React Aria's, and follow the locale."
        ]
      },
      "antiPatterns": [
        {
          "dont": "`value=\"red\"`, `value=\"#f00\"` or `value=\"rgb(255, 0, 0)\"`",
          "do": "`value=\"#FF0000\"`",
          "why": "a value the picker cannot show as it came would come back different, and the form would read as edited the first time it was saved"
        },
        {
          "dont": "saving on `onChange`",
          "do": "`onChangeEnd={(hex) => save(hex)}`",
          "why": "`onChange` fires on every pixel of a drag — dozens of saves for one gesture, answered out of order"
        },
        {
          "dont": "a brand colour taken with no check when white text will sit on it",
          "do": "measure the pair, and `isInvalid` with \"Too light for white text: 2.1:1. Needs 4.5:1.\"",
          "why": "the picker offers every colour, and half of them make the text on them unreadable"
        },
        {
          "dont": "a ColorPicker for an interface colour — a button's, a status's",
          "do": "the token",
          "why": "interface colours are chosen once, in the design system, and checked there; a picker hands that decision to whoever has the settings page open"
        }
      ],
      "stylesheet": "src/styles/color-picker.css",
      "tokens": [
        "--border-focus",
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--ion-color-picker-panel",
        "--ion-color-picker-swatch",
        "--ion-color-picker-thumb",
        "--ion-color-picker-thumb-size",
        "--ion-input-icon-size",
        "--ion-shadow-shadow-xs",
        "--radius-2xs",
        "--radius-full",
        "--radius-sm",
        "--radius-xs",
        "--spacing-12",
        "--spacing-2",
        "--spacing-20",
        "--spacing-24",
        "--spacing-40",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-raised",
        "--text-default"
      ],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Field label. Required for a usable control — see `a11y.requires`."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the field when there is no visible `label`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the field — where the colour is used, and any rule it must meet."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text while the field is invalid."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "size": {
          "type": "ColorPickerSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Input's `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "value": {
          "type": "string | null | undefined",
          "required": false,
          "origin": "own",
          "description": "The colour as `#RRGGBB`. `null` means none is chosen. A malformed value\nthrows rather than rendering an empty field."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The initial colour, for an uncontrolled picker."
        },
        "onChange": {
          "type": "((value: HexColor | null) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires with `#RRGGBB` (upper case) on every change — each step of a drag\nincluded — or `null` when the field is cleared."
        },
        "onChangeEnd": {
          "type": "((value: HexColor | null) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires once a change is finished: a drag let go, a key step, a swatch\npicked, the hex typed and committed. Save or preview here."
        },
        "swatches": {
          "type": "readonly (string | ColorPickerSwatch)[] | undefined",
          "required": false,
          "origin": "own",
          "description": "Preset colours, offered under the picker as one radio group."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Posts the hex under this name, for an uncontrolled form."
        },
        "labels": {
          "type": "ColorPickerLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "The swatch button's name and the presets' group name."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 19,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Combobox": {
      "name": "Combobox",
      "source": "src/components/Combobox.tsx",
      "propsType": "ComboboxProps",
      "description": "Combobox — a text field that filters a list, with one selected value.\n\nDrawn in Figma as `Combobox` (1370:2359) — three sizes by seven states, the\nsame axes `Input` carries, because the field IS Input's box. The open list is\na second Figma component, `Combobox Menu`, with no React export behind it.\n\nWHY THIS IS NOT `Select` WITH A SEARCH BOX\n\n`Select` wraps a native `<select>`, whose list the browser owns: it cannot be\nfiltered, and its rows can only hold text. Everything above about twenty\noptions needs filtering, and `Select`'s own contract has pointed at \"a\ncombobox — not yet in this system\" since it was written. This is it.\n\nThe cost is that everything the native control gave away for free — keyboard\nhandling, the mobile picker, screen-reader semantics — now has to be built.\nReact Aria's `useComboBox` builds it: `role=\"combobox\"` with `aria-expanded`,\n`aria-controls` and `aria-activedescendant`, arrow keys that move a virtual\nfocus while the real focus stays in the input, and Escape that reverts.\n\nFILTERING IS LOCALE-AWARE, NOT `toLowerCase().includes()`\n\n`useFilter({ sensitivity: 'base' })` is `Intl.Collator` underneath, so\n\"resume\" matches \"résumé\" and Turkish dotted/dotless I behave the way a\nTurkish reader expects. The lowercase-and-includes version fails both, and\nfails them silently in exactly the locales least likely to be tested.",
      "import": "import { Combobox } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A text field that filters a list of options down to one selected value.",
      "useWhen": [
        "the list is long enough that scanning it is worse than typing",
        "the user picks exactly one value from a known set",
        "the options carry a second line of context the native <select> cannot render"
      ],
      "useInstead": [
        {
          "when": "the list is short and fully scannable",
          "use": "Select",
          "why": "the native control brings keyboard handling and the mobile platform picker for free; this rebuilds all of it"
        },
        {
          "when": "more than one value may be picked",
          "use": "MultiSelect",
          "why": "its input holds only the filter text, the list stays open between picks, and the chosen values show as tags"
        },
        {
          "when": "there are two or three mutually exclusive choices worth showing at once",
          "use": "RadioGroup"
        },
        {
          "when": "the value is free text and the list is only a convenience",
          "use": "Input",
          "why": "or set `allowsCustomValue`, which is the same decision made explicitly"
        },
        {
          "when": "the rows are commands rather than values",
          "use": "Menu",
          "why": "options are selected and stay selected; menu items are invoked and the menu closes"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms, table filters"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "prominent single-field forms"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "role=combobox with aria-expanded, aria-controls and aria-activedescendant, from react-aria's useComboBox",
          "real focus never leaves the input; arrow keys move a virtual focus through the list, which is what keeps typing and browsing possible at once",
          "the popover is non-modal, so the page behind stays readable and focus stays in the field",
          "Escape reverts to the last committed value rather than clearing the field",
          "filtering is Intl.Collator-based, so accents and locale-specific casing behave correctly",
          "the empty message is a sibling of the listbox, not an option in it, so it is never counted, focused or selectable",
          "the menu matches the field's width, clamped to the space right of it so a full-bleed field cannot push the menu past the viewport edge"
        ],
        "requires": [
          "`label`, or an `aria-label` when no visible label is rendered",
          "`emptyLabel` wording that says why nothing is listed, if the default is too terse"
        ],
        "notes": [
          "While the list is open, React Aria hides everything outside the field and the list from assistive technology. That is the platform pattern for a combobox; a whole-page accessibility scan taken with the list open reports the hidden page, not this control."
        ]
      },
      "antiPatterns": [
        {
          "dont": "giving the disclosure button a tab stop",
          "why": "the input already opens the list with ArrowDown. A second tab stop doubles the cost of tabbing past every combobox in a form and adds nothing"
        },
        {
          "dont": "styling the focused option with :hover or :focus",
          "why": "no option is ever either — focus is virtual. Use [data-focused], which react-aria sets for both pointer and keyboard"
        },
        {
          "dont": "posting the input's text in a form",
          "why": "the text is the label and the value is the key; pass `name` and the component posts the key in a hidden input"
        },
        {
          "dont": "filtering with toLowerCase().includes()",
          "why": "it fails on accents and on Turkish dotted/dotless I, silently, in the locales least likely to be tested"
        },
        {
          "dont": "writing a width onto the menu from a ResizeObserver or any effect that runs while it is open",
          "why": "a positioned popover that changes size feeds back into react-aria's positioning, and any scrollbar that results is read by useCloseOnScroll as a real scroll. The menu opens and closes within about a hundred milliseconds, which presents as a keyboard bug rather than a layout one"
        }
      ],
      "slots": {
        "emptyLabel": {
          "expects": "what to show when the filter matches nothing"
        }
      },
      "stylesheet": "src/styles/combobox.css",
      "tokens": [
        "--border-subtle",
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-disabled",
        "--icon-secondary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-input-icon-size",
        "--ion-shadow-shadow-lg",
        "--radius-md",
        "--radius-sm",
        "--spacing-12",
        "--spacing-2",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-hover",
        "--surface-primary-subtle",
        "--surface-raised",
        "--text-default",
        "--text-disabled",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "options": {
          "type": "readonly ComboboxOption[]",
          "required": true,
          "origin": "own",
          "description": "The full option list. Filtering happens here, against what is typed."
        },
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Field label. Required for a usable control — see `a11y.requires`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the field."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text when `isInvalid` is set."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "size": {
          "type": "ComboboxSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Input's `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "selectedKey": {
          "type": "string | null | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled selection. `null` means nothing is selected."
        },
        "defaultSelectedKey": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "onSelectionChange": {
          "type": "((key: string | null) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "inputValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled text. Usually only needed for async/remote filtering."
        },
        "defaultInputValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "onInputChange": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "allowsCustomValue": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Accept text that matches no option. Off by default: a combobox whose value\nis a free string is a text field with suggestions, and the caller should\nhave to say that is what they want."
        },
        "menuTrigger": {
          "type": "\"focus\" | \"input\" | \"manual\" | undefined",
          "required": false,
          "origin": "own",
          "description": "What opens the list. `input` — react-aria's default and this one — opens it\non the first keystroke; the chevron opens it on demand. `focus` opens it\nthe moment the field is tabbed into, which is loud in a long form.",
          "values": [
            "focus",
            "input",
            "manual"
          ]
        },
        "emptyLabel": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Shown in place of the list when nothing matches."
        },
        "buttonLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible label for the disclosure button."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Posts the selected value under this name, for an uncontrolled form."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 23,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "CommandPalette": {
      "name": "CommandPalette",
      "source": "src/components/CommandPalette.tsx",
      "propsType": "CommandPaletteProps",
      "description": "CommandPalette — every action in the product, one search away.\n\n⌘K (Ctrl+K elsewhere) opens it from anywhere; typing filters; Enter runs.\nMount it once, near the root, and hand it every command the product has.\n\nWHY NOT A MODAL WITH A COMBOBOX IN IT\n\nA Combobox's list is a popover that opens and closes on its own rules, and\nit SELECTS a value that stays selected. A palette's list is always open and\neach row is RUN, once, and the palette goes away. The ARIA shape is the same\n— a combobox driving a listbox through `aria-activedescendant` — so this\nuses it, without the parts of Combobox that exist for choosing a value.\n\nNOT A NAVIGATION MENU\n\nEverything in the palette must also be reachable without it. It is a faster\nroute for people who know what they want, and a shortcut nobody told you\nabout is not a route at all.",
      "import": "import { CommandPalette } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.81.0",
      "summary": "A searchable list of every command in the product, opened with ⌘K / Ctrl+K from anywhere, that runs the chosen command and closes.",
      "useWhen": [
        "the product has more actions and destinations than its navigation shows at once",
        "people who use it daily want to reach anything without the pointer",
        "mounted once, near the root, with every command the product has"
      ],
      "useInstead": [
        {
          "when": "the user picks a value that stays chosen — an assignee, a country",
          "use": "Combobox",
          "why": "a combobox selects and keeps a value in a form; a palette runs a command once and goes away"
        },
        {
          "when": "a few actions belong to one object — a row's Edit, Duplicate, Delete",
          "use": "Menu",
          "why": "those actions belong beside the thing they act on, where people look for them"
        },
        {
          "when": "the search is over records — agents, runs — shown as a page of results",
          "use": "Input",
          "why": "a filtered table is a page with its own URL, and a palette is gone the moment it is used"
        }
      ],
      "a11y": {
        "role": "dialog",
        "guarantees": [
          "a modal dialog named by `label`: focus is trapped, the page behind is hidden from assistive tech, Escape and a scrim click close it, and focus returns to what had it",
          "focus stays in the search field, a combobox whose always-open listbox is driven by aria-activedescendant — typing and choosing never compete for focus",
          "sections are role=group, named by their heading",
          "disabled commands are listed with aria-disabled, skipped by the arrow keys, and do nothing on click",
          "the number of matches is announced through a polite live region as the query changes",
          "`onAction` runs after the palette has closed and handed focus back, so an action that moves focus keeps it",
          "shortcut hints are read as key names — \"Command Shift N\" — not as symbols"
        ],
        "requires": [
          "every command also reachable without the palette — it is a faster route, never the only one",
          "a visible way to open it for people who do not know the shortcut, such as a header button showing the keys"
        ]
      },
      "antiPatterns": [
        {
          "dont": "showing a `shortcut` on a command without binding that shortcut yourself",
          "why": "the palette displays a command's shortcut and does not listen for it; a hint for a key that does nothing is worse than no hint"
        },
        {
          "dont": "moving focus from inside a command's handler by running it before closing, or wrapping onAction to close the palette first",
          "why": "onAction already runs after close. While the dialog is mounted its focus scope pulls focus back to the search field"
        },
        {
          "dont": "rendering <CommandPalette> only when a state flag is true",
          "do": "mount it always and drive `isOpen`, or let it open itself on the shortcut",
          "why": "unmounted, it cannot listen for ⌘K"
        },
        {
          "dont": "labels that are nouns — \"Agents\", \"Dark mode\"",
          "do": "start with a verb — \"Go to Agents\", \"Switch to dark theme\"",
          "why": "a row says what pressing Enter will do; a noun leaves the reader guessing whether it opens, toggles or deletes"
        },
        {
          "dont": "two palettes on one page, or a second listener for ⌘K",
          "why": "both toggle on the same key press and cancel each other out"
        }
      ],
      "slots": {
        "emptyLabel": {
          "expects": "what to show when nothing matches"
        }
      },
      "stylesheet": "src/styles/command-palette.css",
      "tokens": [
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-disabled",
        "--icon-secondary",
        "--icon-size-md",
        "--icon-size-sm",
        "--ion-shadow-shadow-xl",
        "--radius-sm",
        "--radius-xl",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-32",
        "--spacing-4",
        "--spacing-40",
        "--spacing-48",
        "--spacing-8",
        "--spacing-96",
        "--surface-hover",
        "--surface-overlay",
        "--surface-scrim",
        "--text-default",
        "--text-disabled",
        "--text-tertiary",
        "--type-body",
        "--type-body-lg",
        "--type-body-lg-line-height",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "commands": {
          "type": "readonly CommandPaletteItem[]",
          "required": true,
          "origin": "own",
          "description": "Every command. Filtering happens here, against what is typed."
        },
        "onAction": {
          "type": "(id: string) => void",
          "required": true,
          "origin": "own",
          "description": "Runs the chosen command. Called AFTER the palette has closed and handed\nfocus back, so an action that moves focus — to a search field, into a\nform — keeps it."
        },
        "isOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "defaultOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "onOpenChange": {
          "type": "((isOpen: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "openShortcut": {
          "type": "string | null | undefined",
          "required": false,
          "origin": "own",
          "description": "The shortcut that opens it from anywhere on the page, and closes it again.\nDefaults to `mod+k` — ⌘K on a Mac, Ctrl+K elsewhere. `null` turns it off,\nfor a page that opens the palette some other way."
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the dialog and the search field."
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "emptyLabel": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Shown in place of the list when nothing matches."
        },
        "labels": {
          "type": "CommandPaletteLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "Every other string the palette renders, for translation. English by default."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 11,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "ConfidenceIndicator": {
      "name": "ConfidenceIndicator",
      "source": "src/components/ConfidenceIndicator.tsx",
      "propsType": "ConfidenceIndicatorProps",
      "description": "ConfidenceIndicator — how much to trust the thing next to it.\n\nTHERE IS NO PERCENTAGE PROP, AND THERE WILL NOT BE ONE\n\n\"87% confident\" reads as a measurement. Almost nowhere is it one: it is\nusually a softmax score, a heuristic, or a number a model produced about\nitself — none of which are calibrated probabilities, and all of which invite\na reader to treat two digits of precision as real. Three levels cannot\noverclaim in that way.\n\n`basis` IS REQUIRED FOR THE SAME REASON. A level with nothing behind it is\ndecoration that changes behaviour: people act on \"high confidence\" whether or\nnot anything justifies it. Making the justification a required prop is the\nonly enforcement available here, and it is a type error rather than a policy\n— which is the strongest kind this system can offer.\n\nIt renders as text plus a three-bar meter, never the meter alone. The bars\ndiffer in filled COUNT, not only in colour, so the reading survives greyscale\nand forced-colours mode.",
      "import": "import { ConfidenceIndicator } from 'ionbase-ui';",
      "status": "stable",
      "summary": "How much to trust the thing next to it — three levels with a required basis, never a percentage.",
      "useWhen": [
        "a generated answer varies in reliability and the reader should weigh it",
        "you can say what the confidence rests on"
      ],
      "useInstead": [
        {
          "when": "the answer is wrong or missing rather than uncertain",
          "use": "Alert"
        },
        {
          "when": "you want to show where the claim came from",
          "use": "Citation",
          "why": "a source is stronger than a confidence level, and often what the reader actually wanted"
        },
        {
          "when": "it is a status rather than a reliability",
          "use": "Badge"
        },
        {
          "when": "the reader needs to know the content was generated, not how far to trust it",
          "use": "AILabel",
          "why": "who made it and how good it is are different questions; pair them when both matter"
        }
      ],
      "variants": {
        "level": {
          "low": {
            "use": "thin or conflicting evidence. One bar, warning colour"
          },
          "medium": {
            "use": "reasonable support with real gaps. Two bars"
          },
          "high": {
            "use": "corroborated, and you can say by what. Three bars"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "the level is spelled out in text, so it never depends on the meter",
          "the meter differs in filled COUNT rather than only in colour, and in forced-colours mode filled bars are solid while empty ones are outlined"
        ],
        "requires": [
          "`basis` — a type error if omitted, and the reason this component exists rather than a coloured dot"
        ]
      },
      "antiPatterns": [
        {
          "dont": "asking for a percentage prop",
          "why": "\"87% confident\" reads as a measurement and almost never is one — usually a softmax score or a model's claim about itself, neither of which is a calibrated probability. Three levels cannot overclaim that way"
        },
        {
          "dont": "a vague basis like \"model output\"",
          "why": "it satisfies the type and defeats the purpose — say \"3 of 4 sources agree\" or \"single unverified source\""
        },
        {
          "dont": "high confidence on every response",
          "why": "an indicator that never varies is decoration that still changes behaviour"
        }
      ],
      "stylesheet": "src/styles/confidence-indicator.css",
      "tokens": [
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-information",
        "--icon-success",
        "--icon-tertiary",
        "--icon-warning",
        "--radius-2xs",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-4",
        "--spacing-8",
        "--text-default",
        "--text-secondary",
        "--text-tertiary",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "level": {
          "type": "ConfidenceLevel",
          "required": true,
          "origin": "own",
          "values": [
            "low",
            "medium",
            "high"
          ]
        },
        "basis": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "What the level is based on. REQUIRED, and the reason this component exists\nrather than a coloured dot: a confidence with no stated basis is a number\nthe reader has no way to weigh. \"3 of 4 sources agree\", \"no matching\nrecords found\", \"single unverified source\"."
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Override the level's word. Keep it a word, not a percentage."
        }
      },
      "propCounts": {
        "own": 3,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "ContextMenu": {
      "name": "ContextMenu",
      "source": "src/components/Menu.tsx",
      "propsType": "ContextMenuProps",
      "description": "ContextMenu — the menu a right-click opens on a thing: a row's actions, a\nfile's. Shift+F10 and the Menu key open it from the keyboard, anchored to\nthe thing rather than to a pointer that is not there.\n\nA SHORTCUT, NEVER THE ONLY WAY. Nothing on the page says a right-click\ndoes anything, touch screens have no right-click, and on iOS a long press\nopens the browser's callout instead. Every action in a context menu must\nalso be reachable without it — the row's own actions menu, a button, a\ntoolbar. The menu is the same Menu, so it can be the same items.\n\nHOW IT OPENS\n\n  - Right-click: at the pointer, as a desktop app's does. The browser's own\n    menu is prevented, and only on this element.\n  - Shift+F10 or the Menu key, with focus on the element or anything in it:\n    under the element, at its start edge. Focus moves to the first item.\n  - Escape, an outside click or choosing an item closes it and returns focus\n    to where it was — the row's link, the card.\n\nA second right-click on the element moves the open menu there, rather than\nopening the browser's menu over it; one elsewhere closes ours.",
      "import": "import { ContextMenu } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The menu a right-click opens on a thing — a row's actions, a file's. Shift+F10 and the Menu key open it from the keyboard, under the thing. A shortcut to actions that are also reachable some other way.",
      "useWhen": [
        "a thing already has an actions menu or buttons, and people who work in the list all day want them under the pointer — table rows, files, cards",
        "the product feels like a desktop tool, where right-click is expected on the objects in it"
      ],
      "useInstead": [
        {
          "when": "the actions are reachable only through the context menu",
          "use": "MenuTrigger",
          "why": "nothing on the page says a right-click does anything, touch screens have none, and on iOS a long press opens the browser's callout. A context menu must never be the only way in — give the thing a visible ⋯ MenuTrigger with the same items"
        },
        {
          "when": "a text field or a link whose own browser menu people use — copy, open in new tab, spelling",
          "use": "nothing",
          "why": "replacing the browser's menu there takes away actions people rely on"
        }
      ],
      "composition": {
        "order": [
          "ContextMenu",
          "Menu",
          "MenuItem"
        ],
        "note": "`children` is the one element the menu is for; `menu` is a Menu, usually the same one the element's ⋯ MenuTrigger opens, built once and used twice.",
        "example": "<ContextMenu aria-label={`Actions for ${agent.name}`} menu={rowMenu}><TableRow>…<TableCell><MenuTrigger><Button aria-label={`Actions for ${agent.name}`} …/>{rowMenu}</MenuTrigger></TableCell></TableRow></ContextMenu>"
      },
      "slots": {
        "children": {
          "accepts": "TableRow, or any single element",
          "note": "it receives `onContextMenu` and `onKeyDown`, and keeps its own; it is not wrapped, so a `<tr>` stays a `<tr>`"
        },
        "menu": {
          "accepts": "Menu"
        }
      },
      "a11y": {
        "requires": [
          "`aria-label` naming the menu for its thing — \"Actions for Invoice reconciler\" — since no button names it",
          "every action also reachable without the context menu — a ⋯ MenuTrigger on the thing, or buttons"
        ],
        "guarantees": [
          "a right-click opens the menu at the pointer, and prevents the browser's menu on the element only",
          "Shift+F10 or the Menu key, with focus on the element or anything in it, opens it under the element at its start edge — never at a pointer that is not there",
          "focus moves to the first item; Escape, an outside click or choosing an item closes it and returns focus to where it was",
          "a second right-click on the element moves the open menu there, with the browser's menu still prevented; one elsewhere closes ours and leaves the browser's",
          "the menu is a full Menu: ↓ ↑ move between items across sections, and typing jumps to one",
          "on a scrolled page the menu opens at the pointer",
          "`isDisabled` leaves the browser's own menu in place"
        ],
        "notes": [
          "Shift+F10 is caught as a key because not every browser turns it into a contextmenu event; the Menu key arrives as a keyboard contextmenu and is anchored to the element",
          "the element needs something focusable inside it — a link, a checkbox — for Shift+F10 to reach it from the keyboard"
        ]
      },
      "antiPatterns": [
        {
          "dont": "actions that exist only in the context menu",
          "do": "the same Menu behind a visible ⋯ MenuTrigger, and ContextMenu as the shortcut",
          "why": "a right-click is invisible and unavailable on touch; an action there alone is an action most people never find"
        },
        {
          "dont": "a ContextMenu around the whole page or a large region",
          "do": "one around each thing — each row, each file",
          "why": "a menu for \"the page\" has no object; each item has to guess what it acts on, and the browser's useful menu is gone everywhere"
        },
        {
          "dont": "a context menu with different items from the thing's ⋯ menu",
          "do": "one Menu element passed to both",
          "why": "two menus that disagree teach people that neither is complete"
        }
      ],
      "stylesheet": null,
      "tokens": [],
      "props": {
        "children": {
          "type": "React.ReactElement<unknown, string | React.JSXElementConstructor<any>>",
          "required": true,
          "origin": "own",
          "description": "The thing the menu is for — a table row, a card, a file. One element; it\nreceives the right-click and keyboard handlers, and keeps its own. It\nmust pass `onContextMenu` and `onKeyDown` through to the DOM, as\nIonBase's TableRow and any host element do."
        },
        "menu": {
          "type": "React.ReactElement<unknown, string | React.JSXElementConstructor<any>>",
          "required": true,
          "origin": "own",
          "description": "The Menu to open, with its MenuItems and `onAction`."
        },
        "aria-label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The menu's name, since no button names it: \"Actions for Invoice\nreconciler\". Required."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Leaves the browser's own menu in place."
        },
        "onOpenChange": {
          "type": "((isOpen: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 5,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "CopyButton": {
      "name": "CopyButton",
      "source": "src/components/CopyButton.tsx",
      "propsType": "CopyButtonProps",
      "description": "CopyButton — copies a value, confirms in place, and says so.\n\nCONFIRMS WHERE THE EYE ALREADY IS. The icon becomes a check and the label\n\"Copied\" for two seconds, then both return. A toast would confirm in a\ncorner the user is not looking at, and stack up when they copy three\nthings in a row. The labels share one cell, so the button is always as wide\nas its longest one and nothing beside it moves.\n\nSAYS SO ONCE. A polite live region beside the button reads \"Copied\" on each\npress. A changed name alone is not reliably announced, and not at all when\nthe button was pressed by a script or lost focus. Each press replaces the\nregion's text node, so a second copy is announced too.\n\nAND SAYS WHEN IT FAILED. A clipboard can be refused — a denied permission,\na non-secure origin with no fallback. The button then shows \"Couldn't\ncopy\" with an alert icon, and `onCopyError` receives the text so the caller\ncan put it where it can be selected by hand. It never claims a copy that\ndid not happen.",
      "import": "import { CopyButton } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Copies a value — an ID, a key, a command — confirms in place with a check and \"Copied\" for two seconds, and announces it. When the clipboard refuses it says so, and hands over the text.",
      "useWhen": [
        "a value is copied far more often than it is read — a run ID, an API key's prefix, a command, a link",
        "the copy sits beside the value it copies, so confirming there is confirming where the user is looking"
      ],
      "useInstead": [
        {
          "when": "copying is a page action that may collapse into an overflow menu — the Copy run ID in a record's header",
          "use": "Button",
          "why": "a confirmation drawn on the button cannot be seen once the button is in a menu; a Button that confirms with a Toast can be"
        }
      ],
      "composition": {
        "order": [
          "value",
          "CopyButton"
        ],
        "note": "Beside the value, after it. Icon-only in a dense row, with `label` saying what is copied; with a visible label where there is room.",
        "example": "<code>{runId}</code><CopyButton value={runId} isIconOnly label=\"Copy run ID\" copiedLabel=\"Run ID copied\" onCopyError={(_, text) => showForManualCopy(text)} />"
      },
      "variants": {
        "variant": {
          "tertiary": {
            "use": "the default — beside the value it copies"
          },
          "secondary": {
            "use": "among other actions, as the only copy of something"
          }
        },
        "size": {
          "sm": {
            "use": "in tables, description lists and other dense rows"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "beside a large value, such as a one-time key shown once"
          }
        }
      },
      "a11y": {
        "requires": [
          "`label` that says what is copied when `isIconOnly` — \"Copy run ID\", not \"Copy\"; it is the only name the button has",
          "`label`, `copiedLabel` and `failedLabel` translated"
        ],
        "guarantees": [
          "a polite `status` region beside the button says `copiedLabel` on every press, a second press included, and `failedLabel` on a failure",
          "the button's name follows what it shows: `label`, then `copiedLabel` for `resetAfter` milliseconds, then `label` again; another press restarts the time",
          "the labels share one cell, so the button keeps its width and nothing beside it moves",
          "icon-only, it is square, named by `label`, with `label` as its tooltip on hover and focus",
          "without the Clipboard API — a non-secure origin — or when it is refused, it copies by selecting the text off-screen, and returns focus to the button",
          "when both fail it shows and announces `failedLabel` with an alert icon and calls `onCopyError` with the text; it never claims a copy that did not happen",
          "`value` as a function is read at the press, so it copies what is current"
        ],
        "notes": [
          "on `onCopyError`, put the text where it can be selected by hand — a Toast holding it, or a read-only Input — so the user is never left without it",
          "a secret shown once, like a new API key, should also be visible in full beside the button: a copy that failed must not be the only way to get it"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Button onPress={() => navigator.clipboard.writeText(id)}><Icon as={Copy} /></Button>",
          "do": "<CopyButton value={id} isIconOnly label=\"Copy ID\" />",
          "why": "the hand-built one has no name, gives no confirmation, is silent to a screen reader, and fails without a word on a non-secure origin"
        },
        {
          "dont": "a Toast that says \"Copied\" for a CopyButton",
          "do": "the CopyButton's own confirmation",
          "why": "it confirms in a corner the user is not looking at, and stacks up when they copy several things in a row"
        },
        {
          "dont": "<CopyButton isIconOnly label=\"Copy\" /> beside three different values",
          "do": "label=\"Copy run ID\", label=\"Copy agent ID\", …",
          "why": "three buttons named \"Copy\" cannot be told apart by name, and a screen reader's list of buttons reads \"Copy, Copy, Copy\""
        }
      ],
      "stylesheet": "src/styles/copy-button.css",
      "tokens": [
        "--ion-button-size"
      ],
      "props": {
        "value": {
          "type": "string | (() => string)",
          "required": true,
          "origin": "own",
          "description": "What is copied. A function is called at the moment of the press, for a\nvalue read from somewhere that changes — the text in an editor."
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the action — \"Copy\", \"Copy run ID\". Default \"Copy\"."
        },
        "copiedLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Shown and announced once copied. Default \"Copied\"."
        },
        "failedLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Shown and announced when the clipboard refused. Default \"Couldn't copy\"."
        },
        "isIconOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Draw only the icon. `label` becomes the name and the tooltip, so it must\nsay what is copied: \"Copy run ID\", not \"Copy\"."
        },
        "variant": {
          "type": "\"secondary\" | \"tertiary\" | undefined",
          "required": false,
          "origin": "own",
          "description": "Tertiary beside the value it copies; secondary among other actions.",
          "values": [
            "secondary",
            "tertiary"
          ]
        },
        "size": {
          "type": "\"sm\" | \"md\" | \"lg\" | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "resetAfter": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Milliseconds the confirmation stays before the button returns. Default 2000."
        },
        "onCopy": {
          "type": "((text: string) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Copied — with the text that was."
        },
        "onCopyError": {
          "type": "((error: unknown, text: string) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "The clipboard refused. Show `text` where it can be selected by hand; the\nbutton has already said it failed."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 13,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "DatePicker": {
      "name": "DatePicker",
      "source": "src/components/DatePicker.tsx",
      "propsType": "DatePickerProps",
      "description": "DatePicker — a segmented date field with a calendar attached.\n\nWHY NOT `<input type=\"date\">`\n\nThe native control is genuinely good on mobile and genuinely unusable as a\ndesign system component on desktop: its calendar cannot be styled at all, it\ncannot express an unavailable date that is not part of a continuous min/max\nrange, and Safari on macOS renders no calendar button whatsoever. A system\nwhose date field looks like a different control in every browser is not a\ndesign system, so this rebuilds it — and pays for that by having to rebuild\nthe keyboard handling and the screen-reader semantics the native control gave\naway for free. React Aria's `useDatePicker` is what builds them back.\n\nTHE VALUE IS A STRING, THE DISPLAY IS NOT\n\n`value` is always `YYYY-MM-DD` in the Gregorian calendar. What the user SEES\nis their own locale's field order and their own calendar system, resolved\nfrom `Intl` — so a German reader edits DD.MM.YYYY and the value still comes\nback as `2026-04-12`. See `iso-date.ts` for why the public type is not\nreact-aria's `CalendarDate`.\n\nTHE FIELD IS EDITABLE WITHOUT THE CALENDAR, AND THAT IS THE POINT\n\nTyping a date is faster than clicking one for anybody who knows the date they\nwant, and paging a calendar back to a birth year is miserable. The segments\ntake digits and arrow keys directly; the calendar is for the cases where the\ndate is being chosen rather than recalled.",
      "import": "import { DatePicker } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A segmented date field with a calendar attached, taking and returning a YYYY-MM-DD string.",
      "useWhen": [
        "the user picks one calendar date — an event date, a due date, a birthday",
        "the date has to be typed as well as clicked, because the user often already knows it",
        "some dates must be refused individually rather than as a continuous range"
      ],
      "useInstead": [
        {
          "when": "the user picks a start and an end",
          "use": "DateRangePicker",
          "why": "two DatePickers cannot stop the end preceding the start, cannot show the days between them, and give the user two calendars to reconcile from memory"
        },
        {
          "when": "the value is a month or a year rather than a day",
          "use": "Select",
          "why": "a calendar grid to choose 'March' is twelve options rendered as thirty-one"
        },
        {
          "when": "the field is one of a short, known set of dates the product defines",
          "use": "Select or RadioGroup",
          "why": "picking from four delivery slots is a choice from a list, not a date"
        },
        {
          "when": "a time of day is part of the value — one moment, such as when a run starts",
          "use": "DateTimePicker",
          "why": "this picker is fixed to day granularity; a DateTimePicker holds the date and the time as one value and checks a bound such as \"not before now\" against both. A TimeField beside this is only for a time that is a separate, optional choice"
        },
        {
          "when": "the day is chosen by where it falls in the month, and it is the only question — a day to resume on, in a Modal",
          "use": "Calendar",
          "why": "the picker's calendar is a popover, a second layer over the dialog; a Calendar shows the month in the dialog itself"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms, table filters"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "prominent single-field forms"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "each editable segment is its own tab stop, as in Chrome's native <input type=date>; the locale's separators are not, and arrow keys move between segments as well, so neither convention has to be learned",
          "the segments sit in one labelled group, so the field's name is announced once on entry rather than repeated per segment",
          "each segment announces its own name, value and bounds, so 'month, 4, minimum 1, maximum 12' is heard rather than an opaque number",
          "field order and separators come from Intl, so a German reader edits DD.MM.YYYY and a US reader MM/DD/YYYY from the same component and the same value",
          "the calendar overlay is modal: focus moves into the grid, the rest of the page is hidden from assistive tech, Escape closes it and focus returns to the field",
          "every day cell carries the full date as its accessible name in the reader's own language, so the narrow weekday headers are never load-bearing",
          "unavailable and disabled days are distinguished visually and in the accessible name, not by colour alone",
          "today is marked with a dot rather than a ring, so it cannot be mistaken for a second selected day",
          "the selected fill and the segment focus box are redeclared under forced-colors, where background-color is discarded",
          "a typed date outside `minValue`/`maxValue` turns the field invalid and names the bound in the reader's own format, without `isInvalid` — the value is kept, not silently clamped"
        ],
        "requires": [
          "`label`, or an `aria-label` when no visible label is rendered",
          "`calendarLabel` wording if 'Open calendar' is wrong for the context — it is the button's whole accessible name"
        ]
      },
      "antiPatterns": [
        {
          "dont": "passing a Date object, a timestamp, or a locale-formatted string as `value`",
          "why": "the value is a calendar date with no time and no zone, spelled YYYY-MM-DD. `new Date('2026-04-12')` parses as UTC midnight and displays as the 11th for every reader west of Greenwich. A malformed value throws with the prop name rather than rendering an empty field"
        },
        {
          "dont": "using `isDateUnavailable` to express a lower or upper bound",
          "why": "`minValue` and `maxValue` also disable the navigation arrows past the bound, so the user cannot page into a month with nothing selectable in it. A predicate is for the holes inside the bounds"
        },
        {
          "dont": "hiding the field and leaving only the calendar button",
          "why": "typing a date is faster than clicking one for anyone who already knows it, and paging a calendar back to a birth year is the worst interaction in any form"
        },
        {
          "dont": "styling a day cell with :hover for the keyboard highlight",
          "why": "use [data-selected], [data-today], [data-disabled] and [data-unavailable]. Pointer hover and keyboard focus are different states here and a grid where they disagree has two apparent cursors"
        },
        {
          "dont": "reading the posted form value from the segments",
          "why": "they are spinbuttons with no name. Pass `name` and the component posts the ISO value in a hidden input"
        },
        {
          "dont": "wrapping it in a <label>",
          "why": "the group is a set of spinbuttons rather than one form control, so there is nothing for htmlFor to point at. Pass `label` and react-aria wires the name through aria-labelledby"
        },
        {
          "dont": "adding a roving tabindex so the field becomes one tab stop",
          "why": "it buys back two tabs per field and costs the ability to Tab straight to the year, which is the segment users most often fix on its own. It also diverges from the native date input, which is the convention they already have"
        }
      ],
      "slots": {
        "label": {
          "expects": "what the date is for — 'Event starts', not 'Date'"
        },
        "description": {
          "expects": "helper text under the field; replaced by `errorMessage` when `isInvalid`"
        },
        "errorMessage": {
          "expects": "what is wrong and what would be right, shown only when `isInvalid`"
        }
      },
      "stylesheet": "src/styles/date-picker.css",
      "tokens": [
        "--border-disabled",
        "--border-error-strong",
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--font-weight-semibold",
        "--icon-disabled",
        "--icon-interactive",
        "--icon-on-color",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-sm",
        "--ion-calendar-day-size",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-input-icon-size",
        "--ion-shadow-shadow-lg",
        "--radius-2xs",
        "--radius-full",
        "--radius-md",
        "--radius-sm",
        "--spacing-12",
        "--spacing-2",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-disabled",
        "--surface-hover",
        "--surface-primary",
        "--surface-raised",
        "--text-default",
        "--text-disabled",
        "--text-on-color",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Field label. Required for a usable control — see `a11y.requires`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the field."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text while the field is invalid — `isInvalid`, or its own bounds check."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "size": {
          "type": "DatePickerSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Input's `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "value": {
          "type": "string | null | undefined",
          "required": false,
          "origin": "own",
          "description": "The selected date as `YYYY-MM-DD`. `null` means nothing is selected.\n\nNot a `Date` and not a timestamp — a calendar date has no time and no\ntimezone, so it cannot shift across midnight the way `new Date('2026-04-12')`\ndoes for every reader west of Greenwich. A malformed value throws rather\nthan rendering an empty field."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The initial date as `YYYY-MM-DD`, for an uncontrolled picker."
        },
        "onChange": {
          "type": "((value: IsoDate | null) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires with `YYYY-MM-DD`, or `null` when the field is cleared."
        },
        "minValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Earliest selectable date, `YYYY-MM-DD`. Earlier dates are unreachable."
        },
        "maxValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Latest selectable date, `YYYY-MM-DD`."
        },
        "isDateUnavailable": {
          "type": "((date: IsoDate) => boolean) | undefined",
          "required": false,
          "origin": "own",
          "description": "Individual dates the calendar refuses — weekends, holidays, booked days.\nReceives `YYYY-MM-DD`. Use `minValue`/`maxValue` for a continuous bound;\nthis is for the holes inside it."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Posts the value under this name, for an uncontrolled form."
        },
        "calendarLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible label for the button that opens the calendar."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 19,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "DateRangePicker": {
      "name": "DateRangePicker",
      "source": "src/components/DateRangePicker.tsx",
      "propsType": "DateRangePickerProps",
      "description": "DateRangePicker — two segmented date fields with one calendar between them.\n\nNOT TWO `DatePicker`s SIDE BY SIDE, and the difference is the whole reason\nthis exists. Two independent pickers cannot stop the end date being before\nthe start, cannot show the days between them as a band, and give the user two\ncalendars to reconcile by memory. `useDateRangePicker` owns both ends: the\nsecond click completes the range in whichever order the two dates fall, the\nband follows the pointer while it is being dragged out, and a range cannot be\nmade to run backwards.\n\nTHE VALUE COMMITS LIVE. THERE IS NO APPLY BUTTON.\n\n`onChange` fires the moment the second end lands, and the calendar closes.\nThe reference designs this was drawn from put Clear and Apply under the grid,\nwhich is a draft model: the popover holds a pending range and only Apply\npromotes it. That was considered and rejected. Escape and an outside click\nwould then silently discard a selection the user watched themselves make —\nand there is no way to tell those two gestures apart from \"I am done\". A\nbutton labelled Apply beside a value that already applied is worse still.\n\nClear stays, because clearing is a real action with no other affordance.\n\nBOTH ENDS OR NEITHER\n\n`value` has no half-open form. A range with a start and no end is a state the\ncalendar passes THROUGH between the two clicks; it is never a value the\ncaller is handed, because \"everything after March\" and \"March to today\" are\ndifferent questions and a `null` end cannot say which one was meant. A caller\nwho wants an open end passes a sentinel of their own choosing as `maxValue`.",
      "import": "import { DateRangePicker } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Two segmented date fields sharing one calendar, returning an inclusive { start, end } of YYYY-MM-DD strings.",
      "useWhen": [
        "the user picks a period — a report window, a stay, a filter over time",
        "the range commonly crosses a month boundary, so both ends need to be visible at once",
        "there are conventional periods worth offering as shortcuts: last 7 days, month to date, year to date"
      ],
      "useInstead": [
        {
          "when": "the user picks one date",
          "use": "DatePicker"
        },
        {
          "when": "the choice is really a named period and the exact dates are incidental",
          "use": "Select",
          "why": "if 'Last 30 days' is the answer rather than a shortcut to one, a calendar is scaffolding around a list of five options"
        },
        {
          "when": "one end is open — everything before, or everything since",
          "use": "DatePicker",
          "why": "with the open end expressed as minValue or maxValue. This component has no half-open value on purpose; see antiPatterns"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms, table filters, dashboard toolbars"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "prominent single-field forms"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "both fields sit in one labelled group; each announces its own name, so 'Start Date' and 'End Date' are heard without a visible sublabel, and each editable segment is its own tab stop as it is in DatePicker",
          "the dash between them is aria-hidden — the fields have already said what they are, and a spoken dash adds nothing",
          "the calendar is modal: focus enters the grid, the page behind is hidden from assistive tech, Escape closes and focus returns",
          "a range cannot be made to run backwards, in either direction of selection, and cannot be dragged across an unavailable day",
          "preset rows are buttons in a labelled group carrying aria-pressed, so 'which shortcut is active' is answerable without seeing the tint",
          "the selected preset is marked by weight as well as by background, so it does not rely on colour alone",
          "range endpoints and the band between them keep their fill under forced-colors, where background-color is otherwise discarded",
          "the rail stacks above the grids below 32rem rather than being hidden, because shortcuts matter most on the device where dragging across two months is worst",
          "a typed date outside `minValue`/`maxValue`, or an end before the start, turns the field invalid with React Aria's localized message, without `isInvalid` — the value is kept, not silently clamped"
        ],
        "requires": [
          "`label`, or an `aria-label` when no visible label is rendered",
          "`presetsLabel` wording if the default is wrong for the context — it names the whole shortcut group",
          "preset labels that say what period they select, since the label is the button's entire accessible name"
        ]
      },
      "antiPatterns": [
        {
          "dont": "expecting an Apply button, or adding one",
          "why": "the value commits when the second end lands and the calendar closes. A draft model makes Escape and an outside click silently discard a selection the user watched themselves make, and there is no way to tell those gestures apart from 'I am done'. Clear exists because clearing has no other affordance"
        },
        {
          "dont": "passing a range with only one end, or reading one back",
          "why": "a start with no end is a state the calendar passes through between the two clicks, never a value the caller is handed. 'Everything after March' and 'March to today' are different questions and a null end cannot say which was meant"
        },
        {
          "dont": "writing a relative preset as a literal object",
          "why": "`value: { start: '…', end: '…' }` is evaluated once, when the module is first imported. 'Last 7 days' is then seven days before whenever the tab was opened. Pass a function; it is re-run each time the calendar opens"
        },
        {
          "dont": "composing this out of two DatePickers",
          "why": "two independent pickers cannot stop the end preceding the start, cannot draw the band between the ends, and make the user reconcile two calendars from memory"
        },
        {
          "dont": "setting visibleMonths to 1 for a filter that usually spans weeks",
          "why": "the user then pages back and forth to see both ends of their own selection. One month is for ranges that genuinely sit inside one"
        },
        {
          "dont": "treating `end` as exclusive",
          "why": "the range is closed. A stay of 3 nights from the 1st ends on the 4th in checkout terms and on the 3rd here; convert at your boundary, not in the picker"
        }
      ],
      "slots": {
        "label": {
          "expects": "what the period is for — 'Reporting period', not 'Dates'"
        },
        "presets": {
          "expects": "conventional periods for this screen, longest-lived first: today, last 7 days, month to date, year to date"
        },
        "description": {
          "expects": "helper text under the fields; replaced by `errorMessage` when `isInvalid`"
        },
        "errorMessage": {
          "expects": "what is wrong with the range and what would be right, shown only when `isInvalid`"
        }
      },
      "stylesheet": "src/styles/date-range-picker.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-disabled",
        "--icon-interactive",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-input-icon-size",
        "--radius-2xs",
        "--radius-full",
        "--radius-none",
        "--radius-sm",
        "--spacing-12",
        "--spacing-2",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-hover",
        "--surface-primary",
        "--surface-primary-subtle",
        "--surface-primary-subtle-hover",
        "--surface-raised",
        "--text-default",
        "--text-disabled",
        "--text-interactive",
        "--text-interactive-hover",
        "--text-on-color",
        "--text-primary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Field label. Required for a usable control — see `a11y.requires`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the field."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text while the field is invalid — `isInvalid`, or its own bounds check."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "size": {
          "type": "DateRangePickerSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Input's `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "value": {
          "type": "DateRange | null | undefined",
          "required": false,
          "origin": "own",
          "description": "The selected range, both ends as `YYYY-MM-DD`. `null` means no range.\nA half-open range is not representable on purpose — see `antiPatterns`."
        },
        "defaultValue": {
          "type": "DateRange | undefined",
          "required": false,
          "origin": "own",
          "description": "The initial range, for an uncontrolled picker."
        },
        "onChange": {
          "type": "((value: DateRange | null) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires with both ends, or `null` when the range is cleared."
        },
        "minValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Earliest selectable date, `YYYY-MM-DD`."
        },
        "maxValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Latest selectable date, `YYYY-MM-DD`."
        },
        "isDateUnavailable": {
          "type": "((date: IsoDate) => boolean) | undefined",
          "required": false,
          "origin": "own",
          "description": "Individual dates the calendar refuses. Receives `YYYY-MM-DD`.\n\nA range may not be dragged ACROSS one of these — react-aria stops the\nselection at the first unavailable day, which is what makes \"book a room\nfor these nights\" correct rather than merely styled."
        },
        "presets": {
          "type": "readonly DateRangePreset[] | undefined",
          "required": false,
          "origin": "own",
          "description": "Shortcut rows beside the calendar. Omitted, no rail is rendered."
        },
        "visibleMonths": {
          "type": "2 | 3 | 1 | undefined",
          "required": false,
          "origin": "own",
          "description": "How many months the calendar shows at once. Two by default: a range that\ncrosses a month boundary is the common case, and a one-month calendar\nmakes the user page back and forth to see both ends of their own selection."
        },
        "isClearable": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Show a Clear button under the calendar. Defaults to `true`."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Posts `<name>-start` and `<name>-end` for an uncontrolled form."
        },
        "calendarLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible label for the button that opens the calendar."
        },
        "clearLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "presetsLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 24,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "DateTimePicker": {
      "name": "DateTimePicker",
      "source": "src/components/DateTimePicker.tsx",
      "propsType": "DateTimePickerProps",
      "description": "DateTimePicker — a date and a time of day as one value: when a run\nstarts, when a maintenance window opens, when a message is sent.\n\nONE FIELD, NOT A DATEPICKER BESIDE A TIMEFIELD\n\nTwo fields hold two values, and the rules that span them are the caller's:\n\"not before now\" means TimeField's minimum moves whenever the date is\ntoday, and back when it is not. Here `minValue` is one moment, and the\nfield checks the date and the time against it together. One label names\nit, one error describes it, one `name` posts it.\n\nHOW IT IS BUILT\n\n  - React Aria's `useDatePicker` at minute (or second) granularity, over\n    the same segmented field as DatePicker: day, month, year, hour, minute\n    and — in a 12-hour locale — AM/PM, each typed or stepped with the\n    arrow keys, in the reader's own order.\n  - The calendar button opens the calendar with a TimeField under it. A\n    day picked keeps the time already set, and the popover stays open so\n    the time can follow; Escape or a click outside closes it, and a day\n    picked with no time yet takes midnight, shown in the field to change.\n  - The value is a wall-clock string, never a `Date`. See `iso-date-time.ts`.",
      "import": "import { DateTimePicker } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A date and a time of day as one field — day, month, year, hour and minute in segments, with the calendar and a time field in its popover — taking and returning a wall-clock YYYY-MM-DDTHH:MM string.",
      "useWhen": [
        "the value is a moment on the calendar: when a run starts, when a maintenance window opens, when a message is sent",
        "the moment has a bound that spans the date and the time together — not before now, not after the window closes",
        "the date and the time are one value to the product, saved, validated and posted together"
      ],
      "useInstead": [
        {
          "when": "the value is a date with no time",
          "use": "DatePicker",
          "why": "hour and minute segments ask for a time nobody meant, and default to midnight"
        },
        {
          "when": "the value is a time of day that repeats — every day at 09:00",
          "use": "TimeField",
          "why": "a recurring time has no date; a DateTimePicker would ask for one and store it"
        },
        {
          "when": "the date and the time are separate choices, each optional — a due date with an optional reminder time",
          "use": "DatePicker",
          "why": "put a TimeField beside it: two fields can each be empty, where this one value is whole or absent"
        },
        {
          "when": "a start and an end",
          "use": "DateRangePicker",
          "why": "two DateTimePickers cannot keep the end after the start or show the days between; for a window with times, a DateRangePicker with TimeFields for its ends"
        }
      ],
      "composition": {
        "order": [
          "DateTimePicker"
        ],
        "note": "One field. `value` is `YYYY-MM-DDTHH:MM` — a wall-clock reading with no zone — so say which zone in `description` (\"Workspace time, UTC.\"). `minValue` and `maxValue` are moments of the same shape; pass the current reading as `minValue` for \"not before now\". `granularity=\"second\"` only when seconds matter to the reader.",
        "example": "<DateTimePicker label=\"Run starts\" description=\"Workspace time, UTC.\" value={startsAt} onChange={setStartsAt} minValue={nowInWorkspace} isRequired />"
      },
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms, table filters"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "prominent single-field forms"
          }
        },
        "granularity": {
          "minute": {
            "use": "the default — almost every moment a person sets"
          },
          "second": {
            "use": "a moment read to the second, such as the point in a log to start from"
          }
        }
      },
      "slots": {
        "description": {
          "accepts": "text",
          "note": "which timezone the moment is in, whenever the reader could be in another; replaced by `errorMessage` while the field is invalid"
        },
        "labels": {
          "accepts": "text",
          "note": "the calendar button's name and the time field's label"
        }
      },
      "a11y": {
        "requires": [
          "`label`, or an `aria-label` when no visible label is rendered",
          "a `description` naming the timezone whenever the reader could be in a different one from the thing being scheduled"
        ],
        "guarantees": [
          "one labelled group of spinbuttons — day, month, year, hour, minute, and AM/PM in a 12-hour locale — each its own tab stop and each announcing its name, value and bounds, as DatePicker's do",
          "the order, the separators and the 12- or 24-hour clock come from the reader's locale; the value is 24-hour and Gregorian either way",
          "the calendar button is named by `labels.calendar`, not by the field's label, and opens a modal popover: the calendar, then a time field labelled `labels.time`, reached by Tab",
          "a day picked keeps the time already set, and the popover stays open for the time; Escape or a click outside closes it and focus returns to the button",
          "a day picked with no time takes midnight when the popover closes, shown in the field to change — it is never dropped",
          "a moment before `minValue` or after `maxValue` turns the field invalid and names the bound in the reader's own format, without `isInvalid`; the date and the time are checked together, and the value is kept, not clamped",
          "a `Date`, a timestamp or a string with a zone or offset throws with the prop's name, rather than rendering an empty field",
          "`name` posts the ISO value as a hidden input"
        ],
        "notes": [
          "The value is a wall-clock reading, never an instant. Convert to and from an instant where the zone is known — on the server, or beside the zone setting — not in the field.",
          "`isDateUnavailable` refuses whole days; a time bound within a day is `minValue` and `maxValue`."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a DatePicker and a TimeField side by side, with TimeField's `minValue` moved whenever the date is today",
          "do": "<DateTimePicker minValue={now} … />",
          "why": "the rule spans both fields, so every caller writes it again, and each gets a different edge wrong — midnight, the day it changes, a date typed after the time"
        },
        {
          "dont": "`value={new Date()}`, `value={Date.now()}` or `value=\"2026-04-12T14:30Z\"`",
          "do": "`value=\"2026-04-12T14:30\"`, the reading in the zone the description names",
          "why": "an instant is shown in the reader's zone, so two people see and save two different moments from one form"
        },
        {
          "dont": "a DateTimePicker with no word on the timezone",
          "do": "`description=\"Workspace time, UTC.\"`",
          "why": "a time with no zone is read in the reader's own, and a run scheduled for 09:00 starts at someone else's"
        }
      ],
      "stylesheet": "src/styles/date-time-picker.css",
      "tokens": [
        "--border-subtle",
        "--border-width-default",
        "--spacing-12",
        "--surface-raised"
      ],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Field label. Required for a usable control — see `a11y.requires`."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the field when there is no visible `label`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the field. Say which timezone the time is in whenever\nthe reader could be in another — \"Workspace time, UTC.\""
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text while the field is invalid — `isInvalid`, or its own bounds check."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "size": {
          "type": "DateTimePickerSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Input's `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "value": {
          "type": "string | null | undefined",
          "required": false,
          "origin": "own",
          "description": "The date and time as `YYYY-MM-DDTHH:MM` (`:SS` at second granularity):\na wall-clock reading, with no timezone and no offset. `null` means none\nis chosen. A malformed value throws rather than rendering an empty field."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The initial date and time, for an uncontrolled picker."
        },
        "onChange": {
          "type": "((value: IsoDateTime | null) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires with `YYYY-MM-DDTHH:MM`, or `null` when the field is cleared."
        },
        "minValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The earliest allowed moment, `YYYY-MM-DDTHH:MM` — \"not before now\" is\nthe current reading. It bounds the date and the time together: on its\nday, an earlier time is out of range; on a later day, any time is in."
        },
        "maxValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The latest allowed moment, `YYYY-MM-DDTHH:MM`."
        },
        "isDateUnavailable": {
          "type": "((date: IsoDate) => boolean) | undefined",
          "required": false,
          "origin": "own",
          "description": "Days the calendar refuses, whatever the time — weekends, holidays. Receives `YYYY-MM-DD`."
        },
        "granularity": {
          "type": "DateTimePickerGranularity | undefined",
          "required": false,
          "origin": "own",
          "description": "Defaults to `minute`. `second` adds a seconds segment, and `:SS` to the value.",
          "values": [
            "minute",
            "second"
          ]
        },
        "hourCycle": {
          "type": "12 | 24 | undefined",
          "required": false,
          "origin": "own",
          "description": "12 or 24-hour display. Leave it out: the reader's locale already knows.\nThe value is 24-hour either way."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Posts the value under this name, for an uncontrolled form."
        },
        "labels": {
          "type": "DateTimePickerLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "The calendar button's name and the time field's label."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 22,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "DescriptionList": {
      "name": "DescriptionList",
      "source": "src/components/DescriptionList.tsx",
      "propsType": "DescriptionListProps",
      "description": "DescriptionList — label–value pairs: a record's details, a settings\nsummary, a review step.\n\nWHY `<dl>` AND NOT A TABLE OR A GRID OF DIVS\n\nA `<dl>` is the element for name–value groups, and a screen reader says\nso: it announces a list with its count, and reads each term with its\nvalue. A two-column table claims rows and columns that nobody compares\ndown; a grid of `<div>`s says nothing at all, so \"Owner\" and \"Priya Shah\"\narrive as two unrelated lines. Each pair is wrapped in a `<div>`, which\nHTML allows inside a `<dl>`, so the layouts can style a pair as a unit.\n\nEMPTY IS SAID, NOT DRAWN ONLY\n\nAn unset value is shown as an em dash — the convention every record page\nuses — but \"—\" is read as \"dash\", or skipped, depending on the reader.\nThe dash is hidden from assistive technology and `emptyText` is read in\nits place, so \"Last run: Not set\" is heard where \"Last run: —\" is seen.",
      "import": "import { DescriptionList } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Label–value pairs as a real `<dl>` — a record's details, a settings summary, a wizard's review step. Three layouts; an empty value is shown as \"—\" and read as words.",
      "useWhen": [
        "showing a record's details as named values — Owner, Team, Last run",
        "summarising what was entered before it is submitted — a wizard's review step",
        "a strip of facts under a heading or on a card"
      ],
      "useInstead": [
        {
          "when": "several records share the same fields and are compared down a column",
          "use": "Table",
          "why": "a table is for comparing rows; a description list is one record's values"
        },
        {
          "when": "the values are editable here",
          "use": "SettingRow or a Form",
          "why": "a description list is read-only; an editable value is a field with a label, not a term"
        },
        {
          "when": "a handful of headline numbers",
          "use": "StatTile",
          "why": "a stat leads with the number at display size; a description list reads label first, at body size"
        }
      ],
      "variants": {
        "layout": {
          "horizontal": {
            "use": "the default — a record's details, the term beside its value. Stacks when the list itself is narrower than 24rem"
          },
          "stacked": {
            "use": "a narrow column — a SidePanel, a card's side — term above value"
          },
          "row": {
            "use": "a strip of short facts under a heading or in a card: pairs side by side at their own width, wrapping when out of room"
          }
        }
      },
      "a11y": {
        "requires": [
          "DescriptionListItem children only — each renders a `<div>` holding one `<dt>` and one `<dd>`",
          "`emptyText` in the user's language when the page is not in English"
        ],
        "guarantees": [
          "a `<dl>` of `<dt>` terms and `<dd>` definitions, so each value is read with its term and the list with its count",
          "an empty value — null, undefined, false or '' — is drawn as \"—\" hidden from assistive technology and read as `emptyText` (\"Not set\")",
          "0 is a value, not an empty one",
          "`horizontal` stacks by the list's own width (a container query), so one list is right in a page and in a 22rem panel",
          "long values — IDs, URLs — wrap inside their column rather than overflowing it",
          "horizontal fills its parent's width; stacked and row size to their content, and none collapses in a shrink-to-fit parent such as a card's side column"
        ],
        "notes": [
          "a value can be any content — a Badge, a Link, an Avatar with a name. Keep it to one value per term; several values are one `<dd>` holding a list",
          "the term is text/secondary and the value text/default, so the eye lands on the value"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a grid of <div>s with a label and a value in each",
          "do": "<DescriptionList><DescriptionListItem term=\"Owner\">Priya Shah</DescriptionListItem></DescriptionList>",
          "why": "divs say nothing: \"Owner\" and \"Priya Shah\" are read as two unrelated lines"
        },
        {
          "dont": "a two-column <table> of labels and values",
          "do": "<DescriptionList>",
          "why": "a table announces rows and columns and invites comparing down a column; there is nothing to compare"
        },
        {
          "dont": "<DescriptionListItem term=\"Last run\">—</DescriptionListItem>",
          "do": "<DescriptionListItem term=\"Last run\">{lastRun}</DescriptionListItem> — pass null when there is none",
          "why": "a typed dash is read as \"dash\" or skipped; an empty value is read as \"Not set\""
        }
      ],
      "stylesheet": "src/styles/description-list.css",
      "tokens": [
        "--font-family-sans",
        "--spacing-12",
        "--spacing-2",
        "--spacing-32",
        "--spacing-8",
        "--text-default",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "layout": {
          "type": "DescriptionListLayout | undefined",
          "required": false,
          "origin": "own",
          "description": "`horizontal` (default): term beside its value, for a record's details —\nit stacks when the list is narrower than 24rem. `stacked`: term above\nvalue, one pair per line, for a narrow column. `row`: stacked pairs side\nby side at their own width, wrapping when out of room — a strip of\nshort facts under a heading.",
          "values": [
            "horizontal",
            "stacked",
            "row"
          ]
        },
        "emptyText": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "What an empty value is read as. It is shown as \"—\", which a screen\nreader reads as \"dash\" or not at all. Default \"Not set\" — pass the\ntranslation."
        },
        "children": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "DescriptionListItem children."
        }
      },
      "propCounts": {
        "own": 3,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "DescriptionListItem": {
      "name": "DescriptionListItem",
      "source": "src/components/DescriptionList.tsx",
      "propsType": "DescriptionListItemProps",
      "import": "import { DescriptionListItem } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One term and its value inside a DescriptionList: a `<div>` holding a `<dt>` and a `<dd>`.",
      "useWhen": [
        "one label–value pair in a DescriptionList"
      ],
      "a11y": {
        "requires": [
          "a DescriptionList parent — a `<dt>` outside a `<dl>` is invalid, and is not read as a term"
        ],
        "guarantees": [
          "`term` is the `<dt>`; the children are the `<dd>`",
          "empty children are drawn as \"—\" and read as the list's `emptyText`"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a typed \"—\", \"N/A\" or \"None\" as the value",
          "do": "null, and let the list say it",
          "why": "the list draws the dash and reads its translated `emptyText`; a typed one is read as \"dash\" and is not translated"
        }
      ],
      "stylesheet": "src/styles/description-list.css",
      "tokens": [
        "--font-family-sans",
        "--spacing-12",
        "--spacing-2",
        "--spacing-32",
        "--spacing-8",
        "--text-default",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "term": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The label — \"Owner\", \"Started\". Rendered as the `<dt>`."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The value, rendered as the `<dd>`. `null`, `undefined`, `false` or ''\nrender as an empty value: \"—\" to the eye, `emptyText` to a reader."
        },
        "emptyText": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Set by the DescriptionList; pass it here only to override one item."
        }
      },
      "propCounts": {
        "own": 3,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Divider": {
      "name": "Divider",
      "source": "src/components/Divider.tsx",
      "propsType": "DividerProps",
      "description": "A single `<hr>`, matching Figma's `Border` (70:22153).\n\n`<hr>` rather than a styled `<div>`: it is a semantic thematic break, so a\nscreen reader announces it as one, and it needs no `role` to get there.\n`aria-orientation` is set for the vertical case, since a vertical rule\ninside a horizontal toolbar is the one shape a screen reader cannot infer\nfrom the element alone.",
      "import": "import { Divider } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A semantic thematic break, rendered as a real <hr>.",
      "useWhen": [
        "separating groups of content or controls that are related but distinct",
        "a menu, toolbar or form needs a visible section break"
      ],
      "useInstead": [
        {
          "when": "you only want space between two things",
          "use": "CSS gap or margin",
          "why": "a rule announces a break to a screen reader; whitespace does not, and usually shouldn't"
        }
      ],
      "variants": {
        "orientation": {
          "horizontal": {
            "use": "the default — between stacked sections"
          },
          "vertical": {
            "use": "inside a horizontal row: toolbars, button groups, header bars"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "`<hr>` is a thematic break to assistive tech without needing a role",
          "`aria-orientation=\"vertical\"` is set for the vertical case — the one thing a screen reader cannot infer from the element"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a styled <div> for a rule",
          "why": "that is what this component exists to replace"
        },
        {
          "dont": "a Divider between every row of a list",
          "why": "a break announced on every row is noise; use the list's own row borders"
        }
      ],
      "stylesheet": "src/styles/divider.css",
      "tokens": [
        "--border-strong",
        "--border-width-default"
      ],
      "props": {
        "orientation": {
          "type": "DividerOrientation | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Figma's `Style` variant.",
          "values": [
            "vertical",
            "horizontal"
          ]
        }
      },
      "propCounts": {
        "own": 1,
        "aria": 0,
        "dom": 278,
        "other": 0
      }
    },
    "Drawer": {
      "name": "Drawer",
      "source": "src/components/Drawer.tsx",
      "propsType": "DrawerProps",
      "description": "Drawer — a modal panel anchored to an edge.\n\nWHY IT IS A MODAL AND NOT A SIDEBAR\n\nIt takes focus, traps it, closes on Escape and on an outside click, and marks\nthe rest of the page inert — all of `Modal`'s behaviour, differing only in\nwhere the panel sits and how it enters. A persistent side panel that does not\ndo those things is layout, not a drawer, and belongs in the page rather than\nin an overlay.\n\nWHEN TO REACH FOR MODAL INSTEAD\n\nA drawer suits work that is long or list-shaped — filters, a record's detail,\na sequence of settings — because an edge panel can be tall without becoming a\nsquare that fights the viewport. A decision, especially a destructive one,\nbelongs in a Modal: it is centred, it is smaller, and it does not invite\nscrolling past the thing being confirmed.\n\nRENDERS NOTHING WHEN CLOSED, and portals when open, for the reasons recorded\non Modal: a closed overlay left in the tree is still keyboard-focusable, and\nan inline one inherits whatever `overflow` and stacking context surrounds it.",
      "import": "import { Drawer } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A modal panel anchored to an edge. Modal behaviour — focus trap, Escape, scrim — with the shape of a side panel.",
      "useWhen": [
        "work that is long or list-shaped: filters, a record's detail, a run of settings",
        "the page behind should stay visible as context while a task is done beside it",
        "a form long enough that a centred dialog would scroll its own actions off screen"
      ],
      "useInstead": [
        {
          "when": "the user is making a decision, especially a destructive one",
          "use": "Modal",
          "why": "a modal is centred and small enough that the thing being confirmed cannot be scrolled past"
        },
        {
          "when": "the panel should stay open while the user works elsewhere",
          "use": "SidePanel",
          "why": "a drawer traps focus and marks the rest of the page inert. SidePanel opens beside the content and leaves it usable"
        },
        {
          "when": "the content is a short menu of actions",
          "use": "Popover or Menu"
        }
      ],
      "variants": {
        "placement": {
          "start": {
            "use": "navigation and hierarchy, which readers expect at the leading edge"
          },
          "end": {
            "use": "the default — detail and editing beside the record that opened it"
          },
          "top": {
            "use": "rare: a global search or command surface"
          },
          "bottom": {
            "use": "touch-first layouts, where the bottom edge is the reachable one"
          }
        },
        "size": {
          "sm": {
            "use": "a short form or a filter list"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "a record detail with its own sections"
          }
        }
      },
      "slots": {
        "title": {
          "accepts": "text",
          "note": "required — it is the dialog's accessible name"
        },
        "description": {
          "accepts": "text"
        },
        "children": {
          "accepts": "any content",
          "note": "the only scrolling region"
        },
        "footer": {
          "accepts": "Button",
          "note": "pinned to the bottom edge, so actions stay reachable in a long panel"
        }
      },
      "a11y": {
        "requires": [
          "`title`, which becomes the dialog's accessible name via aria-labelledby"
        ],
        "notes": [
          "start and end follow writing direction, so a right-hand drawer in English is a left-hand one in Arabic with no second variant",
          "renders nothing when closed and portals when open: a closed overlay left in the tree is still keyboard-focusable",
          "only the body scrolls, so the title stays on screen — the one piece of context a user needs while working down a long list"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Drawer that stays open while the user edits the page behind it",
          "do": "<SidePanel> inside a SidePanelLayout",
          "why": "it traps focus and marks the rest inert; a keyboard user cannot reach the page behind it"
        },
        {
          "dont": "<Drawer> for a delete confirmation",
          "do": "<Modal>",
          "why": "an edge panel is tall, so the confirm button can sit below the fold — the one control that must never be missed"
        }
      ],
      "stylesheet": "src/styles/drawer.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-semibold",
        "--icon-secondary",
        "--icon-size-md",
        "--ion-drawer-size",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-lg",
        "--radius-sm",
        "--spacing-12",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-8",
        "--surface-hover",
        "--surface-raised",
        "--surface-scrim",
        "--text-default",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-h5",
        "--type-h5-line-height"
      ],
      "props": {
        "isOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether the drawer is shown."
        },
        "onOpenChange": {
          "type": "((isOpen: boolean) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Called with the next open state — `false` when the drawer asks to close."
        },
        "placement": {
          "type": "DrawerPlacement | undefined",
          "required": false,
          "origin": "own",
          "description": "Which edge it enters from. `start` and `end` follow writing direction, so\na right-hand drawer in English is a left-hand one in Arabic without a\nsecond variant.",
          "values": [
            "top",
            "bottom",
            "start",
            "end"
          ],
          "default": "end"
        },
        "size": {
          "type": "DrawerSize | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "title": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "Required: the dialog's accessible name, wired via `aria-labelledby`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Supporting copy under the title."
        },
        "footer": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Action row, pinned to the bottom edge."
        },
        "showClose": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "default": true
        },
        "closeLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "default": "Close drawer"
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "isDismissable": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to close the modal when the user interacts outside it.",
          "tags": {
            "default": "false"
          }
        },
        "isKeyboardDismissDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether pressing the escape key to close the modal should be disabled.",
          "tags": {
            "default": "false"
          }
        },
        "shouldCloseOnInteractOutside": {
          "type": "((element: Element) => boolean) | undefined",
          "required": false,
          "origin": "aria",
          "description": "When user interacts with the argument element outside of the overlay ref,\nreturn true if onClose should be called.  This gives you a chance to filter\nout interaction with elements that should not dismiss the overlay.\nBy default, onClose will always be called on interaction outside the overlay ref."
        }
      },
      "propCounts": {
        "own": 11,
        "aria": 3,
        "dom": 0,
        "other": 0
      }
    },
    "DualListbox": {
      "name": "DualListbox",
      "source": "src/components/DualListbox.tsx",
      "propsType": "DualListboxProps",
      "description": "DualListbox — choose some of many, in two lists side by side: what is\navailable, and what is chosen, with buttons that move options between them.\n\nWHY NOT A MULTISELECT\n\nA MultiSelect hides what is left behind a popover and shows what is chosen\nas tags that wrap. That is right for a handful. It is wrong when the chosen\nset is long and must be reviewed whole, when both sides need to be seen at\nonce, and when the chosen ORDER means something — who is asked to approve\nfirst — since tags cannot be reordered from the keyboard.\n\nHOW IT BEHAVES\n\n  - Two listboxes, each one tab stop, named by the field and by which list\n    it is. Multiple selection with the listbox pattern's recommended model:\n    ↑ ↓ move and select, Shift extends, Ctrl or ⌘ moves without selecting\n    and Space then toggles, Ctrl+A takes all. A click picks one; Shift- and\n    Ctrl-click extend.\n  - Enter or a double click moves an option straight across.\n  - The buttons act on what is picked. What moved stays picked in its new\n    list, so Move up can follow Add at once. Focus stays on the button\n    pressed; when that leaves it disabled — nothing left to add — focus\n    goes to the first option that moved, where the next useful key is.\n  - Each move is announced through a status region that is always there.\n  - Below 30rem of its own width the lists stack, and the buttons point\n    up and down between them.",
      "import": "import { DualListbox } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Choose some of many in two lists side by side — what is available, and what is chosen — with buttons that move options across, and optionally an order for what is chosen. A form field.",
      "useWhen": [
        "the chosen ORDER means something and must be set — who is asked to approve first, which source is searched first, the columns of an export",
        "the chosen set is long and must be reviewed whole, not as tags that wrap",
        "both sides need to be seen at once: what is left out matters as much as what is in"
      ],
      "useInstead": [
        {
          "when": "a handful chosen from a list, with no order",
          "use": "MultiSelect",
          "why": "one field with tags takes a line, where two lists take a panel; the lists earn their room only for a long selection or an order"
        },
        {
          "when": "a few options, all visible at once, with no order",
          "use": "CheckboxGroup",
          "why": "a box per option is one click each and needs no second list"
        },
        {
          "when": "hundreds of options, found by typing",
          "use": "MultiSelect",
          "why": "these lists do not filter; scrolling hundreds of rows to find one is slower than typing three letters"
        },
        {
          "when": "choosing rows of a table to act on",
          "use": "Table",
          "why": "a selection for a bulk action is not a form value — the DataTable pattern's row selection is"
        }
      ],
      "composition": {
        "order": [
          "DualListbox"
        ],
        "note": "Pass every option in `options`, in the order Available should list them; `value` is the chosen ones, in their chosen order. Name what the lists hold through `labels` — \"Available\" and \"Selected\" become \"Everyone\" and \"Approvers\" — and the buttons with them (\"Add to approvers\"). `isReorderable` when the order is part of the value.",
        "example": "<DualListbox label=\"Approval order\" description=\"Asked in this order. The next is asked if one does not answer in a day.\" options={people} value={approvers} onChange={setApprovers} isReorderable labels={{ available: 'Everyone', selected: 'Approvers', add: 'Add to approvers', remove: 'Remove from approvers' }} />"
      },
      "slots": {
        "label": {
          "accepts": "text",
          "note": "names the field, and both lists with it — \"Approval order Available\""
        },
        "description": {
          "accepts": "text",
          "note": "what the choice does; say what the order means when there is one. Replaced by `errorMessage` while `isInvalid` is set"
        },
        "errorMessage": {
          "accepts": "text",
          "note": "shown only while `isInvalid` is set, and read with the chosen list"
        },
        "labels": {
          "accepts": "text",
          "note": "every string it shows or announces: the two lists' names, the four buttons, the empty text, and the announcements after a move"
        }
      },
      "a11y": {
        "requires": [
          "`label` or `aria-label` — the field's name, which each list is named by, then which list it is",
          "`labels.add` and `labels.remove` that name the list they move to, when the lists are renamed — \"Add to approvers\", not \"Add\""
        ],
        "guarantees": [
          "a `group` named by the label and described by the helper text, holding two `listbox`es, each `aria-multiselectable` and one tab stop",
          "the listbox pattern's recommended multiple selection: ↑ ↓ move and select, Shift+↑ ↓ extend, Ctrl or ⌘ with ↑ ↓ moves without selecting and Space toggles, Ctrl+A picks every option; a click picks one, Shift- and Ctrl-click extend; Home and End go to the ends; typing jumps to a matching label",
          "Enter or a double click moves an option straight across",
          "four icon-only Buttons, each named by `labels` and disabled while there is nothing for it to do",
          "what moved stays picked in the list it moved to, so Move up can follow Add at once",
          "focus stays on the button pressed; when the move leaves it disabled, focus goes to the first option that moved",
          "every move is announced through a `status` region that is always in the page — \"2 moved to Selected\", \"Kwame Mensah, 2 of 4\"",
          "a list with nothing in it stays a named, focusable listbox, described by `labels.empty`",
          "`isRequired` and `isInvalid` are `aria-required` and `aria-invalid` on the chosen list, which the error message describes",
          "a disabled option cannot be picked or moved; `isDisabled` disables every option and button",
          "`name` posts each chosen value, in order, as a hidden input",
          "below 30rem of its own width the lists stack, and the buttons turn to point down and up between them; in RTL the lists swap sides and the arrows follow",
          "rows are at least 32px, above the 24px WCAG 2.2 target minimum; in forced colours the picked rows keep an outline"
        ],
        "notes": [
          "`isReorderable` when the order is part of the value — Move up and Move down beside the chosen list. Without it the value is a set, kept in the order it was chosen.",
          "The buttons act on what is picked in each list, not on where focus is — pick in Available, then press Add.",
          "Picking and choosing are different: an option picked in Available is highlighted to be moved; only the Selected list is the value."
        ]
      },
      "antiPatterns": [
        {
          "dont": "two Lists or two MultiSelects with buttons between them",
          "do": "<DualListbox … />",
          "why": "the moves, what stays picked, where focus goes and what is announced are the component; hand-built, each is left out"
        },
        {
          "dont": "labels={{ selected: 'Approvers' }} with the buttons left as \"Add to selected\"",
          "do": "rename the buttons with the list — `add: 'Add to approvers'`",
          "why": "a button that names a list the screen does not show says nothing about where the option goes"
        },
        {
          "dont": "a DualListbox for three or four options",
          "do": "CheckboxGroup",
          "why": "two lists and four buttons to pick among three things is more control than choice"
        },
        {
          "dont": "an order that matters, with `isReorderable` left off",
          "why": "the order is then whatever order they were added in, with no way to change it but removing and adding again"
        }
      ],
      "stylesheet": "src/styles/dual-listbox.css",
      "tokens": [
        "--border-default",
        "--border-error",
        "--border-focus",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--ion-dual-listbox-height",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-md",
        "--radius-sm",
        "--spacing-12",
        "--spacing-2",
        "--spacing-32",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-selected",
        "--surface-selected-hover",
        "--text-default",
        "--text-disabled",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "options": {
          "type": "readonly DualListboxOption[]",
          "required": true,
          "origin": "own",
          "description": "Every choice, in the order the Available list shows them."
        },
        "value": {
          "type": "readonly string[] | undefined",
          "required": false,
          "origin": "own",
          "description": "The chosen values (controlled), in their chosen order."
        },
        "defaultValue": {
          "type": "readonly string[] | undefined",
          "required": false,
          "origin": "own",
          "description": "The initially chosen values (uncontrolled)."
        },
        "onChange": {
          "type": "((value: string[]) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Receives the whole new list of chosen values, in order."
        },
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Names the field. Required for a usable control — see `a11y.requires`."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the field when there is no visible `label`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the lists."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text when `isInvalid` is set."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "At least one value must be chosen."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isReorderable": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "The chosen order means something — who is asked first, which source is\nsearched first — and can be changed: Move up and Move down."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Posts every chosen value under this name, in order, for a form."
        },
        "labels": {
          "type": "DualListboxLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "Every string it renders or announces, for translation. English by default."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the `.ion-field` wrapper."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 16,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "EmptyState": {
      "name": "EmptyState",
      "source": "src/components/EmptyState.tsx",
      "propsType": "EmptyStateProps",
      "description": "EmptyState — the state five of this system's nine patterns require and none\nof them could render.\n\n`DataTable`, `PageShell`, `SettingsPanel`, `AssistantAnswer` and\n`HumanApproval` all specify an empty state in their recipe. Until this\nexisted each consumer invented one, which is how a design system ends up\nwith four different ways to say \"nothing here\".\n\nREASON IS REQUIRED, AND HAS NO DEFAULT\n\nThe four reasons are genuinely different situations with different correct\nactions, and the failure mode is silent: an app that shows \"No invoices yet\n— Create your first invoice\" to a user whose filter excluded everything has\ntold them their records are gone. A default would make that the easy path,\nso there isn't one.\n\n`no-results` and `error` deliberately do NOT offer a create action in the\ncontract's guidance. The first needs the filter cleared; the second needs a\nretry, and offering \"Create\" over a failed fetch invites a duplicate.\n\nNOT A LIVE REGION\n\nAn empty state replaces content, so it is what the user reads next rather\nthan something announced over what they are reading. `role=\"status\"` here\nwould interrupt on every keystroke of a filter box. If a specific flow needs\nthe announcement, the caller owns that decision — the same reasoning\nStreamingText applies to `aria-live`.\n\n`error` is rendered as `role=\"alert\"` ONLY when the caller opts in via\n`role`, for the same reason: an error empty state arriving during typing is\nstill a read, not an interruption.\n\nNo `'use client'`: nothing here holds state. The buttons passed through\n`action` carry their own boundary.",
      "import": "import { EmptyState } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The state a region shows when it has nothing to show, and why — first run, no results, no access, or a failed fetch.",
      "useWhen": [
        "a list, table, panel or route has no content to render",
        "a filter or search returns nothing",
        "the account is not permitted to see what would be here",
        "the request that would have filled the region failed"
      ],
      "useInstead": [
        {
          "when": "content exists and something went wrong beside it",
          "use": "Alert",
          "why": "an Alert sits with the content; an EmptyState replaces it. If there is a table on screen, the message about it is an Alert"
        },
        {
          "when": "the message is transient and the content is unaffected",
          "use": "Toast"
        }
      ],
      "slots": {
        "title": {
          "accepts": "text",
          "note": "required — it is the region's statement. Name the thing and the situation: \"No invoices match these filters\", not \"No results\""
        },
        "description": {
          "accepts": "text",
          "note": "one or two sentences: what would be here, and what puts it here"
        },
        "action": {
          "accepts": "Button",
          "note": "the action that RESOLVES this reason — clearing a filter for no-results, not creating a record"
        },
        "secondaryAction": {
          "accepts": "Button or Link",
          "note": "a lower-emphasis escape hatch — \"Contact an admin\""
        },
        "icon": {
          "accepts": "Icon",
          "note": "decorative only, rendered aria-hidden — the title already says what this is"
        },
        "children": {
          "accepts": "any",
          "note": "extra content between the description and the actions"
        }
      },
      "variants": {
        "reason": {
          "first-run": {
            "use": "nothing exists yet and the user has never created one",
            "note": "the only reason where a create action is the right primary"
          },
          "no-results": {
            "use": "records exist; the current filter or query matches none of them",
            "note": "the action is clearing the filter. Offering \"Create\" here tells a user with 400 records and a typo that their data is gone"
          },
          "no-access": {
            "use": "records exist and match, but this account may not see them",
            "note": "name the permission or plan. \"Nothing here\" for a permissions problem sends the user to support with the wrong question"
          },
          "error": {
            "use": "the request that would have filled the region failed",
            "note": "the action is a retry. Never a create — offering one over a failed fetch invites a duplicate record"
          }
        },
        "size": {
          "inline": {
            "use": "inside something that already has a frame — a table body, a card"
          },
          "panel": {
            "use": "a region of a page. The default"
          },
          "page": {
            "use": "a whole route with nothing in it"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "the title renders as a real heading, so the region appears in the document outline",
          "the icon is aria-hidden, so a screen reader is not told the same thing twice"
        ],
        "requires": [
          "a `title` that states the situation, since it is what a screen-reader user hears in place of the missing content",
          "`headingLevel` matching the surrounding outline — h3 is the default, but only the page knows its real level"
        ],
        "notes": [
          "This is deliberately NOT a live region. An empty state replaces content, so it is what the user reads next rather than something announced over what they are reading — `role=\"status\"` here fires on every keystroke of a filter box. If a flow genuinely needs the announcement, pass `role` yourself.",
          "`headingLevel` takes 2-6 and defaults to 3. It is a number rather than a variant because it is an outline decision, not a visual one — the size prop is what changes how the title looks.",
          "`h1` is not available: an empty state describes a region, and the page's own title belongs to the page. Same call FullCard makes.",
          "Under forced-colors the reason tint is discarded, so the region draws a border to stay distinguishable from the content it replaced."
        ]
      },
      "antiPatterns": [
        {
          "dont": "using `first-run` for an empty filter result",
          "do": "no-results",
          "why": "it is the most common empty-state bug in enterprise software: a user with 400 invoices and a typo in their filter is told they have none, and offered a button to make one"
        },
        {
          "dont": "\"No data\" or \"Nothing found\" as the title",
          "why": "it names neither the thing nor the situation, so the user cannot tell whether to create a record, clear a filter, or ask for access"
        },
        {
          "dont": "an EmptyState while the request is still in flight",
          "why": "it asserts the data is absent before anything knows, then contradicts itself a moment later"
        },
        {
          "dont": "a create action on `error`",
          "why": "the fetch failed, so nobody knows whether the record already exists — this is how duplicates get made"
        },
        {
          "dont": "an icon with its own `aria-label` or visible caption",
          "why": "the slot is decorative and the title already carries the meaning; a labelled icon makes it announce twice"
        },
        {
          "dont": "hiding the region entirely when it is empty",
          "why": "a section that vanishes is indistinguishable from one that failed to render, and the user cannot tell that they are looking at everything there is"
        },
        {
          "dont": "an EmptyState above a form nobody has filled in yet",
          "why": "a form's empty state IS its initial state — see the Form pattern. A message saying the form is empty is noise on top of a form that is visibly empty"
        }
      ],
      "stylesheet": "src/styles/empty-state.css",
      "tokens": [
        "--font-family-sans",
        "--font-weight-semibold",
        "--icon-error",
        "--icon-primary",
        "--icon-size-md",
        "--icon-size-xl",
        "--icon-tertiary",
        "--icon-warning",
        "--ion-empty-state-gap",
        "--ion-empty-state-icon",
        "--ion-empty-state-icon-size",
        "--ion-empty-state-pad-block",
        "--ion-empty-state-title",
        "--ion-empty-state-title-line-height",
        "--radius-md",
        "--spacing-12",
        "--spacing-16",
        "--spacing-24",
        "--spacing-4",
        "--spacing-48",
        "--spacing-64",
        "--spacing-8",
        "--text-default",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-md",
        "--type-body-md-line-height",
        "--type-h3",
        "--type-h3-line-height",
        "--type-h4",
        "--type-h4-line-height"
      ],
      "props": {
        "reason": {
          "type": "EmptyStateReason",
          "required": true,
          "origin": "own",
          "description": "Why there is nothing here. Required, and there is no default: every\ndefault would be a guess, and the wrong guess is the bug this component\nexists to prevent.",
          "values": [
            "error",
            "first-run",
            "no-results",
            "no-access"
          ]
        },
        "title": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "What is missing, in the user's words. Required — it is the accessible name\nof the region, and an empty state with no statement is a blank area with\npadding.\n\nName the thing and the situation: \"No invoices match these filters\", not\n\"No results\"."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "One or two sentences: what would be here, and what puts it here."
        },
        "action": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The action that resolves this state. For `no-results` that is clearing the\nfilter, NOT creating a record — see `useInstead` in the contract."
        },
        "secondaryAction": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "A lower-emphasis escape hatch beside `action` — \"Contact an admin\"."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Decorative mark above the title. Rendered `aria-hidden`: the title already\nsays what this is, and an icon that repeats it announces twice."
        },
        "size": {
          "type": "EmptyStateSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Defaults to `panel`.",
          "values": [
            "inline",
            "page",
            "panel"
          ]
        },
        "headingLevel": {
          "type": "EmptyStateHeadingLevel | undefined",
          "required": false,
          "origin": "own",
          "description": "Heading level for the title. Defaults to `h3`; only the page knows its own\noutline, so it is a prop rather than a constant."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 9,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "Fieldset": {
      "name": "Fieldset",
      "source": "src/components/Fieldset.tsx",
      "propsType": "FieldsetProps",
      "description": "Groups related fields under one label, with one description and one error —\nan address, a date range typed as two fields, a set of limits.\n\nFor checkboxes use CheckboxGroup and for radios RadioGroup: both render this\nsame shell, and add the selection state it deliberately does not own.\n\nThere is no `isDisabled`. A native `disabled` fieldset does disable every\ncontrol inside, but Input, Select and the rest draw their disabled state from\ntheir own prop, so the fields would stop working while still looking live.\nDisable the fields themselves; the choice groups can cascade because their\ncheckboxes and radios style off `:disabled`.",
      "import": "import { Fieldset } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A <fieldset>/<legend> that groups related fields under one label, one description and one error — an address, a pair of limits, a date typed as two fields.",
      "useWhen": [
        "several fields answer one question and a reader must hear the question with each of them — street, city and postcode under \"Billing address\"",
        "one error belongs to the group rather than to a field — \"the minimum must be below the maximum\""
      ],
      "useInstead": [
        {
          "when": "the fields are checkboxes",
          "use": "CheckboxGroup",
          "why": "it renders this same shell and adds the selected values and \"select at least one\""
        },
        {
          "when": "the fields are radios",
          "use": "RadioGroup",
          "why": "it renders this same shell and adds the shared name and the selected value"
        },
        {
          "when": "a single field needs a label and help text",
          "use": "Input",
          "why": "every field already renders its own label, description and error; a fieldset around one field announces the label twice"
        },
        {
          "when": "the grouping is visual — a titled section of a settings page",
          "use": "Card",
          "why": "a fieldset is a form grouping that assistive technology announces on entry; a section of unrelated settings is not one question"
        }
      ],
      "composition": {
        "order": [
          "Fieldset",
          "Input"
        ],
        "note": "Each field keeps its own label — the legend names the group, not the fields. Put a group-level error in `errorMessage` with `isInvalid`, and keep field-level errors on the fields.",
        "example": "<Fieldset label=\"Run limits\" description=\"Runs stop at whichever comes first.\" orientation=\"horizontal\"><NumberInput label=\"Max steps\" /><NumberInput label=\"Max minutes\" /></Fieldset>"
      },
      "variants": {
        "orientation": {
          "vertical": {
            "use": "the default — fields stacked, as a form reads"
          },
          "horizontal": {
            "use": "two or three short fields that read as one value — a range, a size, a city and postcode. Wraps rather than overflows"
          }
        }
      },
      "slots": {
        "label": {
          "accepts": "text",
          "note": "renders as the <legend> — the question the fields answer"
        },
        "description": {
          "accepts": "text",
          "note": "help beneath the fields; replaced by `errorMessage` while `isInvalid` is set, as Input's is"
        },
        "errorMessage": {
          "accepts": "text",
          "note": "the group's error, shown only while `isInvalid` is set"
        },
        "children": {
          "accepts": "Input, Select, NumberInput, DatePicker and other fields"
        }
      },
      "a11y": {
        "role": "group, from <fieldset> and <legend>",
        "guarantees": [
          "the legend is announced as the group's name when focus enters any field inside",
          "`aria-describedby` on the fieldset points at whichever of the description or the error is showing"
        ],
        "requires": [
          "`label`, or `aria-label` when the question is already visible as a heading"
        ],
        "notes": [
          "There is no `isDisabled`. A native disabled fieldset disables every control inside, but Input, Select and the rest draw their disabled look from their own prop — the fields would stop working while still looking live. Disable the fields themselves."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Fieldset around one field",
          "why": "the field's label and the legend both announce, saying the same thing twice"
        },
        {
          "dont": "a Fieldset of Checkboxes",
          "do": "<CheckboxGroup label=\"Notify me when\">…</CheckboxGroup>",
          "why": "it looks the same, but nothing owns the selected values or \"select at least one\""
        },
        {
          "dont": "<fieldset disabled> around Inputs",
          "why": "the fields stop accepting input while still drawn as enabled"
        }
      ],
      "stylesheet": "src/styles/fieldset.css",
      "tokens": [
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--ion-fieldset-gap",
        "--ion-fieldset-inline-gap",
        "--spacing-16",
        "--spacing-6",
        "--spacing-8",
        "--text-error",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The question the fields answer. Renders as the `<legend>`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Help text beneath the fields. Replaced by `errorMessage` while invalid."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Shown in the description's place while `isInvalid` is set."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether the group as a whole fails validation."
        },
        "orientation": {
          "type": "FieldsetOrientation | undefined",
          "required": false,
          "origin": "own",
          "description": "How the fields flow. Horizontal wraps rather than overflowing.",
          "values": [
            "vertical",
            "horizontal"
          ],
          "default": "vertical"
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 279,
        "other": 0
      }
    },
    "FileUpload": {
      "name": "FileUpload",
      "source": "src/components/FileUpload.tsx",
      "propsType": "FileUploadProps",
      "description": "FileUpload — a drop target wrapped around a real `<input type=\"file\">`.\n\nDrawn in Figma as `File Upload` (1367:2333) — two sizes by five states. The\nmeasurements came from `Input` and `EmptyState` rather than being invented:\nthe border, radius and disabled treatment are Input's; the centred\nicon-over-text stack is EmptyState's. Figma draws the drop zone alone, the\nsame split `Input` makes against `Form Field`.\n\nTHE INPUT IS THE CONTROL. THE DROP ZONE IS DECORATION.\n\nThe usual build of this component is a `<div>` with drag handlers and a\nclick that calls `input.click()`. That version cannot be reached by keyboard,\nhas no accessible name, does not participate in a form, and does not work in\nany environment without a pointer — which includes switch access, voice\ncontrol and most screen-reader browse modes.\n\nHere the file input is a real, focusable, labelled control that is visually\nhidden but NOT `display: none` — it keeps its place in the tab order and its\nlabel. Drag-and-drop is layered on top as an enhancement, and every path it\noffers is also reachable without it. That ordering is the whole component.\n\nVALIDATION IS ADVISORY, NOT SECURITY. `accept` and `maxSize` are checked here\nso the user finds out immediately instead of after an upload. A server that\ntrusts either one is trusting a value the client chose.",
      "import": "import { FileUpload } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A drop target wrapped around a real file input, with client-side accept and size checks.",
      "useWhen": [
        "the user attaches one or more files to a form or a message",
        "the constraints (types, size, count) should be stated before the upload is attempted rather than after"
      ],
      "useInstead": [
        {
          "when": "a single file is picked and no drop target or file list is wanted",
          "use": "a plain <input type=\"file\">",
          "why": "this component adds a drop zone and a removable list; without either it is a heavier way to render the same control"
        },
        {
          "when": "upload progress needs to be shown",
          "use": "ProgressBar",
          "why": "FileUpload selects files; it does not transfer them, and deliberately owns no network state"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "inside a dense form, or beside other fields"
          },
          "md": {
            "use": "the default — a standalone attachment area"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "the control is a real <input type=\"file\">, focusable and in the tab order — the drop zone is an enhancement layered on top, never the only way in",
          "the zone is a <label> for that input, so clicking anywhere opens the picker without any script",
          "the input is clipped to 1px rather than display:none, which would remove it from the tab order and the accessibility tree",
          "the focus ring is drawn on the zone, where the user is looking, not on the clipped input",
          "a polite live region announces the number of files held, because a drop changes nothing near the user's focus",
          "dragging is signalled by a solid border as well as a colour, so it is not conveyed by colour alone (WCAG 1.4.1)",
          "each remove button is a 24px target with a name that includes the file (WCAG 2.5.8)"
        ],
        "requires": [
          "`label`, or an `aria-label` — an unnamed file input announces only as \"button\"",
          "`hint` describing the accepted types and size limit, since `accept` itself is never announced"
        ]
      },
      "antiPatterns": [
        {
          "dont": "treating `accept` or `maxSize` as validation",
          "why": "both are client-side and advisory. A browser does not enforce `accept` on drop, and every platform picker offers an \"All files\" escape. The server must check again"
        },
        {
          "dont": "rebuilding this as a div with onClick={() => inputRef.current.click()}",
          "why": "that version has no accessible name, no tab stop, no form participation and no keyboard path — it is the usual build of this component and the usual defect"
        },
        {
          "dont": "putting the file list inside the drop zone",
          "why": "the zone is a label, so every row in it — including each remove button — would also open the file picker"
        },
        {
          "dont": "using `onChange` as a delta",
          "why": "it fires with the complete list after every add and every remove, never with what changed"
        }
      ],
      "slots": {
        "prompt": {
          "expects": "the call to action inside the zone"
        },
        "hint": {
          "expects": "the constraints in words — \"PNG or JPG, up to 5 MB\""
        }
      },
      "stylesheet": "src/styles/file-upload.css",
      "tokens": [
        "--border-default",
        "--border-disabled",
        "--border-error",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-default",
        "--icon-disabled",
        "--icon-secondary",
        "--icon-size-lg",
        "--icon-size-md",
        "--icon-size-sm",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-file-upload-gap",
        "--ion-file-upload-icon-size",
        "--ion-file-upload-padding",
        "--radius-md",
        "--radius-sm",
        "--ring-focus",
        "--spacing-12",
        "--spacing-20",
        "--spacing-24",
        "--spacing-32",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-hover",
        "--surface-primary-subtle",
        "--surface-subtle",
        "--text-default",
        "--text-disabled",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Field label. Also the accessible name of the file input."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the control."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text when `isInvalid` is set."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "size": {
          "type": "FileUploadSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant when drawn. Height of the drop target.",
          "values": [
            "sm",
            "md"
          ]
        },
        "accept": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Native `accept` — a comma-separated list of extensions or MIME types."
        },
        "multiple": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Allow more than one file."
        },
        "maxSize": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Reject anything larger, in bytes."
        },
        "maxFiles": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Reject once this many files are held. Only meaningful with `multiple`."
        },
        "files": {
          "type": "readonly File[] | undefined",
          "required": false,
          "origin": "own",
          "description": "The files held. Pass it to control the list; omit and the component keeps\nits own. A controlled list is the usual case, because the caller is the\nonly thing that knows which uploads have since succeeded or failed."
        },
        "onChange": {
          "type": "((files: File[]) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires with the full list after every add or remove, never with a delta."
        },
        "onReject": {
          "type": "((rejected: RejectedFile[]) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires with anything refused by `accept`, `maxSize` or `maxFiles`."
        },
        "prompt": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Call-to-action inside the drop target."
        },
        "hint": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Second line inside the drop target — the constraints, in words."
        },
        "removeLabel": {
          "type": "((name: string) => string) | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible label for each file's remove button. Receives the file name."
        },
        "labels": {
          "type": "FileUploadLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "Every other string the control renders, for translation. English by default."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 21,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "FullCard": {
      "name": "FullCard",
      "source": "src/components/FullCard.tsx",
      "propsType": "FullCardProps",
      "description": "Full Card — the full-bleed case study row from Figma `Full Card` (592:857).\n\nA text column beside a framed media panel, split down the middle, mirrored\nby the `Alignment` variant.\n\nThe split holds from 1080 and stacks below it, media above content in BOTH\nalignments — `Alignment` names a horizontal side and\nstops meaning anything once there is one column, so it does not get to\ndecide the vertical order too. Size is a media query, not a prop, the same\ncall Header makes about Device.\n\nWHY THE `show*` BOOLEANS ARE GONE\n\nFigma carries `Show Eyebrow`, `Show Description` and `Show Actions` beside\nthe slots they gate, because a Figma component instance always holds every\nlayer and needs a switch to hide one. React has no such constraint — an\nabsent prop is the switch. Badge made the same call with `Show Dot`, and\nkeeping both would have let `showActions` and `actions` disagree.\n\n`headline` is required, and it is the only required prop. The heading is\nwhat makes this a section rather than a decorated div, and every other part\nof the card is optional in Figma too.\n\nNo `'use client'`: nothing here is stateful or interactive. The interactive\nparts arrive through `actions`, and they carry their own boundary.",
      "import": "import { FullCard } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A full-bleed row: a text column beside a framed media panel, mirrored by `alignment`.",
      "useWhen": [
        "a marketing or overview section presents one thing with a screenshot beside it",
        "a case-study or feature row on a landing or product page"
      ],
      "useInstead": [
        {
          "when": "you are listing records with comparable fields",
          "use": "Table"
        },
        {
          "when": "it is a short status message rather than a content section",
          "use": "Alert"
        }
      ],
      "slots": {
        "eyebrow": {
          "accepts": "Badge",
          "note": "any node works"
        },
        "headline": {
          "accepts": "text",
          "note": "required — the heading is what makes this a section rather than a decorated div"
        },
        "description": {
          "accepts": "text"
        },
        "actions": {
          "accepts": "Button",
          "note": "a secondary Button in the design"
        },
        "media": {
          "accepts": "img or video",
          "note": "rendered inside the framed screen holder — pass the screenshot or embed itself, not the frame"
        },
        "children": {
          "accepts": "any",
          "note": "extra content below the description"
        }
      },
      "variants": {
        "alignment": {
          "left": {
            "use": "media on the left, text on the right"
          },
          "right": {
            "use": "media on the right — the variant names the media, not the text"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "the headline renders as a real heading, so the card appears in the document outline"
        ],
        "requires": [
          "`headingLevel` matching the surrounding outline — `h3` is the default because Figma applies Type/H3, but only the page knows the real level",
          "`alt` on whatever you pass as `media`"
        ],
        "notes": [
          "`h1` is deliberately not available: a full card is a section within a page, never the page's own title."
        ]
      },
      "antiPatterns": [
        {
          "dont": "looking for `showEyebrow` / `showDescription` / `showActions`",
          "why": "an absent prop is the switch — Figma needs the boolean because an instance always holds every layer; React does not"
        },
        {
          "dont": "leaving `headingLevel` at the default inside a section that is already at h3",
          "why": "it produces a skipped or duplicated outline level"
        },
        {
          "dont": "passing your own frame or border around `media`",
          "why": "the component already draws the screen holder"
        }
      ],
      "stylesheet": "src/styles/full-card.css",
      "tokens": [
        "--border-default",
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-regular",
        "--font-weight-semibold",
        "--ion-full-card-gap",
        "--ion-full-card-media-gutter",
        "--ion-full-card-media-min-height",
        "--ion-full-card-media-width",
        "--ion-full-card-padding-x",
        "--ion-full-card-padding-y",
        "--radius-5xl",
        "--radius-6xl",
        "--radius-none",
        "--spacing-0",
        "--spacing-24",
        "--spacing-32",
        "--spacing-48",
        "--spacing-8",
        "--surface-default",
        "--surface-page",
        "--surface-sunken",
        "--text-default",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-h3",
        "--type-h3-line-height"
      ],
      "props": {
        "alignment": {
          "type": "FullCardAlignment | undefined",
          "required": false,
          "origin": "own",
          "description": "Which side the media panel sits on. Matches Figma's `Alignment` variant,\nwhose `Right` / `Left` name the media, not the text.",
          "values": [
            "left",
            "right"
          ]
        },
        "eyebrow": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `Eyebrow` slot — a Badge in the design, but any node works."
        },
        "headline": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "Figma's `Headline`. Required: a card with no headline has no accessible name."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `Description`."
        },
        "actions": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `Actions` slot — a secondary Button in the design."
        },
        "media": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `Media` slot. Rendered inside the framed screen holder, so pass\nthe screenshot or embed itself, not the frame."
        },
        "headingLevel": {
          "type": "FullCardHeadingLevel | undefined",
          "required": false,
          "origin": "own",
          "description": "Heading element for the headline. Defaults to `h3`, matching the `Type/H3`\ntext style Figma applies — but the level is a document-outline decision\nonly the page knows, so it is a prop rather than a constant."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "Grid": {
      "name": "Grid",
      "source": "src/components/Grid.tsx",
      "propsType": "GridProps",
      "description": "Grid — columns that line up across rows, gaps from the spacing scale.\n\nWhere Stack lays things along one line, Grid keeps every row's columns\nthe same width: cards, stat tiles, a main column and its aside, a form in\ntwo columns. Every track is `minmax(0, …)`, so one wide child — a table, a\nlong ID — cannot push the page sideways.\n\nLAYOUT ONLY. It has no role and adds none; pass `as` for the element that\nmeans something. It renders on a server.",
      "import": "import { Grid } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Columns that line up across rows, with gaps from the spacing scale. Reach for it before writing grid CSS: cards and tiles, a main column beside its aside, a form in two columns.",
      "useWhen": [
        "things that should line up in columns across rows — cards, tiles, a set of settings",
        "a main column beside a narrower aside that falls under it on a smaller screen",
        "a form's short fields side by side, one above the other on a phone"
      ],
      "useInstead": [
        {
          "when": "things one after another in a single column or along a single line",
          "use": "Stack",
          "why": "a Stack has no columns to keep in line, and a row of controls sizes each to its content"
        },
        {
          "when": "a set of numbers with their labels at the top of a dashboard",
          "use": "StatGroup",
          "why": "it lays its tiles out and names the group for a screen reader"
        },
        {
          "when": "a label beside its value, row after row",
          "use": "DescriptionList",
          "why": "it is a `dl`: a screen reader hears each term with its value"
        }
      ],
      "composition": {
        "order": [
          "Grid"
        ],
        "note": "`columns` is a count of equal columns (`3`) or each column's share of the row (`[2, 1]`: a main column twice its aside); it defaults to 2. Fixed columns fall to one at `collapse`: `mobile` (767px and below, the default), `tablet` (1023px and below — for a main column and its aside), or `never` (a pair that fits a phone, like two short numbers). `minColumnWidth` (160–360) instead fills each row with as many equal columns as fit, so a set of cards needs no breakpoint at all. `gap` is a step of the spacing scale, 16 by default; `rowGap` sets the rows apart when they need more. Cells in a row stretch to the tallest by default, so cards line up at the bottom too; `align=\"start\"` lets each keep its height. Pass `as=\"ul\"` for a list of cards and give each an `li`. Every column is `minmax(0, …)`, so a wide child — a table, a long ID — scrolls inside its cell rather than widening the page.",
        "example": "<Grid columns={[2, 1]} collapse=\"tablet\" gap={12} align=\"start\">\n  <Card title=\"Activity\">…</Card>\n  <Card title=\"Budgets\">…</Card>\n</Grid>\n\n<Grid as=\"ul\" minColumnWidth={280} aria-label=\"Agents\">\n  {agents.map((a) => <li key={a.id}><Card title={a.name}>…</Card></li>)}\n</Grid>"
      },
      "variants": {
        "collapse": {
          "mobile": {
            "use": "the default — columns that read fine on a tablet: a form's fields in two columns, three small cards"
          },
          "tablet": {
            "use": "a main column and its aside, or columns that need a desktop's width to hold what is in them"
          },
          "never": {
            "use": "columns narrow enough for a phone — two short numbers, a pair of small toggles"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "any",
          "note": "one cell each, in reading order across then down; `li` children when `as` is `ul` or `ol`"
        }
      },
      "a11y": {
        "guarantees": [
          "layout only: it adds no role, so the element given by `as` — or the children — carry the meaning",
          "the visual order is the DOM order: cells are placed across then down, never moved by `order` or explicit placement",
          "no column can push the page sideways: every track is `minmax(0, …)`, and a filling grid's minimum gives way to a screen narrower than it",
          "`as=\"ul\"` stays a list to a screen reader while losing its bullets"
        ],
        "requires": [
          "`as` for a group that means something — a list of cards is a `ul` — rather than a div"
        ],
        "notes": [
          "It renders on a server. A gap off the spacing scale, a column count it does not offer and a minimum width off its steps are type errors, not styles."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a hand-written `display: grid` with a pixel gap and its own media query",
          "do": "Grid with `columns`, `collapse` and a `gap` step",
          "why": "the gap drifts off the scale and the breakpoint off the tokens', and nothing catches either"
        },
        {
          "dont": "fixed columns for a set of cards whose count varies",
          "do": "`minColumnWidth`",
          "why": "the columns then follow the space: three on a desktop, one on a phone, with no breakpoint to choose"
        },
        {
          "dont": "`collapse=\"never\"` on columns that hold text or tables",
          "why": "on a phone each column is a sliver; the words wrap one to a line and a table scrolls in a strip"
        },
        {
          "dont": "a Grid with one column",
          "do": "Stack",
          "why": "a single column is a Stack; the Grid only adds a template nothing uses"
        }
      ],
      "stylesheet": "src/styles/grid.css",
      "tokens": [
        "--ion-grid-columns",
        "--ion-grid-gap",
        "--ion-grid-min",
        "--ion-grid-row-gap",
        "--spacing-0",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-20",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-40",
        "--spacing-48",
        "--spacing-6",
        "--spacing-64",
        "--spacing-8"
      ],
      "props": {
        "columns": {
          "type": "GridColumnCount | readonly GridFraction[] | undefined",
          "required": false,
          "origin": "own",
          "description": "The columns: a count of equal ones (`3`), or each column's share\n(`[2, 1]` — a main column twice its aside). Ignored when\n`minColumnWidth` is set. Defaults to 2."
        },
        "minColumnWidth": {
          "type": "GridMinColumnWidth | undefined",
          "required": false,
          "origin": "own",
          "description": "Fill each row with as many columns as fit at this width or more, each\nthe same width. The count follows the space, so it needs no `collapse`."
        },
        "collapse": {
          "type": "GridCollapse | undefined",
          "required": false,
          "origin": "own",
          "description": "Where fixed columns fall to one. Defaults to `mobile`.",
          "values": [
            "tablet",
            "mobile",
            "never"
          ]
        },
        "gap": {
          "type": "StackGap | undefined",
          "required": false,
          "origin": "own",
          "description": "Space between the columns and the rows, from the spacing scale. Defaults to 16."
        },
        "rowGap": {
          "type": "StackGap | undefined",
          "required": false,
          "origin": "own",
          "description": "Space between the rows alone, when it differs from `gap`."
        },
        "align": {
          "type": "GridAlign | undefined",
          "required": false,
          "origin": "own",
          "description": "How a cell sits in its row. `stretch`, the default, makes every card in a\nrow the same height; `start` lets each keep its own.",
          "values": [
            "start",
            "center",
            "end",
            "stretch"
          ]
        },
        "as": {
          "type": "StackElement | undefined",
          "required": false,
          "origin": "own",
          "description": "The element — a `ul` for a list of cards. Grid is layout only.",
          "values": [
            "form",
            "div",
            "span",
            "section",
            "nav",
            "ul",
            "ol",
            "li",
            "header",
            "footer",
            "fieldset"
          ]
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Header": {
      "name": "Header",
      "source": "src/components/Header.tsx",
      "propsType": "HeaderProps",
      "description": "Header renders Figma's four Device variants — Desktop, Tablet,\nMobile-Closed, Mobile-Open — from one DOM tree.\n\nTWO AXES, AND THEY ARE NOT THE SAME KIND OF THING\n\nFigma spells Device as a single four-way variant, but it is really two: a\n*breakpoint* (Desktop / Tablet / Mobile) and a *state* (Closed / Open). They\nare modelled differently here because they are known by different people.\n\n  Breakpoint is a media query. It is the one variant axis in the system the\n  browser already knows the answer to — a header is Mobile because the\n  viewport is narrow — and having React duplicate that judgement is how the\n  two drift apart. Breakpoints match the Breakpoint collection's container\n  widths: Tablet below 1216, Mobile below 896.\n\n  Open/Closed is a prop, because only the caller knows. It follows the\n  controlled/uncontrolled pair the rest of the system uses.\n\nONE TREE, TWO LAYOUTS\n\n`center` and `end` sit inline in the bar on Desktop and Tablet, and inside\nthe dropped Menu-Container on Mobile-Open. They are rendered once, in one\nwrapper, which is `display: contents` above the mobile breakpoint and an\nabsolutely positioned panel below it. Rendering them twice — or moving them\nwith JavaScript — would mean the same nav link exists twice in the\naccessibility tree, and would reset any state a caller put in a slot every\ntime the viewport crossed 896px.\n\nThe centre slot is no longer hidden on Tablet. Figma's Tablet variant ships\nit populated; the previous release collapsed it, which was correct for the\nprevious design and is not for this one.\n\nA `<header>` element with no explicit role: it is a landmark already when it\nis a direct child of body, which is where a page header sits.",
      "import": "import { Header } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The page header bar. Renders Figma's Desktop, Tablet, Mobile-Closed and Mobile-Open from one DOM tree.",
      "useWhen": [
        "the app needs a top bar with a brand, navigation and actions",
        "that bar must collapse to a mobile menu below 896px"
      ],
      "useInstead": [
        {
          "when": "it is a sidebar rather than a top bar",
          "use": "NavItem",
          "why": "Header owns a horizontal bar and its own breakpoint behaviour"
        }
      ],
      "slots": {
        "brand": {
          "accepts": "LogoMark or Logo",
          "note": "rendered at the start; never shrinks"
        },
        "center": {
          "accepts": "NavItem",
          "note": "inline on Desktop and Tablet; moves into the mobile menu below 896px. Rendered once, so slot state survives crossing the breakpoint"
        },
        "end": {
          "accepts": "Button, Avatar",
          "note": "actions; same placement rules as `center`"
        },
        "children": {
          "accepts": "any",
          "note": "escape hatch — rendered in the bar after the menu wrapper and before the mobile toggle. Prefer the named slots"
        }
      },
      "a11y": {
        "role": "banner, from the <header> element when it is a direct child of body",
        "guarantees": [
          "the mobile toggle carries `aria-expanded` and `aria-controls` pointing at the menu container",
          "Escape closes the open mobile menu — and the listener sits on the header, so a header that never opens never listens",
          "the slots are rendered once, so no nav link exists twice in the accessibility tree",
          "with `menuType=\"dialog\"` the toggle carries `aria-haspopup=\"dialog\"` instead of `aria-controls` — the Drawer it opens is not in the DOM while closed — and comes first in DOM order as well as on screen"
        ],
        "requires": [
          "`menuLabel` — the mobile toggle is icon-only and has no other name"
        ],
        "notes": [
          "This is a disclosure, not a modal: no focus trap and no scroll lock, so Escape is the whole dismissal contract."
        ]
      },
      "antiPatterns": [
        {
          "dont": "rendering a second copy of the nav for mobile",
          "why": "Header already moves the one copy; two copies duplicate every link in the accessibility tree"
        },
        {
          "dont": "driving the mobile layout from JS state",
          "why": "the breakpoint is a media query — the browser already knows the answer, and duplicating that judgement is how the two drift"
        },
        {
          "dont": "passing `open` without `onOpenChange`",
          "why": "controlled with no handler means the toggle can never change it"
        },
        {
          "dont": "a second menu button in `brand` to open a navigation Drawer",
          "do": "<Header menuType=\"dialog\" open={navOpen} onOpenChange={setNavOpen} />",
          "why": "the built-in toggle still renders, so a phone shows two menu buttons that open different things"
        },
        {
          "dont": "NavItems in `center` with `menuType=\"dialog\"`",
          "why": "nothing collapses them any more — they overflow a phone, and they duplicate the Sidebar the dialog opens"
        }
      ],
      "variants": {
        "menuType": {
          "panel": {
            "use": "the default, Figma's Mobile-Open — `center` and `end` drop into a panel below the bar"
          },
          "dialog": {
            "use": "the app's navigation is a Sidebar. The toggle moves to the start of the bar and only reports `onOpenChange`; open a Drawer holding the same Sidebar with it. `center` and `end` stay in the bar at every width"
          }
        }
      },
      "stylesheet": "src/styles/header.css",
      "tokens": [
        "--border-default",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--icon-default",
        "--icon-size-sm",
        "--ion-header-center-gap",
        "--ion-header-end-gap",
        "--ion-header-height",
        "--ion-header-padding-x",
        "--ion-header-padding-y",
        "--ion-shadow-raised-lifted-xs",
        "--radius-md",
        "--radius-sm",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-48",
        "--spacing-64",
        "--spacing-8",
        "--surface-default"
      ],
      "props": {
        "brand": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's Logo slot. Rendered at the start, never shrinks."
        },
        "center": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `Center Slot` — nav items. Inline on Desktop and Tablet; moves\ninto the Mobile-Open menu below the mobile breakpoint."
        },
        "end": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `End Slot` — buttons and actions. Same placement rules as `center`."
        },
        "open": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled Mobile-Open state. Pair with `onOpenChange`."
        },
        "defaultOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Initial Mobile-Open state when uncontrolled."
        },
        "onOpenChange": {
          "type": "((open: boolean) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires whenever the menu toggle changes the state, controlled or not."
        },
        "menuLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible name for the mobile menu toggle."
        },
        "menuType": {
          "type": "\"dialog\" | \"panel\" | undefined",
          "required": false,
          "origin": "own",
          "description": "What the mobile toggle opens.\n\n`panel` (default) is Figma's Mobile-Open: `center` and `end` drop below the\nbar. `dialog` is for an app whose navigation lives in a Sidebar: the toggle\nsits at the start of the bar and only reports `onOpenChange`, the caller\nopens a Drawer with it, and `center` and `end` stay in the bar at every\nwidth — so keep `center` empty and `end` short.",
          "values": [
            "dialog",
            "panel"
          ]
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 9,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "I18nProvider": {
      "name": "I18nProvider",
      "source": "../../node_modules/.pnpm/react-aria@3.50.0_react-dom@19.2.8_react@19.2.8__react@19.2.8/node_modules/react-aria/dist/types/src/i18n/I18nProvider.d.ts",
      "propsType": "I18nProviderProps",
      "description": "Provides the locale for the application to all child components.",
      "import": "import { I18nProvider } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Sets the locale every IonBase component reads: react-aria's provider, re-exported. For right-to-left it is half the switch — arrow keys follow the locale, while the layout follows `dir` on the page.",
      "useWhen": [
        "the app is in a language other than the browser's, or must not depend on it",
        "the app is right-to-left: an RTL locale here, and `dir=\"rtl\"` on the root element"
      ],
      "useInstead": [
        {
          "when": "one region should be in the other theme",
          "use": "ThemeZone",
          "why": "a locale is language and direction; a theme is colour"
        }
      ],
      "composition": {
        "order": [
          "I18nProvider"
        ],
        "note": "Mount it once, around the whole app, with the locale the product is written in (`he-IL`, `ar-EG`, `en-GB`). With no `locale`, react-aria falls back to the browser's language. It decides the arrow keys in Tabs, Toolbar, Menu, TreeView, Slider and the calendars, the order of date segments, and number formatting; it does not set `dir`. Right-to-left is both: `dir=\"rtl\"` on <html> for the layout — every stylesheet is logical, so nothing else is needed — and an RTL locale here for the keys. Set only one and the page is mirrored but ← still moves back, or the keys flip on a page that is not mirrored.",
        "example": "<html lang=\"he\" dir=\"rtl\">\n  <body>\n    <I18nProvider locale=\"he-IL\">\n      <App />\n    </I18nProvider>\n  </body>\n</html>"
      },
      "slots": {
        "children": {
          "accepts": "any",
          "note": "the whole app"
        }
      },
      "a11y": {
        "guarantees": [
          "with an RTL locale, arrow keys move in the reading direction: ← goes forward along a toolbar, a tab list or a slider",
          "react-aria's own strings — a calendar's Previous and Next, a date field's segment names — are announced in the locale's language"
        ],
        "requires": [
          "`dir` on the root element to match the locale's direction, and `lang` to match the content's language"
        ],
        "notes": [
          "`useLocale()` is re-exported with it and returns `{ locale, direction }`, for a component of the app's own that has to know."
        ]
      },
      "antiPatterns": [
        {
          "dont": "`dir=\"rtl\"` without an RTL locale",
          "do": "both, set together",
          "why": "the page mirrors, but react-aria's arrow keys still move as if left-to-right: → steps back through a toolbar that now reads right to left"
        },
        {
          "dont": "one provider per component or per page",
          "why": "the locale is the app's; nested providers let parts of a page disagree about the language and the direction"
        }
      ],
      "stylesheet": null,
      "tokens": [],
      "props": {
        "children": {
          "type": "ReactNode",
          "required": true,
          "origin": "aria",
          "description": "Contents that should have the locale applied."
        },
        "locale": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The locale to apply to the children."
        }
      },
      "propCounts": {
        "own": 0,
        "aria": 2,
        "dom": 0,
        "other": 0
      }
    },
    "Icon": {
      "name": "Icon",
      "source": "src/Icon.tsx",
      "propsType": "IconProps",
      "description": "Wrapper that applies the design system's icon sizing and accessibility\ndefaults to whatever icon component you hand it.\n\n  import { Plus } from 'lucide-react';\n  <Icon as={Plus} size=\"sm\" />\n  <Icon as={Plus} label=\"Add item\" />   // meaningful, gets an a11y name\n\nTakes the icon as a prop rather than re-exporting a set: a barrel of a\nthousand-plus icons defeats tree-shaking in several bundlers, and pinning one\nicon library would force it on every consumer. You import the one icon you\nneed, from whichever library you use, and it is the only one bundled.",
      "import": "import { Icon } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Applies the system's icon sizing and accessibility defaults to any SVG component you hand it. The package ships no icon set.",
      "useWhen": [
        "rendering any icon inside this design system, from any library — lucide, heroicons, react-icons, your own SVG"
      ],
      "useInstead": [
        {
          "when": "the graphic is a brand mark",
          "use": "Logo or LogoMark"
        },
        {
          "when": "you need a coloured status marker with text",
          "use": "Badge"
        }
      ],
      "a11y": {
        "guarantees": [
          "with `label`, the icon gets `role=\"img\"` and that name",
          "without `label`, it is `aria-hidden` and `focusable=\"false\"` — decoration, never read twice"
        ],
        "requires": [
          "`label` when the icon carries meaning on its own — an icon-only button, a standalone status marker"
        ],
        "notes": [
          "Omit `label` for an icon beside a visible text label. The text already names it."
        ]
      },
      "antiPatterns": [
        {
          "dont": "`<Button startIcon={<Icon as={Plus} label=\"Add\" />}>Add</Button>`",
          "do": "drop the `label`",
          "why": "the button's own text names it; the labelled icon makes it announce twice"
        },
        {
          "dont": "hardcoding `width`/`height` in px",
          "do": "a `size` rung, or omit `size` to inherit the surrounding font size",
          "why": "inheriting is what lets an icon sit correctly inside a Button without the Button knowing about it"
        },
        {
          "dont": "assuming `size=\"xs\"` is 12px",
          "why": "the ladder shifted in 2026-08: `xs` is 14, and `2xs` is the old 12. This raises no type error"
        }
      ],
      "sizes": {
        "note": "The rungs map one-to-one onto the Semantics `icon-size/*` tokens. Any CSS length is also accepted for a one-off.",
        "ladder": {
          "2xs": 12,
          "xs": 14,
          "sm": 16,
          "md": 20,
          "lg": 24,
          "xl": 32
        }
      },
      "stylesheet": "src/styles/icon.css",
      "tokens": [],
      "props": {
        "as": {
          "type": "IconComponent",
          "required": true,
          "origin": "own",
          "description": "The icon component to render, e.g. `import { Plus } from 'lucide-react'`.\nAny SVG component works — the design system does not ship an icon set."
        },
        "size": {
          "type": "\"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xs\" | \"xs\" | (string & {}) | undefined",
          "required": false,
          "origin": "own",
          "description": "A rung on the `icon-size` ladder — `2xs` 12, `xs` 14, `sm` 16, `md` 20,\n`lg` 24, `xl` 32 — or any CSS length for a one-off. Omit to inherit the surrounding\nfont size, which is what lets an icon sit correctly inside a Button without\nthe Button having to know anything about this component."
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible name. Provide it when the icon carries meaning on its own — an\nicon-only button, a status marker. Omit it for decoration next to a visible\nlabel, and the icon is hidden from assistive tech instead of read out twice."
        }
      },
      "propCounts": {
        "own": 3,
        "aria": 0,
        "dom": 487,
        "other": 0
      }
    },
    "InlineEdit": {
      "name": "InlineEdit",
      "source": "src/components/InlineEdit.tsx",
      "propsType": "InlineEditProps",
      "description": "InlineEdit — a value that becomes a field where it stands: an agent's\npurpose under its name, a row's label. Edit, change, Save or Cancel.\n\nTHE VALUE IS TEXT, AND EDIT IS A BUTTON. In view mode the value is plain\ntext, read as text; beside it is one tab stop, \"Edit purpose\". Clicking the\ntext also opens the editor, for a pointer. A value that is itself a button\nwould be read as \"Edit purpose, button\" with the purpose lost.\n\nFOCUS GOES IN, THEN BACK. Opening the editor focuses the field with its\ntext selected; Save and Cancel return focus to the Edit button, not to the\ntop of the page. Escape cancels from anywhere in the editor, and goes no\nfurther — a dialog it sits in does not close as well.\n\nNOTHING TYPED IS LOST. An invalid value, or a save that fails, keeps the\neditor open with the message as the field's error and what was typed still\nin it. While a save runs the field is read-only and Save says \"Saving…\";\nneither is disabled, so focus stays where it was.\n\nSAVED IS SAID. A status region, always mounted, announces \"Purpose saved\" —\nthe editor closing is not, on its own, news to a screen reader.",
      "import": "import { InlineEdit } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A value that becomes a field where it stands — an agent's purpose under its name, a row's label. The value is text with an Edit button beside it; editing shows the field with Save and Cancel. Enter saves, Escape cancels, focus goes in and comes back, and a failed or invalid save keeps what was typed.",
      "useWhen": [
        "one value on a page that is read far more often than it is changed — a name, a description, a label",
        "changing it should not take the user to a settings page or a dialog"
      ],
      "useInstead": [
        {
          "when": "several values are changed and saved together",
          "use": "Input",
          "why": "a form with one Save is clearer than several editors each saving on their own"
        },
        {
          "when": "the change needs a warning or a second look — deleting, transferring ownership",
          "use": "Modal",
          "why": "an inline Save is one keystroke; a consequential change deserves a confirmation"
        },
        {
          "when": "a switch that applies at once",
          "use": "Toggle",
          "why": "there is nothing to type, so there is nothing to save or cancel"
        }
      ],
      "composition": {
        "order": [
          "InlineEdit"
        ],
        "note": "`label` is required: it names the field and the Edit button. Pass `value` and `onSave` to keep the value yourself; return a promise from `onSave` and throw to report a failure. `validate` runs before `onSave`. `placeholder` stands in for an empty value — \"Add a purpose\". `isMultiline` makes it a Textarea: Enter adds a line and ⌘/Ctrl+Enter saves.",
        "example": "<InlineEdit label=\"Purpose\" editLabel=\"Edit purpose\" value={purpose} onSave={savePurpose} validate={(v) => (v.trim() ? undefined : 'Say what the agent is for.')} />"
      },
      "variants": {
        "size": {
          "md": {
            "use": "the default — a medium field, beside body text"
          },
          "sm": {
            "use": "a small field, in a dense row or a table cell"
          }
        }
      },
      "a11y": {
        "requires": [
          "`label` — the name of the value, translated",
          "`editLabel` when \"Edit\" plus the label does not read naturally — \"Edit purpose\", not \"Edit Purpose\""
        ],
        "guarantees": [
          "the value is plain text, read as text; the Edit button is one tab stop beside it",
          "opening the editor focuses the field with its text selected",
          "Save and Cancel return focus to the Edit button",
          "Escape cancels from the field or either button, and does not reach a dialog around it",
          "an invalid value or a failed save keeps the editor open, with the message as the field's error and what was typed kept",
          "while saving, the field is read-only and Save says Saving… — nothing is disabled, so focus stays put",
          "a save is announced in a status region that is always mounted"
        ],
        "notes": [
          "saving an unchanged value just closes the editor, without calling `onSave`",
          "nothing happens on blur: leaving the field does not save or throw away what was typed",
          "the value inherits the text around it — a page description, a table cell — so it looks like the text it replaces; `size` is the field's"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<button onClick={edit}>{purpose}</button>",
          "do": "<InlineEdit label=\"Purpose\" value={purpose} onSave={save} />",
          "why": "a value that is itself the button is announced as a button, and a sighted user has no sign it can be edited"
        },
        {
          "dont": "saving on blur",
          "do": "Save and Cancel, with Enter and Escape",
          "why": "tabbing away to check something commits a half-typed value"
        }
      ],
      "stylesheet": "src/styles/inline-edit.css",
      "tokens": [
        "--spacing-4",
        "--spacing-8",
        "--text-secondary"
      ],
      "props": {
        "label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "What the value is — \"Purpose\", \"Agent name\". Required: it names the\nfield while editing, and the edit button is \"Edit\" plus it."
        },
        "value": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The saved value, when the caller keeps it."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The starting value, when the component keeps it."
        },
        "onSave": {
          "type": "((value: string) => void | Promise<void>) | undefined",
          "required": false,
          "origin": "own",
          "description": "Called with the new value on Save. Return a promise to show \"Saving…\"\nwhile it runs; if it rejects, the editor stays open with the error's\nmessage, and what was typed is kept."
        },
        "onCancel": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Called when editing is cancelled — Cancel, or Escape."
        },
        "validate": {
          "type": "((value: string) => string | undefined | null) | undefined",
          "required": false,
          "origin": "own",
          "description": "Checked on Save, before `onSave`. Return the message to show, or nothing\nwhen the value is fine."
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Shown, in secondary text, when there is no value — \"Add a purpose\"."
        },
        "isMultiline": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "A Textarea instead of an Input. Enter adds a line; ⌘/Ctrl+Enter saves."
        },
        "size": {
          "type": "InlineEditSize | undefined",
          "required": false,
          "origin": "own",
          "description": "The field's size: `sm` in a dense row, `md` beside body text. The value\nitself inherits the text around it.",
          "values": [
            "sm",
            "md"
          ]
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Shows the value with no way to edit it."
        },
        "editLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The edit button's name. Defaults to \"Edit\" and the label."
        },
        "saveLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "savingLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "cancelLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "savedMessage": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Announced after a save. Defaults to the label and \"saved\"."
        },
        "errorFallback": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Shown when `onSave` rejects without a message of its own."
        }
      },
      "propCounts": {
        "own": 16,
        "aria": 0,
        "dom": 275,
        "other": 0
      }
    },
    "InlineLoading": {
      "name": "InlineLoading",
      "source": "src/components/InlineLoading.tsx",
      "propsType": "InlineLoadingProps",
      "description": "InlineLoading — the pending → done → failed of one action, in place: beside\nthe switch that was flipped, the button that was pressed.\n\nTHE REGION IS ALWAYS THERE. A live region announces a change to its\ncontent, not its own arrival: `{saving && <span role=\"status\">Saving…</span>}`\nis inserted with its words already in it, and most screen readers say\nnothing. So this renders its `role=\"status\"` span in every state, empty\nwhile `inactive`, and only its words change. Mount it with the control.\n\nWORDS, NOT A SPINNER. Each state is StatusIndicator's shape and a word — a\nspinning arc and \"Saving…\", a check and \"Saved\", an octagon and \"Not\nsaved\" — so the outcome reaches someone who cannot see the colour, or the\nscreen. The shape is hidden; the word is what is announced, politely: the\nuser is waiting for it, and an interruption would cut off their own\nscreen reader's echo of the press.\n\n\"SAVED\" GOES AWAY BY ITSELF. `onSuccess` fires `successDelay` after\n`finished` so the caller can go back to `inactive`. An error stays until\nthe next attempt: a failure that vanishes was never reported.\n\nIT DOES NOT TOUCH THE CONTROL. Keep the control enabled while it is\nactive and ignore presses instead: disabling the switch a keyboard user just\npressed throws their focus to the top of the page.",
      "import": "import { InlineLoading } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The pending → done → failed of one action, in place — \"Saving…\", \"Saved\", \"Not saved\" beside the switch that was flipped or the button that was pressed. A live region that is always mounted, around StatusIndicator's arc, check and octagon.",
      "useWhen": [
        "one control saves as soon as it is used — a switch, a row's inline edit — and the user needs to see that it worked",
        "a button's action takes a moment and its result belongs beside it, not in a toast across the screen"
      ],
      "useInstead": [
        {
          "when": "the whole region is loading — a table, a card, a page",
          "use": "Skeleton",
          "why": "it holds the layout of what is coming; InlineLoading is the state of one action"
        },
        {
          "when": "the wait has no outcome to report — a panel opening, a search running",
          "use": "Spinner",
          "why": "InlineLoading's point is the \"Saved\" or \"Not saved\" that follows"
        },
        {
          "when": "the result is about something off screen, or the user has moved on — a report finished, an export ready",
          "use": "Toast",
          "why": "InlineLoading speaks from where the action was taken"
        },
        {
          "when": "the status of each row, as data",
          "use": "StatusIndicator",
          "why": "it is not a live region; fifty rows of InlineLoading would announce fifty times"
        }
      ],
      "composition": {
        "order": [
          "InlineLoading",
          "Toggle"
        ],
        "note": "Mount it with the control and change `status` — never mount it when the save starts. Set `finished` on success and go back to `inactive` in `onSuccess`; leave `error` until the next attempt, with the detail in an Alert. Keep the control enabled while `active` and ignore presses.",
        "example": "<InlineLoading status={status} onSuccess={() => setStatus('inactive')} />"
      },
      "variants": {
        "status": {
          "inactive": {
            "use": "nothing to say — an empty live region, waiting"
          },
          "active": {
            "use": "the default — under way: a spinning arc and \"Saving…\""
          },
          "finished": {
            "use": "it worked: a check and \"Saved\", until `onSuccess`"
          },
          "error": {
            "use": "it failed: an octagon and \"Not saved\", until the next attempt"
          }
        },
        "size": {
          "sm": {
            "use": "the default — beside a small control or in a row; 16px"
          },
          "md": {
            "use": "beside body text; 20px"
          }
        }
      },
      "a11y": {
        "requires": [
          "keep it mounted: the live region must exist before its words change",
          "`activeLabel`, `finishedLabel` and `errorLabel` — translated, and specific when \"Saving…\" is not what happens"
        ],
        "guarantees": [
          "`role=\"status\"` is rendered in every state, so each change of words is announced, politely",
          "each state has its own shape and word, so the outcome is not carried by colour alone (WCAG 1.4.1)",
          "the shape is hidden from assistive tech; only the word is announced",
          "finished calls `onSuccess` after `successDelay`, and a re-render does not restart the delay",
          "an error stays until the status changes"
        ],
        "notes": [
          "the region names nothing — it says what happened to the thing the user just pressed, so put it beside that thing",
          "the in-progress arc stops under `prefers-reduced-motion`; its shape alone says under way"
        ]
      },
      "antiPatterns": [
        {
          "dont": "{saving && <InlineLoading />}",
          "do": "<InlineLoading status={saving ? 'active' : 'inactive'} />",
          "why": "a live region inserted with its words already in it is usually not announced at all"
        },
        {
          "dont": "<Toggle isDisabled={saving} />",
          "do": "<Toggle onSelectionChange={(on) => { if (!saving) save(on); }} />",
          "why": "disabling the switch a keyboard user just pressed moves their focus to the top of the page"
        },
        {
          "dont": "a green check and nothing else when it saves",
          "do": "<InlineLoading status=\"finished\" />",
          "why": "a check alone is silent to a screen reader and only a colour to some sighted users"
        }
      ],
      "stylesheet": "src/styles/inline-loading.css",
      "tokens": [],
      "props": {
        "status": {
          "type": "InlineLoadingStatus | undefined",
          "required": false,
          "origin": "own",
          "description": "Where the action is: `inactive` shows nothing, `active` is under way,\n`finished` succeeded, `error` failed. Keep the component mounted and\nchange this — the region has to exist before its words change, or the\nchange is not announced.",
          "values": [
            "active",
            "error",
            "inactive",
            "finished"
          ]
        },
        "activeLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Shown and announced while `active`."
        },
        "finishedLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Shown and announced when `finished`."
        },
        "errorLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Shown and announced on `error`. Say what failed, briefly; the detail belongs in an Alert."
        },
        "onSuccess": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Called `successDelay` ms after `finished`, usually to set the status back\nto `inactive` — the \"Saved\" has been seen and read by then."
        },
        "successDelay": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "How long \"finished\" stays before `onSuccess`."
        },
        "size": {
          "type": "StatusIndicatorSize | undefined",
          "required": false,
          "origin": "own",
          "description": "`sm` beside a small control or in a row; `md` beside body text.",
          "values": [
            "sm",
            "md"
          ]
        }
      },
      "propCounts": {
        "own": 7,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "Input": {
      "name": "Input",
      "source": "src/components/Input.tsx",
      "propsType": "InputProps",
      "description": "Input covers both Figma components on the Input page: the field itself\n(`Input`, 80:275) and the label/helper stack around it (`Form Field`,\n80:330). They are separate there because a Figma component cannot make a\nslot optional without a boolean; here the wrapper simply isn't rendered when\nthere is nothing to put in it.\n\n`useTextField` owns the wiring that is tedious and easy to get subtly wrong:\nit generates the ids, points `aria-labelledby` at the label and\n`aria-describedby` at whichever of the helper or error message is actually\nshowing, and sets `aria-invalid`. Hand-rolling that is where form fields\nusually lose their accessibility.\n\nFigma's seven states map to three props and three CSS states. Hover and Focus\nare interaction; Filled is the placeholder distinction, which CSS already\nexpresses; Default, Invalid, Disabled and Read-only are props.",
      "import": "import { Input } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A single-line text field, together with its label, helper text and error message.",
      "useWhen": [
        "collecting one line of free text",
        "the field needs a label, helper text, or validation messaging"
      ],
      "useInstead": [
        {
          "when": "the user picks from a fixed set of options",
          "use": "Select"
        },
        {
          "when": "it is a phone number",
          "use": "PhoneInput",
          "why": "country code and formatting are handled for you"
        },
        {
          "when": "it is a single boolean",
          "use": "Checkbox or Toggle"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms, table filters, toolbars"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "prominent single-field forms — search, sign-in"
          }
        }
      },
      "slots": {
        "leadingIcon": {
          "accepts": "Icon",
          "note": "decorative; omit Icon's `label`"
        },
        "trailingIcon": {
          "accepts": "Icon",
          "note": "decorative unless it is interactive, in which case it needs its own button and name"
        },
        "leadingAddon": {
          "note": "a static prefix such as a protocol or currency; not focusable"
        }
      },
      "a11y": {
        "guarantees": [
          "ids are generated and wired — the label points at the input via aria-labelledby",
          "aria-describedby points at whichever of the helper text or error message is actually showing",
          "aria-invalid is set from isInvalid"
        ],
        "requires": [
          "`label`, or `aria-label` when the field is visually self-evident and no label is rendered"
        ],
        "notes": [
          "The label/helper/error wrapper is only rendered when there is something to put in it — Figma models it as a separate `Form Field` component, this does not."
        ]
      },
      "antiPatterns": [
        {
          "dont": "using `placeholder` as the label",
          "do": "pass `label`",
          "why": "the placeholder disappears on focus, so the user loses the field's name exactly when typing"
        },
        {
          "dont": "rendering your own <label htmlFor>",
          "do": "pass `label`",
          "why": "the component generates the id; an external htmlFor will not match it"
        },
        {
          "dont": "showing an error by putting the message in `description`",
          "do": "set `isInvalid` and pass `errorMessage`",
          "why": "only errorMessage drives aria-invalid and the described-by wiring"
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        }
      ],
      "stylesheet": "src/styles/input.css",
      "tokens": [
        "--border-default",
        "--border-disabled",
        "--border-error-strong",
        "--border-focus",
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-disabled",
        "--icon-interactive",
        "--icon-size-md",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-input-border-width",
        "--ion-input-font-size",
        "--ion-input-gap",
        "--ion-input-height",
        "--ion-input-icon-size",
        "--ion-input-line-height",
        "--ion-input-padding-x",
        "--ion-input-radius",
        "--radius-md",
        "--radius-sm",
        "--spacing-12",
        "--spacing-16",
        "--spacing-32",
        "--spacing-40",
        "--spacing-48",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-page",
        "--text-disabled",
        "--text-error",
        "--text-secondary",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "size": {
          "type": "InputSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "autoCapitalize": {
          "type": "\"none\" | \"off\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | undefined",
          "required": false,
          "origin": "own",
          "description": "Narrower than React's own typing, which also allows any `string`.\n`autoCapitalize` is declared on `AriaTextFieldOptions` rather than\n`AriaTextFieldProps`, so `keyof AriaTextFieldProps` above cannot omit it —\nand the wide DOM version does not satisfy what `useTextField` accepts.",
          "values": [
            "none",
            "off",
            "on",
            "sentences",
            "words",
            "characters"
          ]
        },
        "leadingIcon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Leading icon. Figma's `Show Leading Icon` + `Leading Icon` swap."
        },
        "trailingIcon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Trailing icon. Figma's `Show Trailing Icon` + `Trailing Icon` swap."
        },
        "leadingAddon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "A segment butted against the left of the control, sharing its outline —\nFigma's `Input/Phone` (80:372) country-code block.\n\nDistinct from `leadingIcon`, which sits *inside* the box: an addon has its\nown surface and border and is a sibling of the control, so the two cannot\nbe the same slot. It lives here rather than in `PhoneInput` because the\n`.ion-field` wrapper has to enclose the addon and the control together —\nbuilding it outside would put the label above only half the control and\nbreak the `aria-labelledby` wiring `useTextField` sets up.\n\nPhone is the only addon Figma specifies. This is a slot, not a system:\nresist growing a trailing counterpart or an addon variant ladder until a\ndesign actually calls for one."
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the control box (`.ion-input`). Always lands here —\nnever on the form-field wrapper. Use `wrapperClassName` for that."
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the `.ion-field` wrapper when a label or helper is shown."
        },
        "aria-activedescendant": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the currently active element when DOM focus is on a composite widget, textbox,\ngroup, or application."
        },
        "aria-autocomplete": {
          "type": "\"inline\" | \"list\" | \"none\" | \"both\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates whether inputting text could trigger display of one or more predictions of the user's\nintended value for an input and specifies how predictions would be presented if they are made.",
          "values": [
            "inline",
            "list",
            "none",
            "both"
          ]
        },
        "aria-haspopup": {
          "type": "boolean | \"true\" | \"false\" | \"menu\" | \"listbox\" | \"tree\" | \"grid\" | \"dialog\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates the availability and type of interactive popup element, such as menu or dialog, that\ncan be triggered by an element."
        },
        "aria-controls": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) whose contents or presence are controlled by the current\nelement."
        },
        "enterKeyHint": {
          "type": "\"done\" | \"enter\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "An enumerated attribute that defines what action label or icon to preset for the enter key on\nvirtual keyboards. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint).",
          "values": [
            "done",
            "enter",
            "go",
            "next",
            "previous",
            "search",
            "send"
          ]
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input is disabled."
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input can be selected but not changed by the user."
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether user input is required on the input before form submission."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input value is invalid."
        },
        "validationState": {
          "type": "ValidationState | undefined",
          "required": false,
          "origin": "aria",
          "values": [
            "valid",
            "invalid"
          ],
          "tags": {
            "deprecated": "Use `isInvalid` instead."
          }
        },
        "validationBehavior": {
          "type": "\"aria\" | \"native\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to use native HTML form validation to prevent form submission\nwhen the value is missing or invalid, or mark the field as required\nor invalid via ARIA.",
          "values": [
            "aria",
            "native"
          ],
          "tags": {
            "default": "'aria'"
          }
        },
        "validate": {
          "type": "((value: string) => ValidationError | true | null | undefined) | undefined",
          "required": false,
          "origin": "aria",
          "description": "A function that returns an error message if a given value is invalid.\nValidation errors are displayed to the user when the form is submitted\nif `validationBehavior=\"native\"`. For realtime validation, use the `isInvalid`\nprop instead."
        },
        "description": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "A description for the field. Provides a hint such as specific requirements for what to choose."
        },
        "errorMessage": {
          "type": "ReactNode | ((v: ValidationResult) => ReactNode)",
          "required": false,
          "origin": "aria",
          "description": "An error message for the field."
        },
        "autoFocus": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the element should receive focus on render."
        },
        "onFocus": {
          "type": "((e: FocusEvent<HTMLInputElement, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element receives focus."
        },
        "onBlur": {
          "type": "((e: FocusEvent<HTMLInputElement, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element loses focus."
        },
        "onFocusChange": {
          "type": "((isFocused: boolean) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element's focus status changes."
        },
        "onKeyDown": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is pressed."
        },
        "onKeyUp": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is released."
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Temporary text that occupies the text input when it is empty."
        },
        "value": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The current value (controlled)."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The default value (uncontrolled)."
        },
        "onChange": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the value changes."
        },
        "label": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "The content to display as the label."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        },
        "excludeFromTabOrder": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to exclude the element from the sequential tab order. If true,\nthe element will not be focusable via the keyboard by tabbing. This should\nbe avoided except in rare scenarios where an alternative means of accessing\nthe element or its functionality via the keyboard is available."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The element's unique identifier. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id)."
        },
        "autoComplete": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Describes the type of autocomplete functionality the input should provide if any. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete)."
        },
        "maxLength": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The maximum number of characters supported by the input. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefmaxlength)."
        },
        "minLength": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The minimum number of characters required by the input. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefminlength)."
        },
        "pattern": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Regex pattern that the value of the input must match to be valid. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefpattern)."
        },
        "type": {
          "type": "(string & {}) | \"search\" | \"text\" | \"url\" | \"tel\" | \"email\" | \"password\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "The type of input to render. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdeftype).",
          "tags": {
            "default": "'text'"
          }
        },
        "inputMode": {
          "type": "\"none\" | \"search\" | \"text\" | \"url\" | \"tel\" | \"email\" | \"numeric\" | \"decimal\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Hints at the type of data that might be entered by the user while editing the element or its\ncontents. See\n[MDN](https://html.spec.whatwg.org/multipage/interaction.html#input-modalities:-the-inputmode-attribute).",
          "values": [
            "none",
            "search",
            "text",
            "url",
            "tel",
            "email",
            "numeric",
            "decimal"
          ]
        },
        "autoCorrect": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An attribute that takes as its value a space-separated string that describes what, if any, type\nof autocomplete functionality the input should provide. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#autocomplete)."
        },
        "spellCheck": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An enumerated attribute that defines whether the element may be checked for spelling errors.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck)."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The name of the input element, used when submitting an HTML form. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname)."
        },
        "form": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The `<form>` element to associate the input with.\nThe value of this attribute must be the id of a `<form>` in the same document.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form)."
        },
        "onCopy": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user copies text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncopy)."
        },
        "onCut": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user cuts text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncut)."
        },
        "onPaste": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user pastes text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/onpaste)."
        },
        "onCompositionStart": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system starts a new text composition session.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionstart_event)."
        },
        "onCompositionEnd": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system completes or cancels the current text\ncomposition session. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event)."
        },
        "onCompositionUpdate": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a new character is received in the current text composition\nsession. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionupdate_event)."
        },
        "onSelect": {
          "type": "ReactEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when text in the input is selected. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/select_event)."
        },
        "onBeforeInput": {
          "type": "FormEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is about to be modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/beforeinput_event)."
        },
        "onInput": {
          "type": "FormEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event)."
        },
        "aria-errormessage": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element that provides an error message for the object."
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 51,
        "dom": 265,
        "other": 0
      }
    },
    "Kbd": {
      "name": "Kbd",
      "source": "src/components/Kbd.tsx",
      "propsType": "KbdProps",
      "description": "Kbd — a key, or a keyboard shortcut, as printed on the keyboard.\n\n`<kbd>` is the element HTML has for this, and a combination is a `<kbd>` of\n`<kbd>`s — the spec's own shape for \"press these together\".\n\nWHAT A SCREEN READER HEARS\n\nThe symbols do not survive being read aloud: VoiceOver says \"⌘\" as \"place of\ninterest sign\" in some voices and \"⇧\" as \"upwards white arrow\". So a\nshortcut's drawn keys are hidden and a visually hidden copy spells them out\n— \"Command K\", \"Control Shift P\".",
      "import": "import { Kbd } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.81.0",
      "summary": "A key, or a keyboard shortcut, drawn as keycaps in the reader's platform's own symbols and read aloud by name.",
      "useWhen": [
        "telling people a shortcut exists — beside a button, in a tooltip, in a command palette row",
        "instructions that name a key: \"press Esc to cancel\""
      ],
      "useInstead": [
        {
          "when": "the text is code, a file name or a value",
          "use": "render it directly",
          "why": "Kbd means keys to press; `<code>` is the element for code"
        }
      ],
      "a11y": {
        "guarantees": [
          "a combination is a <kbd> of <kbd>s, the HTML spec's shape for keys pressed together",
          "the drawn symbols are hidden and a visually hidden copy spells the keys out — \"Command K\", \"Control Shift P\" — because ⌘ and ⇧ are read as symbol names or not at all",
          "`mod` resolves to ⌘ on Apple platforms and Ctrl elsewhere; server rendering shows Ctrl and corrects after hydration without a mismatch"
        ],
        "requires": [
          "the shortcut to be bound somewhere — Kbd shows a key, it does not listen for one"
        ]
      },
      "antiPatterns": [
        {
          "dont": "writing `ctrl+k` or `cmd+k` for the platform's command key",
          "do": "write `mod+k`",
          "why": "`ctrl` shows a Mac user a key they do not press for it; `cmd` shows everyone else a key they do not have"
        },
        {
          "dont": "children like \"⌘K\" or \"Ctrl+K\"",
          "do": "`shortcut=\"mod+k\"`",
          "why": "hand-typed symbols are wrong on the other platform and are read aloud as symbol names"
        },
        {
          "dont": "setting `platform`",
          "why": "it overrides detection, so half the readers see the other platform's keys; it exists for docs that compare the two"
        }
      ],
      "stylesheet": "src/styles/kbd.css",
      "tokens": [
        "--border-default",
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-medium",
        "--radius-xs",
        "--spacing-2",
        "--spacing-20",
        "--spacing-4",
        "--surface-subtle",
        "--text-secondary",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "shortcut": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "A whole shortcut, `+`-joined: `mod+k`, `mod+shift+p`, `esc`. `mod` is ⌘\non a Mac and Ctrl elsewhere, so write `mod` rather than guessing. Drawn as\none key per part, in the platform's order."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "One key, as printed: `K`, `Esc`, `/`. Use `shortcut` for a combination."
        },
        "platform": {
          "type": "KbdPlatform | undefined",
          "required": false,
          "origin": "own",
          "description": "Leave it out: the reader's platform is detected. Set it only to show a\nspecific platform's keys — a docs page comparing the two.",
          "values": [
            "mac",
            "other"
          ]
        },
        "keyLabels": {
          "type": "Partial<Record<import(\"/Users/raza/Documents/Apps/ionbase-design-system/packages/ionbase-ui/src/components/shortcut\").ShortcutKeyId, { symbol?: string; macSymbol?: string; name?: string; }>> | undefined",
          "required": false,
          "origin": "own",
          "description": "What `shortcut`'s keys are printed as and read out as, for translation —\n`{ control: { symbol: 'Strg', name: 'Steuerung' } }`. English by default.\nKeyed by the physical key: `mod` is `command` on a Mac, `control` elsewhere."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 5,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Link": {
      "name": "Link",
      "source": "src/components/Link.tsx",
      "propsType": "LinkProps",
      "description": "Link — Figma `Link` (774:1516).\n\nRenders an `<a>` when given an `href` and a `<button>` otherwise, the same\njudgment `NavItem` makes: the element follows what the caller is actually\nbuilding rather than a separate `as` prop. A link that does not navigate is\na button, and shipping one as an anchor breaks middle-click, \"open in new\ntab\" and the screen-reader announcement all at once.\n\nNO SIZE PROP, DELIBERATELY. A link is an inline element and inherits its\ntype from the text around it — `font-size: inherit` in the stylesheet, and\nicons sized in `em` so they scale with it. Figma has to draw its variants at\nsome concrete size (16/24, `type/body`), but that is one sample of an\ninheriting element, not a specification. Do not add a size ladder to match\nthe drawing.",
      "import": "import { Link } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Navigation to somewhere else. Renders an <a> when given `href`, a <button> otherwise.",
      "useWhen": [
        "the interaction takes the user to a different page, view or resource",
        "the destination should support middle-click, open-in-new-tab and copy-link"
      ],
      "useInstead": [
        {
          "when": "the interaction changes something rather than going somewhere",
          "use": "Button",
          "why": "a link that does not navigate breaks middle-click, open-in-new-tab and the screen-reader announcement all at once"
        },
        {
          "when": "it is a primary navigation destination in a header or sidebar",
          "use": "NavItem"
        }
      ],
      "variants": {
        "variant": {
          "inline": {
            "use": "the default — a link inside a paragraph. Underlined in every state, because a link in body copy must not rely on colour alone (WCAG 1.4.1)"
          },
          "standalone": {
            "use": "a link that already reads as interactive from its position — a card footer, an action row. Underlines on hover only"
          }
        }
      },
      "slots": {
        "startIcon": {
          "accepts": "Icon",
          "note": "omit Icon's `label` — the link text already names it"
        },
        "endIcon": {
          "accepts": "Icon",
          "note": "the external-link or arrow affordance; same rule about `label`"
        }
      },
      "a11y": {
        "role": "link when `href` is set, button when it is not",
        "guarantees": [
          "the rendered element follows what the caller is actually building, so the announcement is always honest",
          "`inline` stays underlined in every state, so it never depends on colour alone"
        ],
        "requires": [
          "link text that makes sense read on its own — screen-reader users navigate by a list of links"
        ]
      },
      "antiPatterns": [
        {
          "dont": "\"click here\" or \"read more\" as the whole link text",
          "why": "out of context in a links list it names nothing"
        },
        {
          "dont": "adding a size prop or wrapper to scale it",
          "why": "a link is inline and inherits its type from the text around it, by design — icons are sized in `em` to follow"
        },
        {
          "dont": "`target=\"_blank\"` with no warning in the text or endIcon",
          "why": "an unannounced new tab disorients screen-reader and low-vision users"
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        }
      ],
      "stylesheet": "src/styles/link.css",
      "tokens": [
        "--border-width-thick",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-xs",
        "--ring-focus",
        "--spacing-4",
        "--text-disabled",
        "--text-link",
        "--text-link-hover",
        "--text-link-visited"
      ],
      "props": {
        "variant": {
          "type": "LinkVariant | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Type` variant.\n\n`inline` is for links inside body copy and is underlined in every state —\na link in a paragraph must not rely on colour alone (WCAG 1.4.1).\n`standalone` is for links that already read as interactive from their\nposition, and underlines on hover only.",
          "values": [
            "inline",
            "standalone"
          ],
          "default": "inline"
        },
        "startIcon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Optional icon before the label."
        },
        "endIcon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Optional icon after the label — the external-link or arrow affordance."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the link is disabled."
        },
        "elementType": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The HTML element used to render the link, e.g. 'a', or 'span'.",
          "tags": {
            "default": "'a'"
          }
        },
        "onPress": {
          "type": "((e: PressEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the press is released over the target."
        },
        "onPressStart": {
          "type": "((e: PressEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a press interaction starts."
        },
        "onPressEnd": {
          "type": "((e: PressEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a press interaction ends, either\nover the target or when the pointer leaves the target."
        },
        "onPressChange": {
          "type": "((isPressed: boolean) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the press state changes."
        },
        "onPressUp": {
          "type": "((e: PressEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a press is released over the target, regardless of\nwhether it started on the target or not."
        },
        "onClick": {
          "type": "((e: MouseEvent<FocusableElement>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "**Not recommended – use `onPress` instead.** `onClick` is an alias for `onPress`\nprovided for compatibility with other libraries. `onPress` provides\nadditional event details for non-mouse interactions."
        },
        "autoFocus": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the element should receive focus on render."
        },
        "onFocus": {
          "type": "((e: FocusEvent<Element, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element receives focus."
        },
        "onBlur": {
          "type": "((e: FocusEvent<Element, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element loses focus."
        },
        "onFocusChange": {
          "type": "((isFocused: boolean) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element's focus status changes."
        },
        "onKeyDown": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is pressed."
        },
        "onKeyUp": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is released."
        },
        "href": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "A URL to link to. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href)."
        },
        "hrefLang": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Hints at the human language of the linked URL.\nSee[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#hreflang)."
        },
        "target": {
          "type": "HTMLAttributeAnchorTarget | undefined",
          "required": false,
          "origin": "aria",
          "description": "The target window for the link. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target)."
        },
        "rel": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The relationship between the linked resource and the current page. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel)."
        },
        "download": {
          "type": "string | boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Causes the browser to download the linked URL. A string may be provided to suggest a file name.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download)."
        },
        "ping": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "A space-separated list of URLs to ping when the link is followed. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#ping)."
        },
        "referrerPolicy": {
          "type": "HTMLAttributeReferrerPolicy | undefined",
          "required": false,
          "origin": "aria",
          "description": "How much of the referrer to send when following the link. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#referrerpolicy).",
          "values": [
            "",
            "origin",
            "no-referrer",
            "no-referrer-when-downgrade",
            "origin-when-cross-origin",
            "same-origin",
            "strict-origin",
            "strict-origin-when-cross-origin",
            "unsafe-url"
          ]
        },
        "routerOptions": {
          "type": "undefined",
          "required": false,
          "origin": "aria",
          "description": "Options for the configured client side router."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 26,
        "dom": 266,
        "other": 0
      }
    },
    "List": {
      "name": "List",
      "source": "src/components/List.tsx",
      "propsType": "ListProps",
      "description": "List — rows of records to open, pick or act on: an approval inbox, a list\nof files, \"choose one of these\" that is not a form field.\n\nNOT A TABLE, NOT A LISTBOX, NOT A <ul>\n\n  - A Table is for columns that are compared down. A List row is one thing\n    read across — a label, a line under it, a badge — with no header.\n  - A listbox (Select, Combobox) holds values for a form and nothing\n    inside its options can be focused. A List row can hold buttons.\n  - A `<ul>` of links is right for a short static list. It is a tab stop\n    per row, and it has no selection; past a handful of rows, or once rows\n    are picked, it wants this.\n\nHOW IT IS BUILT\n\n  - React Aria's `useGridList` and `useGridListItem` over `useListState`: a\n    `grid` of `row`s, each one `gridcell`. One tab stop; ↑ ↓ move between\n    rows, Home and End go to the ends, typing jumps to a label. → moves into\n    a row's own buttons and ← back out, so a row's actions are reachable\n    without a tab stop each.\n  - `href` rows are links: a press follows them, and a modified click opens\n    a new tab, through React Aria's router.\n  - `multiple` gives each row a real Checkbox, named \"Select\" and the row's\n    label. Not drawn, as TreeView's is: here a press opens the row, so the\n    box is the only way a pointer can select one without opening it.\n  - An empty list keeps its name and says why it is empty, in a row, so a\n    screen reader landing on it hears more than \"grid, 0 rows\".",
      "import": "import { List } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Rows of records to open, pick or act on — an approval inbox, a list of files, \"choose one of these\" that is not a form field. One tab stop, arrow keys inside, a row can hold buttons.",
      "useWhen": [
        "each row is one record read across — a label, a line under it, a badge — and the user opens one, or picks some",
        "an inbox or a queue: rows that open a detail, with several selectable for one action on all of them",
        "picking one item from many where the choice is not a form value — the file to preview, the run to show beside the list",
        "rows carry their own small actions — dismiss, a Menu of more"
      ],
      "useInstead": [
        {
          "when": "the rows have columns that are compared down — duration, owner, status side by side",
          "use": "Table",
          "why": "a List has no header and no columns; values meant to be scanned down a column belong in one"
        },
        {
          "when": "the choice is a value submitted with a form",
          "use": "RadioGroup, Select or CheckboxGroup",
          "why": "a List's selection is not a form field: it has no name, no validation and no label for the question"
        },
        {
          "when": "the items are nested",
          "use": "TreeView"
        },
        {
          "when": "a short, static list of links with nothing to select — related pages, three recent items",
          "use": "a <ul> of Link",
          "why": "links in a list are one tab stop each, which is right for a handful and needs no grid"
        },
        {
          "when": "the items are the app's own pages",
          "use": "Sidebar"
        }
      ],
      "variants": {
        "selectionMode": {
          "none": {
            "use": "the default — a press opens the row: its `href`, or `onAction`"
          },
          "single": {
            "use": "pick one, as in a file list: a press selects, Enter or a double click opens"
          },
          "multiple": {
            "use": "an inbox: a checkbox per row. A press opens the row while nothing is selected and toggles it once something is; Space selects from the keyboard"
          }
        },
        "density": {
          "default": {
            "use": "the default — 48px rows with room for a description and an Avatar, for rows a person reads one at a time"
          },
          "compact": {
            "use": "a long list scanned by label — a log, an activity feed, a queue of hundreds. 32px rows, as TreeView's, with an Icon; a `mini` Avatar makes them 36. `sm` Avatars and descriptions belong in default rows"
          }
        }
      },
      "slots": {
        "items": {
          "accepts": "ListItem[]",
          "note": "each item: `label` (the row's name), `description` (a second line, read with the name), `leading` (Avatar or Icon — decorative, hidden from assistive technology, so it must not carry meaning the label does not), `meta` (Badge or text at the end, not interactive), `href`, `isDisabled`"
        },
        "renderActions": {
          "accepts": "Button, Menu",
          "note": "at the end of each row; each needs an `aria-label` that names its row — \"Dismiss Reconcile March invoices\", not \"Dismiss\""
        },
        "renderEmptyState": {
          "accepts": "text",
          "note": "shown in a row of its own when `items` is empty"
        }
      },
      "a11y": {
        "requires": [
          "`aria-label` or `aria-labelledby` — the list's name, such as the heading above it",
          "a unique `id` on every item — selection is held by id",
          "a string `label`, or `textValue` when the label is not text — typeahead matches it and it is the row's name"
        ],
        "guarantees": [
          "a `grid` of `row`s, one `gridcell` each; `aria-multiselectable` in `multiple`, `aria-selected` on each row only when rows can be selected",
          "one tab stop: ↑ and ↓ move between rows, Home and End go to the ends, typing jumps to a matching label, and Tab leaves the list",
          "→ moves into a row's own buttons and ← back out, so row actions add no tab stops; ↑ and ↓ from a button move to the next row",
          "pressing a button inside a row does its job only — it does not open or select the row",
          "`href` rows are links through React Aria's router: an app's `RouterProvider` if it has one, the browser otherwise, with modified clicks opening a new tab",
          "`multiple` gives each row a real Checkbox named \"Select\" and the row's label; a click on its box toggles once",
          "a disabled item is skipped by the arrow keys and cannot be opened or selected",
          "the description is read with the row's label",
          "an empty list stays a named grid, with `renderEmptyState` in a row of its own",
          "rows are at least 48px tall, 32px when `compact` — above the 24px WCAG 2.2 target minimum either way; long labels wrap and the meta stays at the row's end; the keyboard focus ring is drawn on the row"
        ],
        "notes": [
          "in `multiple` a press opens the row until something is selected — the inbox convention. Say what the checkboxes are for with the batch action they feed, such as a TableBatchBar above the list",
          "in a single-page app with history routing, wrap the app in React Aria's `RouterProvider` so `href` rows navigate without a page load. Hash routes need nothing",
          "`leading` is hidden from assistive technology: an avatar's initials or an icon's meaning must be in the label or description too"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<ul> of <li onClick> rows, or <div role=\"button\"> per row",
          "do": "<List items={…} onAction={open} />",
          "why": "clickable list items are not focusable or announced as actionable, and buttons per row are a tab stop each with no selection"
        },
        {
          "dont": "a Table with one column and no header, to get selectable rows",
          "do": "<List selectionMode=\"multiple\" … />",
          "why": "a table claims columns and a header to compare; a screen reader announces \"1 column\" and reads a header that is not there"
        },
        {
          "dont": "a checkbox drawn in each row's label to show selection",
          "do": "`selectionMode=\"multiple\"`",
          "why": "a drawn box is not a control: it cannot be clicked without opening the row and is not announced with the row"
        },
        {
          "dont": "renderActions={() => <Button>Dismiss</Button>}",
          "do": "renderActions={(item) => <Button aria-label={`Dismiss ${item.label}`}>Dismiss</Button>}",
          "why": "arrowed into from outside its row, a button named only \"Dismiss\" does not say which one it dismisses"
        },
        {
          "dont": "an empty List that renders nothing, or a grid with no rows",
          "do": "`renderEmptyState={() => 'Nothing is waiting for approval.'}`",
          "why": "an empty grid is announced as \"0 rows\" and nothing else; the user is not told whether it is loading, filtered or empty"
        }
      ],
      "stylesheet": "src/styles/list.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-disabled",
        "--icon-secondary",
        "--icon-size-sm",
        "--ion-duration-base",
        "--ion-ease-out",
        "--spacing-12",
        "--spacing-2",
        "--spacing-32",
        "--spacing-4",
        "--spacing-48",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-selected",
        "--surface-selected-hover",
        "--text-default",
        "--text-disabled",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "items": {
          "type": "readonly ListItem[]",
          "required": true,
          "origin": "own"
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the list. Required unless `aria-labelledby` is given."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "selectionMode": {
          "type": "ListSelectionMode | undefined",
          "required": false,
          "origin": "own",
          "description": "`none` (default): a press opens the row — `href` or `onAction`.\n`single`: a press selects one; Enter or a double click opens it.\n`multiple`: each row has a checkbox. A press opens the row while nothing\nis selected, and toggles it once something is.",
          "values": [
            "multiple",
            "single",
            "none"
          ]
        },
        "selectedKeys": {
          "type": "Iterable<string> | undefined",
          "required": false,
          "origin": "own"
        },
        "defaultSelectedKeys": {
          "type": "Iterable<string> | undefined",
          "required": false,
          "origin": "own"
        },
        "onSelectionChange": {
          "type": "((keys: Set<string>) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "onAction": {
          "type": "((key: string) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "A row was opened — \"show this run\". Rows with an `href` follow it instead."
        },
        "renderActions": {
          "type": "((item: ListItem) => React.ReactNode) | undefined",
          "required": false,
          "origin": "own",
          "description": "Buttons at the end of a row — archive, a Menu of more. Reached with → from\nthe row, and each needs a name that says which row it acts on."
        },
        "renderEmptyState": {
          "type": "(() => React.ReactNode) | undefined",
          "required": false,
          "origin": "own",
          "description": "Shown, in a row of its own, when `items` is empty."
        },
        "density": {
          "type": "ListDensity | undefined",
          "required": false,
          "origin": "own",
          "description": "`default`: 48px rows, room for a description and an Avatar. `compact`:\n32px, a TreeView row — for a long list scanned by label, such as a log or\nan activity feed. An Icon keeps a compact row at 32; a `mini` Avatar makes\nit 36; `sm` and larger belong in default rows.",
          "values": [
            "default",
            "compact"
          ]
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 13,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Logo": {
      "name": "Logo",
      "source": "src/components/Logo.tsx",
      "propsType": "LogoProps",
      "description": "The full lockup: mark + wordmark, matching Figma's `Logo-Ionbase`\n(52:21369).\n\nFigma also has a `Property=Name` axis — wordmark only, no mark — but both\nof its variants currently render the placeholder text \"raza\" rather than\n\"IonBase\", in an unbound raw colour. That is leftover debug content, not a\nreal asset, so it is not implemented here. Reproducing it would ship a\nstranger's name into a design system; extending `LogoMark` alone already\ncovers \"no wordmark\" for the cases that are real.",
      "import": "import { Logo } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The full IonBase lockup — mark plus wordmark.",
      "useWhen": [
        "the product needs to identify itself: header, sign-in page, empty state, marketing surface"
      ],
      "useInstead": [
        {
          "when": "space is tight, or a wordmark already appears nearby",
          "use": "LogoMark"
        },
        {
          "when": "you need any graphic that is not the brand",
          "use": "Icon"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "the default — a 24px mark, which is what a header bar wants"
          },
          "lg": {
            "use": "a 32px mark — sign-in, splash, or anywhere the brand is the subject"
          }
        },
        "wordmark": {
          "vector": {
            "use": "the default and the safe choice — Figma's serif logotype as frozen artwork, immune to font substitution and visually distinct from the UI type"
          },
          "text": {
            "use": "live, selectable Host Grotesk at `text/tertiary` — for surfaces where the name should behave like text (copyable, searchable) rather than like a mark"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "the wordmark carries the accessible name, so the mark beside it is not announced twice",
          "artwork uses `currentColor` rather than Figma's baked hex, so it themes with the rest of the icon layer"
        ]
      },
      "antiPatterns": [
        {
          "dont": "recolouring the lockup with your own fill",
          "why": "it inherits `icon/default`; overriding it is how a brand mark ends up failing contrast in one theme"
        },
        {
          "dont": "putting the Logo inside a Link with its own text \"IonBase\"",
          "why": "the name is announced twice; label the link, not both"
        }
      ],
      "stylesheet": "src/styles/logo.css",
      "tokens": [
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-default",
        "--spacing-4",
        "--spacing-8",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height"
      ],
      "props": {
        "size": {
          "type": "LogoSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Figma's `Size` variant: Small (24px mark) or Large (32px).",
          "values": [
            "sm",
            "lg"
          ]
        },
        "wordmark": {
          "type": "LogoWordmark | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Figma's `Type` variant. `vector` is the serif logotype Figma\ncalls \"Logo\" — what `Header` uses, immune to font substitution and\nvisually distinct from the sans-serif UI type everywhere else. `text` is\nwhat Figma calls \"Icon\": a live `Host Grotesk` run, real and selectable,\nat `text/tertiary` rather than the mark's `icon/default`.",
          "values": [
            "text",
            "vector"
          ]
        }
      },
      "propCounts": {
        "own": 2,
        "aria": 0,
        "dom": 278,
        "other": 0
      }
    },
    "LogoMark": {
      "name": "LogoMark",
      "source": "src/components/Logo.tsx",
      "propsType": "LogoMarkProps",
      "description": "The bare icon glyph, no wordmark — what `Header` actually uses.\n\nColour comes from the `.ion-logo__mark` class (`icon/default`), not the SVG\nitself, so it themes the same way every other icon in the system does.",
      "import": "import { LogoMark } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The bare mark, no wordmark — what Header uses.",
      "useWhen": [
        "a wordmark is already visible nearby, or the bar is too narrow for the lockup",
        "a favicon-scale identity marker inside the product"
      ],
      "useInstead": [
        {
          "when": "this is the only place the product names itself",
          "use": "Logo",
          "why": "a mark alone identifies nothing to someone who has not learned it yet"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "the default, 24px — header bars"
          },
          "lg": {
            "use": "32px — sign-in and other brand-forward surfaces"
          }
        }
      },
      "a11y": {
        "requires": [
          "`label` when the mark stands alone — it is the only thing naming it"
        ],
        "notes": [
          "Omit `label` when a visible wordmark sits beside it; that text already carries the name."
        ]
      },
      "antiPatterns": [
        {
          "dont": "`label=\"logo\"`",
          "do": "`label=\"IonBase\"`",
          "why": "the name of the brand is the useful announcement; the word \"logo\" is not"
        }
      ],
      "stylesheet": "src/styles/logo.css",
      "tokens": [
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-default",
        "--spacing-4",
        "--spacing-8",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height"
      ],
      "props": {
        "size": {
          "type": "LogoSize | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "lg"
          ]
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible name for the mark used standalone. Omit when it sits beside\na visible wordmark, which already carries the name."
        }
      },
      "propCounts": {
        "own": 2,
        "aria": 0,
        "dom": 487,
        "other": 0
      }
    },
    "Menu": {
      "name": "Menu",
      "source": "src/components/Menu.tsx",
      "propsType": "MenuProps",
      "description": "Menu — Figma `Menu` (82:306), `Menu Item` and `Menu Section Title`.\n\nA real ARIA menu: `role=\"menu\"`, one tab stop, arrow keys, Home and End,\ntypeahead, and disabled rows listed but skipped. Figma's `Type` is\n`selectionMode` — Single draws one check, Multi draws several — and with\na selection mode the rows become `menuitemradio` / `menuitemcheckbox` and\nannounce their checked state.\n\nOn its own it is the surface only, rendered in flow. Inside a MenuTrigger it\nfloats, opens and closes, is named by its trigger, and can open submenus.",
      "import": "import { Menu } from 'ionbase-ui';",
      "status": "stable",
      "summary": "An ARIA menu: one tab stop, arrow keys, typeahead, and optional single or multiple selection. Children are MenuItem and MenuSection. Inside a MenuTrigger it floats, opens and closes, and can open submenus; on its own it renders in flow.",
      "useWhen": [
        "a list of actions or choices for the thing the user is on — row actions, a workspace switcher, a sort order",
        "the rows need icons, a selected check, or grouping under headings — more than a native <select> can hold"
      ],
      "useInstead": [
        {
          "when": "a button opens it",
          "use": "MenuTrigger",
          "why": "it gives the trigger aria-haspopup and aria-expanded, opens on ArrowDown, and closes on an action and returns focus — a Popover round a Menu does none of that"
        },
        {
          "when": "one value is picked for a form field from a modest list of plain text options",
          "use": "Select",
          "why": "a form value is a listbox, not a menu; the native picker brings type-ahead and the mobile platform picker for free"
        },
        {
          "when": "the user types to narrow a long list down to one value",
          "use": "Combobox"
        },
        {
          "when": "it is primary navigation rather than a list of actions",
          "use": "NavItem",
          "why": "a menu's rows are actions; links to pages belong in the document's navigation, where a screen reader's landmarks find them"
        },
        {
          "when": "any action or page should be reachable by searching",
          "use": "CommandPalette"
        }
      ],
      "composition": {
        "order": [
          "Menu",
          "MenuSection",
          "MenuItem"
        ],
        "note": "MenuItem and MenuSection are collection elements: Menu reads them from its DIRECT children to build the collection, and neither renders anything itself. Every MenuItem needs a `key`, and `onAction`, `selectedKeys` and `disabledKeys` all speak in those keys. To open it from a button, put the Button and the Menu inside a MenuTrigger. Rows that depend on state are written out, not collected into an array: a ternary between two rows, or `cond ? <MenuItem /> : null` for a row that comes and goes. A list that comes from data is `items` with a render function, or a `.map()` that is a MenuSection's only child.",
        "example": "<Menu aria-label=\"Row actions\" onAction={(key) => run(key)}><MenuSection title=\"Edit\"><MenuItem key=\"rename\" icon={<Icon as={Pencil} />}>Rename</MenuItem><MenuItem key=\"duplicate\">Duplicate</MenuItem></MenuSection><MenuSection aria-label=\"Danger\"><MenuItem key=\"archive\" isDisabled>Archive</MenuItem></MenuSection></Menu>"
      },
      "variants": {
        "selectionMode": {
          "none": {
            "use": "the default — a list of actions. Rows are `menuitem`, and there is no check slot"
          },
          "single": {
            "use": "Figma's Type=Single — one current choice, such as a sort order or the active workspace. Rows are `menuitemradio`"
          },
          "multiple": {
            "use": "Figma's Type=Multi — independent toggles, such as visible columns. Rows are `menuitemcheckbox`"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": [
            "MenuItem",
            "MenuSection"
          ]
        }
      },
      "a11y": {
        "role": "menu",
        "guarantees": [
          "one tab stop; arrow keys move between rows, Home and End jump, and typing a letter moves to the next row starting with it",
          "disabled rows are listed and announced as unavailable, and the arrow keys skip them",
          "with a selection mode, rows are `menuitemradio` or `menuitemcheckbox` and announce `aria-checked` — the check glyph is never the only signal",
          "the pointer and the keyboard share one highlighted row: hovering a row focuses it"
        ],
        "requires": [
          "an accessible name — `aria-label` or `aria-labelledby` naming what the actions are for"
        ],
        "notes": [
          "A menu is not a form control. It has no `name` and submits nothing; `selectedKeys` is state you hold.",
          "Titled sections are named groups. An untitled section needs `aria-label`, and is set off by a rule.",
          "Submenus need a MenuTrigger. In a standalone Menu a row with MenuItem children renders as a plain row: there is no root to open a submenu from."
        ]
      },
      "antiPatterns": [
        {
          "dont": "wrapping MenuItems in a <div>, a fragment or your own component inside Menu",
          "why": "Menu reads its collection from its direct children; a wrapped row is invisible to it and renders nothing"
        },
        {
          "dont": "collecting rows into `const rows: React.ReactElement[] = []` with `push`, then `<Menu>{rows}</Menu>`",
          "why": "the children are typed `CollectionChildren`: one collection element, an array of them, or a render function. `ReactElement` defaults its props to `unknown`, which is not an item's props, so the array fails to compile. Contracts-only generations of the demo's Agents screen did this on 2 of 3 tries. Write the rows out, with a ternary for one that changes."
        },
        {
          "dont": "`{cond && <MenuItem />}`, or written-out rows beside a `.map()`",
          "why": "neither compiles: `false` is not a collection element, and an array beside elements is not one either. Write `cond ? <MenuItem /> : null`, and put the mapped rows in a MenuSection of their own"
        },
        {
          "dont": "an `onClick` on MenuItem",
          "why": "MenuItem renders nothing, so there is no element to click. Pass `onAction` to Menu; it receives the item's key, from the pointer, Enter and Space alike"
        },
        {
          "dont": "using Menu for a form field's value",
          "why": "a screen reader announces a menu as actions, and a form cannot read it. Use Select or Combobox"
        },
        {
          "dont": "putting links to other pages in a Menu",
          "why": "they vanish from the page's link list and landmarks. Navigation belongs in NavItem or Sidebar"
        }
      ],
      "stylesheet": "src/styles/menu.css",
      "tokens": [
        "--border-focus",
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-regular",
        "--icon-default",
        "--icon-disabled",
        "--icon-primary",
        "--icon-size-sm",
        "--ion-menu-min-width",
        "--ion-shadow-shadow-lg",
        "--radius-sm",
        "--radius-xl",
        "--spacing-12",
        "--spacing-2",
        "--spacing-4",
        "--spacing-40",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-primary-subtle",
        "--text-disabled",
        "--text-primary",
        "--text-secondary",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "style": {
          "type": "React.CSSProperties | undefined",
          "required": false,
          "origin": "own"
        },
        "escapeKeyBehavior": {
          "type": "\"none\" | \"clearSelection\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether pressing the escape key should clear selection in the menu or not.\n\nMost experiences should not modify this option as it eliminates a keyboard user's ability to\neasily clear selection. Only use if the escape key is being handled externally or should not\ntrigger selection clearing contextually.",
          "values": [
            "none",
            "clearSelection"
          ],
          "tags": {
            "default": "'clearSelection'"
          }
        },
        "autoFocus": {
          "type": "boolean | FocusStrategy | undefined",
          "required": false,
          "origin": "aria",
          "description": "Where the focus should be set."
        },
        "shouldFocusWrap": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether keyboard navigation is circular."
        },
        "onAction": {
          "type": "((key: Key, value: T) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when an item is selected."
        },
        "onClose": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the menu should close after selecting an item."
        },
        "children": {
          "type": "CollectionChildren<T>",
          "required": true,
          "origin": "aria",
          "description": "The contents of the collection."
        },
        "items": {
          "type": "Iterable<T> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Item objects in the collection."
        },
        "disabledKeys": {
          "type": "Iterable<Key> | undefined",
          "required": false,
          "origin": "aria",
          "description": "The item keys that are disabled. These items cannot be selected, focused, or otherwise\ninteracted with."
        },
        "selectionMode": {
          "type": "SelectionMode | undefined",
          "required": false,
          "origin": "aria",
          "description": "The type of selection that is allowed in the collection.",
          "values": [
            "multiple",
            "single",
            "none"
          ]
        },
        "disallowEmptySelection": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the collection allows empty selection."
        },
        "selectedKeys": {
          "type": "Iterable<Key> | \"all\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "The currently selected keys in the collection (controlled)."
        },
        "defaultSelectedKeys": {
          "type": "Iterable<Key> | \"all\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "The initial selected keys in the collection (uncontrolled)."
        },
        "onSelectionChange": {
          "type": "((keys: Selection) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the selection changes."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The element's unique identifier. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id)."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        }
      },
      "propCounts": {
        "own": 2,
        "aria": 18,
        "dom": 0,
        "other": 0
      }
    },
    "MenuItem": {
      "name": "MenuItem",
      "source": "src/components/Menu.tsx",
      "propsType": "MenuItemProps",
      "description": "One row of a Menu. A collection element, like TabItem: it renders nothing\nitself — Menu reads its props and draws the row — so it only means anything\nas a direct child of Menu or MenuSection. Give it a `key`; that key is what\n`onAction` and `selectedKeys` speak in.",
      "import": "import { MenuItem } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One row of a Menu. A collection element: Menu reads its props and draws the row as `menuitem`, `menuitemradio` or `menuitemcheckbox`.",
      "useWhen": [
        "a row in a Menu or a MenuSection — it is not usable anywhere else"
      ],
      "useInstead": [
        {
          "when": "it is a top-level navigation destination",
          "use": "NavItem"
        },
        {
          "when": "it is a standalone action outside a list",
          "use": "Button"
        }
      ],
      "composition": {
        "order": [
          "Menu",
          "MenuItem"
        ],
        "note": "A collection element, like TabItem: it renders nothing itself and never appears in the DOM. `key` identifies it to Menu's `onAction`, `selectedKeys` and `disabledKeys`.",
        "example": "<MenuItem key=\"duplicate\" icon={<Icon as={Copy} />}>Duplicate</MenuItem>\n<MenuItem key=\"move\" title=\"Move to\"><MenuItem key=\"archive\">Archive</MenuItem></MenuItem>"
      },
      "slots": {
        "icon": {
          "accepts": "Icon",
          "note": "leading icon; omit its `label` — the row's text names it"
        },
        "children": {
          "accepts": "text",
          "note": "the row label"
        },
        "title": {
          "accepts": "text",
          "note": "only on a row that opens a submenu: the row's label, while its children are the submenu's MenuItems. Submenus open only inside a MenuTrigger"
        }
      },
      "a11y": {
        "guarantees": [
          "the role, `aria-checked` and `aria-disabled` are supplied by Menu from its selection mode and keys, not by this element",
          "the check glyph occupies its slot whenever the menu has a selection mode, so rows never reflow as the selection moves"
        ],
        "requires": [
          "a stable `key`",
          "text in `children`, or `textValue` when the children are not plain text — typeahead matches against it"
        ]
      },
      "antiPatterns": [
        {
          "dont": "using MenuItem outside a Menu",
          "why": "it renders nothing on its own"
        },
        {
          "dont": "an icon-only MenuItem with no children",
          "why": "the row announces nothing and typeahead cannot reach it"
        },
        {
          "dont": "array indexes as keys in a list that can change",
          "why": "selection and actions are expressed in keys, so the selected row silently becomes a different row"
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        },
        {
          "prop": "isSelected",
          "note": "Put the item's key in Menu's `selectedKeys` and set its `selectionMode`."
        }
      ],
      "stylesheet": "src/styles/menu.css",
      "tokens": [
        "--border-focus",
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-regular",
        "--icon-default",
        "--icon-disabled",
        "--icon-primary",
        "--icon-size-sm",
        "--ion-menu-min-width",
        "--ion-shadow-shadow-lg",
        "--radius-sm",
        "--radius-xl",
        "--spacing-12",
        "--spacing-2",
        "--spacing-4",
        "--spacing-40",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-primary-subtle",
        "--text-disabled",
        "--text-primary",
        "--text-secondary",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "title": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Only on a row that opens a submenu: the row's label. Its children are then\nthe submenu's MenuItems rather than its label. Submenus open only inside a\nMenuTrigger — a standalone Menu has nothing to open them from."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `Show Leading Icon` + `Leading Icon` swap."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether the item is disabled. Listed, skipped by the arrow keys."
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "isSelected": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Put the item's key in Menu's `selectedKeys`. Accepted for one\nminor version: when no item's key is in `selectedKeys` and Menu is given\nneither `selectedKeys` nor `defaultSelectedKeys`, the items passing this\nbecome the selection."
          }
        },
        "children": {
          "type": "ReactNode",
          "required": true,
          "origin": "aria",
          "description": "Rendered contents of the item or child items."
        },
        "textValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "A string representation of the item's contents, used for features like typeahead."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An accessibility label for this item."
        },
        "childItems": {
          "type": "Iterable<T> | undefined",
          "required": false,
          "origin": "aria",
          "description": "A list of child item objects. Used for dynamic collections."
        },
        "hasChildItems": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether this item has children, even if not loaded yet."
        },
        "href": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "A URL to link to. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href)."
        },
        "hrefLang": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Hints at the human language of the linked URL.\nSee[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#hreflang)."
        },
        "target": {
          "type": "HTMLAttributeAnchorTarget | undefined",
          "required": false,
          "origin": "aria",
          "description": "The target window for the link. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#target)."
        },
        "rel": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The relationship between the linked resource and the current page. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/rel)."
        },
        "download": {
          "type": "string | boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Causes the browser to download the linked URL. A string may be provided to suggest a file name.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#download)."
        },
        "ping": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "A space-separated list of URLs to ping when the link is followed. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#ping)."
        },
        "referrerPolicy": {
          "type": "HTMLAttributeReferrerPolicy | undefined",
          "required": false,
          "origin": "aria",
          "description": "How much of the referrer to send when following the link. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#referrerpolicy).",
          "values": [
            "",
            "origin",
            "no-referrer",
            "no-referrer-when-downgrade",
            "origin-when-cross-origin",
            "same-origin",
            "strict-origin",
            "strict-origin-when-cross-origin",
            "unsafe-url"
          ]
        },
        "routerOptions": {
          "type": "undefined",
          "required": false,
          "origin": "aria",
          "description": "Options for the configured client side router."
        }
      },
      "propCounts": {
        "own": 5,
        "aria": 13,
        "dom": 0,
        "other": 0
      }
    },
    "MenuSection": {
      "name": "MenuSection",
      "source": "../../node_modules/.pnpm/react-stately@3.48.0_react@19.2.8/node_modules/react-stately/dist/types/src/collections/Section.d.ts",
      "propsType": null,
      "import": "import { MenuSection } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A named group of MenuItems. Re-exported from react-stately's collection Section — `title` is Figma's Menu Section Title.",
      "useWhen": [
        "a Menu has more than one kind of action, such as editing actions and destructive ones",
        "the rows fall into groups the user scans by name"
      ],
      "useInstead": [
        {
          "when": "the menu has one group",
          "use": "MenuItem",
          "why": "a single section adds a heading the user must hear before every menu, and says nothing the menu's own label does not"
        }
      ],
      "composition": {
        "order": [
          "Menu",
          "MenuSection",
          "MenuItem"
        ],
        "note": "A collection element: Menu reads it from its direct children. With `title`, the heading is drawn with a rule after it; without one, `aria-label` names the group and a rule is drawn above it instead.",
        "example": "<MenuSection title=\"Sort by\"><MenuItem key=\"name\">Name</MenuItem><MenuItem key=\"updated\">Last updated</MenuItem></MenuSection>"
      },
      "a11y": {
        "role": "group",
        "guarantees": [
          "the group is named by its title, so a screen reader announces it on entering the first row"
        ],
        "requires": [
          "a `title`; an untitled section still needs a label for the group, or it is announced as an unnamed group"
        ],
        "notes": [
          "The accessible-name lint rule is deliberately not driven from here: a titled section is named by its title and needs nothing more, so demanding a label on every MenuSection would be wrong for the common case."
        ]
      },
      "antiPatterns": [
        {
          "dont": "nesting a MenuSection inside another",
          "why": "a menu has one level of grouping; a section inside a section renders nothing"
        }
      ],
      "stylesheet": "src/styles/menu.css",
      "tokens": [
        "--border-focus",
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-regular",
        "--icon-default",
        "--icon-disabled",
        "--icon-primary",
        "--icon-size-sm",
        "--ion-menu-min-width",
        "--ion-shadow-shadow-lg",
        "--radius-sm",
        "--radius-xl",
        "--spacing-12",
        "--spacing-2",
        "--spacing-4",
        "--spacing-40",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-primary-subtle",
        "--text-disabled",
        "--text-primary",
        "--text-secondary",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {},
      "propCounts": {
        "own": 0,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "MenuTrigger": {
      "name": "MenuTrigger",
      "source": "src/components/Menu.tsx",
      "propsType": "MenuTriggerProps",
      "description": "MenuTrigger — a Button that opens a Menu.\n\nThe \"⋯\" overflow menu is this with an icon-only Button; there is no separate\ncomponent for it, because the only difference is the Button's content and\nits required aria-label.\n\n`useMenuTrigger` does what a Popover wrapped round a Menu could not: the\ntrigger announces `aria-haspopup=\"menu\"` and `aria-expanded`, the menu is\nnamed by the trigger, ArrowDown and ArrowUp open it with focus on the first\nor last row, and choosing an action closes it and returns focus to the\ntrigger.",
      "import": "import { MenuTrigger } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A Button that opens a Menu. The \"⋯\" overflow menu is this with an icon-only Button. Owns open state, positioning, focus, and the ARIA link between the two.",
      "useWhen": [
        "a button opens a list of actions — row actions, an overflow \"⋯\", a \"New\" button with several kinds of new",
        "a button opens a list of choices applied immediately, such as a sort order",
        "the actions nest one level or more — submenus open only inside a MenuTrigger"
      ],
      "useInstead": [
        {
          "when": "one action is the common case and the menu holds variations of it — Save, and Save as draft",
          "use": "SplitButton",
          "why": "a menu hides the common action behind a click; a split button keeps it one press away and puts the variations beside it"
        },
        {
          "when": "the button opens a form, a filter panel or anything that is not a list of rows",
          "use": "Popover",
          "why": "a menu promises arrow keys between rows and nothing else; a field inside it cannot be reached"
        },
        {
          "when": "the choice is a form field's value",
          "use": "Select",
          "why": "a form value is a listbox the form can read, not a menu of actions"
        },
        {
          "when": "there are only two or three actions and room to show them",
          "use": "Button",
          "why": "hiding actions behind a menu costs a click and makes them undiscoverable; show them when they fit"
        }
      ],
      "composition": {
        "order": [
          "MenuTrigger",
          "Button",
          "Menu"
        ],
        "note": "Exactly two children, in this order: the Button, then the Menu. The Menu is named by the Button unless it has its own aria-label. A MenuItem with a `title` and MenuItem children opens a submenu; `onAction` on the root Menu receives actions from every level.",
        "example": "<MenuTrigger><Button variant=\"tertiary\" aria-label=\"Actions for Payroll\" startIcon={<Icon as={Ellipsis} />} /><Menu onAction={(key) => run(key)}><MenuItem key=\"rename\">Rename</MenuItem><MenuItem key=\"move\" title=\"Move to\"><MenuItem key=\"archive\">Archive</MenuItem><MenuItem key=\"trash\">Trash</MenuItem></MenuItem></Menu></MenuTrigger>"
      },
      "variants": {
        "placement": {
          "bottom start": {
            "use": "the default — below, aligned to the trigger's start edge"
          },
          "bottom end": {
            "use": "a trigger at the end of a row or toolbar, so the menu does not run off the edge"
          },
          "top start": {
            "use": "a trigger near the bottom of the viewport, such as a footer"
          },
          "top end": {
            "use": "as top start, for a trigger at the end of its row"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": [
            "Button",
            "Menu"
          ],
          "note": "the Button first, the Menu second"
        }
      },
      "a11y": {
        "role": "none — the Button gets aria-haspopup=\"menu\" and aria-expanded; the Menu is the menu",
        "namesChild": true,
        "guarantees": [
          "Enter, Space and ArrowDown open the menu with focus on its first row; ArrowUp opens it on the last",
          "choosing an action closes every open level and returns focus to the trigger; so do Escape and an outside click",
          "the menu is named by the trigger unless it has its own aria-label",
          "a submenu opens with the right arrow, Enter or Space and closes with the left arrow or Escape, which puts focus back on the row that opened it",
          "submenus are non-modal: the parent menu stays on screen and hoverable, so the pointer can move between levels"
        ],
        "requires": [
          "an icon-only trigger Button needs its own aria-label — it names the menu too"
        ]
      },
      "antiPatterns": [
        {
          "dont": "wrapping a Menu in a Popover to make a dropdown",
          "why": "the trigger then announces a dialog, not a menu, the arrow keys cannot open it, and choosing an action does not close it. That is exactly what MenuTrigger exists to replace"
        },
        {
          "dont": "a plain <button> as the trigger",
          "why": "the trigger must accept react-aria press props and a ref; IonBase's Button does, a DOM button silently never opens"
        },
        {
          "dont": "more than two levels of submenu",
          "why": "each level is a hover target narrower than the last; past two, the pointer falls off the path and the menu closes. Flatten with sections instead"
        }
      ],
      "stylesheet": "src/styles/menu.css",
      "tokens": [
        "--border-focus",
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-regular",
        "--icon-default",
        "--icon-disabled",
        "--icon-primary",
        "--icon-size-sm",
        "--ion-menu-min-width",
        "--ion-shadow-shadow-lg",
        "--radius-sm",
        "--radius-xl",
        "--spacing-12",
        "--spacing-2",
        "--spacing-4",
        "--spacing-40",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-primary-subtle",
        "--text-disabled",
        "--text-primary",
        "--text-secondary",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "children": {
          "type": "[React.ReactElement<unknown, string | React.JSXElementConstructor<any>>, React.ReactElement<unknown, string | React.JSXElementConstructor<any>>]",
          "required": true,
          "origin": "own",
          "description": "Exactly two children: the Button that opens the menu, then the Menu. The\nButton must accept a ref and react-aria press props — IonBase's Button\ndoes; a plain `<button>` does not."
        },
        "placement": {
          "type": "MenuTriggerPlacement | undefined",
          "required": false,
          "origin": "own",
          "description": "Where the menu opens, relative to the trigger. A preference, not a\nguarantee: it flips when there is no room.",
          "values": [
            "bottom start",
            "bottom end",
            "top start",
            "top end"
          ]
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether the trigger is disabled."
        },
        "isOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "defaultOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "onOpenChange": {
          "type": "((isOpen: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Modal": {
      "name": "Modal",
      "source": "src/components/Modal.tsx",
      "propsType": "ModalProps",
      "description": "Modal — Figma `Modal` (792:1537).\n\n`Size` x `Align`, with the sections as props rather than variants. The panel\nhugs its content at every size except `fullscreen`, which fills the viewport\nand pushes the actions to the bottom edge.\n\nRENDERS NOTHING WHEN CLOSED, and mounts into a portal when open. Both come\nfrom React Aria's `Overlay`, and both matter: a modal left in the tree while\nclosed is still focusable by keyboard, and one rendered inline inherits any\n`overflow: hidden` or stacking context from wherever it was written.\n\nThe scrim is part of this component in code, unlike in Figma — see the note\nin modal.css.",
      "import": "import { Modal } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.11.0",
      "summary": "A focus-trapping dialog. Renders nothing when closed and portals when open.",
      "useWhen": [
        "the task must be completed or abandoned before anything else",
        "confirming a destructive action",
        "a short focused form that would otherwise need its own page"
      ],
      "useInstead": [
        {
          "when": "the content is supplementary and the page stays usable",
          "use": "Popover",
          "why": "a Popover does not trap focus or block the page"
        },
        {
          "when": "it is a short label for a control",
          "use": "Tooltip"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "confirmations and single-question prompts"
          },
          "md": {
            "use": "the default; short forms"
          },
          "lg": {
            "use": "multi-section forms"
          },
          "fullscreen": {
            "use": "immersive tasks; fills the viewport and pins the actions to the bottom edge"
          }
        },
        "align": {
          "left": {
            "use": "the default; forms and anything the user reads"
          },
          "center": {
            "use": "single-message confirmations only"
          }
        }
      },
      "slots": {
        "footer": {
          "accepts": "Button",
          "note": "the primary action goes last, at the trailing edge"
        },
        "media": {
          "note": "rendered above the title"
        }
      },
      "a11y": {
        "role": "dialog",
        "guarantees": [
          "focus moves into the panel on open and returns to the trigger on close",
          "Escape and a scrim click both close it",
          "focus is trapped while open",
          "nothing is rendered at all while closed, so no closed-modal content is keyboard reachable"
        ],
        "requires": [
          "`title`, which provides the dialog's accessible name"
        ]
      },
      "antiPatterns": [
        {
          "dont": "rendering <Modal> only when a state flag is true",
          "do": "render it always and drive `isOpen`",
          "why": "Modal already renders nothing when closed. Wrapping it in a conditional discards the open/close transition and the focus-return behaviour."
        },
        {
          "dont": "opening a Modal from inside another Modal",
          "why": "two focus traps compete and Escape becomes ambiguous"
        },
        {
          "dont": "a destructive action as the only button, with no cancel",
          "why": "a confirmation with no way out is not a confirmation"
        }
      ],
      "stylesheet": "src/styles/modal.css",
      "tokens": [
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-regular",
        "--font-weight-semibold",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-lg",
        "--icon-size-md",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-modal-width",
        "--ion-shadow-shadow-xl",
        "--radius-full",
        "--radius-sm",
        "--radius-xl",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-32",
        "--spacing-48",
        "--spacing-8",
        "--surface-hover",
        "--surface-overlay",
        "--surface-primary-subtle",
        "--surface-scrim",
        "--text-default",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-h5",
        "--type-h5-line-height"
      ],
      "props": {
        "isOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether the modal is shown."
        },
        "onOpenChange": {
          "type": "((isOpen: boolean) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Called with the next open state — `false` when the modal asks to close."
        },
        "size": {
          "type": "ModalSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant.",
          "values": [
            "sm",
            "md",
            "lg",
            "fullscreen"
          ],
          "default": "md"
        },
        "align": {
          "type": "ModalAlign | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Align` variant.",
          "values": [
            "left",
            "center"
          ],
          "default": "left"
        },
        "title": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "Required: the dialog's accessible name, wired via `aria-labelledby`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Supporting copy under the title."
        },
        "media": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The featured slot above the title — an icon or an illustration. Rendered\ninside a circular container that hugs it."
        },
        "footer": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Action row. Right-aligned when `align=\"left\"`, centred when `\"center\"`."
        },
        "showClose": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Show the close button. Defaults to `true`.",
          "default": true
        },
        "closeLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible name for the close button.",
          "default": "Close dialog"
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Body content."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "isDismissable": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to close the modal when the user interacts outside it.",
          "tags": {
            "default": "false"
          }
        },
        "isKeyboardDismissDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether pressing the escape key to close the modal should be disabled.",
          "tags": {
            "default": "false"
          }
        },
        "shouldCloseOnInteractOutside": {
          "type": "((element: Element) => boolean) | undefined",
          "required": false,
          "origin": "aria",
          "description": "When user interacts with the argument element outside of the overlay ref,\nreturn true if onClose should be called.  This gives you a chance to filter\nout interaction with elements that should not dismiss the overlay.\nBy default, onClose will always be called on interaction outside the overlay ref."
        }
      },
      "propCounts": {
        "own": 12,
        "aria": 3,
        "dom": 0,
        "other": 0
      }
    },
    "MultiSelect": {
      "name": "MultiSelect",
      "source": "src/components/MultiSelect.tsx",
      "propsType": "MultiSelectProps",
      "description": "MultiSelect — a text field that filters a list, with any number of selected\nvalues shown as removable tags beneath it.\n\nWHY IT IS ITS OWN COMPONENT, NOT `Combobox selectionMode=\"multiple\"`\n\nThe two differ in what the field holds. Combobox's input shows the chosen\nlabel — typing edits the value. Here the input only ever holds the filter\ntext, the value lives in the tags, and choosing an option leaves the list\nopen for the next one. One prop switching the meaning of the text in the\nbox is the kind of contract agents get wrong in generated code.\n\nWHAT REACT ARIA GIVES IT\n\n`useComboBoxState` and `useComboBox` in `selectionMode: 'multiple'`: the\nlistbox is `aria-multiselectable`, Enter and click toggle an option without\nclosing the list, the filter text clears after each pick, and native\n`required` is set only while nothing is chosen — \"at least one\", the same\nrule CheckboxGroup enforces.\n\nWHAT IT ADDS\n\n  - The tags. A TagGroup under the field, named by the field's label, one\n    tab stop, Delete or the × to remove. The selection must be visible with\n    the list closed, and \"3 selected\" hides which three.\n  - The value is announced with the field. React Aria points the input's\n    `aria-describedby` at a value element; this fills it with the chosen\n    labels joined by `Intl.ListFormat`, so \"Billing, Legal and Ops\" is read\n    in the user's language with no string shipped for it.\n  - Backspace in an empty field removes the last value, which is what\n    every tag input on the web has taught people to expect.\n  - Focus lands back in the field when the last tag is removed, instead of\n    falling to <body> with the TagGroup that held it.",
      "import": "import { MultiSelect } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A text field that filters a list, with any number of values chosen. The choices show as removable tags beneath the field and are read with it.",
      "useWhen": [
        "the user picks several values from a list too long to show as checkboxes — teams, regions, labels, assignees",
        "a table filter can match more than one value — \"Status is Failing or Paused\""
      ],
      "useInstead": [
        {
          "when": "exactly one value is picked",
          "use": "Combobox",
          "why": "Combobox's input shows the chosen label; here the input only ever holds the filter text, and the list stays open for the next pick"
        },
        {
          "when": "there are about seven options or fewer",
          "use": "CheckboxGroup",
          "why": "every option visible at once beats typing to find one, and nothing is hidden behind a popover"
        },
        {
          "when": "the values are free text the user invents, not options from a list",
          "use": "TagGroup",
          "why": "MultiSelect only chooses from `options`; a free-text tag input is a TagGroup beside an Input"
        }
      ],
      "composition": {
        "order": [
          "MultiSelect"
        ],
        "note": "Pass the full `options` list; filtering, the tags and the announcement are the component's. In a table's filter bar, give it `aria-label` and `hideTags`, and list its values in the table's active-filters TagGroup — one removable tag per value, not the same tags twice.",
        "example": "<MultiSelect label=\"Notify teams\" options={teams} value={teamIds} onChange={setTeamIds} placeholder=\"Search teams\" />"
      },
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms, table filters"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "prominent single-field forms"
          }
        }
      },
      "slots": {
        "label": {
          "accepts": "text",
          "note": "names the field and the tags beneath it"
        },
        "description": {
          "accepts": "text",
          "note": "replaced by `errorMessage` while `isInvalid` is set"
        },
        "errorMessage": {
          "accepts": "text",
          "note": "shown only while `isInvalid` is set"
        },
        "emptyLabel": {
          "accepts": "text",
          "note": "shown in the list when the filter matches nothing"
        }
      },
      "a11y": {
        "role": "combobox, controlling a listbox that is aria-multiselectable",
        "guarantees": [
          "React Aria's multi-select combobox: arrow keys move through the list while focus stays in the input, Enter or click toggles an option, and the list stays open for the next pick",
          "the chosen labels are on the input's `aria-describedby`, joined by `Intl.ListFormat` — read with the field in the user's language, with no string shipped for it",
          "the chosen values are a TagGroup named by the field's label: one tab stop, arrow keys between tags, Delete or the × to remove",
          "Backspace in an empty field removes the last value; with text in the field it only edits the text",
          "removing the last tag returns focus to the field instead of dropping it on <body>",
          "`isRequired` marks the field required only while nothing is chosen — at least one"
        ],
        "requires": [
          "`label`, or `aria-label` when there is no visible label — it names the tags as well as the field"
        ],
        "notes": [
          "While the list is open, React Aria hides everything outside the field and the list from assistive technology, the tags included. That is the platform pattern for a combobox, not a defect: the chosen options are marked selected in the list itself."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Combobox that appends each pick to a TagGroup by hand",
          "why": "the list closes after every pick, the input shows the last label, and nothing announces what is chosen"
        },
        {
          "dont": "a native <select multiple>",
          "why": "it needs Ctrl or Cmd to pick a second option, which most people never discover, and it cannot filter"
        },
        {
          "dont": "a count in place of the values — \"3 selected\"",
          "why": "it hides which three; the tags show them and the field reads them"
        },
        {
          "dont": "`hideTags` with nowhere else showing the values",
          "why": "the field shows only the filter text, so the choice is invisible until the list is opened"
        }
      ],
      "stylesheet": "src/styles/multi-select.css",
      "tokens": [
        "--border-disabled",
        "--border-primary-strong",
        "--border-stronger",
        "--border-width-default",
        "--border-width-thick",
        "--font-weight-regular",
        "--icon-on-color",
        "--ion-shadow-raised-flush-xs",
        "--radius-xs",
        "--spacing-16",
        "--spacing-2",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-hover",
        "--surface-primary",
        "--type-body-line-height"
      ],
      "props": {
        "options": {
          "type": "readonly ComboboxOption[]",
          "required": true,
          "origin": "own",
          "description": "The full option list. Filtering happens here, against what is typed."
        },
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Field label. Required for a usable control — see `a11y.requires`."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the field, and its tags, when there is no visible `label`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the field."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text when `isInvalid` is set."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "At least one value must be chosen."
        },
        "size": {
          "type": "MultiSelectSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Input's `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "value": {
          "type": "readonly string[] | undefined",
          "required": false,
          "origin": "own",
          "description": "The selected values (controlled), in the order they were chosen."
        },
        "defaultValue": {
          "type": "readonly string[] | undefined",
          "required": false,
          "origin": "own",
          "description": "The initially selected values (uncontrolled)."
        },
        "onChange": {
          "type": "((value: string[]) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Receives the whole new selection."
        },
        "inputValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled filter text. Usually only needed for remote filtering."
        },
        "onInputChange": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "hideTags": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Leave the tags out, when the chosen values are already on screen as\nremovable tags — a table's active-filters row. The values are still read\nwith the field, and still marked in the list."
        },
        "emptyLabel": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Shown in place of the list when nothing matches."
        },
        "buttonLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible label for the disclosure button."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Posts every selected value under this name, for an uncontrolled form."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the field box (`.ion-input`)."
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the `.ion-field` wrapper."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 23,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "NavItem": {
      "name": "NavItem",
      "source": "src/components/NavItem.tsx",
      "propsType": "NavItemProps",
      "description": "Nav Item renders an `<a>` when given `href`, a `<button>` otherwise —\nchosen by what the caller is actually building, the same judgment call\nMenu and Select make. A primary nav bar is links; a nav item with\n`showChevron` that opens a menu (no `href` of its own) is a button.\n\nInteraction state comes from React Aria rather than CSS pseudo-classes,\nmatching Button: `useHover` is pointer-aware so a tap does not stay\n\"hovered\" until the next tap elsewhere, and `useFocusRing` shows the ring\nonly for keyboard navigation. The CSS keeps its own `:hover` /\n`:focus-visible` rules so the stylesheet still works without React.",
      "import": "import { NavItem } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A primary navigation destination. Renders an <a> when given `href`, a <button> otherwise.",
      "useWhen": [
        "building a header nav bar, a sidebar or a section switcher",
        "an item opens a menu rather than navigating — pass `showChevron` and no `href`"
      ],
      "useInstead": [
        {
          "when": "the link sits inside body copy or a content block",
          "use": "Link"
        },
        {
          "when": "it is a row in a dropdown list rather than a top-level destination",
          "use": "MenuItem"
        }
      ],
      "slots": {
        "icon": {
          "accepts": "Icon",
          "note": "leading icon; omit its `label` — the item's text names it"
        }
      },
      "a11y": {
        "role": "link when `href` is set, button when it is not",
        "guarantees": [
          "hover state comes from React Aria's pointer-aware `useHover`, so a tap does not stay hovered until the next tap elsewhere",
          "the focus ring shows for keyboard navigation only",
          "the CSS keeps its own `:hover` / `:focus-visible` rules, so the styling survives without React",
          "`isCurrent` sets `aria-current=\"page\"`, and the current item is marked by an underline as well as colour, which survives forced-colours mode"
        ],
        "requires": [
          "an accessible name — `children`, or `aria-label` on an icon-only item"
        ],
        "notes": [
          "NavItem is one item, not the landmark. Wrap the set in a <nav> with its own accessible name."
        ]
      },
      "antiPatterns": [
        {
          "dont": "`showChevron` on a plain link that navigates",
          "why": "the chevron promises a menu; nothing opens"
        },
        {
          "dont": "marking the current page with colour only",
          "do": "`aria-current=\"page\"`",
          "why": "a screen-reader user is otherwise never told where they are"
        },
        {
          "dont": "marking the current page with a className or colour of your own instead of `isCurrent`",
          "why": "a screen reader hears nothing without `aria-current`, and a colour-only marker fails anyone who cannot see the difference"
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        }
      ],
      "stylesheet": "src/styles/nav-item.css",
      "tokens": [
        "--border-focus",
        "--border-primary",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-disabled",
        "--icon-interactive-hover",
        "--icon-primary",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-sm",
        "--spacing-4",
        "--spacing-8",
        "--text-default",
        "--text-disabled",
        "--text-interactive-hover",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `Icon` slot — an optional leading icon."
        },
        "showChevron": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's `Show Chevron`. Set on a nav item that opens a menu, not a plain link."
        },
        "isCurrent": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "The page the user is on. Marks the item `aria-current=\"page\"` and draws\nthe same indicator Tabs' underline does — so a section switcher built from\nNavItems says where you are, in words for a screen reader and in more than\ncolour for everyone else."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "href": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "NotificationsPanel": {
      "name": "NotificationsPanel",
      "source": "src/components/NotificationsPanel.tsx",
      "propsType": "NotificationsPanelProps",
      "description": "NotificationsPanel — what the bell opens: notifications grouped by day,\nunread ones marked, each one openable and markable, and a way to clear\nthem all.\n\nCONTENT, NOT A CONTAINER. It is the panel's contents; put it in a Popover\nunder the bell on a desktop — whose `title` names it — or a Drawer on a\nphone. It owns no data: the caller passes `notifications` and updates them\nfrom `onReadChange` and `onMarkAllRead`.\n\nUNREAD IS SAID, NOT ONLY SHOWN. An unread notification has a dot and a\nheavier title; to a screen reader its title begins \"Unread:\". The bar\ncounts them in words. The bell that opens it should say the count too —\n\"Notifications, 3 unread\" — for the same reason.\n\nNOTHING THAT HAS FOCUS DISAPPEARS. Each notification's toggle stays where\nit is and swaps \"Mark as read\" for \"Mark as unread\", rather than vanishing\nonce pressed. \"Mark all as read\" does go — there is nothing left to mark —\nso focus moves to the first notification first, not to the top of the page.\nEach change is announced in a status region that is always mounted.\n\nGROUPED BY DAY, NEWEST FIRST. Today, Yesterday, Earlier — each a heading,\nso a screen reader can jump between them — counted from the local calendar\nday, not from 24-hour windows.",
      "import": "import { NotificationsPanel } from 'ionbase-ui';",
      "status": "stable",
      "summary": "What the bell opens: notifications grouped by day — Today, Yesterday, Earlier — with unread ones marked in words as well as a dot, a read toggle on each that never disappears, Mark all as read, an empty state and a loading state. Content for a Popover or a Drawer; the caller owns the data.",
      "useWhen": [
        "the product tells a user about things that happened while they were elsewhere — an approval waiting, a run that failed, a mention",
        "each item can be opened, and whether it has been seen matters"
      ],
      "useInstead": [
        {
          "when": "the result of what the user just did — saved, sent, deleted",
          "use": "Toast",
          "why": "it comes and goes by itself; a notification waits in the list"
        },
        {
          "when": "a notice about the whole product — maintenance, a trial ending",
          "use": "Banner",
          "why": "it is seen on every page without opening anything"
        },
        {
          "when": "the history of one record — who changed it and when",
          "use": "List",
          "why": "an activity log has nothing to mark as read; Timeline, when it ships, is the record's history"
        }
      ],
      "composition": {
        "order": [
          "Button",
          "Popover",
          "NotificationsPanel"
        ],
        "note": "The bell is a Button whose name says the count — \"Notifications, 3 unread\" — and opens a Popover titled Notifications, lg, whose content is the panel. On a phone use a Drawer instead. The caller keeps the list and updates it from `onReadChange` and `onMarkAllRead`; `href` makes a title a link, `onOpen` a button. With `notifications` empty it shows \"You’re all caught up\"; with `isLoading`, rows of Skeleton and \"Loading notifications\" announced.",
        "example": "<Popover title=\"Notifications\" size=\"lg\" content={<NotificationsPanel notifications={items} onReadChange={setRead} onMarkAllRead={readAll} />}><Button aria-label=\"Notifications, 3 unread\" … /></Popover>"
      },
      "a11y": {
        "requires": [
          "the container's name — the Popover's or Drawer's `title`, \"Notifications\"",
          "the bell's name includes the unread count, since its badge is not read",
          "`labels` — translated, with `unread` as a function of the count"
        ],
        "guarantees": [
          "an unread notification's title begins \"Unread:\" for a screen reader; the dot and weight are not the only signal",
          "the unread count is in words in the bar",
          "each group is a section named by its heading, so a screen reader can jump between days",
          "each toggle stays where it is and swaps Mark as read for Mark as unread — focus is never on something that vanishes",
          "Mark all as read moves focus to the first notification before it goes",
          "every change is announced in a status region that is always mounted",
          "groups are counted from the local calendar day, not 24-hour windows; each has a machine-readable <time>"
        ],
        "notes": [
          "opening an unread notification marks it read, through `onReadChange`",
          "the list scrolls inside the panel past 28rem, so the popover never runs off the screen"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a blue dot as the only sign a notification is unread",
          "do": "NotificationsPanel, which also says \"Unread:\" and counts them",
          "why": "a dot is silent to a screen reader and missing in forced colours"
        },
        {
          "dont": "<Button aria-label=\"Notifications\"><Badge>3</Badge></Button>",
          "do": "<Button aria-label=\"Notifications, 3 unread\" />",
          "why": "the badge's number is not part of the name that is read"
        },
        {
          "dont": "a Mark as read button that is removed once pressed",
          "do": "the panel's toggle, which becomes Mark as unread",
          "why": "removing the focused button sends a keyboard user to the top of the page"
        }
      ],
      "stylesheet": "src/styles/notifications-panel.css",
      "tokens": [
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-semibold",
        "--radius-full",
        "--radius-xs",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-32",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-primary",
        "--text-default",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "notifications": {
          "type": "NotificationItem[]",
          "required": true,
          "origin": "own"
        },
        "onReadChange": {
          "type": "((id: string, isRead: boolean) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "A notification's read state changed — its toggle was pressed, or it was\nopened while unread. The caller owns the data and updates it."
        },
        "onMarkAllRead": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Shows \"Mark all as read\" while anything is unread."
        },
        "onOpen": {
          "type": "((id: string) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "A notification was opened — its title pressed. With `href` it is also a\nlink. An unread one is marked read as well."
        },
        "isLoading": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Rows of Skeleton in place of the list, while the first fetch runs."
        },
        "now": {
          "type": "Date | undefined",
          "required": false,
          "origin": "own",
          "description": "\"Today\" and \"Yesterday\" are counted from here. Defaults to now."
        },
        "locale": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "For the times shown; defaults to the browser's."
        },
        "headingLevel": {
          "type": "EmptyStateHeadingLevel | undefined",
          "required": false,
          "origin": "own",
          "description": "The group headings' level. Defaults to 3, under the popover's title."
        },
        "labels": {
          "type": "Partial<NotificationsPanelLabels> | undefined",
          "required": false,
          "origin": "own",
          "description": "Every string it shows or announces, for translation."
        }
      },
      "propCounts": {
        "own": 9,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "NumberInput": {
      "name": "NumberInput",
      "source": "src/components/NumberInput.tsx",
      "propsType": "NumberInputProps",
      "description": "NumberInput — a quantity, with formatting, bounds and steps.\n\nNOT `<input type=\"number\">`, and the list of reasons is the component:\n\n  It scrolls. A focused `type=\"number\"` changes value under the mouse wheel,\n  so a user scrolling the page past a price field edits the price. The wheel\n  is OFF here by default (`isWheelDisabled`), and a caller has to ask for it.\n\n  It cannot format. \"1,234.50\", \"€12\", \"15%\" are text to the browser's number\n  input, which rejects them. `formatOptions` takes `Intl.NumberFormatOptions`,\n  so currency, percent and units display and parse in the user's locale —\n  including \"1.234,50\" for a German user.\n\n  It is not a number to the browser's validation either. `type=\"text\"` with\n  the right `inputMode` gets the numeric keyboard on a phone without\n  Firefox letting letters in and Chrome silently reporting an empty value.\n\nEMPTY IS `null`, NOT `NaN`. React Aria reports an empty field as `NaN`, and\n`NaN === NaN` is false — so every \"is it empty\" check a caller writes is\nwrong. This converts at the boundary, both ways.\n\n− AND + SIT SIDE BY SIDE, NOT STACKED. Stacked chevrons split the field's\nheight in two, which puts a Small field's targets at 16px — under the 24px\nWCAG 2.5.8 asks. Side by side, every size clears it. They are out of the tab\norder, as React Aria sets them: the arrow keys do the same job from the field,\nand two extra tab stops per number on a form is a tax on every keyboard user.\n\nThe box is Input's, class for class, so every state — hover, focus, invalid,\nread-only, disabled — is Input's and cannot drift from it.",
      "import": "import { NumberInput } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A quantity field: formats and parses in the user's locale, clamps to bounds, steps with − and + or the arrow keys, and never changes under the scroll wheel unless asked.",
      "useWhen": [
        "the value is a quantity someone might add to or compare — a price, a count, a percentage, a duration",
        "the value has bounds or a step the field should enforce",
        "the value needs formatting — currency, percent, units — in the user's locale"
      ],
      "useInstead": [
        {
          "when": "the value is made of digits but is not a quantity — a phone number, postcode, card number, account or order ID",
          "use": "Input",
          "why": "a number field strips leading zeros, adds grouping separators and offers + and −, all of which corrupt an identifier. Nobody increments a postcode"
        },
        {
          "when": "the value is a phone number",
          "use": "PhoneInput"
        },
        {
          "when": "a rough value within a range matters more than an exact one — volume, a zoom level",
          "use": "Slider",
          "why": "a stepper that takes forty presses to cross the range is the wrong control; a track shows where the value sits"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms and table cells"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "a form where this value is the main input"
          }
        }
      },
      "a11y": {
        "requires": [
          "`label` or `aria-label` — the accessible name. A unit shown by `formatOptions` is not a name"
        ],
        "guarantees": [
          "Arrow Up and Down step the value from the field; Page Up and Down step by ten; Home and End go to the bounds",
          "the − and + buttons are named and out of the tab order, because the arrow keys already do their job from the field",
          "every size's step buttons are at least 24px square — WCAG 2.5.8",
          "the field is `type=\"text\"` with the matching `inputMode`, so phones show a numeric keyboard without the native number input's parsing quirks"
        ],
        "notes": [
          "the scroll wheel does not change the value unless `isWheelDisabled={false}` — a focused number input edited by scrolling past it is the classic bug",
          "`onChange` fires on commit — blur, Enter, a step — not per keystroke. `null` means empty; `NaN` never reaches the caller"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<NumberInput label=\"Postcode\" />",
          "do": "<Input label=\"Postcode\" inputMode=\"numeric\" />",
          "why": "\"02134\" becomes 2,134 — leading zero gone, separator added, and a + button nobody should press"
        },
        {
          "dont": "<Input type=\"number\" />",
          "do": "<NumberInput />",
          "why": "the native number input changes under the scroll wheel, cannot show currency or grouping, and reports an empty string for input it cannot parse"
        },
        {
          "dont": "a unit typed into the label and the value — label=\"Price ($)\" with a \"$\" typed in",
          "do": "formatOptions={{ style: 'currency', currency: 'USD' }}",
          "why": "formatOptions displays and parses the unit in the user's locale; a hand-typed symbol is text the parser has to reject"
        },
        {
          "dont": "checking `Number.isNaN(value)` for empty",
          "do": "`value === null`",
          "why": "this component converts the hook's NaN to null at the boundary, so NaN never arrives"
        }
      ],
      "stylesheet": "src/styles/number-input.css",
      "tokens": [
        "--icon-default",
        "--icon-disabled",
        "--icon-secondary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-input-height",
        "--ion-input-icon-size",
        "--radius-sm",
        "--spacing-2",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-pressed"
      ],
      "props": {
        "value": {
          "type": "number | null | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled value. `null` is empty — never `NaN`, which is what the\nunderlying hook uses and which fails every `===` check a caller writes."
        },
        "defaultValue": {
          "type": "number | null | undefined",
          "required": false,
          "origin": "own",
          "description": "Uncontrolled starting value. `null` for empty."
        },
        "onChange": {
          "type": "((value: number | null) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires when the value is committed — on blur, Enter, a step button or an\narrow key — not on every keystroke. `null` when the field was cleared."
        },
        "size": {
          "type": "NumberInputSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Input's `size`: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "showStepper": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "The − and + buttons. On by default; turn off for a dense table cell where\nthe arrow keys are enough.",
          "default": true
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the control box (`.ion-input`)."
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the `.ion-field` wrapper when a label or helper is shown."
        },
        "autoFocus": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the element should receive focus on render."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The element's unique identifier. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id)."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "onCopy": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user copies text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncopy)."
        },
        "onCut": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user cuts text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncut)."
        },
        "onPaste": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user pastes text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/onpaste)."
        },
        "onCompositionEnd": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system completes or cancels the current text\ncomposition session. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event)."
        },
        "onCompositionStart": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system starts a new text composition session.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionstart_event)."
        },
        "onCompositionUpdate": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a new character is received in the current text composition\nsession. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionupdate_event)."
        },
        "onFocus": {
          "type": "((e: FocusEvent<Element, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element receives focus."
        },
        "onBlur": {
          "type": "((e: FocusEvent<Element, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element loses focus."
        },
        "onBeforeInput": {
          "type": "FormEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is about to be modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/beforeinput_event)."
        },
        "onInput": {
          "type": "FormEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event)."
        },
        "onKeyDown": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is pressed."
        },
        "onKeyUp": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is released."
        },
        "onSelect": {
          "type": "ReactEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when text in the input is selected. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/select_event)."
        },
        "step": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The amount that the input value changes with each increment or decrement \"tick\"."
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Temporary text that occupies the text input when it is empty."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input is disabled."
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input can be selected but not changed by the user."
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether user input is required on the input before form submission."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input value is invalid."
        },
        "validationState": {
          "type": "ValidationState | undefined",
          "required": false,
          "origin": "aria",
          "values": [
            "valid",
            "invalid"
          ],
          "tags": {
            "deprecated": "Use `isInvalid` instead."
          }
        },
        "validationBehavior": {
          "type": "\"aria\" | \"native\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to use native HTML form validation to prevent form submission\nwhen the value is missing or invalid, or mark the field as required\nor invalid via ARIA.",
          "values": [
            "aria",
            "native"
          ],
          "tags": {
            "default": "'aria'"
          }
        },
        "validate": {
          "type": "((value: number) => ValidationError | true | null | undefined) | undefined",
          "required": false,
          "origin": "aria",
          "description": "A function that returns an error message if a given value is invalid.\nValidation errors are displayed to the user when the form is submitted\nif `validationBehavior=\"native\"`. For realtime validation, use the `isInvalid`\nprop instead."
        },
        "description": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "A description for the field. Provides a hint such as specific requirements for what to choose."
        },
        "errorMessage": {
          "type": "ReactNode | ((v: ValidationResult) => ReactNode)",
          "required": false,
          "origin": "aria",
          "description": "An error message for the field."
        },
        "onFocusChange": {
          "type": "((isFocused: boolean) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element's focus status changes."
        },
        "label": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "The content to display as the label."
        },
        "decrementAriaLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "A custom aria-label for the decrement button. If not provided, the localized string \"Decrement\"\nis used."
        },
        "incrementAriaLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "A custom aria-label for the increment button. If not provided, the localized string \"Increment\"\nis used."
        },
        "isWheelDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Enables or disables changing the value with scroll.",
          "default": true
        },
        "formatOptions": {
          "type": "Intl.NumberFormatOptions | undefined",
          "required": false,
          "origin": "other",
          "description": "Formatting options for the value displayed in the number field.\nThis also affects what characters are allowed to be typed by the user."
        },
        "commitBehavior": {
          "type": "\"validate\" | \"snap\" | undefined",
          "required": false,
          "origin": "other",
          "description": "Controls the behavior of the number field when the user blurs the field after editing. 'snap'\nwill clamp the value to the min/max values, and snap to the nearest step value. 'validate' will\nnot clamp the value, and will validate that the value is within the min/max range and on a\nvalid step.",
          "values": [
            "validate",
            "snap"
          ],
          "tags": {
            "default": "'snap'"
          }
        },
        "minValue": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The smallest value allowed for the input."
        },
        "maxValue": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The largest value allowed for the input."
        }
      },
      "propCounts": {
        "own": 7,
        "aria": 37,
        "dom": 278,
        "other": 2
      }
    },
    "PageHeader": {
      "name": "PageHeader",
      "source": "src/components/PageHeader.tsx",
      "propsType": "PageHeaderProps",
      "description": "PageHeader — the top of a page: where you are, what it is, and what you can\ndo to it.\n\nPromoted from the demo app, where five screens hand-wrote it in three\ndifferent shapes (`.demo-page__header`, `.demo-run-header`, and a bare\n`<div>`), each with its own gap and its own idea of where the actions\naligned. Every enterprise system ships one — Carbon's PageHeader,\nLightning's page headers — because every page has one.\n\nNOT A LANDMARK\n\nIt renders a `<div>`, not a `<header>`. A `<header>` that is a child of\n`<body>` is the page's banner, and the app shell's Header already is that.\nThe page's landmark is `<main>`, named by this title: pass `titleId` and\npoint `<main aria-labelledby>` at it.\n\nNo `'use client'`: `useId` resolves on the server, and nothing here is\ninteractive. Interactive parts arrive through the slots.",
      "import": "import { PageHeader } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The top of a page: breadcrumb, the page's h1, a description, the record's status, and the page's actions. Not a landmark — main is, named by this title.",
      "useWhen": [
        "the top of every page inside the app shell — list pages, record pages, settings, wizards",
        "the page has a title and actions that act on the whole page, such as New agent or Pause"
      ],
      "useInstead": [
        {
          "when": "it is the bar across the top of the whole app, with the logo and the account menu",
          "use": "Header",
          "why": "Header is the banner landmark, rendered once by the shell. PageHeader is inside main and changes with each page"
        },
        {
          "when": "it titles one section of a page rather than the page",
          "use": "Card",
          "why": "a Card's title is an h2 for a named region; a page has one PageHeader and one h1"
        },
        {
          "when": "the page is empty or failed to load",
          "use": "EmptyState",
          "why": "keep the PageHeader so the user still knows where they are, and put the EmptyState beneath it with headingLevel 2"
        }
      ],
      "composition": {
        "order": [
          "PageHeader",
          "Breadcrumb",
          "Badge",
          "Button",
          "MenuTrigger",
          "Tabs"
        ],
        "note": "Slots, not children: `breadcrumb` above, `status` beside the title, `actions` at the end of the title row, and `children` as a row beneath for the page's Tabs or filters. Pass `titleId` and point `<main aria-labelledby>` at it.",
        "example": "<main aria-labelledby=\"page-title\"><PageHeader titleId=\"page-title\" title=\"Payroll reconciler\" description=\"Matches payroll exports against the ledger every night.\" breadcrumb={<Breadcrumb><BreadcrumbItem href=\"/agents\">Agents</BreadcrumbItem><BreadcrumbItem isCurrent>Payroll reconciler</BreadcrumbItem></Breadcrumb>} status={<Badge intent=\"warning\">Paused</Badge>} actions={<><MenuTrigger><Button variant=\"tertiary\" aria-label=\"More actions\" startIcon={<Icon as={Ellipsis} />} /><Menu onAction={run}><MenuItem key=\"delete\">Delete…</MenuItem></Menu></MenuTrigger><Button variant=\"primary-brand\">Resume</Button></>} /></main>"
      },
      "slots": {
        "breadcrumb": {
          "accepts": "Breadcrumb"
        },
        "status": {
          "accepts": [
            "Badge",
            "Tag"
          ],
          "note": "the record's state. Never an action, and never colour alone — a Badge carries its label"
        },
        "actions": {
          "accepts": [
            "Button",
            "MenuTrigger",
            "Link"
          ],
          "note": "most important last, so the primary action sits at the end of the row. One primary Button at most; beyond two or three actions, put the rest in a MenuTrigger"
        },
        "children": {
          "accepts": [
            "Tabs",
            "SegmentedControl"
          ],
          "note": "a row that belongs to the header: the page's own tabs, or the filters its table answers to"
        }
      },
      "a11y": {
        "role": "none — a <div>. The page's landmark is <main>, named by this title",
        "guarantees": [
          "the title is a real heading, h1 by default, with a stable id for aria-labelledby",
          "actions keep their DOM order when they wrap beneath the title on a narrow screen, so reading order matches visual order"
        ],
        "notes": [
          "`headingLevel` is 1 by default. Use 2 only for a header on a pane that is not the page — the detail half of a list-detail layout, under the list's h1.",
          "It is deliberately not a <header>: a <header> directly inside <body> is the banner, and the app shell's Header already is that."
        ]
      },
      "antiPatterns": [
        {
          "dont": "two PageHeaders on one page",
          "why": "two h1s, and two answers to where am I. Sections below the header are Cards with h2 titles"
        },
        {
          "dont": "dropping the PageHeader while the page loads or fails",
          "why": "the user loses where they are exactly when something went wrong. Keep the title; put Skeleton or EmptyState beneath it"
        },
        {
          "dont": "putting filters or a search field in `actions`",
          "why": "actions act on the page; filters narrow what it shows. Filters go in `children`, the row beneath"
        },
        {
          "dont": "several primary Buttons in `actions`",
          "why": "a page has one next step. The one-primary-action lint rule counts them"
        }
      ],
      "stylesheet": "src/styles/page-header.css",
      "tokens": [
        "--font-family-sans",
        "--font-weight-semibold",
        "--ion-page-header-heading-basis",
        "--spacing-16",
        "--spacing-4",
        "--spacing-8",
        "--text-default",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-h4",
        "--type-h4-line-height"
      ],
      "props": {
        "title": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The page's title. Rendered as the `h1`."
        },
        "titleId": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The title's id. Pass it when something else points at the heading —\n`<main aria-labelledby>` is the usual one. Generated when omitted."
        },
        "headingLevel": {
          "type": "PageHeaderHeadingLevel | undefined",
          "required": false,
          "origin": "own",
          "description": "Heading element for the title. Defaults to `1`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "One or two sentences under the title: what this page is for."
        },
        "breadcrumb": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "A Breadcrumb, above the title."
        },
        "status": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Beside the title: a Badge or two for the record's state — \"Paused\",\n\"Draft\". State, not actions."
        },
        "actions": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "At the end of the title row: the page's actions, most important last.\nOne primary Button at most; several more go in a MenuTrigger."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "A row beneath, that belongs to the header rather than the page's\ncontent: the page's Tabs, or the filters its table answers to."
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "Pagination": {
      "name": "Pagination",
      "source": "src/components/Pagination.tsx",
      "propsType": "PaginationProps",
      "description": "Page navigation for a table or list.\n\nGeometry from the Figma `Pagination` (1291:503) and `Pagination Item`\n(1283:289) components. The Figma item set is not exported — a caller places a\nPagination, never a cell.",
      "import": "import { Pagination } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Page navigation for a table or list. Renders prev/next arrows, a truncated run of page numbers, and an optional rows-per-page Select.",
      "useWhen": [
        "a result set is split into pages and the user needs to move between them",
        "the total number of pages is known — the numbered run needs a last page to anchor to"
      ],
      "useInstead": [
        {
          "when": "the total is unknown or unbounded, as with a cursor-based or infinite feed",
          "use": "Button",
          "why": "a numbered pager has to draw a last page; with no total there is nothing to draw, and a bare Load more button is honest about that"
        },
        {
          "when": "the user is moving between peer views rather than through one long list",
          "use": "Tabs",
          "why": "pagination implies a sequence with a position; tabs are peers in any order"
        },
        {
          "when": "it is navigation between pages of the app rather than pages of data",
          "use": "NavItem"
        }
      ],
      "variants": {
        "type": {
          "numbered": {
            "use": "the default — prev, the page numbers, next. Use wherever the numbers fit"
          },
          "simple": {
            "use": "prev, \"Page N of M\", next. For drawers, narrow columns and mobile, where a numbered run would wrap or truncate to uselessness"
          }
        },
        "size": {
          "sm": {
            "use": "dense tables, and toolbars"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "roomy layouts, and touch targets on tablet"
          }
        }
      },
      "a11y": {
        "role": "navigation / list",
        "guarantees": [
          "the whole control is a landmark — a `nav` with an accessible name, defaulting to \"Pagination\"",
          "the current page carries `aria-current=\"page\"`, not `aria-pressed`: this is a position in a set, not a toggle",
          "every cell has an explicit label — \"Go to page 4\", \"Go to previous page\" — so a screen reader never announces a bare digit",
          "the ellipsis is `aria-hidden` and not focusable: it marks absent pages, and a tab stop that does nothing when activated is worse than no tab stop",
          "in `simple`, the \"Page N of M\" text is an `aria-live=\"polite\"` region, because the page can change without anything else on screen announcing it",
          "prev and next are genuinely `disabled` at the ends rather than styled to look it",
          "the focus ring shows for keyboard focus only, and hover does not latch on touch"
        ],
        "requires": [
          "`aria-label` when more than one Pagination is on a page — two identically named landmarks are two indistinguishable landmarks",
          "`pageSize` when `showPageSize` is set, or the rows-per-page Select renders with nothing selected"
        ]
      },
      "antiPatterns": [
        {
          "avoid": "passing `pageCount={0}` for an empty result set",
          "instead": "render EmptyState and no pager — the component clamps to a single page, which draws a lone disabled `1` that says a page exists when none does"
        },
        {
          "avoid": "raising `siblingCount` to avoid truncation",
          "instead": "switch to `type=\"simple\"`. Widening the run past the container is the problem truncation exists to solve, and it reappears one breakpoint down"
        },
        {
          "avoid": "treating `page` as 0-based",
          "instead": "it is 1-based, matching what the numbers say. An off-by-one here is silent — the pager looks right and fetches the wrong rows"
        }
      ],
      "stylesheet": "src/styles/pagination.css",
      "tokens": [
        "--border-focus",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-size-lg",
        "--icon-size-md",
        "--icon-size-xs",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-pagination-box",
        "--ion-pagination-font-size",
        "--ion-pagination-icon",
        "--ion-pagination-line-height",
        "--radius-md",
        "--spacing-16",
        "--spacing-2",
        "--spacing-32",
        "--spacing-4",
        "--spacing-40",
        "--spacing-48",
        "--spacing-6",
        "--spacing-8",
        "--surface-hover",
        "--surface-selected",
        "--surface-selected-hover",
        "--text-default",
        "--text-disabled",
        "--text-secondary",
        "--text-tertiary",
        "--type-body",
        "--type-body-lg",
        "--type-body-lg-line-height",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "page": {
          "type": "number",
          "required": true,
          "origin": "own",
          "description": "The current page, 1-based."
        },
        "pageCount": {
          "type": "number",
          "required": true,
          "origin": "own",
          "description": "Total number of pages. One or more."
        },
        "onPageChange": {
          "type": "((page: number) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Called with the requested page. Not called for the current page."
        },
        "type": {
          "type": "PaginationType | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Type` variant.",
          "values": [
            "numbered",
            "simple"
          ]
        },
        "size": {
          "type": "PaginationSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "siblingCount": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "How many pages to show either side of the current one. The first and last\nare always shown, so the widest run is `siblingCount * 2 + 5`."
        },
        "showPageSize": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Renders the rows-per-page control. Matches the Figma `Show Page Size`."
        },
        "pageSize": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Current rows-per-page value. Required when `showPageSize`."
        },
        "pageSizeOptions": {
          "type": "number[] | undefined",
          "required": false,
          "origin": "own",
          "description": "Options offered by the rows-per-page control."
        },
        "onPageSizeChange": {
          "type": "((pageSize: number) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "labels": {
          "type": "PaginationLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "Every string the pager renders, for translation. English by default. The\nlandmark's name is `aria-label` — \"Pagination\" when it is left out."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 12,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "PasswordInput": {
      "name": "PasswordInput",
      "source": "src/components/PasswordInput.tsx",
      "propsType": "PasswordInputProps",
      "description": "PasswordInput — a password field with a way to see what was typed.\n\nTHE TOGGLE IS A TOGGLE. Its name stays \"Show password\" and `aria-pressed`\nsays whether it is on, which is how a screen reader expects a toggle to\nbehave: a name that flips to \"Hide password\" reads as a different button.\nA polite status beside it also says \"Password shown\" or \"Password hidden\"\nwhen it changes, because some screen readers do not read a changed pressed\nstate aloud. It is a real tab stop, not the clear button's skipped one: it\nhas no keyboard shortcut to stand in for it.\n\nWHAT A SHOWN PASSWORD MUST NOT DO\n\n  Go to a spellchecker. A shown password is `type=\"text\"`, and browsers\n  spellcheck text fields, some by sending them to a server. Spellcheck,\n  autocorrect and autocapitalise are off whatever the state.\n\n  Be submitted shown. A password manager recognises a login by its\n  `type=\"password\"` field when the form is submitted; a shown one is not\n  offered for saving. Submitting the form hides it first.\n\nThe box IS Input's — the same `.ion-input` classes as SearchField and\nNumberInput — so a password field under an email field matches it.",
      "import": "import { PasswordInput } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A password field with a Show password toggle. The toggle keeps its name and says its state with `aria-pressed`, and a polite status says \"Password shown\" or \"Password hidden\". A shown password is never spellchecked, and is hidden again before its form is submitted.",
      "useWhen": [
        "the user types a password, a passphrase or a PIN they must get right without seeing it",
        "signing in, signing up, changing a password, or confirming one before a sensitive action"
      ],
      "useInstead": [
        {
          "when": "the secret is shown once for the user to copy — a new API key",
          "use": "CodeSnippet",
          "why": "it is shown on purpose, in full, with a copy button; masking it and then offering to show it adds a step and nothing else"
        },
        {
          "when": "a one-time code sent by text or email",
          "use": "Input",
          "why": "a one-time code is read off another screen and typed; masking it only makes it harder to check. Use `inputMode=\"numeric\"` and `autoComplete=\"one-time-code\"`"
        }
      ],
      "composition": {
        "order": [
          "PasswordInput"
        ],
        "note": "Takes Input's `label`, `description`, `isInvalid` and `errorMessage`. `autoComplete` is `current-password` by default; pass `new-password` where one is being set.",
        "example": "<PasswordInput label=\"New password\" autoComplete=\"new-password\" description=\"At least 12 characters.\" />"
      },
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms and dialogs"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "a sign-in page's main fields"
          }
        }
      },
      "a11y": {
        "requires": [
          "`label` — a visible one; a placeholder is not a label",
          "`revealLabel`, `shownMessage` and `hiddenMessage` translated"
        ],
        "guarantees": [
          "the toggle is a tab stop after the field, named `revealLabel` (\"Show password\") whatever the state; `aria-pressed` says whether the password is shown, and `aria-controls` points at the field",
          "a polite status says `shownMessage` or `hiddenMessage` when the toggle changes it, and nothing on first render",
          "a mouse press on the toggle leaves focus in the field; a keyboard press leaves it on the toggle; what was typed and the caret's place are kept",
          "spellcheck, autocorrect and autocapitalise are off, shown or hidden, and cannot be turned back on by a prop",
          "submitting the form hides the password before the form's own handler runs, so a password manager sees `type=\"password\"`",
          "`autoComplete` is `current-password` unless set; `new-password` lets a password manager offer to generate one",
          "`isDisabled` disables the toggle too; `isReadOnly` leaves it working",
          "the box is Input's, with Input's sizes and states"
        ],
        "notes": [
          "do not block paste into a password field: it breaks password managers, and WCAG 3.3.8 counts it against an accessible sign-in",
          "state the rules before the user types — \"At least 12 characters\" in `description` — rather than only in the error after"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Input type=\"password\" trailingIcon={<Eye />} /> with an onClick on the icon",
          "do": "<PasswordInput>",
          "why": "an icon is not a button: it cannot be focused or pressed from the keyboard, is not announced, and the shown password is spellchecked"
        },
        {
          "dont": "a toggle whose name flips between \"Show password\" and \"Hide password\"",
          "do": "one name, and `aria-pressed`",
          "why": "a screen reader reads a changed name as a different control, and the user cannot tell whether the press worked"
        },
        {
          "dont": "a \"Confirm password\" field under a new-password field",
          "do": "one PasswordInput the user can show",
          "why": "typing a password twice blind catches fewer mistakes than seeing it once, and costs every user a field"
        }
      ],
      "stylesheet": "src/styles/password-input.css",
      "tokens": [
        "--border-focus",
        "--border-width-thick",
        "--icon-default",
        "--icon-disabled",
        "--icon-secondary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-input-height",
        "--ion-input-icon-size",
        "--radius-sm",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-pressed"
      ],
      "props": {
        "size": {
          "type": "PasswordInputSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Input's sizes: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "autoComplete": {
          "type": "\"off\" | \"current-password\" | \"new-password\" | undefined",
          "required": false,
          "origin": "own",
          "description": "Default `current-password`. Pass `new-password` on a sign-up or change\nform, so a password manager offers to generate one instead of filling\nthe old.",
          "values": [
            "off",
            "current-password",
            "new-password"
          ],
          "default": "current-password"
        },
        "isRevealed": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled: whether the password is shown."
        },
        "defaultRevealed": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Uncontrolled: whether it starts shown. Default false.",
          "default": false
        },
        "onRevealedChange": {
          "type": "((isRevealed: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "revealLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The toggle's name. It does not change with the state — `aria-pressed`\nsays whether it is on. Default \"Show password\".",
          "default": "Show password"
        },
        "shownMessage": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Announced when the password is shown. Default \"Password shown\".",
          "default": "Password shown"
        },
        "hiddenMessage": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Announced when it is hidden again. Default \"Password hidden\".",
          "default": "Password hidden"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the control box (`.ion-input`)."
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the `.ion-field` wrapper when a label or helper is shown."
        },
        "onChange": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the value changes."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The default value (uncontrolled)."
        },
        "autoFocus": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the element should receive focus on render."
        },
        "enterKeyHint": {
          "type": "\"done\" | \"enter\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "An enumerated attribute that defines what action label or icon to preset for the enter key on\nvirtual keyboards. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint).",
          "values": [
            "done",
            "enter",
            "go",
            "next",
            "previous",
            "search",
            "send"
          ]
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The element's unique identifier. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id)."
        },
        "spellCheck": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An enumerated attribute that defines whether the element may be checked for spelling errors.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck)."
        },
        "autoCorrect": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An attribute that takes as its value a space-separated string that describes what, if any, type\nof autocomplete functionality the input should provide. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#autocomplete)."
        },
        "inputMode": {
          "type": "\"none\" | \"search\" | \"text\" | \"url\" | \"tel\" | \"email\" | \"numeric\" | \"decimal\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Hints at the type of data that might be entered by the user while editing the element or its\ncontents. See\n[MDN](https://html.spec.whatwg.org/multipage/interaction.html#input-modalities:-the-inputmode-attribute).",
          "values": [
            "none",
            "search",
            "text",
            "url",
            "tel",
            "email",
            "numeric",
            "decimal"
          ]
        },
        "aria-activedescendant": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the currently active element when DOM focus is on a composite widget, textbox,\ngroup, or application."
        },
        "aria-autocomplete": {
          "type": "\"inline\" | \"list\" | \"none\" | \"both\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates whether inputting text could trigger display of one or more predictions of the user's\nintended value for an input and specifies how predictions would be presented if they are made.",
          "values": [
            "inline",
            "list",
            "none",
            "both"
          ]
        },
        "aria-controls": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) whose contents or presence are controlled by the current\nelement."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        },
        "aria-errormessage": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element that provides an error message for the object."
        },
        "aria-haspopup": {
          "type": "boolean | \"true\" | \"false\" | \"menu\" | \"listbox\" | \"tree\" | \"grid\" | \"dialog\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates the availability and type of interactive popup element, such as menu or dialog, that\ncan be triggered by an element."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "onCopy": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user copies text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncopy)."
        },
        "onCut": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user cuts text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncut)."
        },
        "onPaste": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user pastes text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/onpaste)."
        },
        "onCompositionEnd": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system completes or cancels the current text\ncomposition session. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event)."
        },
        "onCompositionStart": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system starts a new text composition session.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionstart_event)."
        },
        "onCompositionUpdate": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a new character is received in the current text composition\nsession. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionupdate_event)."
        },
        "onFocus": {
          "type": "((e: FocusEvent<HTMLInputElement, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element receives focus."
        },
        "onBlur": {
          "type": "((e: FocusEvent<HTMLInputElement, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element loses focus."
        },
        "onBeforeInput": {
          "type": "FormEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is about to be modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/beforeinput_event)."
        },
        "onInput": {
          "type": "FormEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event)."
        },
        "onKeyDown": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is pressed."
        },
        "onKeyUp": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is released."
        },
        "onSelect": {
          "type": "ReactEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when text in the input is selected. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/select_event)."
        },
        "form": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The `<form>` element to associate the input with.\nThe value of this attribute must be the id of a `<form>` in the same document.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form)."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The name of the input element, used when submitting an HTML form. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname)."
        },
        "value": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The current value (controlled)."
        },
        "maxLength": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The maximum number of characters supported by the input. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefmaxlength)."
        },
        "minLength": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The minimum number of characters required by the input. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefminlength)."
        },
        "pattern": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Regex pattern that the value of the input must match to be valid. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefpattern)."
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Temporary text that occupies the text input when it is empty."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input is disabled."
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input can be selected but not changed by the user."
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether user input is required on the input before form submission."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input value is invalid."
        },
        "validationState": {
          "type": "ValidationState | undefined",
          "required": false,
          "origin": "aria",
          "values": [
            "valid",
            "invalid"
          ],
          "tags": {
            "deprecated": "Use `isInvalid` instead."
          }
        },
        "validationBehavior": {
          "type": "\"aria\" | \"native\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to use native HTML form validation to prevent form submission\nwhen the value is missing or invalid, or mark the field as required\nor invalid via ARIA.",
          "values": [
            "aria",
            "native"
          ],
          "tags": {
            "default": "'aria'"
          }
        },
        "validate": {
          "type": "((value: string) => ValidationError | true | null | undefined) | undefined",
          "required": false,
          "origin": "aria",
          "description": "A function that returns an error message if a given value is invalid.\nValidation errors are displayed to the user when the form is submitted\nif `validationBehavior=\"native\"`. For realtime validation, use the `isInvalid`\nprop instead."
        },
        "description": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "A description for the field. Provides a hint such as specific requirements for what to choose."
        },
        "errorMessage": {
          "type": "ReactNode | ((v: ValidationResult) => ReactNode)",
          "required": false,
          "origin": "aria",
          "description": "An error message for the field."
        },
        "onFocusChange": {
          "type": "((isFocused: boolean) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element's focus status changes."
        },
        "label": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "The content to display as the label."
        },
        "excludeFromTabOrder": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to exclude the element from the sequential tab order. If true,\nthe element will not be focusable via the keyboard by tabbing. This should\nbe avoided except in rare scenarios where an alternative means of accessing\nthe element or its functionality via the keyboard is available."
        }
      },
      "propCounts": {
        "own": 10,
        "aria": 49,
        "dom": 265,
        "other": 0
      }
    },
    "PhoneInput": {
      "name": "PhoneInput",
      "source": "src/components/PhoneInput.tsx",
      "propsType": "PhoneInputProps",
      "description": "PhoneInput — Figma `Input/Phone` (80:372).\n\nA dial-code block butted against an Input, sharing one outline: the block\ncarries the left radii and the control the right, so the seam is square on\nboth sides and reads as a single control. Both keep their full 1px border,\nwhich is what Figma draws — the 2px seam is the two strokes meeting, not an\naccident.\n\nThree sizes, no State axis. Every interaction state is the Input's, reached\nthrough the ordinary props, because Figma composes this from the same `Input`\ninstance rather than redrawing it.\n\nWHAT THIS DELIBERATELY DOES NOT DO\n\nIt does not pick countries. The chevron implies a menu and Figma specifies no\nopen state for it — no list, no flags, no search, no selected state. Country\ndata and the picker are application concerns with real editorial weight (which\nterritories, which names, which order), and inventing them here would be\ndesigning rather than implementing.\n\nSo the trigger is a real `<button>` with an accessible name, and\n`countryButtonProps` is how you wire it to a `Menu`, a popover or your own\nlistbox — including the `aria-haspopup` and `aria-expanded` that only the\nthing owning the popup can set honestly.",
      "import": "import { PhoneInput } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A dial-code block butted against an Input, sharing one outline. It does not pick countries.",
      "useWhen": [
        "collecting a phone number where the country calling code matters"
      ],
      "useInstead": [
        {
          "when": "the number is free-form and the code does not matter",
          "use": "Input"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "prominent single-field forms"
          }
        }
      },
      "slots": {
        "trailingIcon": {
          "accepts": "Icon",
          "note": "passed through to the Input"
        },
        "countryButtonProps": {
          "accepts": "button props",
          "note": "how a country picker is attached — spread your trigger's props here, including the `aria-haspopup` and `aria-expanded` only the thing owning the popup can set honestly"
        }
      },
      "a11y": {
        "guarantees": [
          "the dial-code trigger is a real <button> with an accessible name",
          "every interaction state is the Input's, reached through the ordinary Input props"
        ],
        "requires": [
          "`label`, or `aria-label` when no visible label is rendered",
          "`countryLabel` if you change the default — the trigger shows only `+1`, which names a value rather than an action"
        ]
      },
      "antiPatterns": [
        {
          "dont": "expecting the chevron to open a country list",
          "why": "the picker is deliberately not implemented — country data and ordering are editorial application decisions, and Figma specifies no open state. Wire your own through `countryButtonProps`"
        },
        {
          "dont": "wrapping it in your own bordered container",
          "why": "the block and the control already share one outline; a second border reads as two controls"
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        }
      ],
      "stylesheet": "src/styles/phone-input.css",
      "tokens": [
        "--border-default",
        "--border-disabled",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-regular",
        "--icon-disabled",
        "--icon-size-md",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-phone-input-border-width",
        "--ion-phone-input-font-size",
        "--ion-phone-input-gap",
        "--ion-phone-input-icon-size",
        "--ion-phone-input-line-height",
        "--ion-phone-input-padding-x",
        "--ion-phone-input-radius",
        "--radius-md",
        "--radius-sm",
        "--spacing-12",
        "--spacing-16",
        "--spacing-4",
        "--surface-disabled",
        "--surface-page",
        "--text-disabled",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "dialCode": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The dial code shown in the leading block, e.g. `+1`.",
          "default": "+1"
        },
        "countryLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible name for the dial-code trigger. It shows only `+1`, which names\na value rather than an action, so the button needs a label of its own.",
          "default": "Select country calling code"
        },
        "countryButtonProps": {
          "type": "React.ButtonHTMLAttributes<HTMLButtonElement> | undefined",
          "required": false,
          "origin": "own",
          "description": "Spread onto the dial-code `<button>`. This is how a country picker gets\nattached — see the note on scope below."
        },
        "onChange": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the value changes."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The default value (uncontrolled)."
        },
        "autoCapitalize": {
          "type": "\"none\" | \"off\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | undefined",
          "required": false,
          "origin": "own",
          "description": "Narrower than React's own typing, which also allows any `string`.\n`autoCapitalize` is declared on `AriaTextFieldOptions` rather than\n`AriaTextFieldProps`, so `keyof AriaTextFieldProps` above cannot omit it —\nand the wide DOM version does not satisfy what `useTextField` accepts.",
          "values": [
            "none",
            "off",
            "on",
            "sentences",
            "words",
            "characters"
          ]
        },
        "autoFocus": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the element should receive focus on render."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the control box (`.ion-input`). Always lands here —\nnever on the form-field wrapper. Use `wrapperClassName` for that."
        },
        "enterKeyHint": {
          "type": "\"done\" | \"enter\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "An enumerated attribute that defines what action label or icon to preset for the enter key on\nvirtual keyboards. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint).",
          "values": [
            "done",
            "enter",
            "go",
            "next",
            "previous",
            "search",
            "send"
          ]
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The element's unique identifier. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id)."
        },
        "spellCheck": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An enumerated attribute that defines whether the element may be checked for spelling errors.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck)."
        },
        "autoCorrect": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An attribute that takes as its value a space-separated string that describes what, if any, type\nof autocomplete functionality the input should provide. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#autocomplete)."
        },
        "inputMode": {
          "type": "\"none\" | \"search\" | \"text\" | \"url\" | \"tel\" | \"email\" | \"numeric\" | \"decimal\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Hints at the type of data that might be entered by the user while editing the element or its\ncontents. See\n[MDN](https://html.spec.whatwg.org/multipage/interaction.html#input-modalities:-the-inputmode-attribute).",
          "values": [
            "none",
            "search",
            "text",
            "url",
            "tel",
            "email",
            "numeric",
            "decimal"
          ]
        },
        "aria-activedescendant": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the currently active element when DOM focus is on a composite widget, textbox,\ngroup, or application."
        },
        "aria-autocomplete": {
          "type": "\"inline\" | \"list\" | \"none\" | \"both\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates whether inputting text could trigger display of one or more predictions of the user's\nintended value for an input and specifies how predictions would be presented if they are made.",
          "values": [
            "inline",
            "list",
            "none",
            "both"
          ]
        },
        "aria-controls": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) whose contents or presence are controlled by the current\nelement."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        },
        "aria-errormessage": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element that provides an error message for the object."
        },
        "aria-haspopup": {
          "type": "boolean | \"true\" | \"false\" | \"menu\" | \"listbox\" | \"tree\" | \"grid\" | \"dialog\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates the availability and type of interactive popup element, such as menu or dialog, that\ncan be triggered by an element."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "onCopy": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user copies text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncopy)."
        },
        "onCut": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user cuts text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncut)."
        },
        "onPaste": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user pastes text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/onpaste)."
        },
        "onCompositionEnd": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system completes or cancels the current text\ncomposition session. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event)."
        },
        "onCompositionStart": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system starts a new text composition session.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionstart_event)."
        },
        "onCompositionUpdate": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a new character is received in the current text composition\nsession. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionupdate_event)."
        },
        "onFocus": {
          "type": "((e: FocusEvent<HTMLInputElement, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element receives focus."
        },
        "onBlur": {
          "type": "((e: FocusEvent<HTMLInputElement, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element loses focus."
        },
        "onBeforeInput": {
          "type": "FormEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is about to be modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/beforeinput_event)."
        },
        "onInput": {
          "type": "FormEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event)."
        },
        "onKeyDown": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is pressed."
        },
        "onKeyUp": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is released."
        },
        "onSelect": {
          "type": "ReactEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when text in the input is selected. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/select_event)."
        },
        "type": {
          "type": "(string & {}) | \"search\" | \"text\" | \"url\" | \"tel\" | \"email\" | \"password\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "The type of input to render. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdeftype).",
          "tags": {
            "default": "'text'"
          },
          "default": "tel"
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "form": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The `<form>` element to associate the input with.\nThe value of this attribute must be the id of a `<form>` in the same document.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form)."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The name of the input element, used when submitting an HTML form. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname)."
        },
        "value": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The current value (controlled)."
        },
        "size": {
          "type": "InputSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "autoComplete": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Describes the type of autocomplete functionality the input should provide if any. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete)."
        },
        "maxLength": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The maximum number of characters supported by the input. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefmaxlength)."
        },
        "minLength": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The minimum number of characters required by the input. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefminlength)."
        },
        "pattern": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Regex pattern that the value of the input must match to be valid. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefpattern)."
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Temporary text that occupies the text input when it is empty."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input is disabled."
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input can be selected but not changed by the user."
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether user input is required on the input before form submission."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input value is invalid."
        },
        "validationState": {
          "type": "ValidationState | undefined",
          "required": false,
          "origin": "aria",
          "values": [
            "valid",
            "invalid"
          ],
          "tags": {
            "deprecated": "Use `isInvalid` instead."
          }
        },
        "validationBehavior": {
          "type": "\"aria\" | \"native\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to use native HTML form validation to prevent form submission\nwhen the value is missing or invalid, or mark the field as required\nor invalid via ARIA.",
          "values": [
            "aria",
            "native"
          ],
          "tags": {
            "default": "'aria'"
          }
        },
        "validate": {
          "type": "((value: string) => ValidationError | true | null | undefined) | undefined",
          "required": false,
          "origin": "aria",
          "description": "A function that returns an error message if a given value is invalid.\nValidation errors are displayed to the user when the form is submitted\nif `validationBehavior=\"native\"`. For realtime validation, use the `isInvalid`\nprop instead."
        },
        "description": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "A description for the field. Provides a hint such as specific requirements for what to choose."
        },
        "errorMessage": {
          "type": "ReactNode | ((v: ValidationResult) => ReactNode)",
          "required": false,
          "origin": "aria",
          "description": "An error message for the field."
        },
        "onFocusChange": {
          "type": "((isFocused: boolean) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element's focus status changes."
        },
        "label": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "The content to display as the label."
        },
        "excludeFromTabOrder": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to exclude the element from the sequential tab order. If true,\nthe element will not be focusable via the keyboard by tabbing. This should\nbe avoided except in rare scenarios where an alternative means of accessing\nthe element or its functionality via the keyboard is available."
        },
        "trailingIcon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Trailing icon. Figma's `Show Trailing Icon` + `Trailing Icon` swap."
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the `.ion-field` wrapper when a label or helper is shown."
        }
      },
      "propCounts": {
        "own": 9,
        "aria": 51,
        "dom": 265,
        "other": 0
      }
    },
    "Popover": {
      "name": "Popover",
      "source": "src/components/Popover.tsx",
      "propsType": "PopoverProps",
      "description": "Popover — Figma `Popover` (825:1853).\n\nSITS BETWEEN TOOLTIP AND MODAL. A tooltip is a hint that cannot hold\nfocusable content; a modal is a task that takes over the page. A popover\nholds interactive content but stays attached to the control that opened it.\n\n`usePopover` contains focus, closes on Escape or an outside click, and hides\nthe rest of the page from assistive tech while open — the same guarantees\nModal gives. What differs is the framing, and it is deliberate: no visible\nscrim, anchored to its trigger, and `surface/raised` rather than Modal's\n`surface/overlay`. It reads as attached to the page rather than replacing it.",
      "import": "import { Popover } from 'ionbase-ui';",
      "status": "stable",
      "summary": "An anchored panel that holds interactive content. Sits between Tooltip and Modal.",
      "useWhen": [
        "the content is interactive — a form, a filter, a list of actions — but should stay attached to the control that opened it",
        "the surrounding page should stay visible and in context"
      ],
      "useInstead": [
        {
          "when": "the content is a short hint with nothing focusable in it",
          "use": "Tooltip",
          "why": "a tooltip cannot be focused or scrolled and closes when the pointer leaves"
        },
        {
          "when": "the task takes over the page or must be completed before anything else",
          "use": "Modal"
        },
        {
          "when": "you only need the list surface and will position it yourself",
          "use": "Menu"
        }
      ],
      "variants": {
        "placement": {
          "top": {
            "use": "above the trigger"
          },
          "bottom": {
            "use": "below the trigger — the usual choice for a control in a header"
          },
          "left": {
            "use": "to the left of the trigger"
          },
          "right": {
            "use": "to the right of the trigger"
          }
        },
        "size": {
          "sm": {
            "use": "a couple of actions or one short field"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "a small form or a filter panel"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "Button",
          "note": "the trigger. Must forward both a ref and DOM props — every component in this library does"
        },
        "content": {
          "accepts": "any"
        },
        "title": {
          "accepts": "text",
          "note": "also gives the dialog its accessible name"
        },
        "footer": {
          "accepts": "Button",
          "note": "action row, right-aligned"
        }
      },
      "a11y": {
        "role": "dialog",
        "guarantees": [
          "focus is contained while open, Escape and outside clicks close it, and the rest of the page is hidden from assistive tech",
          "`placement` is a preference, not a guarantee — it flips when there is no room, and the arrow follows"
        ],
        "requires": [
          "`title`, or `aria-label` on the popover — a dialog with no name announces nothing about what opened"
        ],
        "notes": [
          "`placement` names where the POPOVER sits, not where the arrow points."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a function-component trigger that drops its ref",
          "why": "it renders, but the popover has nothing to position against"
        },
        {
          "dont": "using a Popover where a Modal belongs",
          "why": "a popover closes on an outside click; a task that must be finished should not be dismissible by a stray click"
        },
        {
          "dont": "nesting a Popover inside another Popover",
          "why": "two overlapping focus containments, and dismissing the outer one takes the inner one with it"
        }
      ],
      "stylesheet": "src/styles/popover.css",
      "tokens": [
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-secondary",
        "--icon-size-md",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-popover-width",
        "--ion-shadow-shadow-lg",
        "--radius-md",
        "--radius-sm",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-32",
        "--spacing-8",
        "--surface-hover",
        "--surface-raised",
        "--text-default",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "children": {
          "type": "React.ReactElement<unknown, string | React.JSXElementConstructor<any>>",
          "required": true,
          "origin": "own",
          "description": "The element that opens it. Must accept a ref and DOM props."
        },
        "content": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Panel contents."
        },
        "title": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Heading. Also gives the dialog its accessible name."
        },
        "footer": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Action row, right-aligned."
        },
        "placement": {
          "type": "PopoverPlacement | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Placement` variant, and names where the POPOVER sits —\nnot where the arrow points. A preference, not a guarantee: it flips when\nthere is no room, and the arrow follows.",
          "values": [
            "top",
            "bottom",
            "left",
            "right"
          ]
        },
        "size": {
          "type": "PopoverSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant. Widths only; height always hugs.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "showClose": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Show the close button. Defaults to `true`."
        },
        "hideArrow": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Hide the caret."
        },
        "closeLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "isOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "onOpenChange": {
          "type": "((isOpen: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 12,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "ProgressBar": {
      "name": "ProgressBar",
      "source": "src/components/ProgressBar.tsx",
      "propsType": "ProgressBarProps",
      "description": "ProgressBar — a determinate or indeterminate measure of work.\n\nDETERMINATE AND INDETERMINATE ARE THE SAME COMPONENT ON PURPOSE\n\nWork that starts unmeasurable and becomes measurable is the common case — a\nupload that does not know its size until the first chunk lands. Splitting\nthem into two components would make that transition a swap, which remounts\nthe node and loses the live region with it.\n\nOmitting `value` is what makes it indeterminate. `aria-valuenow` is then\nomitted too, which is exactly what the ARIA spec asks for and is the detail\nhand-rolled progress bars get wrong most often — a bar reporting\n`aria-valuenow=\"0\"` forever tells a screen reader the work is stuck at zero,\nnot that it is unmeasured.\n\nWHY `label` IS REQUIRED\n\n`role=\"progressbar\"` announces a number. Without a name the user hears \"42\npercent\" with no indication of what is at 42 percent, which is worse than\nsilence because it sounds like information.",
      "import": "import { ProgressBar } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A determinate or indeterminate measure of work, with the name of the work attached. Omitting `value` is what makes it indeterminate.",
      "useWhen": [
        "work reports a fraction complete — an upload, an import, a multi-step job",
        "work will become measurable but is not yet, and the bar should not remount when it does",
        "a quota or usage figure is better read as a bar than a number"
      ],
      "useInstead": [
        {
          "when": "the wait is short and unmeasurable",
          "use": "Spinner",
          "why": "an indeterminate bar occupies a block of layout to say what a spinner says inline"
        },
        {
          "when": "the region is being replaced by content",
          "use": "Skeleton"
        },
        {
          "when": "measurable progress has to fit a square or a line of text — a header's status, a table cell",
          "use": "ProgressRing",
          "why": "a bar needs a row's width to be read; a ring holds the same fraction in the space of an icon"
        }
      ],
      "variants": {
        "intent": {
          "primary": {
            "use": "the default — work proceeding normally"
          },
          "success": {
            "use": "work that finished, where the bar stays on screen as a record"
          },
          "warning": {
            "use": "a quota approaching its limit",
            "note": "warning describes the VALUE, not a failure of the bar"
          },
          "error": {
            "use": "a quota exceeded, or a job that failed part-way",
            "note": "the bar is still reporting correctly — reserve this for the measured thing being bad"
          }
        },
        "size": {
          "sm": {
            "use": "inside a row or a dense table cell"
          },
          "md": {
            "use": "the default"
          }
        }
      },
      "a11y": {
        "requires": [
          "`label` — it is required, because role=\"progressbar\" announces a number and a number with no noun sounds like information while carrying none",
          "`valueText` when a percentage is not what a person wants read aloud — \"3 of 12 files\""
        ],
        "notes": [
          "indeterminate omits aria-valuenow entirely, as the ARIA spec asks; a bar reporting aria-valuenow=0 forever says the work is stuck rather than unmeasured",
          "value is clamped to 0..max, so a caller cannot render a bar past its own end"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<ProgressBar label=\"Loading\" value={0} /> while the total is unknown",
          "do": "<ProgressBar label=\"Loading\" />",
          "why": "value={0} announces \"0 percent\" indefinitely. Omitting value is how you say \"not measurable yet\""
        },
        {
          "dont": "swapping a Spinner for a ProgressBar once the size is known",
          "do": "one ProgressBar, with `value` appearing when it becomes known",
          "why": "the swap remounts the node and takes the live region with it, so the transition is silent to a screen reader"
        },
        {
          "dont": "intent=\"error\" because the request failed",
          "do": "leave the bar and put the failure in an Alert",
          "why": "intent describes the value being measured. A failed request is not a red measurement, it is a different message"
        }
      ],
      "stylesheet": "src/styles/progress-bar.css",
      "tokens": [
        "--border-width-default",
        "--font-family-sans",
        "--ion-duration-base",
        "--ion-duration-cycle",
        "--ion-duration-slow",
        "--ion-ease-in-out",
        "--ion-ease-out",
        "--ion-progress-bar-fill",
        "--ion-progress-bar-height",
        "--ion-progress-bar-pct",
        "--radius-full",
        "--spacing-4",
        "--spacing-8",
        "--surface-error",
        "--surface-muted",
        "--surface-primary",
        "--surface-success",
        "--surface-warning",
        "--text-default",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "What is progressing. Required — a bare bar announces a number and no noun."
        },
        "value": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "0 to `max`. Omit for an indeterminate bar."
        },
        "max": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Defaults to 100."
        },
        "intent": {
          "type": "ProgressBarIntent | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "primary",
            "success",
            "warning",
            "error"
          ]
        },
        "size": {
          "type": "ProgressBarSize | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md"
          ]
        },
        "isLabelVisible": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Show the label as text above the track."
        },
        "isValueVisible": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Show the percentage beside the label. Determinate bars only."
        },
        "valueText": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Spoken instead of the percentage — \"3 of 12 files\". A percentage is rarely\nthe thing a person wants read aloud."
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "ProgressRing": {
      "name": "ProgressRing",
      "source": "src/components/ProgressRing.tsx",
      "propsType": "ProgressRingProps",
      "description": "ProgressRing — how far a piece of work has got, as a ring that fills\nclockwise from the top. ProgressBar's contract in a square: a value out of\n`max`, a required label, an intent for the thing measured.\n\nDETERMINATE ONLY\n\nA ring with no value is a Spinner, and a second component that also spins\nwould make an agent choose between two for the same wait. Work that starts\nunmeasurable is ProgressBar's, which keeps its node when the value arrives.\n\nCLOCKWISE IN EVERY DIRECTION\n\nA ring fills the way a clock's hand moves, and clocks do not mirror in a\nright-to-left locale. Only the text beside it moves to the other side.\n\nTHE STROKE IS A TENTH OF THE DIAMETER\n\nThe circle is drawn in a 100-unit box and scales with the ring, so the\ntrack is 2px on Small, 5.6px on Medium and 8px on Large without a stroke\nsize per step. The fill is `pathLength=\"100\"` dashed to the percentage, so\nthe CSS needs the percentage and nothing else.",
      "import": "import { ProgressRing } from 'ionbase-ui';",
      "status": "stable",
      "summary": "How far a piece of work has got, as a ring that fills clockwise from the top: a value out of `max`, a required label, an intent for the thing measured. Determinate only.",
      "useWhen": [
        "measurable progress has to fit a square or a line of text — a page header's status, a table cell, beside a step count",
        "one figure is the point of a card — a setup that is 3 of 5 done, a quota at 72% — and a ring with the percentage inside reads at a glance",
        "the work is measurable from the start; it will not begin as an unknown and become a number"
      ],
      "useInstead": [
        {
          "when": "the work cannot be measured — a wait with no fraction",
          "use": "Spinner",
          "why": "a ring stuck at 0% says the work has not started; a spinner says it is running and unmeasured"
        },
        {
          "when": "the work starts unmeasurable and becomes measurable, such as an upload before its size is known",
          "use": "ProgressBar",
          "why": "ProgressBar keeps its node when the value arrives, so the change is announced; swapping a Spinner for a ring remounts it and goes silent"
        },
        {
          "when": "there is room for a row, or several are compared down a list",
          "use": "ProgressBar",
          "why": "lengths side by side compare at a glance; rings side by side are angles, which people read far less accurately"
        }
      ],
      "composition": {
        "order": [
          "ProgressRing"
        ],
        "note": "`label` names the work and is required, shown or not. Give `valueText` whenever a count means more than a percentage — \"3 of 5 steps done\" — and it is both spoken and shown beside the ring. Small sits in a line of text and puts its value beside it; Medium and Large hold the percentage inside.",
        "example": "<ProgressRing size=\"sm\" label=\"Run progress\" value={done} max={total} valueText={`${done} of ${total} steps done`} isValueVisible />"
      },
      "variants": {
        "size": {
          "sm": {
            "use": "in a line of text — a header's status, a table cell; its value goes beside it"
          },
          "md": {
            "use": "the default — a card, with the percentage inside"
          },
          "lg": {
            "use": "the one figure a card or an empty page is about"
          }
        },
        "intent": {
          "primary": {
            "use": "the default — work proceeding normally"
          },
          "success": {
            "use": "work that finished, where the ring stays on screen as a record"
          },
          "warning": {
            "use": "a quota approaching its limit",
            "note": "warning describes the VALUE, not a failure of the ring"
          },
          "error": {
            "use": "a quota exceeded, or a job that failed part-way",
            "note": "reserve this for the measured thing being bad"
          }
        }
      },
      "a11y": {
        "requires": [
          "`label` — it is required, because role=\"progressbar\" announces a number and a number with no noun sounds like information while carrying none",
          "`valueText` when a percentage is not what a person wants read aloud — \"3 of 5 steps done\""
        ],
        "guarantees": [
          "one progressbar named by `label`, shown or not, with `aria-valuenow`, its bounds, and `aria-valuetext` from `valueText`",
          "the drawing and the percentage inside it are hidden from assistive tech: the value is already announced",
          "`value` is held to 0..`max`, and `max={0}` draws an empty ring rather than NaN",
          "the ring fills clockwise from twelve o'clock in every direction; in right to left, only the text beside it moves",
          "\"100%\" fits inside Medium and Large with room to spare",
          "in forced colours the track is GrayText and the fill Highlight"
        ],
        "notes": [
          "Not a live region. A value that changes while the reader waits for it belongs in a status message as well — \"Indexing finished\" — not in a ring that announces every step.",
          "The fill is `surface/*`, as ProgressBar's is, and its track is `surface/muted`."
        ]
      },
      "antiPatterns": [
        {
          "dont": "<ProgressRing label=\"Loading\" value={0} /> while the total is unknown",
          "do": "<Spinner label=\"Loading\" />, or a ProgressBar with no `value`",
          "why": "a ring at 0% says nothing has happened; unknown progress is a different message"
        },
        {
          "dont": "a column of rings comparing agents' budgets",
          "do": "a list of ProgressBars",
          "why": "people compare lengths well and angles badly, so the column hides which is nearest its limit"
        },
        {
          "dont": "intent=\"error\" because the request failed",
          "do": "leave the ring and put the failure in an Alert",
          "why": "intent describes the value being measured; a failed request is a different message"
        }
      ],
      "stylesheet": "src/styles/progress-ring.css",
      "tokens": [
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-size-md",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-progress-ring-fill",
        "--ion-progress-ring-pct",
        "--ion-progress-ring-size",
        "--spacing-12",
        "--spacing-56",
        "--spacing-6",
        "--spacing-8",
        "--spacing-80",
        "--surface-error",
        "--surface-muted",
        "--surface-primary",
        "--surface-success",
        "--surface-warning",
        "--text-default",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "What is progressing. Required — a bare ring announces a number and no noun."
        },
        "value": {
          "type": "number",
          "required": true,
          "origin": "own",
          "description": "0 to `max`. Required: a ring with no value is a Spinner."
        },
        "max": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Defaults to 100."
        },
        "intent": {
          "type": "ProgressRingIntent | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "primary",
            "success",
            "warning",
            "error"
          ]
        },
        "size": {
          "type": "ProgressRingSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Small sits in a line of text; Medium and Large hold the percentage inside.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "isLabelVisible": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Show the label as text beside the ring."
        },
        "isValueVisible": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Show the value. Medium and Large put the percentage inside the ring;\nSmall has no room, so the value follows the ring as text."
        },
        "valueText": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Spoken instead of the percentage — \"3 of 5 steps done\" — and shown beside\nthe ring in place of it. The centre keeps the percentage, which fits."
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "PromptInput": {
      "name": "PromptInput",
      "source": "src/components/PromptInput.tsx",
      "propsType": "PromptInputProps",
      "description": "PromptInput — where a person writes to an agent.\n\nWHY IT IS NOT A TEXTAREA AND A BUTTON\n\nEvery composer is assembled that way, and every one re-implements the same\nfour bugs:\n\n  Enter during IME composition sends half a word. Japanese, Chinese and\n  Korean input confirm a candidate with Enter; a handler that does not check\n  `isComposing` sends the message mid-character. That excludes whole\n  languages from the product, silently, and nobody on an English keyboard\n  ever sees it.\n\n  The prompt is lost when sending fails. Clearing on submit is right —\n  waiting for the server makes the composer feel broken — but clearing and\n  never restoring destroys what may have been a long message. Uncontrolled,\n  a rejected `onSubmit` promise puts the text back.\n\n  The stop control lives somewhere else. While a run is going, the send\n  button's place is exactly where the user's pointer and attention already\n  are, so that is where `AgentStop` goes — the same control, the same\n  guarantees, not a second stop button with different ones.\n\n  The keyboard contract is invisible. \"Enter sends, Shift+Enter for a new\n  line\" is announced through `aria-describedby`, because a screen-reader\n  user who presses Enter expecting a newline has just sent a message.\n\nWHAT IT DOES NOT DO\n\nIt does not upload, pick models or render messages. `attachments` and\n`actions` are slots, because what a product attaches and which tools it\noffers are the product's decisions.",
      "import": "import { PromptInput } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Where a person writes to an agent: a growing text field, a send control that becomes the stop control while a run is going, and slots for attachments and tools.",
      "useWhen": [
        "a chat or assistant surface where the user's message starts a run",
        "a command bar that sends free text to an agent"
      ],
      "useInstead": [
        {
          "when": "the text is a form value submitted with other fields",
          "use": "Textarea",
          "why": "Enter-to-send inside a form fights the form's own submit, and a single field of a larger form is not a composer"
        },
        {
          "when": "the input is a single-line search",
          "use": "Input",
          "why": "a search box does not grow, does not stop runs, and Enter means search rather than send"
        },
        {
          "when": "the run needs a stop control and was not started from this composer",
          "use": "AgentStop",
          "why": "the composer's stop belongs to the run it sent; a run started elsewhere keeps its stop beside its own activity"
        }
      ],
      "variants": {
        "submitKey": {
          "enter": {
            "use": "the default — chat. Enter sends, Shift+Enter is a new line"
          },
          "mod-enter": {
            "use": "long messages, where a stray Enter is expensive. Cmd/Ctrl+Enter sends and Enter is a new line"
          }
        }
      },
      "slots": {
        "actions": {
          "accepts": "Button",
          "note": "attach, a model picker, a tool toggle — at the start of the toolbar. Icon-only buttons need their own aria-label"
        },
        "attachments": {
          "accepts": "Badge",
          "note": "what is attached, above the text. Each removable item needs a remove control named for the file"
        }
      },
      "a11y": {
        "requires": [
          "`label` — the accessible name of the field. A placeholder is not one: it disappears on the first keystroke"
        ],
        "guarantees": [
          "the send key is described through aria-describedby, so a screen-reader user knows Enter sends before pressing it",
          "Enter pressed while an IME is composing never sends — Japanese, Chinese and Korean input confirm candidates with Enter",
          "while `isRunning` with `onStop`, the send control is replaced in place by AgentStop, with its guarantees: it keeps its place while stopping and announces once",
          "focus stays in the field after sending, so the next message can be typed straight away"
        ],
        "notes": [
          "the field stays editable while a run is going; only submitting is refused",
          "uncontrolled, the field clears on send and a rejected onSubmit promise restores the text — unless the user has already started typing something new"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Textarea> beside a <Button> with an onKeyDown that checks for Enter",
          "do": "<PromptInput label=\"Message\" onSubmit={send} />",
          "why": "the hand-built version sends mid-character for IME users and loses the prompt when sending fails"
        },
        {
          "dont": "a separate AgentStop elsewhere on screen as well as `isRunning` with `onStop`",
          "why": "two stop controls for one run raises the question of whether they do different things"
        },
        {
          "dont": "`isRunning` without `onStop`",
          "why": "the send control disables and nothing replaces it — the user can see the agent working and has no way to stop it"
        },
        {
          "dont": "controlled `value` cleared in onSubmit before the request settles",
          "why": "controlled, restoring the text on failure is the caller's job; clearing early and not restoring loses the message"
        }
      ],
      "stylesheet": "src/styles/prompt-input.css",
      "tokens": [
        "--border-default",
        "--border-disabled",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--font-family-sans",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-lg",
        "--spacing-32",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--text-default",
        "--text-disabled",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height"
      ],
      "props": {
        "label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The accessible name of the text field. Required, and not a placeholder:\nplaceholder text disappears the moment the user types, and is not reliably\nannounced as a name."
        },
        "value": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled text. Pass `onChange` with it."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Uncontrolled starting text."
        },
        "onChange": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "onSubmit": {
          "type": "((value: string) => void | Promise<unknown>) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires with the text as typed. Uncontrolled, the field clears straight away;\nif the returned promise rejects, the text is put back — a prompt lost to a\nnetwork error is the one thing a composer must never do."
        },
        "submitKey": {
          "type": "PromptInputSubmitKey | undefined",
          "required": false,
          "origin": "own",
          "description": "Which key sends. `enter` sends on Enter with Shift+Enter for a new line —\na chat. `mod-enter` sends on Cmd/Ctrl+Enter and leaves Enter for new lines —\na composer where messages are long and a stray Enter is expensive.",
          "values": [
            "enter",
            "mod-enter"
          ]
        },
        "isRunning": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "A run is in progress. The send control becomes an `AgentStop` in the same\nplace, and submitting is refused. The field stays editable, so the user can\ndraft the next message while the agent works."
        },
        "onStop": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Called by the stop control while `isRunning`."
        },
        "isStopping": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Passed to the stop control once the stop has been requested."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "minRows": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Rows the field starts at."
        },
        "maxRows": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Rows the field grows to before it scrolls."
        },
        "actions": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Controls at the start of the toolbar — attach, a model picker, a tool\ntoggle. Icon-only controls here need their own `aria-label`."
        },
        "attachments": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Rendered above the text — attached files, a quoted message."
        },
        "sendLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible name of the send control."
        },
        "stopLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The stop control's label while a run is in progress — AgentStop's `label`."
        },
        "stoppingLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The stop control's label once pressed — AgentStop's `stoppingLabel`."
        },
        "submitHint": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "How to send, read after the field's name — \"Press Enter to send, Shift\nand Enter for a new line.\" in English, or the ⌘/Ctrl+Enter version when\n`submitKey` is `mod-enter`. Pass the translation for your `submitKey`."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the outer box."
        }
      },
      "propCounts": {
        "own": 19,
        "aria": 0,
        "dom": 284,
        "other": 0
      }
    },
    "Radio": {
      "name": "Radio",
      "source": "src/components/Radio.tsx",
      "propsType": "RadioProps",
      "import": "import { Radio } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One option inside a RadioGroup. Meaningless on its own — the group owns the name and the selected value.",
      "useWhen": [
        "a child of RadioGroup"
      ],
      "useInstead": [
        {
          "when": "the user may pick more than one",
          "use": "Checkbox"
        },
        {
          "when": "there are more than about five options",
          "use": "Select",
          "why": "a long radio list costs vertical space no one reads"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "touch targets and prominent choices"
          }
        },
        "intent": {
          "brand": {
            "use": "the default"
          },
          "neutral": {
            "use": "a choice with no positive reading — sort order, display mode"
          },
          "danger": {
            "use": "selecting a destructive option"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "text",
          "note": "the option's visible label"
        }
      },
      "a11y": {
        "role": "radio",
        "guarantees": [
          "`name` and the selected value come from the group's context, so no Radio has to repeat them",
          "`isDisabled` falls back to the group's"
        ],
        "requires": [
          "a `value` unique within the group",
          "a text label in `children`"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Radio outside a RadioGroup",
          "why": "it has no name and no siblings, so it can be selected but never unselected"
        },
        {
          "dont": "wiring `checked`/`onChange` on each Radio",
          "why": "the group owns selection; per-option wiring is what the context exists to remove"
        },
        {
          "dont": "a group of one radio",
          "why": "a single radio cannot be cleared once chosen — that is a Checkbox"
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        }
      ],
      "stylesheet": "src/styles/radio.css",
      "tokens": [
        "--border-disabled",
        "--border-error-strong",
        "--border-focus",
        "--border-inverse",
        "--border-primary-strong",
        "--border-stronger",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--icon-disabled",
        "--icon-on-color",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-radio-border",
        "--ion-radio-dot",
        "--ion-radio-fill",
        "--ion-radio-font-size",
        "--ion-radio-gap",
        "--ion-radio-line-height",
        "--ion-radio-raised",
        "--ion-radio-size",
        "--ion-shadow-raised-flush-lg",
        "--ion-shadow-raised-flush-sm",
        "--ion-shadow-raised-flush-xs",
        "--radius-full",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-20",
        "--spacing-24",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-error",
        "--surface-inverse",
        "--surface-primary",
        "--text-disabled",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "value": {
          "type": "string",
          "required": true,
          "origin": "own"
        },
        "size": {
          "type": "RadioSize | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "intent": {
          "type": "RadioIntent | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "neutral",
            "danger",
            "brand"
          ]
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether this radio is disabled. Falls back to the group's `isDisabled`."
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 303,
        "other": 0
      }
    },
    "RadioGroup": {
      "name": "RadioGroup",
      "source": "src/components/Radio.tsx",
      "propsType": "RadioGroupProps",
      "description": "Renders a `<fieldset>` with a `<legend>` rather than a div with\n`role=\"radiogroup\"`. Both are announced correctly, but a fieldset also groups\nthe inputs for form submission and native validation, which the ARIA version\ndoes not.\n\nThe fieldset itself is Fieldset's shell, shared with CheckboxGroup, so the\ntwo choice groups take the same label, help, error and orientation props.",
      "import": "import { RadioGroup } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A <fieldset>/<legend> group of mutually exclusive options. Owns the shared name and the selected value, and the group's label, help and error.",
      "useWhen": [
        "exactly one choice from a small set, all worth showing at once",
        "the options need explaining — each one can carry its own label text"
      ],
      "useInstead": [
        {
          "when": "the list is long, or space is tight",
          "use": "Select"
        },
        {
          "when": "more than one may be picked",
          "use": "CheckboxGroup"
        },
        {
          "when": "there are exactly two states and the change is immediate",
          "use": "Toggle"
        },
        {
          "when": "each option needs a sentence to be chosen well — a plan, a trigger, a schedule",
          "use": "SelectableTile",
          "why": "the same group with tiles for its options: each a real radio the size of a card, its sentence read after its name"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "touch targets and prominent choices"
          }
        },
        "intent": {
          "brand": {
            "use": "the default"
          },
          "neutral": {
            "use": "a choice with no positive reading — sort order, display mode"
          },
          "danger": {
            "use": "a set where the options are destructive"
          }
        },
        "orientation": {
          "vertical": {
            "use": "the default, and always when a label runs past a few words"
          },
          "horizontal": {
            "use": "two to four one-word options. Wraps rather than overflows"
          }
        }
      },
      "slots": {
        "label": {
          "accepts": "text",
          "note": "renders as the <legend> — the question the options answer"
        },
        "description": {
          "accepts": "text",
          "note": "help beneath the options; replaced by `errorMessage` while `isInvalid` is set"
        },
        "errorMessage": {
          "accepts": "text",
          "note": "shown only while `isInvalid` is set"
        },
        "children": {
          "accepts": "Radio"
        }
      },
      "a11y": {
        "role": "group, from <fieldset> and <legend>",
        "guarantees": [
          "a real fieldset rather than `role=\"radiogroup\"`: both announce correctly, but the fieldset also groups the inputs for form submission and native validation",
          "`name` is generated when omitted, so two groups on one page never collide",
          "`isDisabled` cascades to every Radio that has not set its own",
          "the fieldset is Fieldset's shell, shared with CheckboxGroup — the same label, help, error and orientation props",
          "the description or error is on every radio's `aria-describedby` as well as the fieldset's, so it is read on the radio that takes focus",
          "`isRequired` is native `required` on the radios, which the platform already reads as \"one of this name\""
        ],
        "requires": [
          "`label`, or `aria-label` when the question is already visible elsewhere"
        ]
      },
      "antiPatterns": [
        {
          "dont": "an unlabelled group",
          "why": "the options are announced with no question attached"
        },
        {
          "dont": "passing both `value` and `defaultValue`",
          "why": "controlled and uncontrolled at once; the controlled value wins and `defaultValue` silently does nothing"
        },
        {
          "dont": "no option selected by default with no \"none\" option",
          "why": "a radio group cannot be returned to empty once touched"
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        }
      ],
      "stylesheet": "src/styles/radio.css",
      "tokens": [
        "--border-disabled",
        "--border-error-strong",
        "--border-focus",
        "--border-inverse",
        "--border-primary-strong",
        "--border-stronger",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--icon-disabled",
        "--icon-on-color",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-radio-border",
        "--ion-radio-dot",
        "--ion-radio-fill",
        "--ion-radio-font-size",
        "--ion-radio-gap",
        "--ion-radio-line-height",
        "--ion-radio-raised",
        "--ion-radio-size",
        "--ion-shadow-raised-flush-lg",
        "--ion-shadow-raised-flush-sm",
        "--ion-shadow-raised-flush-xs",
        "--radius-full",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-20",
        "--spacing-24",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-error",
        "--surface-inverse",
        "--surface-primary",
        "--text-disabled",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Shared input name. Generated when omitted."
        },
        "value": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "onChange": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The question the options answer. Renders as the `<legend>`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Help text beneath the options. Replaced by `errorMessage` while invalid."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Shown in the description's place while `isInvalid` is set."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Shows `errorMessage` in the description's place."
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "One option must be chosen before the form submits."
        },
        "orientation": {
          "type": "FieldsetOrientation | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "vertical",
            "horizontal"
          ]
        },
        "size": {
          "type": "RadioSize | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "intent": {
          "type": "RadioIntent | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "neutral",
            "danger",
            "brand"
          ]
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether every radio in the group is disabled."
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 15,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "ScrollProgress": {
      "name": "ScrollProgress",
      "source": "src/components/ScrollProgress.tsx",
      "propsType": "ScrollProgressProps",
      "description": "The compact rail is the disclosure trigger. It is a real `<button>`, so a\nkeyboard user reaches it by Tab and opens it the same way as any button —\nEnter or Space — no `:focus-within` trick required; a mouse user can also\njust hover it. Built as a WAI-ARIA Disclosure (`aria-expanded` +\n`aria-controls`) rather than a menu: see the CSS header for why\n`role=\"menu\"` is deliberately not used here.\n\nCloses on outside pointerdown and on Escape — the minimum a disclosure\nneeds to not trap the page once opened, not a full popover/focus-trap\nimplementation.",
      "import": "import { ScrollProgress } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A compact progress rail that expands into a section list. A disclosure, not a menu — and it computes nothing itself.",
      "useWhen": [
        "a long document or page needs an in-page section index with reading progress",
        "the caller already knows the scroll fraction and the active section"
      ],
      "useInstead": [
        {
          "when": "the sections are peer views rather than positions in one document",
          "use": "Tabs"
        },
        {
          "when": "it is site navigation rather than in-page position",
          "use": "NavItem"
        }
      ],
      "variants": {
        "placement": {
          "right": {
            "use": "the default — a rail on the left of the content, opening across it"
          },
          "left": {
            "use": "a rail against the right edge of the page; `right` would open the list off the screen"
          }
        }
      },
      "a11y": {
        "role": "disclosure — a real <button> with aria-expanded and aria-controls",
        "guarantees": [
          "the rail is reachable by Tab and opens with Enter or Space, not just hover",
          "closes on outside pointerdown and on Escape"
        ],
        "requires": [
          "`progress` as 0–100 and `activeId` — the component has no opinion on where they come from",
          "handling `onSelect` yourself: it reports the choice and does not scroll anywhere"
        ],
        "notes": [
          "Deliberately not `role=\"menu\"` — see the stylesheet header. It is a disclosure with a list inside it, not a menu with roving focus.",
          "Not a focus trap: it is the minimum a disclosure needs to not trap the page once opened."
        ]
      },
      "antiPatterns": [
        {
          "dont": "expecting it to scroll the page when a section is chosen",
          "why": "it reports the choice, matching Menu — what \"select this section\" means is the page's decision"
        },
        {
          "dont": "deriving `progress` from a scroll listener without throttling",
          "why": "this re-renders the rail on every scroll frame"
        },
        {
          "dont": "expecting every section to be visible at once",
          "why": "the panel caps at 60vh and scrolls — a document-length index would otherwise grow off both edges of the viewport, taking the rows nearest the active one with it"
        },
        {
          "dont": "leaving `placement` at `right` for a rail on the page's right edge",
          "why": "the open list runs past the viewport; `left` opens it back over the content"
        }
      ],
      "stylesheet": "src/styles/scroll-progress.css",
      "tokens": [
        "--border-default",
        "--border-focus",
        "--border-strong",
        "--border-stronger",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-regular",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-scroll-progress-slide",
        "--ion-shadow-shadow-md",
        "--radius-full",
        "--radius-md",
        "--radius-sm",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-muted",
        "--text-default",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "progress": {
          "type": "number",
          "required": true,
          "origin": "own",
          "description": "0-100. The caller computes this from whatever it is tracking — a\nwindow scroll fraction, a custom scroll container, a reading-time\nestimate. This component has no opinion on the source."
        },
        "sections": {
          "type": "ScrollProgressSection[]",
          "required": true,
          "origin": "own"
        },
        "activeId": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The section the rail's heavier tick and the panel's selected row point\nto. Also the caller's to compute — see `progress`."
        },
        "onSelect": {
          "type": "((id: string) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fired when a row is chosen. The component does not scroll anywhere\nitself; matching Menu, it reports the choice and lets the caller decide\nwhat \"select this section\" means for their page."
        },
        "placement": {
          "type": "ScrollProgressPlacement | undefined",
          "required": false,
          "origin": "own",
          "description": "Which way the section list opens. `right` suits a rail on the left of the\ncontent; a rail against the right edge of the page needs `left`, or the\nopen list runs off the screen.",
          "values": [
            "left",
            "right"
          ]
        },
        "triggerLabel": {
          "type": "((progress: number, section?: string) => string) | undefined",
          "required": false,
          "origin": "own",
          "description": "The rail button's name — \"Reading progress: 32%. Currently in Usage.\nActivate to jump to a section.\" in English. Receives the rounded\npercentage and the active section's label, when there is one."
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "SearchField": {
      "name": "SearchField",
      "source": "src/components/SearchField.tsx",
      "propsType": "SearchFieldProps",
      "description": "SearchField — a text field for a search query.\n\nNOT `<Input type=\"search\">`. That gets the right input type and nothing\nelse. `useSearchField` adds what a search box owes its user:\n\n  `role=\"searchbox\"`, so a screen reader announces a search field, not a\n  text field. Enter calls `onSubmit` with the query. Escape clears it — a\n  second Escape then reaches whatever the field sits in, so a search inside\n  a dialog clears first and closes second. A clear button appears once there\n  is something to clear, named in the user's language by React Aria.\n\nThe clear button is out of the tab order on purpose, as React Aria sets it:\nEscape is the keyboard's way to clear, and an extra tab stop in every search\nbox costs every keyboard user a keystroke. Pressing it puts focus back in the\nfield.\n\nThe box IS Input's — the component renders `.ion-input` and its size and\nstate classes, so a search field beside an Input in a toolbar matches it\nexactly and cannot drift. Same arrangement as NumberInput.",
      "import": "import { SearchField } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A text field for a search query: role=\"searchbox\", Enter submits, Escape clears, and a clear button once there is something to clear. Input's box and sizes.",
      "useWhen": [
        "the user types to search or filter a list, a table or a page — the search above a DataTable, the filter at the top of a Sidebar",
        "the query is free text that narrows what is shown, not a value chosen from a list"
      ],
      "useInstead": [
        {
          "when": "the typed text picks one value from a known list — an assignee, a country",
          "use": "Combobox",
          "why": "a combobox commits a choice and offers the options; a search field commits a query and offers nothing"
        },
        {
          "when": "the user is looking for a page or an action anywhere in the product",
          "use": "CommandPalette",
          "why": "that is global and keyboard-first; a SearchField searches the content in front of it"
        },
        {
          "when": "it is ordinary text entry — a name, an email — that happens to be used for lookup later",
          "use": "Input",
          "why": "announcing a searchbox promises search behaviour: Escape clears, Enter searches"
        }
      ],
      "composition": {
        "order": [
          "SearchField"
        ],
        "note": "Filter as the user types with `onChange`, or search on Enter with `onSubmit` — pick one per field and say which in the placeholder or label. In a table toolbar, give it `aria-label` naming what it searches.",
        "example": "<SearchField aria-label=\"Search agents\" placeholder=\"Search by name or purpose\" value={query} onChange={setQuery} />"
      },
      "variants": {
        "size": {
          "sm": {
            "use": "toolbars, table headers and Sidebar filters, beside small Buttons"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "a page whose main task is searching"
          }
        }
      },
      "a11y": {
        "role": "searchbox",
        "guarantees": [
          "the input is role=\"searchbox\" and type=\"search\", so it is announced as a search field",
          "Escape clears the query; a second Escape is left for whatever contains the field, so a search in a dialog clears before it closes",
          "Enter calls onSubmit with the query",
          "the clear button is named by React Aria in the user's language, appears only when there is something to clear, and returns focus to the field",
          "the clear button is out of the tab order on purpose: Escape is the keyboard's way to clear, and an extra tab stop in every search box costs every keyboard user a keystroke",
          "the browser's own cancel button is hidden, so there are never two"
        ],
        "requires": [
          "`label`, or an `aria-label` naming what is searched when no visible label is rendered — \"Search\" alone does not say what"
        ],
        "notes": [
          "When filtering as the user types, announce the result count with a polite live region near the results. The field cannot know how many results its query produced; the list does."
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Input type=\"search\"> with a magnifier as leadingIcon",
          "why": "it looks the same and has none of the behaviour: no searchbox role, no Escape to clear, no labelled clear button"
        },
        {
          "dont": "placeholder as the only name",
          "why": "a placeholder disappears once the user types, and is not a reliable accessible name. Pass `aria-label` or `label`"
        },
        {
          "dont": "a separate Search button beside a field that already filters as you type",
          "why": "it teaches the user that nothing happens until they press it, and it does nothing when they do"
        }
      ],
      "stylesheet": "src/styles/search-field.css",
      "tokens": [
        "--border-focus",
        "--border-width-thick",
        "--icon-default",
        "--icon-secondary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-input-height",
        "--ion-input-icon-size",
        "--radius-sm",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-pressed"
      ],
      "props": {
        "size": {
          "type": "SearchFieldSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Input's sizes: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the control box (`.ion-input`)."
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the `.ion-field` wrapper when a label or helper is shown."
        },
        "enterKeyHint": {
          "type": "\"done\" | \"enter\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "An enumerated attribute that defines what action label or icon to preset for the enter key on\nvirtual keyboards. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint).",
          "values": [
            "done",
            "enter",
            "go",
            "next",
            "previous",
            "search",
            "send"
          ]
        },
        "type": {
          "type": "(string & {}) | \"search\" | \"text\" | \"url\" | \"tel\" | \"email\" | \"password\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "The type of input to render. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdeftype).",
          "tags": {
            "default": "'search'"
          }
        },
        "onSubmit": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "other",
          "description": "Handler that is called when the SearchField is submitted."
        },
        "onClear": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "other",
          "description": "Handler that is called when the clear button is pressed."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input is disabled."
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input can be selected but not changed by the user."
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether user input is required on the input before form submission."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input value is invalid."
        },
        "validationState": {
          "type": "ValidationState | undefined",
          "required": false,
          "origin": "aria",
          "values": [
            "valid",
            "invalid"
          ],
          "tags": {
            "deprecated": "Use `isInvalid` instead."
          }
        },
        "validationBehavior": {
          "type": "\"aria\" | \"native\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to use native HTML form validation to prevent form submission\nwhen the value is missing or invalid, or mark the field as required\nor invalid via ARIA.",
          "values": [
            "aria",
            "native"
          ],
          "tags": {
            "default": "'aria'"
          }
        },
        "validate": {
          "type": "((value: string) => ValidationError | true | null | undefined) | undefined",
          "required": false,
          "origin": "aria",
          "description": "A function that returns an error message if a given value is invalid.\nValidation errors are displayed to the user when the form is submitted\nif `validationBehavior=\"native\"`. For realtime validation, use the `isInvalid`\nprop instead."
        },
        "description": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "A description for the field. Provides a hint such as specific requirements for what to choose."
        },
        "errorMessage": {
          "type": "ReactNode | ((v: ValidationResult) => ReactNode)",
          "required": false,
          "origin": "aria",
          "description": "An error message for the field."
        },
        "autoFocus": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the element should receive focus on render."
        },
        "onFocus": {
          "type": "((e: FocusEvent<HTMLInputElement, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element receives focus."
        },
        "onBlur": {
          "type": "((e: FocusEvent<HTMLInputElement, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element loses focus."
        },
        "onFocusChange": {
          "type": "((isFocused: boolean) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element's focus status changes."
        },
        "onKeyDown": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is pressed."
        },
        "onKeyUp": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is released."
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Temporary text that occupies the text input when it is empty."
        },
        "value": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The current value (controlled)."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The default value (uncontrolled)."
        },
        "onChange": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the value changes."
        },
        "label": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "The content to display as the label."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The element's unique identifier. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id)."
        },
        "spellCheck": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An enumerated attribute that defines whether the element may be checked for spelling errors.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck)."
        },
        "autoCorrect": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An attribute that takes as its value a space-separated string that describes what, if any, type\nof autocomplete functionality the input should provide. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#autocomplete)."
        },
        "inputMode": {
          "type": "\"none\" | \"search\" | \"text\" | \"url\" | \"tel\" | \"email\" | \"numeric\" | \"decimal\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Hints at the type of data that might be entered by the user while editing the element or its\ncontents. See\n[MDN](https://html.spec.whatwg.org/multipage/interaction.html#input-modalities:-the-inputmode-attribute).",
          "values": [
            "none",
            "search",
            "text",
            "url",
            "tel",
            "email",
            "numeric",
            "decimal"
          ]
        },
        "aria-activedescendant": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the currently active element when DOM focus is on a composite widget, textbox,\ngroup, or application."
        },
        "aria-autocomplete": {
          "type": "\"inline\" | \"list\" | \"none\" | \"both\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates whether inputting text could trigger display of one or more predictions of the user's\nintended value for an input and specifies how predictions would be presented if they are made.",
          "values": [
            "inline",
            "list",
            "none",
            "both"
          ]
        },
        "aria-controls": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) whose contents or presence are controlled by the current\nelement."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        },
        "aria-errormessage": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element that provides an error message for the object."
        },
        "aria-haspopup": {
          "type": "boolean | \"true\" | \"false\" | \"menu\" | \"listbox\" | \"tree\" | \"grid\" | \"dialog\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates the availability and type of interactive popup element, such as menu or dialog, that\ncan be triggered by an element."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "onCopy": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user copies text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncopy)."
        },
        "onCut": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user cuts text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncut)."
        },
        "onPaste": {
          "type": "ClipboardEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user pastes text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/onpaste)."
        },
        "onCompositionEnd": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system completes or cancels the current text\ncomposition session. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event)."
        },
        "onCompositionStart": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system starts a new text composition session.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionstart_event)."
        },
        "onCompositionUpdate": {
          "type": "CompositionEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a new character is received in the current text composition\nsession. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionupdate_event)."
        },
        "onBeforeInput": {
          "type": "FormEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is about to be modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/beforeinput_event)."
        },
        "onInput": {
          "type": "FormEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event)."
        },
        "onSelect": {
          "type": "ReactEventHandler<HTMLInputElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when text in the input is selected. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/select_event)."
        },
        "form": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The `<form>` element to associate the input with.\nThe value of this attribute must be the id of a `<form>` in the same document.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form)."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The name of the input element, used when submitting an HTML form. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname)."
        },
        "autoComplete": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Describes the type of autocomplete functionality the input should provide if any. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete)."
        },
        "maxLength": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The maximum number of characters supported by the input. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefmaxlength)."
        },
        "minLength": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The minimum number of characters required by the input. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefminlength)."
        },
        "pattern": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Regex pattern that the value of the input must match to be valid. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefpattern)."
        },
        "excludeFromTabOrder": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to exclude the element from the sequential tab order. If true,\nthe element will not be focusable via the keyboard by tabbing. This should\nbe avoided except in rare scenarios where an alternative means of accessing\nthe element or its functionality via the keyboard is available."
        }
      },
      "propCounts": {
        "own": 3,
        "aria": 51,
        "dom": 264,
        "other": 2
      }
    },
    "SegmentedControl": {
      "name": "SegmentedControl",
      "source": "src/components/SegmentedControl.tsx",
      "propsType": "SegmentedControlProps",
      "description": "SegmentedControl — pick one of a few options, and see all of them.\n\nNOT TABS, ALTHOUGH IT LOOKS LIKE THE PILL TYPE, and the difference is the\ncontract. Tabs switch between panels: `tablist`, `tab`, `tabpanel`, and each\ntab controls a region. This sets a VALUE — a view mode, a time range, a\nunit — and controls no panel. It is a `radiogroup`, so it has one tab stop,\narrow keys move and select, and it submits with a form like any radio.\n\nBuilt from real radio inputs, visually hidden, not from buttons with\n`aria-pressed`. A row of toggle buttons is a tab stop per option and no\nmutual exclusion that assistive tech can announce; radios give \"2 of 3,\nselected\" for free.\n\nThe look is Tabs' pill track, token for token, so the two cannot drift\napart visually — which is exactly why the intent file spends its words on\nwhen to use which.",
      "import": "import { SegmentedControl } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Pick one of two to five options, all visible at once. A radio group drawn as a segmented track — it sets a value and controls no panel.",
      "useWhen": [
        "switching how the same content is shown — List / Board / Calendar",
        "a short, fixed set of mutually exclusive settings — Day / Week / Month, °C / °F",
        "a filter with a few exclusive choices where seeing every option matters"
      ],
      "useInstead": [
        {
          "when": "each option shows a different panel of content",
          "use": "Tabs",
          "why": "Tabs wires each tab to its panel with tablist/tabpanel semantics. A SegmentedControl controls no region, so a screen-reader user is never told which content changed"
        },
        {
          "when": "there are more than five options, or the labels are long",
          "use": "Select",
          "why": "segments do not wrap; a track that overflows hides the options it exists to show"
        },
        {
          "when": "the choice sits in a form with a visible question and descriptions per option",
          "use": "RadioGroup",
          "why": "same semantics, but room for a label and helper text on each option"
        },
        {
          "when": "the setting is a single on/off",
          "use": "Toggle"
        },
        {
          "when": "the options trigger actions rather than set a value — Bold, Italic",
          "use": "Button",
          "why": "an action is not a selection; a radio group announces a state that does not exist"
        }
      ],
      "composition": {
        "order": [
          "SegmentedControl",
          "SegmentedControlItem"
        ],
        "example": "<SegmentedControl label=\"View\" defaultValue=\"list\"><SegmentedControlItem value=\"list\">List</SegmentedControlItem><SegmentedControlItem value=\"board\">Board</SegmentedControlItem></SegmentedControl>"
      },
      "variants": {
        "size": {
          "sm": {
            "use": "toolbars and dense panel headers"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "a primary view switch at the top of a page"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "SegmentedControlItem",
          "note": "two to five. One item is not a choice; six is a Select"
        }
      },
      "a11y": {
        "role": "radiogroup / radio",
        "requires": [
          "`label` — the group's accessible name. Hidden by default; `showLabel` renders it above the track"
        ],
        "guarantees": [
          "one tab stop for the whole control; arrow keys move and select, skipping disabled segments",
          "built from real radio inputs, so it announces \"2 of 3, selected\" and submits with a form under `name`",
          "a disabled selected segment keeps its selected surface — a disabled control still has a value"
        ],
        "notes": [
          "it looks exactly like Tabs' pill type on purpose, and behaves differently on purpose. Choose by what changes, not by how it looks"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a SegmentedControl that swaps the panel below it",
          "do": "<Tabs type=\"pill\">",
          "why": "the panel relationship is exactly what tabpanel semantics announce; without it the content changes silently"
        },
        {
          "dont": "no defaultValue or value",
          "why": "a segmented track with nothing selected reads as broken, not as a choice still to make"
        },
        {
          "dont": "icon-only segments with no aria-label",
          "do": "<SegmentedControlItem value=\"grid\" icon={<Grid />} aria-label=\"Grid view\" />",
          "why": "each segment is a radio, and an unnamed radio is announced as just \"radio button\""
        }
      ],
      "stylesheet": "src/styles/segmented-control.css",
      "tokens": [
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-size-md",
        "--icon-size-sm",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-segmented-font-size",
        "--ion-segmented-icon-size",
        "--ion-segmented-item-gap",
        "--ion-segmented-item-height",
        "--ion-segmented-item-padding-x",
        "--ion-segmented-line-height",
        "--radius-lg",
        "--radius-md",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-20",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-40",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-muted",
        "--surface-page",
        "--text-default",
        "--text-disabled",
        "--text-tertiary",
        "--type-body",
        "--type-body-lg",
        "--type-body-lg-line-height",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The accessible name of the group. Required: \"List, Board, Calendar\" means\nnothing until something says they are views."
        },
        "showLabel": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Show `label` above the control, as a form field does. Hidden by default.",
          "default": false
        },
        "value": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled selected value. Pass `onChange` with it."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Uncontrolled starting value. One option should always be selected — a\nsegmented control with nothing chosen reads as broken, not as \"no choice\"."
        },
        "onChange": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "size": {
          "type": "SegmentedControlSize | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "isFullWidth": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Stretch to the container, with equal-width segments.",
          "default": false
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "default": false
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Submitted with a form under this name."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "`SegmentedControlItem` elements. Two to five of them."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 11,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "SegmentedControlItem": {
      "name": "SegmentedControlItem",
      "source": "src/components/SegmentedControl.tsx",
      "propsType": "SegmentedControlItemProps",
      "import": "import { SegmentedControlItem } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One segment: a radio drawn as part of the track, with an optional icon.",
      "useWhen": [
        "inside a SegmentedControl, once per option"
      ],
      "useInstead": [
        {
          "when": "outside a segmented track",
          "use": "Radio"
        }
      ],
      "slots": {
        "children": {
          "accepts": "text",
          "note": "one or two words. Segments do not wrap, and a long label truncates"
        },
        "icon": {
          "accepts": "Icon",
          "note": "decorative beside a label; with no label, pass aria-label"
        }
      },
      "a11y": {
        "requires": [
          "`aria-label` when the segment has an icon and no text — the accessible name otherwise comes from its children"
        ]
      },
      "antiPatterns": [
        {
          "dont": "using it outside SegmentedControl",
          "why": "it reads its selection state from the group, and throws without one"
        }
      ],
      "stylesheet": "src/styles/segmented-control.css",
      "tokens": [
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-size-md",
        "--icon-size-sm",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-segmented-font-size",
        "--ion-segmented-icon-size",
        "--ion-segmented-item-gap",
        "--ion-segmented-item-height",
        "--ion-segmented-item-padding-x",
        "--ion-segmented-line-height",
        "--radius-lg",
        "--radius-md",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-20",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-40",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-muted",
        "--surface-page",
        "--text-default",
        "--text-disabled",
        "--text-tertiary",
        "--type-body",
        "--type-body-lg",
        "--type-body-lg-line-height",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "value": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The value this segment selects."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Icon before the label. `aria-hidden` is applied for you."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Required when the segment has no visible text — an icon-only segment is\notherwise announced as an unnamed radio button."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "default": false
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Select": {
      "name": "Select",
      "source": "src/components/Select.tsx",
      "propsType": "SelectProps",
      "description": "Select wraps a native `<select>` rather than rebuilding one.\n\nReact Aria's `useSelect` builds a listbox in a popover, which is the right\nanswer when the menu needs custom rows — but Figma models only the trigger\nhere, and the dropdown list is a separate component on the Menu page. Until\nthat is built, the native control is the honest match: it brings keyboard\nhandling, type-ahead and the platform picker on mobile for free, and it is\naccessible without any of it being reimplemented.\n\nFigma's six states map the same way Input's do — Hover and Focus from\ninteraction, Filled from whether a value is set, the rest from props.",
      "import": "import { Select } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A dropdown over a native <select>, with the same label and error wiring as Input.",
      "useWhen": [
        "the user picks exactly one value from a known, modest list",
        "the native platform picker is acceptable — and on mobile it usually is the best option"
      ],
      "useInstead": [
        {
          "when": "the list is long enough to need search or type-ahead beyond the native behaviour",
          "use": "Combobox",
          "why": "a native <select> list is the browser's, and cannot be filtered"
        },
        {
          "when": "the rows need icons, avatars or two lines of text",
          "use": "Menu",
          "why": "Select wraps a native <select>, whose options can only be text"
        },
        {
          "when": "there are two or three mutually exclusive choices worth showing at once",
          "use": "RadioGroup"
        },
        {
          "when": "the user may pick more than one",
          "use": "MultiSelect",
          "why": "a native <select multiple> needs Ctrl or Cmd for a second pick and cannot filter; for a handful of options use CheckboxGroup"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms, table filters"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "prominent single-field forms"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "keyboard handling, type-ahead and the mobile platform picker come from the native <select> rather than being reimplemented",
          "label, helper and error wiring match Input"
        ],
        "requires": [
          "`label`, or `aria-label` when no visible label is rendered"
        ]
      },
      "antiPatterns": [
        {
          "dont": "passing rich React nodes as option content",
          "why": "this renders a native <select>; options are text only"
        },
        {
          "dont": "using a Select with two options for an on/off setting",
          "do": "Toggle",
          "why": "a two-item dropdown costs two interactions to do what one control does in one"
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        }
      ],
      "stylesheet": "src/styles/select.css",
      "tokens": [
        "--border-default",
        "--border-disabled",
        "--border-error-strong",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-regular",
        "--icon-disabled",
        "--icon-size-md",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-select-border-width",
        "--ion-select-font-size",
        "--ion-select-gap",
        "--ion-select-height",
        "--ion-select-icon-size",
        "--ion-select-line-height",
        "--ion-select-padding-x",
        "--ion-select-radius",
        "--radius-md",
        "--radius-sm",
        "--spacing-12",
        "--spacing-16",
        "--spacing-32",
        "--spacing-40",
        "--spacing-48",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--text-disabled",
        "--text-secondary",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "size": {
          "type": "SelectSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "options": {
          "type": "readonly SelectOption[] | undefined",
          "required": false,
          "origin": "own",
          "description": "The options to render. Omit and pass `children` for grouped options.\n\n`readonly` because an options list is almost always a module-level constant,\nand `as const` on one produces a readonly array that a mutable parameter\nrejects. The component only ever maps over it, so demanding a mutable array\nbought nothing and cost callers a spread or a cast."
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Text shown when nothing is selected. Renders as a disabled option so the\nfield can start empty without that being a valid choice."
        },
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Field label. Renders the Figma `Form Field` wrapper around the control."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the field."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text when `isInvalid` is set."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the control box (`.ion-select`). Always lands here —\nnever on the form-field wrapper. Use `wrapperClassName` for that."
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the `.ion-field` wrapper when a label or helper is shown."
        }
      },
      "propCounts": {
        "own": 11,
        "aria": 0,
        "dom": 283,
        "other": 0
      }
    },
    "SelectableTile": {
      "name": "SelectableTile",
      "source": "src/components/SelectableTile.tsx",
      "propsType": "SelectableTileProps",
      "description": "SelectableTile — a choice the size of a card: a plan, a schedule, a\ntemplate, with a sentence about each.\n\nA REAL CHECKBOX OR RADIO, THE SIZE OF A CARD. Inside a RadioGroup it is a\nRadio; inside a CheckboxGroup, or alone, a Checkbox — the same native input\nthose render, so the group's name, value, required rule, disabled state and\nerror wiring all apply, arrow keys move between radios, and a form submits\nit. The title is the input's label; its label is stretched over the tile,\nso the whole card is the target, not only the box.\n\nTHE NAME IS THE TITLE. The description and anything below it are read after\nthe name, through `aria-describedby`, rather than folded into it: \"Pro,\nradio button, 2 of 3, Unlimited agents and priority support\".\n\nNOTHING INSIDE IS INTERACTIVE. The whole tile is one control; a link in it\nwould be unreachable by pointer and confusing by keyboard. Put a \"Compare\nplans\" link beside the group.\n\nSELECTED IS MORE THAN A COLOUR. The tile's border thickens and takes the\nbrand colour, and the box or dot is filled — the same indicator as a plain\ncheckbox or radio, so the state survives greyscale and forced colours.",
      "import": "import { SelectableTile } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A choice the size of a card — a plan, a schedule, a template, each with a sentence. A real Radio inside a RadioGroup, a real Checkbox inside a CheckboxGroup or alone; its label is stretched over the tile, so the whole card is the target. The title is the name; the description is read after it.",
      "useWhen": [
        "each option needs a sentence or a detail to be chosen well — what a plan includes, what a schedule costs",
        "there are a handful of options, two to six, worth seeing side by side"
      ],
      "useInstead": [
        {
          "when": "the options are short labels that need no explanation",
          "use": "RadioGroup",
          "why": "a column of radios is denser and quicker to scan than a grid of cards"
        },
        {
          "when": "more than about six options, or a list that grows",
          "use": "Select",
          "why": "a grid of tiles for twenty options is a wall of cards"
        },
        {
          "when": "the card leads somewhere, or holds actions",
          "use": "Card",
          "why": "a tile is one control and must hold nothing interactive"
        }
      ],
      "composition": {
        "order": [
          "RadioGroup",
          "SelectableTile"
        ],
        "note": "Inside a RadioGroup the tiles are radios — one choice; inside a CheckboxGroup, checkboxes — several. The group's label, description, isRequired, isInvalid and errorMessage apply as they do to Radio and Checkbox, and the tiles wrap into a grid. Alone, a tile is a checkbox with `isSelected` and `onSelectionChange`. Put links — \"Compare plans\" — beside the group, not in a tile.",
        "example": "<RadioGroup label=\"Plan\" value={plan} onChange={setPlan}><SelectableTile value=\"team\" title=\"Team\" description=\"Up to 20 agents.\">€49 a month</SelectableTile></RadioGroup>"
      },
      "a11y": {
        "requires": [
          "`title` — the option's name, short: it is the input's accessible name",
          "a group label on the RadioGroup or CheckboxGroup — the question the tiles answer"
        ],
        "guarantees": [
          "it is a native radio or checkbox: arrow keys move between radios, Space toggles a checkbox, and a form submits its value",
          "the whole tile is the target, through the input's own label",
          "the description and extra detail are read after the name, through aria-describedby",
          "the group's help or error is read on each tile, as on Radio and Checkbox",
          "selected thickens the border and fills the indicator — not a colour change alone",
          "a keyboard focus ring goes round the whole tile",
          "the icon is hidden from assistive tech"
        ],
        "notes": [
          "nothing inside a tile may be interactive — the whole tile is one control",
          "the tiles wrap into a grid 14rem and up, so a phone gets one column"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Card onClick={() => setPlan('team')} aria-pressed={plan === 'team'}>",
          "do": "<SelectableTile value=\"team\" title=\"Team\" />",
          "why": "a clickable card is not a radio: it is not grouped, arrow keys do nothing, and a form does not submit it"
        },
        {
          "dont": "a \"Learn more\" link inside a tile",
          "do": "the link beside the group",
          "why": "the whole tile is the control; a link on top of it is a second target in the same place"
        }
      ],
      "stylesheet": "src/styles/selectable-tile.css",
      "tokens": [
        "--border-default",
        "--border-disabled",
        "--border-error-strong",
        "--border-primary-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-semibold",
        "--icon-secondary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-lg",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-hover",
        "--text-default",
        "--text-disabled",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "value": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The option's value — what the group reports."
        },
        "title": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The option's name, and the tile's accessible name: \"Pro\", \"Weekly\"."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "What choosing it means, in a sentence. Read after the name."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "A decorative mark above the title. Hidden from assistive tech."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "More detail below the description — a price, a limit. Read with the\ndescription. Text only: nothing inside a tile may be interactive, since\nthe whole tile is its control."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isSelected": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Standalone only, outside a group: whether it is ticked."
        },
        "defaultSelected": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Standalone only: the starting state."
        },
        "onSelectionChange": {
          "type": "((isSelected: boolean) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Standalone only: called with the new state."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 10,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "SettingRow": {
      "name": "SettingRow",
      "source": "src/components/SettingRow.tsx",
      "propsType": "SettingRowProps",
      "description": "SettingRow — one setting: its name and what it does on the left, the control\non the right. The row the SettingsPanel pattern is built from.\n\nTHE WIRING IS THE POINT, NOT THE LAYOUT\n\nA label placed beside a Toggle looks labelled and is not: a screen reader\nannounces \"switch, off\" and nothing else, and the description is never read\nat all. The pattern names this as its first a11y requirement, and the demo\napp's stand-in handed the ids to a render function — which still left the\nwiring as a step to remember. Here an element child is cloned with\n`aria-labelledby` and `aria-describedby` already set, so the row cannot be\nused without them. A value the caller passes explicitly wins, so an\nexisting name is never overwritten.\n\nA CONTROL WITH ITS OWN TEXT KEEPS ITS OWN NAME\n\nA Button reading \"Delete workspace…\" must be announced by that text — WCAG\n2.5.3 wants the visible words inside the accessible name, and a row label of\n\"Danger zone\" would replace them. So a child with children of its own is\nleft named by them, and the row's label joins its description instead: the\nsetting is still announced, just as context rather than as the name.\n\nThe label is text rather than a `<label>` because the control may not be a\nform field, and a `<label>` pointing at a button is invalid.\n\nIt stacks below 30rem of its OWN width, not the viewport's, so the same row\nworks in a full page and in a narrow drawer.",
      "import": "import { SettingRow } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.72.0",
      "summary": "One setting: its name and description on the left, the control on the right — and the control named and described by them automatically.",
      "useWhen": [
        "a settings or preferences panel, one row per setting",
        "a control whose meaning needs a sentence of explanation beside it"
      ],
      "useInstead": [
        {
          "when": "it is a form field with its own visible label above it",
          "use": "Input",
          "why": "Input, Select and the pickers carry their own label and description; a SettingRow around them names the field twice"
        },
        {
          "when": "a group of options answers one question",
          "use": "RadioGroup",
          "why": "the group has its own label; put the RadioGroup under a heading rather than in a row"
        }
      ],
      "slots": {
        "children": {
          "accepts": [
            "Toggle",
            "Checkbox",
            "Select",
            "Button"
          ],
          "note": "an element is cloned with aria-labelledby and aria-describedby pointing at the row's text; a function receives the ids instead, for a control that needs them somewhere else"
        }
      },
      "a11y": {
        "namesChild": true,
        "guarantees": [
          "an element child with no text of its own — a Toggle, a Checkbox with no label — is named by the row's label and described by its description, so it is announced as \"Weekly digest, switch, off\" plus what it does, not \"switch, off\"",
          "a child with its own text — a Button — keeps that text as its name (WCAG 2.5.3), and the row's label and description are attached as its description instead",
          "an aria-labelledby or aria-describedby the caller passes explicitly is kept, never overwritten",
          "it stacks by its own width, so it reads the same in a page and in a narrow drawer"
        ],
        "requires": [
          "one control per row — the ids name one element"
        ],
        "notes": [
          "The label is text, not a <label>, because the control may be a Button; a <label> pointing at a button is invalid."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Toggle or Checkbox with its own visible label inside a SettingRow",
          "why": "the control then names itself, the row's label becomes a description, and the setting's name is shown twice"
        },
        {
          "dont": "a Checkbox row that saves immediately",
          "why": "Toggle is immediate and Checkbox is saved with a submit — the SettingsPanel pattern's first rule"
        }
      ],
      "stylesheet": "src/styles/setting-row.css",
      "tokens": [
        "--font-family-sans",
        "--font-weight-semibold",
        "--spacing-16",
        "--spacing-2",
        "--spacing-8",
        "--text-default",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The setting's name. It becomes the control's accessible name."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "What the setting does, announced with the control."
        },
        "children": {
          "type": "React.ReactElement<{ 'aria-labelledby'?: string; 'aria-describedby'?: string; children?: React.ReactNode; }, string | React.JSXElementConstructor<any>> | ((ids: SettingRowIds) => React.ReactNode)",
          "required": true,
          "origin": "own",
          "description": "The control. Pass it as an element — `<Toggle />`, `<Select />`,\n`<Button />` — and the row names and describes it for you. Pass a\nfunction when the ids have to go somewhere other than the element's own\nprops; it receives them."
        }
      },
      "propCounts": {
        "own": 3,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Sidebar": {
      "name": "Sidebar",
      "source": "src/components/Sidebar.tsx",
      "propsType": "SidebarProps",
      "description": "Sidebar — an application's navigation, as a column.\n\nDISCLOSURE NAVIGATION, NOT `role=\"tree\"`\n\nA file-tree look invites the tree role, and the tree role is wrong here.\nA treeview takes the arrow keys, allows one tab stop for the whole widget,\nand does not permit interactive content inside an item — so the `…` and `+`\nbuttons on a row, which are half the point of a workspace sidebar, could not\nexist in it. This is the WAI-ARIA disclosure navigation pattern instead:\nnested lists of links, each expandable level behind a real button with\n`aria-expanded`, and everything reachable with Tab.\n\nAN ITEM THAT IS A LINK AND HAS CHILDREN GETS TWO CONTROLS\n\nA button cannot sit inside a link. So a row that navigates AND expands\nrenders the link and a separate chevron button beside it; a row with no\n`href` is one button that expands. The actions slot is a sibling of both,\nnever nested inside either.\n\nTHE CURRENT PAGE IS NEVER FOLDED AWAY\n\nAn item whose subtree contains the current page starts open. A sidebar that\nloads with the user's location collapsed inside a closed group tells a\nsighted user nothing and a screen-reader user less.\n\nExpansion is per item, controlled or not. Remembering it across visits is the\ncaller's job — this component cannot know where that state should live.",
      "import": "import { Sidebar } from 'ionbase-ui';",
      "status": "stable",
      "summary": "An application's navigation as a column: pinned header and footer that take any component, a scrolling body of titled, collapsible sections, and nested items that expand.",
      "useWhen": [
        "an app has more destinations than fit in a header — workspaces, projects, saved views",
        "the navigation is hierarchical and users need to see where they are inside it",
        "rows need their own actions — create inside this space, rename, favourite"
      ],
      "useInstead": [
        {
          "when": "there are five or fewer top-level destinations and no hierarchy",
          "use": "Header",
          "why": "a sidebar spends a column of width on what a header row carries in its height"
        },
        {
          "when": "the hierarchy describes the current page's position rather than offering destinations",
          "use": "Breadcrumb"
        },
        {
          "when": "sections of content collapse, not destinations",
          "use": "Accordion",
          "why": "Accordion's headings belong to the document outline; a sidebar is a navigation landmark"
        },
        {
          "when": "the hierarchy is data to browse or pick from — folders of files, teams in departments — not the app's pages",
          "use": "TreeView",
          "why": "a sidebar row is a link to a place; a tree row is an item, selected or opened in place, with a level and a position the tree announces"
        }
      ],
      "composition": {
        "order": [
          "Sidebar",
          "SidebarSection",
          "SidebarItem"
        ],
        "note": "Items always go inside a section, because a section renders the list. Anything that is not an item — a Divider, a promotional Alert — goes directly in Sidebar between sections, or in `header` / `footer`. On narrow screens, put the Sidebar inside a Drawer; it does not collapse itself.",
        "example": "<Sidebar label=\"Workspace\" header={<WorkspaceMenu />} footer={<Button>Invite</Button>}><SidebarSection><SidebarItem label=\"Home\" href=\"/\" isCurrent /></SidebarSection><Divider /><SidebarSection title=\"Spaces\" isCollapsible><SidebarItem label=\"Team Space\" href=\"/team\"><SidebarItem label=\"Projects\" href=\"/team/projects\" /></SidebarItem></SidebarSection></Sidebar>"
      },
      "slots": {
        "header": {
          "accepts": "Menu",
          "note": "pinned above the scroll — a workspace switcher, search, icon Buttons"
        },
        "footer": {
          "accepts": "Button",
          "note": "pinned below the scroll — Invite, Help, a trial notice in an Alert"
        },
        "children": {
          "accepts": "SidebarSection",
          "note": "and Divider or other non-item content between sections"
        }
      },
      "a11y": {
        "role": "navigation",
        "requires": [
          "`label` — the accessible name of the navigation landmark. Pages with a sidebar usually have a second nav, and two unnamed ones cannot be told apart"
        ],
        "guarantees": [
          "only the body scrolls; the header and footer stay reachable at any scroll position"
        ],
        "notes": [
          "it is disclosure navigation, not a treeview: everything is reachable with Tab, and rows can hold buttons. The tree role forbids interactive content inside items and would have ruled out row actions"
        ]
      },
      "antiPatterns": [
        {
          "dont": "role=\"tree\" on a sidebar with row actions",
          "why": "a treeview item cannot contain buttons, and its arrow-key model hides every action from Tab"
        },
        {
          "dont": "a SidebarItem placed directly in Sidebar, outside a section",
          "do": "wrap it in an untitled <SidebarSection>",
          "why": "the item renders an <li>, which is only valid inside the list a section provides"
        },
        {
          "dont": "a sidebar that is only ever a hamburger menu on desktop",
          "why": "hierarchical navigation that is hidden by default is navigation nobody uses; hide it only where the width forces it"
        }
      ],
      "stylesheet": "src/styles/sidebar.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-disabled",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-md",
        "--radius-sm",
        "--radius-xs",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-selected",
        "--surface-selected-hover",
        "--text-default",
        "--text-disabled",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "Names the navigation landmark. Required: a page with a sidebar usually has\na second `<nav>` too, and \"navigation, navigation\" tells nobody which is\nwhich."
        },
        "header": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Pinned above the scrolling body — a workspace switcher, a search trigger,\nicon buttons. Any component."
        },
        "footer": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Pinned below the scrolling body — an Invite button, help, a trial notice.\nAny component."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "`SidebarSection`s, and anything that sits between them — a `Divider`, a\npromotional card. Items themselves always go inside a section."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "SidebarItem": {
      "name": "SidebarItem",
      "source": "src/components/Sidebar.tsx",
      "propsType": "SidebarItemProps",
      "import": "import { SidebarItem } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One sidebar row: a link or a button, with an icon, a count, actions revealed on hover or focus, and nested items that expand.",
      "useWhen": [
        "inside a SidebarSection, or nested inside another SidebarItem"
      ],
      "useInstead": [
        {
          "when": "it is a link in a header bar",
          "use": "NavItem",
          "why": "NavItem recolours on hover without a fill, as measured for the header; sidebar rows fill"
        },
        {
          "when": "it is a row in a dropdown",
          "use": "MenuItem"
        }
      ],
      "slots": {
        "icon": {
          "accepts": "Icon",
          "note": "decorative; label names the row"
        },
        "badge": {
          "accepts": "text",
          "note": "a count or short status, trailing"
        },
        "actions": {
          "accepts": "Button",
          "note": "icon-only Buttons with aria-label, or a Menu trigger for more. Keep to two"
        },
        "children": {
          "accepts": "SidebarItem",
          "note": "nested items; their presence makes the row expandable"
        }
      },
      "a11y": {
        "requires": [
          "`label` — the row's name, and the name of its expand button when it has children"
        ],
        "guarantees": [
          "`isCurrent` sets aria-current=\"page\" on the link",
          "a row with `href` and children renders the link and a separate expand button beside it — a button cannot be nested in a link",
          "a row with children and no `href` is a single button with aria-expanded",
          "a row whose subtree holds the current page starts expanded",
          "actions stay in the tab order and become visible when focus reaches them; they are always visible on touch screens"
        ]
      },
      "antiPatterns": [
        {
          "dont": "onClick on a row to navigate",
          "do": "href",
          "why": "a real link opens in a new tab, shows its address and is announced as a link"
        },
        {
          "dont": "actions nested inside the label or icon",
          "do": "the actions prop",
          "why": "interactive content inside a link is invalid and unreachable by keyboard in most browsers"
        },
        {
          "dont": "marking a parent isCurrent because a child page is open",
          "why": "only the page the user is on is current; its ancestors are opened for them instead"
        }
      ],
      "stylesheet": "src/styles/sidebar.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-disabled",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-md",
        "--radius-sm",
        "--radius-xs",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-selected",
        "--surface-selected-hover",
        "--text-default",
        "--text-disabled",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The item's text. A string, because it also names the expand button of an\nitem that has children."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Leading icon. Decorative — `label` names the item."
        },
        "href": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Where it goes. Omit for an item that only groups its children."
        },
        "onPress": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "For an item that acts rather than navigates. Ignored with `href`."
        },
        "isCurrent": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "The page the user is on. Marked `aria-current=\"page\"`."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "badge": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Trailing text — a count, a shortcut, a status."
        },
        "actions": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Controls for this row — `…` and `+`. Revealed on hover and whenever focus\nis inside the row; always shown on touch screens. Icon-only buttons need\ntheir own `aria-label`."
        },
        "defaultExpanded": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Uncontrolled: whether the children start open. Defaults to open when the\ncurrent page is somewhere inside."
        },
        "isExpanded": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled: whether the children are open."
        },
        "onExpandedChange": {
          "type": "((isExpanded: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Nested `SidebarItem`s. Their presence is what makes the item expandable."
        }
      },
      "propCounts": {
        "own": 12,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "SidebarSection": {
      "name": "SidebarSection",
      "source": "src/components/Sidebar.tsx",
      "propsType": "SidebarSectionProps",
      "import": "import { SidebarSection } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A group of sidebar items under an optional title, which can fold away and carry its own actions.",
      "useWhen": [
        "inside a Sidebar, once per group — the untitled top group, then Favorites, Spaces"
      ],
      "useInstead": [
        {
          "when": "the grouping is one item and its children",
          "use": "SidebarItem",
          "why": "an item with nested items already expands; a section per parent doubles the structure"
        }
      ],
      "slots": {
        "children": {
          "accepts": "SidebarItem",
          "note": "items only — the section renders a list"
        },
        "actions": {
          "accepts": "Button",
          "note": "beside the title — a + to create in this group. Icon-only buttons need aria-label"
        }
      },
      "a11y": {
        "guarantees": [
          "a titled section's list is named by its title, so a screen reader announces \"Spaces, list, 4 items\"",
          "a collapsible title is a button with aria-expanded and aria-controls; the folded list is hidden, not unmounted"
        ]
      },
      "antiPatterns": [
        {
          "dont": "isCollapsible with no title",
          "why": "there is nothing to press; the prop is ignored"
        },
        {
          "dont": "collapsing the section that holds the current page by default",
          "why": "the user's location is hidden on arrival — leave it open, or open it when the page is inside"
        }
      ],
      "stylesheet": "src/styles/sidebar.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-disabled",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-md",
        "--radius-sm",
        "--radius-xs",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-selected",
        "--surface-selected-hover",
        "--text-default",
        "--text-disabled",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "title": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The group's name — \"Spaces\", \"Favorites\". Omit for the top group."
        },
        "isCollapsible": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Lets the title fold the section away. Needs a `title`."
        },
        "defaultExpanded": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Uncontrolled: whether a collapsible section starts open."
        },
        "isExpanded": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled: whether a collapsible section is open."
        },
        "onExpandedChange": {
          "type": "((isExpanded: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "actions": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Controls beside the title — a `+` Button to create a space. Icon-only\nbuttons need their own `aria-label`."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "`SidebarItem`s only — the section renders a list."
        }
      },
      "propCounts": {
        "own": 7,
        "aria": 0,
        "dom": 276,
        "other": 0
      }
    },
    "SidePanel": {
      "name": "SidePanel",
      "source": "src/components/SidePanel.tsx",
      "propsType": "SidePanelProps",
      "description": "SidePanel — detail that opens beside the content, and leaves it usable.\n\nWHY NOT A DRAWER\n\nA Drawer is a modal dialog: it traps focus, marks the page inert and tells\na screen reader the page behind is gone. A detail pane beside a list is the\nopposite — the list stays live, the next row can be picked with the pane\nopen, and saying \"dialog\" about it would be a lie about where the user can\ngo. Same reasoning as ApprovalGate not being a Modal: the page is the\ncontext.\n\nHOW IT BEHAVES\n\n  - A `<section>` region named by its title, rendered in place — not\n    portalled — so it is part of the page's layout and reading order. Put\n    it in a SidePanelLayout beside the content.\n  - Opening focuses the title — unless `autoFocus={false}`, for a panel\n    that follows a List's selection. Closing with focus inside returns it\n    to the last thing focused outside the panel. Changing its content\n    does neither.\n  - Escape closes it from inside; Escape elsewhere is the page's own.\n  - No focus trap, no scrim, nothing hidden. Closed, it renders nothing.\n  - Below `overlayBelow` (768px) there is no \"beside\": the panel would\n    cover the list, so it opens as a Drawer — modal, honestly so.",
      "import": "import { SidePanel } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Detail that opens beside the content and leaves it usable — pick a row, read it in the panel, pick the next. A labelled region in the page, not a dialog.",
      "useWhen": [
        "the detail of a list item should sit beside the list, and the next item should be pickable without closing anything — runs, tickets, records",
        "the user compares or works down a list, reading each item's detail in turn",
        "the detail is read or lightly acted on, not a task that must be finished or cancelled"
      ],
      "useInstead": [
        {
          "when": "the page behind must not be touched until the task is done — an edit form, a filter set that applies on confirm",
          "use": "Drawer",
          "why": "that is modal behaviour, and a Drawer says so: it traps focus and hides the page. A SidePanel leaves the page live"
        },
        {
          "when": "the detail is the whole of what the user came for — a record they will spend minutes in",
          "use": "a page of its own",
          "why": "a panel is a side view; a record with sections, tabs and history needs a URL and the full width"
        },
        {
          "when": "a sentence or two about one field",
          "use": "Toggletip"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "a summary: a few facts and an action — 22rem"
          },
          "md": {
            "use": "the default — 30rem"
          },
          "lg": {
            "use": "detail with its own sections, on wide screens — 40rem"
          }
        }
      },
      "slots": {
        "title": {
          "accepts": "text",
          "note": "required — it names the region and is where focus lands on open"
        },
        "description": {
          "accepts": "text"
        },
        "children": {
          "accepts": "any content",
          "note": "the only scrolling region"
        },
        "footer": {
          "accepts": "Button",
          "note": "pinned to the bottom edge"
        }
      },
      "a11y": {
        "requires": [
          "`title` — the region's accessible name, through `aria-labelledby`",
          "a `SidePanelLayout` with the content first and the panel second — DOM order is reading order, and the panel is the detail of what comes before it"
        ],
        "guarantees": [
          "a `<section>` region named by its title, rendered in place — not portalled, not a dialog, and nothing on the page is hidden or made inert",
          "opening focuses the title, so the panel is announced and reading starts at its top — unless `autoFocus={false}`, when focus stays where it is",
          "changing what an open panel shows does not move focus — the list can be arrowed through with the panel open",
          "Escape from inside closes it; Escape elsewhere on the page does not. A Toggletip, Menu or Select inside closes first",
          "closing with focus inside returns focus to the last thing focused outside it — the row picked most recently",
          "no focus trap: Tab walks out of the panel into the rest of the page",
          "closed, it renders nothing, so it leaves no tab stops",
          "below 768px (`overlayBelow`) it opens as a Drawer, because there is no room beside the content and a panel covering the list must be modal to say so",
          "it sticks to `--ion-side-panel-top` while the page scrolls, and its body scrolls when the detail is taller than the screen",
          "the content column has `min-width: 0`, so a wide Table beside the panel scrolls in its own container rather than pushing the panel off screen"
        ],
        "notes": [
          "set `--ion-side-panel-top` on the app shell (any ancestor) to the height of a sticky app header, or the panel's top slides under it",
          "the title's heading level is `headingLevel`, default 2 — set it to follow the page's own outline",
          "`isOpen` and `onOpenChange` usually derive from the selected item: open while one is selected, and `onOpenChange(false)` clears the selection",
          "`autoFocus={false}` beside a List with `selectionMode=\"single\"`: its selection follows focus, so tabbing into the list selects the first row and opens the panel. Taking focus then would pull the user out of the list before they had pressed a key. Tab reaches the panel next, because it comes next in the DOM. The ListDetail pattern is this case. As a Drawer, below `overlayBelow`, it is modal and always takes focus"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Drawer> for a record's detail beside the list it came from",
          "do": "<SidePanelLayout><Table …/><SidePanel …/></SidePanelLayout>",
          "why": "a Drawer blocks the list: picking the next row means closing, clicking, reopening, and a screen reader is told the list is gone"
        },
        {
          "dont": "a SidePanel holding a form that must be saved or cancelled before anything else",
          "do": "<Drawer> or <Modal>",
          "why": "a live page beside an unsaved form lets the user wander off and lose it; that task wants to be modal"
        },
        {
          "dont": "the panel before the content in the DOM, moved to the right with CSS",
          "do": "content first, SidePanel second, inside SidePanelLayout",
          "why": "reading and Tab order would reach the detail before the list it is the detail of"
        },
        {
          "dont": "a SidePanel that takes focus on open, beside a List whose selection follows focus",
          "do": "`autoFocus={false}`, and rows that open on a press below `overlayBelow`",
          "why": "tabbing into the list selects its first row, the panel opens and takes focus, and the list can never be walked"
        },
        {
          "dont": "moving focus into the panel every time the selected row changes",
          "do": "let the panel follow the selection; it focuses its title only when it opens",
          "why": "focus jumping to the panel on each pick makes the list impossible to walk with the keyboard"
        }
      ],
      "stylesheet": "src/styles/side-panel.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-semibold",
        "--icon-secondary",
        "--icon-size-md",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-side-panel-size",
        "--ion-side-panel-top",
        "--radius-lg",
        "--radius-sm",
        "--spacing-12",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--text-default",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-h5",
        "--type-h5-line-height"
      ],
      "props": {
        "isOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "defaultOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "onOpenChange": {
          "type": "((isOpen: boolean) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "`false` when the panel asks to close — its close button, or Escape."
        },
        "title": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "Required: names the region, and is where focus lands on open."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "footer": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Actions pinned to the bottom edge."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "size": {
          "type": "SidePanelSize | undefined",
          "required": false,
          "origin": "own",
          "description": "22rem, 30rem or 40rem — Drawer's sizes.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "headingLevel": {
          "type": "2 | 3 | 4 | undefined",
          "required": false,
          "origin": "own",
          "description": "The title's heading level. Default 2."
        },
        "showClose": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "closeLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "autoFocus": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Focus the title on open. Default `true`. `false` when the panel opens\nbecause of where focus already is — a List whose selection follows focus,\nwhere tabbing in selects the first row — so focus stays in the list and\nthe next ↓ still reaches the next row. Tab from the list reaches the\npanel, which comes next in the DOM. Below `overlayBelow` it opens as a\nDrawer, which is modal and always takes focus."
        },
        "overlayBelow": {
          "type": "number | false | undefined",
          "required": false,
          "origin": "own",
          "description": "Below this viewport width, in px, there is no room beside the content and\nthe panel opens as a Drawer instead — modal, because it covers what it\nwould have sat beside. Default 768. `false` never switches.",
          "default": 768
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 15,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "SidePanelLayout": {
      "name": "SidePanelLayout",
      "source": "src/components/SidePanel.tsx",
      "propsType": "SidePanelLayoutProps",
      "description": "The row a SidePanel sits in: content, then panel. The content column gets\n`min-width: 0`, without which a wide Table beside the panel pushes it off\nscreen instead of scrolling inside its own container.",
      "import": "import { SidePanelLayout } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The row a SidePanel sits in: the content, then the panel. Gives the content column `min-width: 0` so wide content scrolls in place instead of pushing the panel off screen.",
      "useWhen": [
        "a SidePanel opens beside a list, table or feed"
      ],
      "useInstead": [
        {
          "when": "the side content is the app's navigation",
          "use": "Sidebar",
          "why": "navigation belongs to the page shell, not to a content row"
        },
        {
          "when": "two panes that are always there, read across, whose split the reader sets",
          "use": "SplitPane",
          "why": "a SidePanel comes and goes; a SplitPane's panes stay, with a divider between them that moves"
        }
      ],
      "a11y": {
        "requires": [
          "exactly two children, content first and the SidePanel second — DOM order is reading and Tab order"
        ],
        "guarantees": [
          "the first child fills the row and may shrink below its content's width, so a wide Table scrolls in its own container",
          "adds no role and no landmark: the content and the panel carry their own names"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<SidePanelLayout><SidePanel …/><Table …/></SidePanelLayout>",
          "do": "<SidePanelLayout><Table …/><SidePanel …/></SidePanelLayout>",
          "why": "with the panel first, the detail is read and tabbed to before the list it is the detail of"
        }
      ],
      "stylesheet": "src/styles/side-panel.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-semibold",
        "--icon-secondary",
        "--icon-size-md",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-side-panel-size",
        "--ion-side-panel-top",
        "--radius-lg",
        "--radius-sm",
        "--spacing-12",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--text-default",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-h5",
        "--type-h5-line-height"
      ],
      "props": {
        "children": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The content first, then the SidePanel — reading order is DOM order."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 2,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Skeleton": {
      "name": "Skeleton",
      "source": "src/components/Skeleton.tsx",
      "propsType": "SkeletonProps",
      "description": "Skeleton — the shape of content that has not arrived.\n\nIT IS HIDDEN FROM ASSISTIVE TECHNOLOGY, ALWAYS, AND THAT IS THE WHOLE POINT\n\nA skeleton is a picture of content, and a screen reader cannot use a picture\nof content. Announcing it produces a stream of empty boxes between the user\nand the thing they asked for. So every skeleton is `aria-hidden`, with no way\nto opt out — a prop for that would only ever be used by mistake.\n\nWHICH MEANS THE CALLER OWES THE ANNOUNCEMENT, AND THIS COMPONENT CANNOT DO IT\n\nHiding the placeholder is only half an answer: something still has to tell a\nscreen-reader user that the region is loading. That belongs on the region\nbeing replaced — `aria-busy=\"true\"` while it loads — because only the caller\nknows where that region starts and ends. `Spinner` is the other half when the\nwait deserves an announcement of its own.\n\n  <section aria-busy={isLoading}>\n    {isLoading ? <Skeleton lines={3} /> : <Rows data={data} />}\n  </section>\n\nThis is the one component in the system whose correct use REQUIRES something\nof the caller that the type system cannot check, which is why it is stated\nhere, in the contract, and in the story.",
      "import": "import { Skeleton } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The shape of content that has not arrived. Always hidden from assistive technology — the caller owns the announcement.",
      "useWhen": [
        "a region will be replaced by content whose shape is known in advance",
        "the layout would otherwise jump when the content lands",
        "several regions load independently and a single page spinner would hide that"
      ],
      "useInstead": [
        {
          "when": "the shape of what is coming is not known",
          "use": "Spinner",
          "why": "a skeleton that does not match what replaces it is a worse promise than no promise"
        },
        {
          "when": "the region is empty because there is nothing to show",
          "use": "EmptyState",
          "why": "a skeleton that never resolves is how an empty list looks like a broken one"
        }
      ],
      "variants": {
        "variant": {
          "text": {
            "use": "lines of copy",
            "note": "height comes from the line box, so the block occupies what the real paragraph will. With `lines`, the last is drawn short because real paragraphs end mid-measure"
          },
          "circle": {
            "use": "an avatar or an icon slot"
          },
          "rect": {
            "use": "a card, a thumbnail, a chart"
          }
        }
      },
      "a11y": {
        "requires": [
          "`aria-busy=\"true\"` on the region being replaced, set by the CALLER — this component cannot do it, because only the caller knows where the region starts and ends"
        ],
        "notes": [
          "every skeleton is aria-hidden with no opt-out: a screen reader cannot use a picture of content, and announcing it puts a stream of empty boxes between the user and what they asked for",
          "prefers-reduced-motion stops the pulse outright, unlike Spinner and ProgressBar — a skeleton conveys nothing a static block does not, so there is no signal to preserve"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Skeleton /> inside a region with no aria-busy",
          "do": "<section aria-busy={isLoading}>{isLoading ? <Skeleton lines={3} /> : <Rows />}</section>",
          "why": "the skeleton is hidden, so without aria-busy a screen-reader user is told nothing at all — the region simply reads as empty"
        },
        {
          "dont": "a skeleton whose shape does not match the content that replaces it",
          "do": "match the line count and block sizes to the real thing",
          "why": "the entire benefit is that the layout does not move; a mismatched skeleton pays the cost and keeps the jump"
        },
        {
          "dont": "leaving skeletons on screen when a request fails",
          "do": "render EmptyState with reason=\"error\"",
          "why": "a skeleton that never resolves is indistinguishable from a hung page"
        }
      ],
      "stylesheet": "src/styles/skeleton.css",
      "tokens": [
        "--border-width-default",
        "--icon-size-lg",
        "--ion-duration-slow",
        "--ion-ease-in-out",
        "--ion-skeleton-height",
        "--ion-skeleton-radius",
        "--ion-skeleton-width",
        "--radius-full",
        "--radius-md",
        "--radius-sm",
        "--spacing-16",
        "--spacing-64",
        "--spacing-8",
        "--surface-muted",
        "--type-body-line-height"
      ],
      "props": {
        "variant": {
          "type": "SkeletonVariant | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "circle",
            "text",
            "rect"
          ]
        },
        "width": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Any CSS length. Defaults to filling the width available."
        },
        "height": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Any CSS length. `text` derives its height from the line box instead."
        },
        "lines": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "`text` only: how many lines to draw. The last is drawn short."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "SkipLink": {
      "name": "SkipLink",
      "source": "src/components/SkipLink.tsx",
      "propsType": "SkipLinkProps",
      "description": "SkipLink — the first focusable thing on the page, so a keyboard user can\npass the header and navigation straight to the content.\n\nHidden until it has focus, then shown at the top of the window over\neverything, a toast included. Put it first in the body, before banners and\nthe Header, and point `target` at the page's `<main>`.",
      "import": "import { SkipLink } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The first focusable thing on the page: a keyboard user's way past the header and navigation, straight to the content. Hidden until it has focus.",
      "useWhen": [
        "every page of an app with a header or navigation before its content — one, first in the body, pointing at <main>"
      ],
      "useInstead": [
        {
          "when": "a link to a section further down the same page",
          "use": "Link",
          "why": "a table of contents or an in-page link is always visible; a skip link exists only for the keyboard, only at the top"
        }
      ],
      "composition": {
        "order": [
          "SkipLink"
        ],
        "note": "Render it once, in the app shell, as the very first element in the body: before banners, before the Header. `target` is the `id` of the page's <main>. It moves focus to the target itself rather than following `#id`, so it works under a hash router; a target that is not focusable gets `tabindex=\"-1\"` while it holds focus, and the next Tab continues from inside it. Its words default to \"Skip to main content\"; pass children only to translate them. It is fixed at the window's top-left above everything while focused, so where it sits in the shell's layout does not matter.",
        "example": "<body>\n  <SkipLink target=\"main\" />\n  <Header … />\n  <main id=\"main\" aria-labelledby=\"page-title\">…</main>\n</body>"
      },
      "slots": {
        "children": {
          "accepts": "text",
          "note": "optional; the default is \"Skip to main content\""
        }
      },
      "a11y": {
        "guarantees": [
          "the first Tab on a freshly loaded page reaches it, and it is shown, above anything fixed on the page, while it has focus",
          "activating it puts focus inside the content: the next Tab is the content's first control, not the Header's",
          "it leaves the URL alone, so a hash route is not replaced by `#main`",
          "hidden by clipping, not `display: none`, so it stays in the tab order"
        ],
        "requires": [
          "an element with the `target` id — the page's <main> — rendered on every page the link is"
        ],
        "notes": [
          "It satisfies WCAG 2.4.1 Bypass Blocks. A missing target falls back to following the link, so it is never a dead control. After an in-app navigation the app's own focus management decides where focus goes; the skip link is for a fresh load and for anyone who returns to the top."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a skip link after the Header or the banners",
          "why": "it is only useful as the first stop: a keyboard user has already tabbed through what it was meant to skip"
        },
        {
          "dont": "`<a href=\"#main\">` by hand in an app with a hash router",
          "do": "SkipLink",
          "why": "following `#main` replaces the route: the page changes, or the router shows its not-found screen"
        },
        {
          "dont": "several skip links, to each section",
          "why": "a list of stops before the content is a second navigation to tab through; headings and landmarks already let a screen reader jump between sections"
        }
      ],
      "stylesheet": "src/styles/skip-link.css",
      "tokens": [
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--ion-shadow-shadow-lg",
        "--radius-md",
        "--ring-focus",
        "--spacing-16",
        "--spacing-8",
        "--surface-raised",
        "--text-primary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "target": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The `id` of what to skip to — the page's `<main>`."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "What it says. Defaults to \"Skip to main content\"."
        }
      },
      "propCounts": {
        "own": 2,
        "aria": 0,
        "dom": 283,
        "other": 0
      }
    },
    "Slider": {
      "name": "Slider",
      "source": "src/components/Slider.tsx",
      "propsType": "SliderProps",
      "description": "Slider — a value, or a range, picked along a track.\n\nWHEN A SLIDER, AND WHEN NOT\n\nFor a rough value in a known range, where where-it-sits matters more than\nthe exact number: a threshold, a zoom, a \"between 30s and 2m\" filter. When\nthe number itself matters — a price, a budget — NumberInput: dragging to\nexactly 4,250 is a test of hand steadiness.\n\nHOW IT IS BUILT\n\n  - Each thumb is a real `<input type=\"range\">`, visually hidden, so the\n    platform's slider keyboard and screen-reader model come for free:\n    arrows step, Page Up/Down jump a tenth, Home/End go to the bounds.\n    `aria-valuetext` carries the formatted value, so \"45%\" is read, not\n    \"0.45\".\n  - A range's thumbs cannot cross: each one's bounds are the other's value.\n  - Pressing the track moves the nearest thumb there.\n  - Every thumb's hit area is 24px square — WCAG 2.5.8 — around a 20px knob,\n    and the track is 24px tall for the same reason, around a 4px rail.\n  - In a right-to-left locale the track runs right to left, keys included.",
      "import": "import { Slider } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A value, or a low–high range, picked along a track. For a rough value in a known range, where where-it-sits matters more than the exact number.",
      "useWhen": [
        "the value is a position in a known range and roughly right is right — a threshold, a zoom, a volume",
        "a filter bounds a quantity from both ends — \"between 30s and 2m\", \"$20 to $80\" — pass `[low, high]`",
        "seeing the value's place in the range helps the choice more than typing it would"
      ],
      "useInstead": [
        {
          "when": "the exact number matters — a price, a budget, a count someone will be held to",
          "use": "NumberInput",
          "why": "dragging to exactly 4,250 is a test of hand steadiness; a field takes the number as typed"
        },
        {
          "when": "there are a few named choices, not a continuum — Low, Medium, High",
          "use": "SegmentedControl",
          "why": "a slider with three stops hides the names behind positions and reads out numbers nobody chose"
        },
        {
          "when": "the value is on or off",
          "use": "Toggle"
        },
        {
          "when": "showing how far something has got, not asking for a value",
          "use": "ProgressBar",
          "why": "a track with a knob invites a drag; progress is not something the user sets"
        }
      ],
      "a11y": {
        "requires": [
          "`label` or `aria-label` — the group's name, and every thumb's name through it",
          "`formatOptions` whenever the number has a unit — the formatted value is what is read out, so a percentage without it is announced as \"0.7\""
        ],
        "guarantees": [
          "every thumb is a visually hidden `<input type=\"range\">`: arrows step, Page Up and Down jump a tenth of the range, Home and End go to the bounds",
          "`aria-valuetext` carries the formatted value — \"70%\", \"120 sec\" — in the user's locale",
          "a range's thumbs are named with the label — \"Minimum Duration\" and \"Maximum Duration\" — and cannot cross; each stops at the other's value",
          "pressing the track moves the nearest thumb there",
          "every thumb's target is 24px square, around a 20px knob, and the track is 24px tall — WCAG 2.5.8",
          "thumbs at the bounds stay inside the component; the track is inset by half a thumb",
          "in a right-to-left locale the track runs right to left, and the arrow keys follow it",
          "a disabled slider's thumbs are disabled inputs, out of the tab order",
          "`description` is linked to every thumb with `aria-describedby`",
          "a range with `name={[low, high]}` submits two form fields"
        ],
        "notes": [
          "the value beside the label is an `<output>` with `aria-live=\"off\"`: the thumb already announces each change, and a second voice would read every step twice",
          "`onChange` fires on every step of a drag; `onChangeEnd` fires once when it ends. Fetch, filter a server or save on `onChangeEnd`",
          "`thumbLabels` names a range's two thumbs and defaults to English \"Minimum\" and \"Maximum\" — pass translations"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Slider label=\"Monthly budget\" maxValue={5000000} />",
          "do": "<NumberInput label=\"Monthly budget\" />",
          "why": "an exact number across millions is out of reach by drag — each pixel is thousands"
        },
        {
          "dont": "fetching on `onChange`",
          "do": "`onChangeEnd={(v) => refetch(v)}`",
          "why": "`onChange` fires on every pixel of a drag — dozens of requests for one gesture, answered out of order"
        },
        {
          "dont": "<Slider label=\"Threshold\" minValue={0} maxValue={1} step={0.01} />",
          "do": "<Slider label=\"Threshold\" minValue={0} maxValue={1} step={0.01} formatOptions={{ style: 'percent' }} />",
          "why": "without a format the value is shown and read as \"0.7\"; with it, \"70%\""
        },
        {
          "dont": "two Sliders for the low and high end of one filter",
          "do": "<Slider label=\"Duration\" defaultValue={[30, 120]} />",
          "why": "two separate sliders can cross — a minimum above the maximum — and read as two unrelated settings"
        },
        {
          "dont": "a Slider with three steps standing in for Low, Medium, High",
          "do": "<SegmentedControl label=\"Effort\">…</SegmentedControl>",
          "why": "the choices have names; a slider reads them out as 1, 2 and 3"
        }
      ],
      "stylesheet": "src/styles/slider.css",
      "tokens": [
        "--border-disabled",
        "--border-focus",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--ion-duration-fast",
        "--ion-ease-out",
        "--ion-shadow-shadow-xs",
        "--ion-slider-fill",
        "--ion-slider-from",
        "--ion-slider-knob",
        "--ion-slider-rail",
        "--ion-slider-thumb",
        "--ion-slider-to",
        "--radius-full",
        "--spacing-20",
        "--spacing-24",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-muted",
        "--surface-primary",
        "--text-default",
        "--text-disabled",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Visible label. Names the group, and every thumb through it."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Only when there is no visible label."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text under the track, linked to every thumb."
        },
        "value": {
          "type": "V | undefined",
          "required": false,
          "origin": "own",
          "description": "A number for one thumb, `[low, high]` for two. The shape of `value` or\n`defaultValue` decides which slider this is."
        },
        "defaultValue": {
          "type": "V | undefined",
          "required": false,
          "origin": "own"
        },
        "onChange": {
          "type": "((value: Widen<V>) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires on every step of a drag or key press."
        },
        "onChangeEnd": {
          "type": "((value: Widen<V>) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires once, when a drag ends or a key is released. Use this, not\n`onChange`, for anything that fetches or saves."
        },
        "minValue": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Default 0."
        },
        "maxValue": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Default 100."
        },
        "step": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Default 1. Page Up and Page Down move by a tenth of the range."
        },
        "formatOptions": {
          "type": "Intl.NumberFormatOptions | undefined",
          "required": false,
          "origin": "own",
          "description": "How values are shown and announced — `{ style: 'percent' }`,\n`{ style: 'unit', unit: 'second' }`, currency. In the user's locale."
        },
        "thumbLabels": {
          "type": "readonly [string, string] | undefined",
          "required": false,
          "origin": "own",
          "description": "A range's two thumbs, named. Each is read before the label — \"Minimum\nDuration\". Pass translations here."
        },
        "showValue": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "The current value beside the label. On by default."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "name": {
          "type": "(V extends number ? string : readonly [string, string]) | undefined",
          "required": false,
          "origin": "own",
          "description": "The form field name. A range submits two fields, so it takes a pair."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 17,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Spinner": {
      "name": "Spinner",
      "source": "src/components/Spinner.tsx",
      "propsType": "SpinnerProps",
      "description": "Spinner — an indeterminate wait, announced once.\n\nWHY THIS IS NOT A DIV WITH A CSS ANIMATION\n\nThe obvious implementation is inaccessible in a specific and common way: a\nspinning graphic with no text is silence to a screen reader, so the user is\ntold nothing happened. Generated code in this repo's own eval corpus reached\nfor a bare `role=\"status\"` in 48 files, which is the right instinct and only\nhalf the job — the role creates a live region, but an empty one announces\nnothing at all.\n\nSo the default carries a label. It is visually hidden, it lives inside the\nlive region, and it is what makes \"Loading\" reach the user who cannot see the\nring.\n\n`isDecorative` exists because the opposite mistake is just as easy. A spinner\ninside a button whose label already changed to \"Saving…\" would announce\ntwice, and two live regions racing is worse than one. Decorative renders no\nrole at all rather than `aria-hidden` on a live region, which browsers treat\ninconsistently.\n\nMOTION\n\n`prefers-reduced-motion` slows the rotation rather than stopping it. A\nstationary spinner is not a calmer spinner — it is a broken one, and it\nremoves the only signal a sighted user has that the wait is still live.",
      "import": "import { Spinner } from 'ionbase-ui';",
      "status": "stable",
      "summary": "An indeterminate wait, announced once. A ring plus a politely-announced label, sized off the icon ramp.",
      "useWhen": [
        "work is in flight and its duration cannot be measured",
        "a control has started something and must show it is working",
        "a small region is waiting and a skeleton would be more scaffolding than content"
      ],
      "useInstead": [
        {
          "when": "the work has a measurable fraction complete",
          "use": "ProgressBar",
          "why": "a spinner over measurable work discards information the user wants — a spinner and a 90%-complete upload look identical"
        },
        {
          "when": "the work is measurable and has to fit a square or a line of text",
          "use": "ProgressRing",
          "why": "the ring is the spinner's size with the fraction in it; a spinner there discards what the user wants to know"
        },
        {
          "when": "a region is being replaced by content with a known shape",
          "use": "Skeleton",
          "why": "a skeleton holds the layout so it does not jump when content lands; a spinner leaves a hole that then reflows"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "inside a control, or beside a line of text"
          },
          "md": {
            "use": "the default — a panel or a section"
          },
          "lg": {
            "use": "a whole route or a full-page wait"
          }
        }
      },
      "a11y": {
        "requires": [
          "a `label` naming what is loading — it is announced politely and is the only thing a screen-reader user receives",
          "`isDecorative` when something else already announces the wait, so two live regions do not race"
        ],
        "notes": [
          "the ring is aria-hidden; the label carries the meaning",
          "prefers-reduced-motion slows the rotation rather than stopping it — a stationary spinner is indistinguishable from a hung one"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<div className=\"spinner\" role=\"status\" />",
          "do": "<Spinner label=\"Loading invoices\" />",
          "why": "role=\"status\" creates a live region; an EMPTY live region announces nothing. This exact half-measure appeared in 48 files of this repo's eval corpus"
        },
        {
          "dont": "<Button isDisabled>Saving… <Spinner /></Button>",
          "do": "<Button isDisabled>Saving… <Spinner isDecorative /></Button>",
          "why": "the button label already says it. Two live regions announcing the same wait talk over each other"
        },
        {
          "dont": "a Spinner for an upload that reports bytes transferred",
          "do": "ProgressBar with value",
          "why": "throwing away a known percentage makes a finite wait feel unbounded"
        }
      ],
      "stylesheet": "src/styles/spinner.css",
      "tokens": [
        "--border-primary-strong",
        "--border-subtle",
        "--border-width-thick",
        "--font-family-sans",
        "--icon-size-lg",
        "--icon-size-md",
        "--icon-size-sm",
        "--ion-duration-cycle",
        "--ion-duration-slow",
        "--ion-ease-linear",
        "--ion-spinner-head",
        "--ion-spinner-size",
        "--ion-spinner-track",
        "--radius-full",
        "--spacing-8",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "size": {
          "type": "SpinnerSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the icon size ramp: sm, md, lg.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "What is loading. Announced politely; visible only with `isLabelVisible`."
        },
        "isLabelVisible": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Render the label as text beside the ring as well as announcing it."
        },
        "isDecorative": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "The spinner conveys nothing on its own — something else already says the\nregion is busy. Renders inert: no role, no live region, hidden from AT."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "SplitButton": {
      "name": "SplitButton",
      "source": "src/components/SplitButton.tsx",
      "propsType": "SplitButtonProps",
      "description": "SplitButton — one main action, with a menu of variations on it beside:\nSave, and Save as draft; Run now, and Run with a test input.\n\nTWO BUTTONS, TWO TAB STOPS\n\nThe halves are separate Buttons in a `group`, not one control with a hot\nzone. Each is announced and reached on its own: the main one by its label,\nthe menu one as \"More options, Save\" — its own name, then the action it\nbelongs to, so a page with three split buttons has three distinct menu\nbuttons, not three called \"More options\". The menu one is a MenuTrigger,\nso it announces `aria-haspopup` and `aria-expanded`, and ↓ opens it.\n\nNOT A MENU WITH A DEFAULT. Pressing the main half does the action, always\nthe same one; it never becomes whichever alternative was chosen last. A\nbutton whose meaning changes with history cannot be pressed with\nconfidence.",
      "import": "import { SplitButton } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One main action with a menu of variations on it beside — Save, and Save as draft; Create agent, and Create and start. Two buttons, two tab stops, one shape.",
      "useWhen": [
        "one action is the common case and a few variations of the same action are occasionally needed — save as draft, create and start, export as CSV",
        "the variations would crowd the row as buttons of their own, and hiding the main action behind a menu would cost the common case a click"
      ],
      "useInstead": [
        {
          "when": "the choices are different actions, not variations of one",
          "use": "MenuTrigger",
          "why": "a split button promises its menu is \"this, but slightly otherwise\"; unrelated actions under it hide what they are behind the wrong label"
        },
        {
          "when": "there are two actions of equal standing",
          "use": "ButtonGroup",
          "why": "two visible buttons are faster than one and a menu, and neither is hidden"
        },
        {
          "when": "the choice is a setting that changes what the main action does — the export format for every export",
          "use": "Select beside a Button",
          "why": "a split button's main half always does the same thing; a remembered choice belongs in a control that shows it"
        }
      ],
      "composition": {
        "order": [
          "SplitButton",
          "MenuItem"
        ],
        "note": "`label` and `onPress` are the main action; the children are MenuItems for the variations, each with a `key` that `onAction` receives.",
        "example": "<SplitButton label=\"Save\" onPress={save} onAction={(key) => key === 'draft' && saveDraft()}><MenuItem key=\"draft\">Save as draft</MenuItem></SplitButton>"
      },
      "variants": {
        "variant": {
          "primary-brand": {
            "use": "the default — the single most important action in a view; one per view, as for Button"
          },
          "primary-neutral": {
            "use": "a primary action that should not carry brand colour"
          },
          "secondary": {
            "use": "an action beside the primary, such as Export among a table's actions"
          }
        },
        "size": {
          "sm": {
            "use": "toolbars and table headers"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "a page's single call to action"
          },
          "xl": {
            "use": "rarely — hero actions"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "MenuItem, MenuSection",
          "note": "the variations; each names the whole action — \"Save as draft\", not \"As draft\""
        },
        "startIcon": {
          "accepts": "Icon",
          "note": "on the main half only"
        }
      },
      "a11y": {
        "requires": [
          "`label` — the main half's name and the group's name",
          "`menuLabel` translated; the default is \"More options\""
        ],
        "guarantees": [
          "a `group` named by `label`, holding two Buttons that are two tab stops, the main one first",
          "the menu half is named for itself and the action — \"More options, Save\" — so several split buttons on a page have distinct menu buttons",
          "the menu half is a MenuTrigger: `aria-haspopup`, `aria-expanded`, ↓ opens on the first item, choosing closes the menu and returns focus to it",
          "the main half always does the same action; choosing a variation never changes it",
          "`isDisabled` disables both halves; `isMenuDisabled` only the menu, leaving the action; `disabledKeys` disables single variations",
          "`type=\"submit\"` makes the main half submit its form; the menu half never does",
          "drawn as one button: one shared border at the seam, square inner corners, a square menu half; the focused half is lifted so its ring is not cut",
          "in a stacked ButtonGroup it takes the full width, the main half growing"
        ],
        "notes": [
          "a variation that is dangerous — \"Delete permanently\" beside \"Archive\" — should not share a button with a safe action; it needs its own confirmation",
          "the menu's actions are reached through `onAction`, not through a submit: a variation that has to submit the form calls the same handler with its option, as the demo's wizard does"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a main half that becomes whichever variation was chosen last",
          "do": "the same main action every time",
          "why": "a button whose meaning changes with history cannot be pressed with confidence, and a screen reader is not told it changed"
        },
        {
          "dont": "a Button and an icon-only MenuTrigger placed side by side by hand",
          "do": "<SplitButton>",
          "why": "the hand-built menu button is usually named only \"More\", has its own gap and radius, and is not grouped with the action it belongs to"
        },
        {
          "dont": "<SplitButton label=\"Actions\">…edit, delete, share…</SplitButton>",
          "do": "<MenuTrigger> with a Button named for what it opens",
          "why": "\"Actions\" is not an action; pressing the main half has to do something"
        },
        {
          "dont": "collecting MenuItems into a `React.ReactElement[]` and passing the array as the children",
          "why": "the children are typed `CollectionChildren`: one collection element, an array of them, or a render function. `ReactElement` defaults its props to `unknown`, which is not an item's props, so the array fails to compile. Write the variations out, with `cond ? <MenuItem /> : null` for one that comes and goes"
        }
      ],
      "stylesheet": "src/styles/split-button.css",
      "tokens": [
        "--border-width-default",
        "--ion-button-size"
      ],
      "props": {
        "label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The main action's label — \"Save\", \"Run now\"."
        },
        "onPress": {
          "type": "((e: import(\"/Users/raza/Documents/Apps/ionbase-design-system/node_modules/.pnpm/@react-types+shared@3.36.0_react@19.2.8/node_modules/@react-types/shared/src/events\").PressEvent) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "The main action."
        },
        "type": {
          "type": "\"button\" | \"submit\" | undefined",
          "required": false,
          "origin": "own",
          "description": "`type=\"submit\"` makes the main action submit its form.",
          "values": [
            "button",
            "submit"
          ]
        },
        "children": {
          "type": "CollectionChildren<object>",
          "required": true,
          "origin": "own",
          "description": "The alternatives, as MenuItems — \"Save as draft\", \"Run with a test\ninput\". Each is a variation on the main action, not a different one."
        },
        "onAction": {
          "type": "((key: Key) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "An alternative was chosen, by its MenuItem `key`."
        },
        "menuLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The menu button's name, read with the main label: \"More options, Save\".\nDefault \"More options\" — pass the translation."
        },
        "variant": {
          "type": "SplitButtonVariant | undefined",
          "required": false,
          "origin": "own",
          "description": "The main action's weight. Tertiary and destructive are not offered.",
          "values": [
            "primary-brand",
            "primary-neutral",
            "secondary"
          ]
        },
        "size": {
          "type": "\"sm\" | \"md\" | \"lg\" | \"xl\" | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md",
            "lg",
            "xl"
          ]
        },
        "startIcon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Disables both halves."
        },
        "isMenuDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Disables only the menu — the alternatives are unavailable, the action is not."
        },
        "disabledKeys": {
          "type": "Iterable<Key> | undefined",
          "required": false,
          "origin": "own",
          "description": "MenuItem keys to disable."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 13,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "SplitPane": {
      "name": "SplitPane",
      "source": "src/components/SplitPane.tsx",
      "propsType": "SplitPaneProps",
      "description": "SplitPane — two panes and a divider between them that a person moves.\n\nTHE WINDOW-SPLITTER PATTERN\n\nThe divider is a focusable `separator` whose value is the first pane's\nshare, 0 to 100, bounded by `minSize` and `maxSize`, and it controls that\npane (`aria-controls`). The arrow keys along its axis move it, Shift for\na bigger step; Home and End go to the bounds; a double-click, or Enter,\nputs it back where it started. The same handle Table's column resize is.\n\nPERCENT, NOT PIXELS\n\nThe share survives the window being resized, and the value a screen\nreader announces means something without knowing the width.\n\nONE COLUMN ON A PHONE\n\nSide by side, two panes on a phone are two unreadable strips. At the\n`collapse` breakpoint they stack and the divider is removed — from the\ntab order too, since `display: none` takes it out.",
      "import": "import { SplitPane } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Two panes and a divider between them that a person moves — by dragging, or with the arrow keys — for two views read across, where which one needs the room depends on the reader.",
      "useWhen": [
        "two views of one thing are read across and both have long lines — a request and its response, a source and its preview, a log and its detail",
        "the right split depends on the content and on the reader, so it is theirs to set"
      ],
      "useInstead": [
        {
          "when": "detail that opens beside a list when something is picked, and closes",
          "use": "SidePanel",
          "why": "a SidePanel, in SidePanelLayout, is a region that comes and goes, with a title, a close button and a Drawer on a phone; a split is two panes that are always there"
        },
        {
          "when": "columns that are simply laid out — a main column and an aside",
          "use": "Grid",
          "why": "a layout nobody needs to change should not offer a control that changes it; every divider is one more tab stop"
        },
        {
          "when": "one table column is too narrow",
          "use": "TableCell",
          "why": "its `resize`, from useTableColumns, is the column's own handle in its header; a split around a table resizes the table, not the column"
        }
      ],
      "composition": {
        "order": [
          "SplitPane"
        ],
        "note": "`start` and `end` are the panes; `label` names the divider by what it resizes — \"Resize the API request\". The value is the first pane's share in percent: `defaultSize`, or `size` with `onSizeChange` to keep it (in the browser, say). Bound it with `minSize` and `maxSize`. Side by side it stacks at `collapse` (mobile by default); a vertical split needs a height from its container.",
        "example": "<SplitPane label=\"Resize the API request\" defaultSize={55} minSize={30} maxSize={70} collapse=\"tablet\" start={<CodeSnippet type=\"multi\" …>{request}</CodeSnippet>} end={<CodeSnippet type=\"multi\" …>{response}</CodeSnippet>} />"
      },
      "slots": {
        "start": {
          "accepts": "any",
          "note": "the first pane — start side, or top — whose share the divider sets; it scrolls on its own"
        },
        "end": {
          "accepts": "any",
          "note": "the second pane, taking the rest; it scrolls on its own"
        }
      },
      "a11y": {
        "requires": [
          "a `label` naming what the divider resizes: it is the control's only name"
        ],
        "guarantees": [
          "the divider is a focusable `separator` (the ARIA window-splitter pattern) with the first pane's share as `aria-valuenow`, `minSize` and `maxSize` as its bounds, and `aria-controls` pointing at the first pane",
          "the arrow keys along its axis move it 2%, with Shift 10%; Home and End go to the bounds; Enter and a double-click put it back at `defaultSize`",
          "the arrow moves the divider the way it points: side by side in a right-to-left page the first pane is on the right, so → shrinks it",
          "a drag moves it, with the pointer held, and selects no text on the way",
          "each pane scrolls on its own, and its content cannot widen it",
          "at the collapse breakpoint the panes stack and the divider is removed, from the tab order too",
          "a grip at the divider's middle marks it movable at 3:1; focused or dragged, the line and the grip turn to border/focus, and in forced colours to Highlight"
        ],
        "notes": [
          "The share is percent, not pixels: it survives a window resize, and the value announced means something without knowing the width.",
          "Nothing is kept between visits; keep `size` yourself, from `onSizeChange`, when it should be."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a SplitPane for a main column and an aside nobody needs to resize",
          "do": "<Grid columns={[2, 1]} collapse=\"tablet\">",
          "why": "a divider is a control and a tab stop; offered where nothing is gained, it is one more thing to tab past"
        },
        {
          "dont": "a SplitPane whose end pane is a detail that opens and closes",
          "do": "SidePanelLayout with a SidePanel",
          "why": "the panel's title, close button and Drawer fallback are SidePanel's; an empty pane behind a divider is a layout with a hole in it"
        },
        {
          "dont": "`collapse=\"never\"` for a side-by-side split",
          "do": "the default, `mobile`",
          "why": "two panes side by side on a phone are two strips too narrow to read"
        }
      ],
      "stylesheet": "src/styles/split-pane.css",
      "tokens": [
        "--border-default",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--icon-secondary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-split-pane-size",
        "--radius-full",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-24",
        "--spacing-4",
        "--spacing-8"
      ],
      "props": {
        "start": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The first pane: the start side, or the top. Its size is the value."
        },
        "end": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The second pane, which takes the rest."
        },
        "orientation": {
          "type": "SplitPaneOrientation | undefined",
          "required": false,
          "origin": "own",
          "description": "`horizontal` puts the panes side by side with a vertical divider;\n`vertical` stacks them with a horizontal one. A vertical split needs a\nheight from its container.",
          "values": [
            "vertical",
            "horizontal"
          ]
        },
        "label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The divider's name, and so the resize control's: \"Resize the run list\".\nIt names what moving it changes — the first pane."
        },
        "defaultSize": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "The first pane's share, in percent, for an uncontrolled split. Default 50."
        },
        "size": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "The first pane's share, in percent, controlled. Pair with `onSizeChange`."
        },
        "onSizeChange": {
          "type": "((size: number) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires with the new share, in percent, rounded to a whole number."
        },
        "minSize": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "The smallest share the first pane can be given. Default 20."
        },
        "maxSize": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "The largest share. Default 80."
        },
        "collapse": {
          "type": "SplitPaneCollapse | undefined",
          "required": false,
          "origin": "own",
          "description": "The breakpoint at and below which a side-by-side split stacks, and the\ndivider goes: `mobile` (767px) by default, `tablet` (1023px), or\n`never`. Grid's breakpoints. A vertical split never collapses.",
          "values": [
            "tablet",
            "mobile",
            "never"
          ]
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "style": {
          "type": "React.CSSProperties | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 12,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Stack": {
      "name": "Stack",
      "source": "src/components/Stack.tsx",
      "propsType": "StackProps",
      "description": "Stack — children in a column or a row, a gap from the spacing scale\nbetween them.\n\nThe one layout primitive an agent should reach for before writing flex\nCSS: a screen built from raw `display: flex` and pixel gaps is where token\ndiscipline breaks. `gap` takes the scale's steps and nothing else.\n\nLAYOUT ONLY. It has no role and adds none; pass `as` for the element that\nmeans something. It renders on a server.",
      "import": "import { Stack } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Children in a column or a row with a gap from the spacing scale between them. Reach for it before writing flex CSS; `gap` takes the scale's steps and nothing else.",
      "useWhen": [
        "laying out a group of things one after another — the sections of a page, the fields of a form, the controls of a filter row",
        "a row of controls that should wrap onto more lines on a narrow screen rather than overflow"
      ],
      "useInstead": [
        {
          "when": "the layout has columns that line up across rows — cards, stat tiles, a form in two columns",
          "use": "Grid",
          "why": "a wrapping row lines its items up per line, not across lines; a Grid keeps every row's columns the same width"
        },
        {
          "when": "a row of related controls a keyboard user should cross with the arrow keys",
          "use": "Toolbar",
          "why": "a Toolbar is one tab stop with arrow keys between its controls; a Stack is layout and leaves every control its own tab stop"
        },
        {
          "when": "two or three actions at the end of a dialog, drawer or form",
          "use": "ButtonGroup",
          "why": "it orders the primary action, and moves the rest into More actions when there is no room"
        }
      ],
      "composition": {
        "order": [
          "Stack"
        ],
        "note": "`direction` is `column` (the default) or `row`; `gap` is a step of the spacing scale — 16 by default. A row centres its children on the line and a column stretches them; `align` overrides either, `justify=\"between\"` pushes the first and last to the ends, and `wrap` lets a row break onto more lines. Pass `as` for the element that means something — `ul` for a list (its bullets and indent are removed; give each child an `li`), `nav` for links, `form`, `fieldset`. Nest Stacks for a row inside a column; each keeps its own gap. Gaps by what they separate: 4 for tight pairs (an icon and its label), 8 for controls in a row, 16 for fields and items (the default), 24 for sections of a card or panel, 32 for sections of a page.",
        "example": "<Stack gap={24}>\n  <PageHeader title=\"Agents\" />\n  <Stack direction=\"row\" wrap gap={8}>\n    <SearchField size=\"sm\" aria-label=\"Search agents\" />\n    <Select size=\"sm\" aria-label=\"Status\" options={statuses} />\n  </Stack>\n  <Table aria-label=\"Agents\">…</Table>\n</Stack>"
      },
      "variants": {
        "direction": {
          "column": {
            "use": "the default — things read top to bottom: page sections, fields, cards in a list"
          },
          "row": {
            "use": "things along a line: a filter row, a name beside its avatar, suggestions; add `wrap` when it may not fit"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "any",
          "note": "laid out in order; `li` children when `as` is `ul` or `ol`"
        }
      },
      "a11y": {
        "guarantees": [
          "layout only: it adds no role, so the element given by `as` — or the children — carry the meaning",
          "the visual order is the DOM order: no `order` or reversed direction that would make reading and tabbing disagree",
          "a row's children shrink to fit, so text wraps or is cut rather than pushing the row past the screen's edge",
          "`as=\"ul\"` stays a list to a screen reader while losing its bullets"
        ],
        "requires": [
          "`as` for a group that means something — a list, navigation, a form — rather than a div"
        ],
        "notes": [
          "It renders on a server. A gap off the spacing scale is a type error, not a style."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a hand-written `display: flex` with a pixel gap",
          "do": "Stack with a `gap` step",
          "why": "a gap outside the scale is the first place a screen drifts off the tokens, and nothing catches it"
        },
        {
          "dont": "wrapping a single child in a Stack",
          "why": "a gap between one thing and nothing is no layout at all — the Stack is noise in the tree"
        },
        {
          "dont": "a Stack of cards that should line up in columns",
          "do": "Grid",
          "why": "a wrapping row sizes each line on its own, so the columns never line up"
        },
        {
          "dont": "reversing a row with CSS to put the primary action last",
          "why": "the visual order and the tab order then disagree — write the children in the order they are read"
        }
      ],
      "stylesheet": "src/styles/stack.css",
      "tokens": [
        "--ion-stack-gap",
        "--spacing-0",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-20",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-40",
        "--spacing-48",
        "--spacing-6",
        "--spacing-64",
        "--spacing-8"
      ],
      "props": {
        "direction": {
          "type": "StackDirection | undefined",
          "required": false,
          "origin": "own",
          "description": "`column` stacks top to bottom — the default; `row` runs along the line.",
          "values": [
            "row",
            "column"
          ]
        },
        "gap": {
          "type": "StackGap | undefined",
          "required": false,
          "origin": "own",
          "description": "Space between the children, from the spacing scale. Defaults to 16."
        },
        "align": {
          "type": "StackAlign | undefined",
          "required": false,
          "origin": "own",
          "description": "Cross-axis alignment. Defaults to `stretch` in a column, `center` in a row.",
          "values": [
            "start",
            "center",
            "end",
            "stretch",
            "baseline"
          ]
        },
        "justify": {
          "type": "StackJustify | undefined",
          "required": false,
          "origin": "own",
          "description": "Main-axis distribution. `between` pushes the first and last to the ends.",
          "values": [
            "start",
            "center",
            "end",
            "between"
          ]
        },
        "wrap": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Let a row wrap onto more lines instead of overflowing."
        },
        "as": {
          "type": "StackElement | undefined",
          "required": false,
          "origin": "own",
          "description": "The element: a list of things is a `ul`, a group of navigation links a\n`nav`. Stack is layout only — the element carries the meaning.",
          "values": [
            "form",
            "div",
            "span",
            "section",
            "nav",
            "ul",
            "ol",
            "li",
            "header",
            "footer",
            "fieldset"
          ]
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 7,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "StatGroup": {
      "name": "StatGroup",
      "source": "src/components/StatTile.tsx",
      "propsType": "StatGroupProps",
      "description": "StatGroup — a row of StatTiles.\n\nA `<dl>`: each tile is a term (the label) and its description (the figure),\nwhich is what a screen reader announces as a pair. The grid wraps on its own,\nso four tiles are one row on a desktop and two on a phone with no breakpoint\nin the caller's code.",
      "import": "import { StatGroup } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.71.0",
      "summary": "A row of StatTiles, as a description list. Wraps on its own — no breakpoints in the caller's code.",
      "useWhen": [
        "two or more headline figures shown side by side"
      ],
      "useInstead": [
        {
          "when": "the figures are rows of records to compare field by field",
          "use": "Table"
        }
      ],
      "composition": {
        "order": [
          "StatGroup",
          "StatTile"
        ],
        "note": "StatGroup renders the <dl>; each StatTile renders a <dt> and a <dd> inside it. A StatTile outside a StatGroup is invalid HTML.",
        "example": "<StatGroup><StatTile label=\"Runs\" value=\"1.2k\" change={4.1} goodWhen=\"neutral\" /><StatTile label=\"Median run time\" value=\"38s\" change={-6.2} goodWhen=\"down\" /></StatGroup>"
      },
      "slots": {
        "children": {
          "accepts": [
            "StatTile"
          ],
          "note": "StatTiles only — the group is a <dl>, so anything else inside it is invalid"
        }
      },
      "a11y": {
        "guarantees": [
          "a <dl>, so each label is announced as the term for its figure"
        ],
        "notes": [
          "Give the group a heading or aria-label when the page has more than one, so a screen reader can tell them apart."
        ]
      },
      "antiPatterns": [
        {
          "dont": "adding media queries to change the column count",
          "why": "the grid already wraps by tile width; a breakpoint fights it"
        }
      ],
      "stylesheet": null,
      "tokens": [],
      "props": {
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "StatTile elements."
        }
      },
      "propCounts": {
        "own": 1,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "StatTile": {
      "name": "StatTile",
      "source": "src/components/StatTile.tsx",
      "propsType": "StatTileProps",
      "description": "StatTile — one headline figure, and how it moved.\n\nPromoted from the demo app, where it was built for the Overview KPI row and\nsurvived three phases unchanged.\n\nGOOD AND BAD ARE NOT UP AND DOWN\n\nA rising median run time is bad news and a rising success rate is good, so\nthe colour comes from `goodWhen` and the direction together — never from the\nsign alone. `neutral` never colours: a run count going up is not a verdict.\nThe verdict is also spoken, as visually hidden text after the change, so it\nnever rests on the badge's colour.\n\nThe change is rounded BEFORE it is judged, so −0.04 reads \"No change\" rather\nthan a red \"−0.0%\".\n\nIt must sit inside a StatGroup: the tile renders a `<dt>` and a `<dd>`, which\nare only valid inside a `<dl>`.",
      "import": "import { StatTile } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.71.0",
      "summary": "One headline figure and how it moved against a comparison period. Good and bad come from `goodWhen`, never from the sign alone.",
      "useWhen": [
        "a dashboard or overview leads with a handful of key numbers",
        "a change against a previous period is part of what the number means"
      ],
      "useInstead": [
        {
          "when": "it is a summary with text and media rather than a number",
          "use": "FullCard"
        },
        {
          "when": "the figure is progress toward a known total",
          "use": "ProgressBar"
        },
        {
          "when": "there are more than about six figures, or they need comparing across rows",
          "use": "Table"
        }
      ],
      "composition": {
        "order": [
          "StatGroup",
          "StatTile"
        ],
        "note": "Always inside a StatGroup: the tile renders a <dt> and a <dd>, which are only valid inside a <dl>.",
        "example": "<StatGroup><StatTile label=\"Success rate\" value=\"96.2%\" change={1.1} changeUnit=\"points\" /></StatGroup>"
      },
      "variants": {
        "goodWhen": {
          "up": {
            "use": "the default — revenue, success rate, anything where more is better"
          },
          "down": {
            "use": "costs, durations, error counts and queues, where a rise is bad news"
          },
          "neutral": {
            "use": "a change that is neither — run volume, traffic — so it is never coloured"
          }
        },
        "changeUnit": {
          "percent": {
            "use": "the default — a relative change"
          },
          "points": {
            "use": "the metric is itself a percentage: 95% to 96% is +1.0 pts, not +1.1%"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "the verdict is spoken as text (\"better\" or \"worse\") after the change, so it never rests on the badge's colour",
          "the direction is in the text as a sign as well as in the arrow",
          "`isLoading` marks the tile aria-busy while its placeholder shows"
        ],
        "requires": [
          "`value` already formatted — the tile cannot know the locale, precision or unit",
          "the right `goodWhen` for the metric; the default `up` colours a rising cost green"
        ]
      },
      "antiPatterns": [
        {
          "dont": "leaving `goodWhen` at `up` for a duration, cost or error count",
          "why": "a slower, costlier or more error-prone week is shown green and announced as better"
        },
        {
          "dont": "a percentage change for a metric that is already a percentage",
          "why": "95% to 96% is +1 point; +1.1% misreports it — use changeUnit=\"points\""
        },
        {
          "dont": "passing a change of 0 to mean \"no data\"",
          "why": "0 renders \"No change\", a claim; omit `change` when there is nothing to compare"
        }
      ],
      "stylesheet": "src/styles/stat-tile.css",
      "tokens": [
        "--border-subtle",
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-semibold",
        "--radius-lg",
        "--spacing-12",
        "--spacing-16",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--text-default",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height",
        "--type-h4",
        "--type-h4-line-height"
      ],
      "props": {
        "label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "What is being measured. Short: \"Success rate\", not a sentence."
        },
        "value": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The figure, already formatted — \"1.2k\", \"96.2%\", \"38s\". Formatting is the\ncaller's: only it knows the locale, the precision and the unit."
        },
        "change": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Change against the comparison period. Omit when there is none."
        },
        "changeUnit": {
          "type": "StatTileChangeUnit | undefined",
          "required": false,
          "origin": "own",
          "description": "`percent` for a relative change; `points` for a metric that is already a\npercentage — 95% to 96% is +1 pt, and calling it +1.1% misreports it.",
          "values": [
            "points",
            "percent"
          ]
        },
        "goodWhen": {
          "type": "StatTileGoodWhen | undefined",
          "required": false,
          "origin": "own",
          "description": "Which direction is good news. `down` for costs, durations and queues;\n`neutral` when a change is neither, so it is never coloured.",
          "values": [
            "neutral",
            "up",
            "down"
          ]
        },
        "comparison": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "What `change` is measured against."
        },
        "labels": {
          "type": "StatTileLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "The change's words, for translation. English by default. StatTile renders\non the server, where there is no locale to read, so its default formats\nthe number the English way — pass `labels.change` to format it in the\nreader's."
        },
        "isLoading": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Shows a placeholder for the value and change while data loads."
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "StatusIndicator": {
      "name": "StatusIndicator",
      "source": "src/components/StatusIndicator.tsx",
      "propsType": "StatusIndicatorProps",
      "description": "StatusIndicator — a status as a shape, a colour and a word, together.\n\nTHE TABLE'S COLOURED DOT, FIXED. A green dot and a red dot are the same\nshape: to anyone who cannot tell the colours apart they say nothing, and\nWCAG 1.4.1 fails. Here every status has its own shape, the colour is only\nthe second signal, and the word is always there. It is the general form of\nwhat AgentActivity does for an agent's steps.\n\nQUIET BY DESIGN. The word keeps the text colour — only the shape is\ncoloured — so a column of statuses reads as text with markers, not as a\ncolumn of chips. A Badge is for a label that should stand out; this is for\nthe status of every row.\n\nNOT A LIVE REGION. It does not announce itself when it changes: in a table\nof fifty rows that would be fifty announcements. Say what changed with a\nToast, or AgentRun's own announcements, where it matters.",
      "import": "import { StatusIndicator } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A status as a shape, a colour and a word together — Running, Failing, Paused. Every intent has its own shape, so the status survives greyscale and colour blindness; only the shape is coloured, and the word keeps the text colour.",
      "useWhen": [
        "the status of each row in a table or list — an agent's state, a run's outcome, a job's health",
        "a status beside other text, where a Badge's chip would shout"
      ],
      "useInstead": [
        {
          "when": "a label that should stand out — a record's status in its header, Beta, New",
          "use": "Badge",
          "why": "a Badge's chip draws the eye; a column of them turns a table into a wall of colour"
        },
        {
          "when": "the steps of an agent's run",
          "use": "AgentActivity",
          "why": "it has the step vocabulary — pending, active, done, failed, skipped — and says which step is current"
        },
        {
          "when": "the result of what the user just did, beside it — Saving…, Saved",
          "use": "InlineLoading",
          "why": "it is a live region that is always mounted, so each change is announced"
        },
        {
          "when": "the change must be announced — a job just failed",
          "use": "Toast",
          "why": "StatusIndicator is not a live region; a table of them would announce every row"
        }
      ],
      "composition": {
        "order": [
          "StatusIndicator"
        ],
        "note": "`intent` picks the shape; `children` is the status in words, and is required.",
        "example": "<TableCell><StatusIndicator intent=\"error\">Failing</StatusIndicator></TableCell>"
      },
      "variants": {
        "intent": {
          "success": {
            "use": "healthy or done well: Running, Passed, Paid — a circle with a check"
          },
          "warning": {
            "use": "needs attention, not broken: Expiring, Degraded — a triangle"
          },
          "error": {
            "use": "failed or blocked: Failing, Rejected — an octagon with a cross"
          },
          "information": {
            "use": "a fact worth marking: Draft, Scheduled — a circle with an i"
          },
          "neutral": {
            "use": "the default — no judgement: Paused, Stopped — a ring"
          },
          "progress": {
            "use": "under way right now: Deploying, Syncing — a spinning arc, still when motion is reduced"
          }
        },
        "size": {
          "sm": {
            "use": "the default — table cells and dense lists; 16px"
          },
          "md": {
            "use": "beside body text; 20px"
          }
        }
      },
      "a11y": {
        "requires": [
          "`children` — the status in words, translated"
        ],
        "guarantees": [
          "each intent has a different shape, so the status is not carried by colour alone (WCAG 1.4.1)",
          "the shape is hidden from assistive tech; the word is what is read",
          "no role and no live region: it does not announce itself when it changes",
          "the word keeps the text colour in every intent; only the shape takes the intent's icon colour",
          "it stays on one line, so the shape is never left alone above its word",
          "progress spins, and is still under `prefers-reduced-motion`; its shape alone says in progress"
        ],
        "notes": [
          "pick the intent for what the status means to the user, not for its colour: Paused is neutral even if a designer wants it grey-blue",
          "keep the word short — one or two words — and put the detail beside it or in a Tooltip on the cell"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<span className=\"dot dot--green\" /> Running",
          "do": "<StatusIndicator intent=\"success\">Running</StatusIndicator>",
          "why": "a green dot and a red dot are the same shape; to a colour-blind user every row looks the same"
        },
        {
          "dont": "<StatusIndicator intent=\"error\" aria-label=\"Failing\" />",
          "do": "<StatusIndicator intent=\"error\">Failing</StatusIndicator>",
          "why": "a shape with no word has to be learned, and a sighted user cannot read the aria-label"
        },
        {
          "dont": "a Badge with a dot in every row of a table",
          "do": "StatusIndicator in the cell",
          "why": "the chips' backgrounds compete with the data, and the dot is the same shape for every status"
        }
      ],
      "stylesheet": "src/styles/status-indicator.css",
      "tokens": [
        "--font-family-sans",
        "--icon-error",
        "--icon-information",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-md",
        "--icon-size-sm",
        "--icon-success",
        "--icon-warning",
        "--ion-duration-cycle",
        "--ion-ease-linear",
        "--spacing-4",
        "--spacing-6",
        "--text-default",
        "--type-body-md",
        "--type-body-md-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "intent": {
          "type": "StatusIndicatorIntent | undefined",
          "required": false,
          "origin": "own",
          "description": "Picks the shape and its colour. The shape is what says it: a check, a\ntriangle, an octagon, an \"i\", a ring, a spinning arc — so the status\nsurvives greyscale, colour blindness and forced colours (WCAG 1.4.1).",
          "values": [
            "neutral",
            "success",
            "warning",
            "error",
            "information",
            "progress"
          ]
        },
        "children": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The status in words — \"Running\", \"Failed\". Required: it is the name."
        },
        "size": {
          "type": "StatusIndicatorSize | undefined",
          "required": false,
          "origin": "own",
          "description": "`sm` for table cells and dense lists; `md` beside body text.",
          "values": [
            "sm",
            "md"
          ]
        }
      },
      "propCounts": {
        "own": 3,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Stepper": {
      "name": "Stepper",
      "source": "src/components/Stepper.tsx",
      "propsType": "StepperProps",
      "description": "Stepper — where the user is in a task that has an order.\n\nWHY NOT TABS\n\nThe `tablist` role announces peers that can be visited in any order, which is\nthe opposite of a wizard. This is an ordered list: the steps are a sequence,\nand `ol` says so. Nothing here is a tab, and arrow keys do nothing.\n\nPOSITION AND STATUS ARE TEXT, NOT ONLY DRAWING\n\nEvery step renders \"Step 2 of 5\" and its status as visually hidden text. The\nnumber in the circle is for the eye; a screen reader cannot be relied on to\nannounce list position, and a coloured ring carries nothing in forced-colours\nmode. The complete and error glyphs also differ in SHAPE — a check and an\nexclamation — so the states survive greyscale, which WCAG 1.4.1 requires.\n\nONLY VISITED STEPS ARE LINKS\n\n`href` / `onPress` are honoured on `complete` and `error` steps that are not\ncurrent. An `incomplete` step renders as text even when given one, because\njumping forward past unanswered steps is exactly what a wizard exists to\nprevent — and a link that is there but should not be followed is worse than\nno link. The current step is text for the same reason Breadcrumb's is: a link\nto where you already are is a dead control.\n\nIT DOES NOT ANNOUNCE STEP CHANGES\n\nThe Wizard pattern moves focus to the new step's heading, which announces it.\nA live region here as well would say the same thing twice, on every step.",
      "import": "import { Stepper } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Where the user is in a task that has an order. An ordered list of steps, each stating its position and status in text.",
      "useWhen": [
        "a Wizard — later steps depend on earlier answers and the user needs to see what is left",
        "a multi-page flow such as checkout or onboarding, where the steps are fixed and known in advance"
      ],
      "useInstead": [
        {
          "when": "the sections can be visited in any order",
          "use": "Tabs",
          "why": "tabs promise peers; a stepper promises a sequence, and using one for the other misleads in both directions"
        },
        {
          "when": "the progress is a quantity with no named stages — an upload, an import",
          "use": "ProgressBar",
          "why": "a stepper with no meaningful step names is a progress bar drawn in circles"
        },
        {
          "when": "the steps are an agent's actions rather than the user's",
          "use": "AgentActivity",
          "why": "the user is not moving through those steps; they are supervising them"
        }
      ],
      "variants": {
        "orientation": {
          "horizontal": {
            "use": "the default — a header above the step's form. Below 40rem it keeps only the current label visible"
          },
          "vertical": {
            "use": "a side rail beside the form, or when step names or descriptions are long"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "StepperStep",
          "note": "first step first, and exactly one with isCurrent"
        }
      },
      "a11y": {
        "requires": [
          "`label` naming the task when a page carries more than one stepper — it defaults to \"Progress\""
        ],
        "guarantees": [
          "every step renders \"Step N of M\" and its status as visually hidden text, derived from the children rather than passed in",
          "the current step carries aria-current=\"step\"",
          "connectors are CSS and never enter the accessibility tree"
        ],
        "notes": [
          "it does not announce step changes. The Wizard pattern moves focus to the new step's heading, which announces it; a live region here would say it twice"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Tabs> as a wizard's step indicator",
          "do": "<Stepper>",
          "why": "the tablist role announces peers that can be visited in any order, which is the opposite of a wizard"
        },
        {
          "dont": "more than about seven steps",
          "why": "a stepper that has to scroll or wrap no longer shows what is left, which is the only thing it is for — group the steps instead"
        }
      ],
      "stylesheet": "src/styles/stepper.css",
      "tokens": [
        "--border-default",
        "--border-error",
        "--border-focus",
        "--border-primary",
        "--border-strong",
        "--border-width-thick",
        "--border-width-thicker",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-semibold",
        "--icon-error",
        "--icon-primary",
        "--icon-size-lg",
        "--icon-size-sm",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-full",
        "--radius-xs",
        "--spacing-16",
        "--spacing-2",
        "--spacing-24",
        "--spacing-4",
        "--spacing-8",
        "--surface-error",
        "--surface-primary",
        "--text-default",
        "--text-error",
        "--text-on-color",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the list. Defaults to \"Progress\"; give it the task's name when a page\ncarries more than one — \"Checkout progress\"."
        },
        "orientation": {
          "type": "StepperOrientation | undefined",
          "required": false,
          "origin": "own",
          "description": "`horizontal` for a page-width header above the form, `vertical` for a side\nrail. Below 40rem a horizontal stepper hides every label but the current\none, so it does not need a second variant for mobile.",
          "values": [
            "vertical",
            "horizontal"
          ]
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "`StepperStep` elements, first step first."
        },
        "labels": {
          "type": "StepperLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "Every string the steps read out, for translation. English by default.\nGiven once here rather than on each step."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "StepperStep": {
      "name": "StepperStep",
      "source": "src/components/Stepper.tsx",
      "propsType": "StepperStepProps",
      "import": "import { StepperStep } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One step in a Stepper: a numbered indicator, a name, and a status carried by shape and text as well as colour.",
      "useWhen": [
        "inside a Stepper, once per step"
      ],
      "useInstead": [
        {
          "when": "it is a step an agent is running",
          "use": "AgentActivityStep"
        }
      ],
      "variants": {
        "status": {
          "incomplete": {
            "use": "the default — not yet answered. Never a link, even with href"
          },
          "complete": {
            "use": "answers accepted by the server, not merely filled in"
          },
          "error": {
            "use": "the step's answers were rejected. Put the reason on the step's fields, not here"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "text",
          "note": "the step's name, short — \"Shipping\", not \"Enter your shipping details\""
        },
        "description": {
          "accepts": "text",
          "note": "what the step asks for, or a summary of the answer once complete"
        }
      },
      "a11y": {
        "guarantees": [
          "the current step and incomplete steps render as text, never as links, whatever href or onPress say",
          "complete and error use different glyphs — a check and an exclamation — so status survives greyscale and forced-colours mode"
        ],
        "notes": [
          "status and isCurrent are separate on purpose: a user who goes Back to step 1 is on a step that is both current and complete"
        ]
      },
      "antiPatterns": [
        {
          "dont": "status=\"complete\" as soon as the fields are filled",
          "do": "mark it complete when the server accepts the step",
          "why": "a green check on answers that are later rejected sends the user back through a step they were told was finished"
        },
        {
          "dont": "href on every step so users can jump ahead",
          "why": "incomplete steps ignore it; skipping unanswered steps is what a wizard exists to prevent"
        },
        {
          "dont": "using it outside Stepper",
          "why": "it renders an <li> and reads its position from the Stepper, and throws without one"
        }
      ],
      "stylesheet": "src/styles/stepper.css",
      "tokens": [
        "--border-default",
        "--border-error",
        "--border-focus",
        "--border-primary",
        "--border-strong",
        "--border-width-thick",
        "--border-width-thicker",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-semibold",
        "--icon-error",
        "--icon-primary",
        "--icon-size-lg",
        "--icon-size-sm",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-full",
        "--radius-xs",
        "--spacing-16",
        "--spacing-2",
        "--spacing-24",
        "--spacing-4",
        "--spacing-8",
        "--surface-error",
        "--surface-primary",
        "--text-default",
        "--text-error",
        "--text-on-color",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The step's name, in the user's language."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "A short second line — what the step asks for, or a summary of the answer."
        },
        "status": {
          "type": "StepperStepStatus | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether the step's answers have been accepted. Orthogonal to `isCurrent`:\na user who goes Back to step 1 is on a step that is both current and\ncomplete.",
          "values": [
            "error",
            "incomplete",
            "complete"
          ]
        },
        "isCurrent": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "The step the user is on. Marked `aria-current=\"step\"`."
        },
        "href": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Makes a visited step a link back to it. Ignored on the current step and on\n`incomplete` steps — see the component's comment for why."
        },
        "onPress": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "As `href`, for a wizard that changes step without a route."
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "StreamingText": {
      "name": "StreamingText",
      "source": "src/components/StreamingText.tsx",
      "propsType": "StreamingTextProps",
      "description": "StreamingText — model output arriving a token at a time.\n\nIT IS NOT A LIVE REGION, AND THAT IS THE WHOLE DESIGN\n\nThe obvious implementation — `aria-live=\"polite\"` on the container — is the\none that makes a screen reader unusable. Every token mutation queues an\nannouncement, so the user hears the answer re-read, stuttered, dozens of\ntimes, and cannot get ahead of it. `aria-live=\"off\"` is not an oversight\nhere; it is the accessible choice.\n\nWhat it does instead: `aria-busy` while streaming, so assistive tech knows\nthe region is unsettled and can wait. The text is ordinary readable content\nthroughout — a screen-reader user navigates into it whenever they want,\nexactly like sighted users reading ahead of the cursor.\n\nANNOUNCING COMPLETION IS THE CALLER'S CALL, not this component's. Some\nsurfaces want \"response complete\"; a chat with ten turns on screen does not\nwant ten of them. Render your own `role=\"status\"` when you want it.\n\nTHE HEIGHT IS RESERVED, NOT ANIMATED. `minLines` holds space in `lh` units so\nthe content below stays still. A container that grows token by token drags\nthe whole page, which is worse for someone using magnification than the wait.\n\nThe cursor is CSS and `aria-hidden`. It stops blinking under\n`prefers-reduced-motion` — a blinking element is a WCAG 2.3.1 concern and a\ngenuine problem for some vestibular and attention conditions.",
      "import": "import { StreamingText } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Model output arriving a token at a time, in a container that does not move. Deliberately not a live region.",
      "useWhen": [
        "rendering a response that streams in rather than arriving whole",
        "the reader should be able to start reading before generation finishes"
      ],
      "useInstead": [
        {
          "when": "the text is already complete",
          "use": "Alert",
          "why": "nothing about this component helps once there is nothing left to arrive"
        },
        {
          "when": "you are showing progress through steps rather than producing text",
          "use": "AgentActivity"
        }
      ],
      "a11y": {
        "role": "region when `label` is set, none otherwise",
        "guarantees": [
          "`aria-live=\"off\"`, deliberately — a polite live region re-announces the whole answer on every token, which makes a streaming response unusable with a screen reader",
          "`aria-busy` while streaming, so assistive tech knows the region is unsettled",
          "the text stays ordinary readable content throughout, so a screen-reader user can read ahead exactly as a sighted user does",
          "the cursor is `aria-hidden`, is a styled box rather than a text character, and stops blinking under `prefers-reduced-motion` (WCAG 2.3.1)"
        ],
        "requires": [
          "announcing completion yourself, with your own `role=\"status\"`, if the surface wants it — a chat with ten turns on screen does not want ten announcements",
          "`label` when the region needs a name of its own"
        ]
      },
      "antiPatterns": [
        {
          "dont": "wrapping it in `aria-live=\"polite\"`",
          "why": "every token queues an announcement; the user hears the answer stuttered dozens of times and cannot get ahead of it"
        },
        {
          "dont": "letting the container grow token by token with no reserved height",
          "do": "`minLines`",
          "why": "the content below climbs the screen while someone is reading it, which is worse under magnification than the wait"
        },
        {
          "dont": "a text-character cursor like ▌",
          "why": "some screen readers read it aloud even inside an aria-hidden span"
        }
      ],
      "stylesheet": "src/styles/streaming-text.css",
      "tokens": [
        "--font-family-sans",
        "--ion-streaming-min-lines",
        "--text-default",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height"
      ],
      "props": {
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The text so far. Re-render with more of it; this component appends nothing."
        },
        "isStreaming": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "More is still arriving. Shows the cursor and marks the region busy."
        },
        "minLines": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Rows of height to hold while the text is short, so the page below does not\nclimb the screen as tokens arrive. Costs blank space at the start and buys\na layout that does not move under a reader."
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Accessible name for the region."
        },
        "hideCursor": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Hide the trailing cursor. The text still marks itself busy."
        }
      },
      "propCounts": {
        "own": 5,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "TabItem": {
      "name": "TabItem",
      "source": "../../node_modules/.pnpm/react-stately@3.48.0_react@19.2.8/node_modules/react-stately/dist/types/src/collections/Item.d.ts",
      "propsType": null,
      "import": "import { TabItem } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One tab and its panel. Re-exported from react-stately's collection Item — `title` is the tab label, children are the panel.",
      "useWhen": [
        "a child of Tabs — it is not usable anywhere else"
      ],
      "useInstead": [
        {
          "when": "it is a row in a dropdown list",
          "use": "MenuItem"
        },
        {
          "when": "it is a navigation destination",
          "use": "NavItem"
        }
      ],
      "composition": {
        "order": [
          "Tabs",
          "TabItem"
        ],
        "note": "A collection item, not a rendered component: Tabs reads it to build the collection, so it never appears in the DOM itself. That is why it exposes no props of its own here — `key` and `title` are the collection's API.",
        "example": "<TabItem key=\"billing\" title=\"Billing\">Billing panel content</TabItem>"
      },
      "a11y": {
        "guarantees": [
          "the tab/panel roles, ids and aria-controls pairing are supplied by Tabs, not by this element"
        ],
        "requires": [
          "a stable `key` — it identifies the tab across renders and is what selection is expressed in",
          "a `title` that reads as a label on its own"
        ]
      },
      "antiPatterns": [
        {
          "dont": "wrapping TabItem in a <div> or a fragment inside Tabs",
          "why": "Tabs reads the collection from its direct children; a wrapper makes the item invisible to it"
        },
        {
          "dont": "conditionally rendering TabItems with array indexes as keys",
          "why": "selection is tracked by key, so the selected tab silently becomes a different tab"
        }
      ],
      "stylesheet": null,
      "tokens": [],
      "props": {},
      "propCounts": {
        "own": 0,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Table": {
      "name": "Table",
      "source": "src/components/Table.tsx",
      "propsType": "TableProps",
      "import": "import { Table } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A data table. Table owns the scroll container and density; rows and cells are composed.",
      "useWhen": [
        "showing records with more than one attribute worth comparing across rows"
      ],
      "useInstead": [
        {
          "when": "each item is a self-contained summary rather than a row of fields",
          "use": "FullCard"
        },
        {
          "when": "each row is one record read across — a label, a line under it, a badge — with nothing to compare down a column",
          "use": "List",
          "why": "a table claims columns and a header; a one-column table is announced as one and has no row actions a keyboard can reach"
        },
        {
          "when": "the rows nest — a total and the rows it is made of, each with the same columns",
          "use": "TreeGrid",
          "why": "indented rows in a Table carry no level and no expand keys; a TreeGrid is the treegrid pattern, drawn as a Table"
        },
        {
          "when": "it is a layout grid rather than data",
          "use": "CSS grid",
          "why": "a <table> announces row and column semantics that a layout does not have"
        }
      ],
      "composition": {
        "order": [
          "Table",
          "TableHead",
          "TableRow",
          "TableCell"
        ],
        "note": "Table > TableHead/TableBody > TableRow > TableCell, matching how Figma layers Table Row > Table Cell > Cell Text. TableHead and TableBody take no props of their own; they place the rows and give TableCell its head-or-body context. For a long list, `maxHeight` scrolls the rows inside the table with the header held at its top; for a wide one, `stickyFirstColumn` holds the first column — and any toggle and checkbox cells before it — while the rest scrolls sideways. Both hold to the table's own scroll region, never the page.",
        "example": "<Table aria-label=\"Users\"><TableHead><TableRow><TableCell scope=\"col\">Name</TableCell></TableRow></TableHead><TableBody><TableRow><TableCell>Ada</TableCell></TableRow></TableBody></Table>"
      },
      "variants": {
        "density": {
          "compact": {
            "use": "dense operational tables where row count matters more than comfort"
          },
          "default": {
            "use": "the default"
          },
          "relaxed": {
            "use": "short tables where each row deserves attention"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "the scroll container is keyboard reachable, which overflow alone does not provide (WCAG 2.1.1)",
          "`maxHeight` holds the header at the top of the table's scroll region; its bottom rule is drawn by the cells, so it moves with them",
          "`stickyFirstColumn` holds the first column, and the toggle and checkbox cells before it, each placed after the one before; a held cell takes its row's background, so nothing shows through",
          "nothing focused is left under a held header or column (WCAG 2.4.11): the region's scroll padding is their measured size, and a control half under them is scrolled clear on focus",
          "the held column's edge shows a shadow only once something has scrolled under it",
          "rows that arrive later — a page change, a row opening — are measured and held too; an open row's detail spans everything and is not held"
        ],
        "requires": [
          "`aria-label` or `aria-labelledby` on Table — it names the scroll region, and the container takes no props of its own, so this is the only place the name can live",
          "`scope=\"col\"` on header cells",
          "an accessible name on every row-selection Checkbox — those have no visible label by design"
        ],
        "notes": [
          "Prefer a visible <caption> referenced by aria-labelledby when the table's name should also be seen.",
          "The header cannot hold to the page: the table's region scrolls sideways, and a sticky cell sticks to its nearest scroller. Give it `maxHeight` instead."
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Table> with no aria-label",
          "why": "the scroll region is announced with no name, and a keyboard user reaching it is told nothing about what they have entered"
        },
        {
          "dont": "header cells without scope",
          "why": "screen readers cannot associate data cells with their column"
        },
        {
          "dont": "wrapping Table in your own overflow container",
          "why": "Table already owns the scroll container and makes it focusable; a second one is unreachable by keyboard"
        },
        {
          "dont": "row-selection checkboxes with no aria-label",
          "why": "every row announces an unnamed checkbox"
        },
        {
          "dont": "position: sticky on your own <thead> cells",
          "do": "`maxHeight` on Table",
          "why": "the header sticks to the table's scroll region, which does not scroll up and down without a height, and its rule stays behind with border-collapse; focus can also land under it"
        },
        {
          "dont": "stickyFirstColumn on a table that fits its space",
          "why": "nothing scrolls under the column; it only adds layers. Hold the column where the table is wider than its space — a phone, a panel beside it"
        }
      ],
      "missingStates": {
        "note": "Table has no empty, loading or error state of its own — those are the caller's, and they are the states most often forgotten. Pattern coverage is planned; see docs/agent-readiness-plan.md phase 4a."
      },
      "stylesheet": "src/styles/table.css",
      "tokens": [
        "--border-default",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-default",
        "--icon-interactive",
        "--icon-secondary",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-shadow-md",
        "--ion-table-head-height",
        "--ion-table-held-width",
        "--ion-table-sticky-left",
        "--radius-full",
        "--radius-md",
        "--radius-xs",
        "--ring-focus",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-page",
        "--surface-primary-subtle",
        "--text-default",
        "--text-link",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "density": {
          "type": "TableDensity | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Figma's `Density` variant on `Table Cell` / `Table Row`.",
          "values": [
            "default",
            "compact",
            "relaxed"
          ]
        },
        "isStriped": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Alternating row background, read from the row's position, not a prop\nrepeated on every `TableRow`."
        },
        "maxHeight": {
          "type": "string | number | undefined",
          "required": false,
          "origin": "own",
          "description": "Scroll the rows inside the table past this height, with the header held\nat the top — a number is pixels, a string any CSS length (`\"60vh\"`).\nThe header sticks to the table's own scroll region, not the page: that\nregion already scrolls sideways, and a sticky cell sticks to the nearest\nscroller."
        },
        "stickyFirstColumn": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Hold the first column in place while the table scrolls sideways — with\nthe toggle and checkbox cells before it, when the rows have them. For a\ntable wider than its space whose rows are named by their first column."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 287,
        "other": 0
      }
    },
    "TableBatchBar": {
      "name": "TableBatchBar",
      "source": "src/components/TableBatchBar.tsx",
      "propsType": "TableBatchBarProps",
      "description": "TableBatchBar — the count, \"select all N\", the bulk actions and \"clear\",\nshown while any row of a table is selected.\n\nALWAYS MOUNTED, EVEN WHEN NOTHING IS SELECTED\n\nThe count is a `role=\"status\"` live region, and a live region only speaks\nabout changes to content it already held: one inserted into the page along\nwith its first message is, in most screen readers, silent. So the bar is\nrendered from the start, empty and visually hidden at zero, and the first\nticked box is announced as \"1 selected\" instead of nothing.\n\nWHY \"SELECT ALL\" IS A BUTTON HERE AND NOT THE HEADER CHECKBOX\n\nSee `useTableSelection`: the header selects the rows you can see, and\nreaching the ones you cannot is a separate press, offered only once the\nwhole page is ticked and more rows match than it holds.\n\nThe actions go in a Toolbar — one tab stop, arrows between them — named\nfor what they act on.",
      "import": "import { TableBatchBar } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The count, \"Select all N\", the bulk actions and \"Clear selection\", shown above a table while any of its rows is selected. Pair it with useTableSelection.",
      "useWhen": [
        "a table's rows can be selected and acted on together — pause, export, delete",
        "the rows are paged, and an action may need to reach every row that matches, not just the page on screen"
      ],
      "useInstead": [
        {
          "when": "there are actions but no row selection",
          "use": "Toolbar",
          "why": "the bar exists to say how many are selected and to act on them; without a selection it has nothing to count"
        },
        {
          "when": "an action applies to one row",
          "use": "MenuTrigger",
          "why": "put it in that row's own actions menu; ticking a box to act on one row is two steps where one would do"
        }
      ],
      "composition": {
        "order": [
          "TableBatchBar",
          "Table"
        ],
        "note": "Hold the selection in `useTableSelection({ total })`: spread its `count`, `isAllMatching`, `selectAllMatching` and `clear` onto the bar, `headSelection(pageKeys)` onto the head TableRow's `selection`, and `rowSelection(key, label)` plus `isSelected(key)` onto each body row. Render the bar always — it hides itself at zero — and give the Table an `id` passed as `tableId`. Call `clear()` whenever the filters, the sort or the page size change. When `selection.mode` is `all`, send the server \"every row matching these filters, except `selection.except`\".",
        "example": "<TableBatchBar count={sel.count} total={total} isAllMatching={sel.isAllMatching} onSelectAll={sel.selectAllMatching} onClear={sel.clear} tableId=\"agents\"><Button size=\"sm\" variant=\"secondary\">Pause</Button><Button size=\"sm\" variant=\"destructive\">Delete</Button></TableBatchBar>"
      },
      "slots": {
        "children": {
          "accepts": "Button, Divider, MenuTrigger",
          "note": "the bulk actions; placed in a Toolbar named for the count"
        },
        "labels": {
          "accepts": "text",
          "note": "every string the bar renders — selected, allSelected, selectAll, clear, actions — for translation. Each receives its number already formatted for the locale"
        }
      },
      "a11y": {
        "role": "status for the count; toolbar for the actions",
        "guarantees": [
          "the count is a `role=\"status\"` live region that is in the page before the first selection, so \"1 selected\" is announced rather than inserted silently",
          "the actions are a Toolbar: one tab stop, arrow keys between them, named \"Actions for N selected rows\"",
          "\"Clear selection\" returns focus to the table's select-all checkbox (with `tableId`), since the bar and the button pressed go away with the selection",
          "numbers are formatted with `Intl.NumberFormat` in the user's locale"
        ],
        "requires": [
          "an `id` on the Table, passed as `tableId`, so focus has somewhere to go on clear",
          "the selection checkboxes labelled — `headSelection` and `rowSelection` take the label"
        ],
        "notes": [
          "The header checkbox selects the page you can see. Rows you cannot see are the bar's separate \"Select all N\" press, offered once fewer than all are selected — a bulk delete must never reach thousands of unseen rows from one tick."
        ]
      },
      "antiPatterns": [
        {
          "dont": "mounting the bar only once something is selected",
          "why": "a live region inserted with its first message is silent in most screen readers — the first tick goes unannounced"
        },
        {
          "dont": "a header checkbox that selects every matching row",
          "do": "`headSelection(pageKeys)`, and the bar's \"Select all N\"",
          "why": "one tick reaching rows nobody has seen is how a bulk delete removes data by surprise"
        },
        {
          "dont": "keeping the selection across a filter or sort change",
          "why": "the ticked rows may no longer be on screen, and the bar would act on rows the user can no longer see"
        },
        {
          "dont": "a destructive bulk action with no confirmation",
          "do": "the DestructiveConfirm pattern, naming the count",
          "why": "\"Delete 128\" after one press of Select all is one click from removing everything the filter matched"
        }
      ],
      "stylesheet": "src/styles/table-batch.css",
      "tokens": [
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-medium",
        "--radius-md",
        "--spacing-12",
        "--spacing-4",
        "--spacing-8",
        "--surface-primary-subtle",
        "--text-default",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "count": {
          "type": "number",
          "required": true,
          "origin": "own",
          "description": "How many rows are selected — `useTableSelection`'s `count`."
        },
        "total": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "How many rows match, across every page. Needed to offer \"select all\"."
        },
        "isAllMatching": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether every matching row is selected — `isAllMatching`."
        },
        "onSelectAll": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Select every matching row. Leave it out to keep selection page-by-page."
        },
        "onClear": {
          "type": "() => void",
          "required": true,
          "origin": "own",
          "description": "Select nothing."
        },
        "tableId": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The id on the Table this bar acts on. Clearing hands focus to that\ntable's select-all Checkbox, since the bar — and the button just pressed\n— goes away with the selection."
        },
        "labels": {
          "type": "TableBatchBarLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "Every string the bar renders, for translation. English by default."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The actions: Buttons and Dividers, placed in a Toolbar."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 9,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "TableBody": {
      "name": "TableBody",
      "source": "src/components/Table.tsx",
      "propsType": null,
      "import": "import { TableBody } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The <tbody> section. Takes no props — it places the data rows and gives their cells body context.",
      "useWhen": [
        "every Table — the data rows go here"
      ],
      "useInstead": [
        {
          "when": "the row names the columns",
          "use": "TableHead"
        }
      ],
      "composition": {
        "order": [
          "Table",
          "TableBody",
          "TableRow",
          "TableCell"
        ],
        "note": "Zebra striping is read from each row's position inside this section rather than a prop repeated on every TableRow.",
        "example": "<TableBody><TableRow><TableCell>Ada</TableCell></TableRow></TableBody>"
      },
      "a11y": {
        "guarantees": [
          "cells inside it default to <td>, and `scope` is inferred as `row` when a cell is marked `header`"
        ]
      },
      "antiPatterns": [
        {
          "dont": "rendering rows directly under Table with no TableBody",
          "why": "the browser inserts a tbody anyway, but the head-or-body context does not exist, so cells cannot infer their scope"
        },
        {
          "dont": "an empty TableBody with no empty state",
          "why": "an empty table looks like a broken one — Table has no empty state of its own, so it is the caller's"
        }
      ],
      "stylesheet": "src/styles/table.css",
      "tokens": [
        "--border-default",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-default",
        "--icon-interactive",
        "--icon-secondary",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-shadow-md",
        "--ion-table-head-height",
        "--ion-table-held-width",
        "--ion-table-sticky-left",
        "--radius-full",
        "--radius-md",
        "--radius-xs",
        "--ring-focus",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-page",
        "--surface-primary-subtle",
        "--text-default",
        "--text-link",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {},
      "propCounts": {
        "own": 0,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "TableCell": {
      "name": "TableCell",
      "source": "src/components/Table.tsx",
      "propsType": "TableCellProps",
      "description": "One component covers Figma's `Table Cell` + `Cell Text`: the two are never\nused apart in the design (`Table Cell` always wraps exactly one `Cell\nText`), so splitting them into two exported components would only add API\nsurface for a composition nothing ever varies independently.\n\n`header` decides `<th>` vs `<td>` directly rather than a `type` prop that\ncould disagree with where the cell actually sits — a `<th>` rendered inside\n`<tbody>` is still a header cell to the browser and to CSS either way.",
      "import": "import { TableCell } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One cell, covering Figma's Table Cell and Cell Text together. `header` decides <th> versus <td>.",
      "useWhen": [
        "every cell in a Table, header or data"
      ],
      "useInstead": [
        {
          "when": "the content is a status label",
          "use": "Badge",
          "why": "put it inside the cell — the cell is the container, not the label"
        }
      ],
      "variants": {
        "scope": {
          "col": {
            "use": "a column header — inferred inside TableHead"
          },
          "row": {
            "use": "a row header, e.g. the name column — inferred for a `header` cell in TableBody"
          },
          "colgroup": {
            "use": "a header spanning several columns, with colspan"
          },
          "rowgroup": {
            "use": "a header spanning several rows, with rowspan"
          }
        },
        "align": {
          "leading": {
            "use": "the default — text, names, identifiers"
          },
          "trailing": {
            "use": "numbers, currency and dates, so digits line up column-wise"
          },
          "center": {
            "use": "icons, short status marks, checkboxes"
          }
        },
        "variant": {
          "default": {
            "use": "the default — body text and icon colours"
          },
          "link": {
            "use": "recolours the content to `text/link` / `icon/primary` for a cell whose content navigates"
          }
        },
        "sortDirection": {
          "ascending": {
            "use": "the column the rows are sorted by, smallest or earliest first"
          },
          "descending": {
            "use": "the column the rows are sorted by, largest or newest first"
          },
          "none": {
            "use": "a column that can be sorted but is not the current sort — it shows the neutral indicator"
          }
        }
      },
      "slots": {
        "icon": {
          "accepts": "Icon",
          "note": "leading icon; omit its `label`"
        },
        "trailingIcon": {
          "accepts": "Icon",
          "note": "trailing icon; same rule"
        },
        "children": {
          "accepts": "any"
        }
      },
      "a11y": {
        "guarantees": [
          "`scope` is inferred when omitted — `col` in TableHead, `row` in TableBody — and can be set explicitly when the inference is wrong",
          "`header` decides the element directly rather than a `type` prop that could disagree with where the cell sits",
          "a sortable header renders a real <button> inside the <th>, so it is focusable and answers Enter and Space, and the header keeps its column-header role",
          "`aria-sort` is set on the sorted header only, never `none` on the rest",
          "`resize` puts a handle on a header's trailing edge: a focusable `role=\"separator\"` named for the column (\"Resize Agent\"), with the width as `aria-valuenow` and its limits as `aria-valuemin` / `aria-valuemax`",
          "the handle moves by drag, by the arrow keys (16px; 64px with Shift) and by Home and End to the limits, and a double-click puts the column back to its own width; right to left, ArrowLeft widens",
          "a resizable header is named by its label alone, so it does not read the handle's name as well"
        ],
        "requires": [
          "`scope=\"col\"` on header cells — inferred inside TableHead, but explicit for a header rendered outside it"
        ],
        "notes": [
          "`useTableSort` returns `sortDirection` and `onSort` for each column. The table never reorders rows itself: they may be one page of a server's thousands.",
          "`useTableColumns` returns `resize` for each column through `headerProps(key)`. The width set is the column's, padding included, and is its `min-width` too: when the other columns fill the table, the table scrolls sideways rather than squeezing a widened column back."
        ]
      },
      "antiPatterns": [
        {
          "dont": "`variant=\"link\"` on a cell with no link in it",
          "why": "link-coloured text that does nothing is the clearest possible false affordance"
        },
        {
          "dont": "leading-aligning a numeric column",
          "why": "digits stop lining up, so the column can no longer be scanned for magnitude"
        },
        {
          "dont": "`showDivider` on every cell",
          "why": "it is a column rule, not a row rule — on every cell it becomes a grid"
        },
        {
          "dont": "`onSort` on a header without `sortDirection`",
          "why": "the cell stays plain text, so nothing can be clicked; `sortDirection` is what makes it a sort control — pass `none` for an unsorted column"
        },
        {
          "dont": "`trailingIcon` on a sortable header",
          "why": "the sort indicator takes the trailing slot; the icon is not rendered"
        },
        {
          "dont": "`resize` on a body cell",
          "why": "a column has one width and its header holds it; the prop is ignored on a `<td>`"
        }
      ],
      "stylesheet": "src/styles/table.css",
      "tokens": [
        "--border-default",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-default",
        "--icon-interactive",
        "--icon-secondary",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-shadow-md",
        "--ion-table-head-height",
        "--ion-table-held-width",
        "--ion-table-sticky-left",
        "--radius-full",
        "--radius-md",
        "--radius-xs",
        "--ring-focus",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-page",
        "--surface-primary-subtle",
        "--text-default",
        "--text-link",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "header": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Renders `<th>` instead of `<td>` — Figma's header cell, `surface/page`\nfill included."
        },
        "scope": {
          "type": "TableCellScope | undefined",
          "required": false,
          "origin": "own",
          "description": "`<th>` scope. Inferred when omitted: `col` in `<thead>`, `row` in\n`<tbody>`. Pass explicitly when the inference is wrong (e.g. a column\nheader rendered outside `<thead>`).",
          "values": [
            "row",
            "rowgroup",
            "col",
            "colgroup"
          ]
        },
        "align": {
          "type": "TableCellAlign | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "center",
            "leading",
            "trailing"
          ]
        },
        "variant": {
          "type": "\"default\" | \"link\" | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's `Type=Link` — recolours the content to `text/link` /\n`icon/primary` rather than the body defaults.",
          "values": [
            "default",
            "link"
          ]
        },
        "showDivider": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's per-cell `Show Divider` — a column rule, not a row rule."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Figma's `Leading Icon` / `Trailing Icon` slots on `Cell Text`."
        },
        "trailingIcon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "sortDirection": {
          "type": "TableSortDirection | undefined",
          "required": false,
          "origin": "own",
          "description": "Makes a header cell a sort control. Set it on every sortable column:\n`ascending` or `descending` on the one the rows are sorted by, `none` on\nthe rest. Header cells only — ignored on a body cell. `useTableSort`\nreturns this and `onSort` for each column.",
          "values": [
            "none",
            "ascending",
            "descending"
          ]
        },
        "onSort": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Called when the header is activated. The table does not reorder rows:\nsorting is the caller's, since only it knows whether the data is local\nor paged from a server."
        },
        "resize": {
          "type": "TableCellResize | undefined",
          "required": false,
          "origin": "own",
          "description": "Makes a header cell's column resizable: a handle on its trailing edge,\ndragged or moved with the arrow keys. Header cells only. `useTableColumns`\nreturns this for each column through `headerProps`."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 11,
        "aria": 0,
        "dom": 284,
        "other": 0
      }
    },
    "TableColumnsMenu": {
      "name": "TableColumnsMenu",
      "source": "src/components/TableColumnsMenu.tsx",
      "propsType": "TableColumnsMenuProps",
      "description": "TableColumnsMenu — a Button that opens a checklist of a table's columns:\nchecked is shown. A Menu with multiple selection, so each row is a\n`menuitemcheckbox` that says whether its column is shown, and the menu\nstays open while several are changed.\n\nA column that cannot be hidden is listed, checked and disabled, so the\nlist is every column and says why that one stays. The last one shown is\ndisabled too: a table with no columns is not a view of anything.",
      "import": "import { TableColumnsMenu } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A Button that opens a checklist of a table's columns — checked is shown. Pair it with useTableColumns, which also gives resizable headers their handles.",
      "useWhen": [
        "a table has more columns than most people need at once, and which ones matter differs by person",
        "a column's content is long — a purpose, a description — and people should be able to give it more room"
      ],
      "useInstead": [
        {
          "when": "some columns matter only on a narrow screen, or only on a wide one",
          "use": "Table",
          "why": "that is layout, not a choice: stickyFirstColumn and the table's own sideways scroll handle it; a menu asks each person to fix what the page should"
        },
        {
          "when": "the choice is between a few fixed views of the same data — summary and detail",
          "use": "SegmentedControl",
          "why": "one control naming each view says what the choices are; a checklist of twelve columns makes each person build the view"
        }
      ],
      "composition": {
        "order": [
          "TableColumnsMenu",
          "Table"
        ],
        "note": "Declare the columns once as `TableColumn`s and pass them to `useTableColumns(columns)`. Render the header and body cells of `visibleColumns`, in that order, so a hidden column's cells are not in the table at all; spread `headerProps(key)` onto each header TableCell for its resize handle. Pass the hook's result to `TableColumnsMenu` as `columns`. Give the column that names each row `canHide: false`, and `canResize: true` with `defaultWidth`, `minWidth` and `maxWidth` to the columns whose content is long. To keep a view, store `hidden` and `widths` and restore them with `setHidden` and `setWidths`.",
        "example": "const cols = useTableColumns([{ key: 'agent', label: 'Agent', canHide: false }, { key: 'purpose', label: 'Purpose', canResize: true, defaultWidth: 240 }, { key: 'owner', label: 'Owner' }]);\n<TableColumnsMenu columns={cols} />\n<Table aria-label=\"Agents\"><TableHead><TableRow>{cols.visibleColumns.map((c) => <TableCell key={c.key} header {...cols.headerProps(c.key)}>{c.label}</TableCell>)}</TableRow></TableHead>…</Table>"
      },
      "slots": {
        "columns": {
          "accepts": "useTableColumns",
          "note": "the hook's whole result; the menu lists `columns` and changes `hidden`"
        },
        "label": {
          "accepts": "text",
          "note": "the button's text and so the menu's name; \"Columns\" by default"
        }
      },
      "a11y": {
        "role": "button that opens a menu of menuitemcheckbox rows",
        "guarantees": [
          "each column is a `menuitemcheckbox` that says whether it is shown; Space or a click toggles it, and the menu stays open for the next",
          "a column that cannot be hidden is listed, checked and disabled, and the arrow keys skip it; so is the last column shown",
          "the menu is named by its button, opens on ArrowDown and ArrowUp, and Escape returns focus to the button",
          "a hidden column's cells are not rendered, so a screen reader's column count is the columns on screen"
        ],
        "requires": [
          "every `TableColumn` has a `label` — its header text, and its row in the menu"
        ],
        "notes": [
          "Resizing is a drag or the arrow keys on the handle. There is no single-pointer way to set a width other than dragging (WCAG 2.5.7); long content should still be readable at the default width — TruncatedText shows the rest on focus — so a width is a preference, never the only way to read a cell."
        ]
      },
      "antiPatterns": [
        {
          "dont": "hiding a column with CSS, or rendering empty cells for it",
          "do": "render the cells of `visibleColumns` only",
          "why": "a screen reader still counts and reads the column, and the headers stop matching what is on screen"
        },
        {
          "dont": "letting the naming column be hidden",
          "do": "`canHide: false` on it",
          "why": "rows of statuses and dates with nothing to say which row is which are not a table of anything"
        },
        {
          "dont": "making every column resizable",
          "why": "a handle on every header is a row of drag targets beside every sort button; give one to the columns whose content is long"
        },
        {
          "dont": "clipping a cell's text to fit its width",
          "do": "TruncatedText, which puts the rest in a tooltip on focus",
          "why": "text cut off with no way to read it is lost, whatever width someone chose"
        }
      ],
      "stylesheet": "src/styles/table.css",
      "tokens": [
        "--border-default",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-default",
        "--icon-interactive",
        "--icon-secondary",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-shadow-md",
        "--ion-table-head-height",
        "--ion-table-held-width",
        "--ion-table-sticky-left",
        "--radius-full",
        "--radius-md",
        "--radius-xs",
        "--ring-focus",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-page",
        "--surface-primary-subtle",
        "--text-default",
        "--text-link",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "columns": {
          "type": "UseTableColumnsResult<Key>",
          "required": true,
          "origin": "own",
          "description": "What `useTableColumns` returned."
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The button's text, and so the menu's name. \"Columns\" by default."
        },
        "variant": {
          "type": "\"success\" | \"primary-brand\" | \"primary-neutral\" | \"primary-soft\" | \"secondary\" | \"tertiary\" | \"destructive\" | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "success",
            "primary-brand",
            "primary-neutral",
            "primary-soft",
            "secondary",
            "tertiary",
            "destructive"
          ]
        },
        "size": {
          "type": "\"sm\" | \"md\" | \"lg\" | \"xl\" | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "sm",
            "md",
            "lg",
            "xl"
          ]
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "TableHead": {
      "name": "TableHead",
      "source": "src/components/Table.tsx",
      "propsType": null,
      "import": "import { TableHead } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The <thead> section. Takes no props — it places the header row and gives its cells their head-or-body context.",
      "useWhen": [
        "every Table with column headers, which is every data table"
      ],
      "useInstead": [
        {
          "when": "the rows are data",
          "use": "TableBody"
        }
      ],
      "composition": {
        "order": [
          "Table",
          "TableHead",
          "TableRow",
          "TableCell"
        ],
        "note": "Exists so TableRow and TableCell can tell head from body through context, rather than the caller repeating a prop that has to stay in sync with where the row actually sits.",
        "example": "<TableHead><TableRow><TableCell scope=\"col\">Name</TableCell></TableRow></TableHead>"
      },
      "a11y": {
        "guarantees": [
          "cells inside it default to <th scope=\"col\">, and a row-selection cell becomes the select-all header"
        ],
        "requires": [
          "a header row for every data table — without one, no data cell has a column to be associated with"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a data table with no TableHead",
          "why": "a screen-reader user hears values with no field names"
        },
        {
          "dont": "styling a first TableBody row to look like a header",
          "why": "it looks like a header and announces as data"
        }
      ],
      "stylesheet": "src/styles/table.css",
      "tokens": [
        "--border-default",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-default",
        "--icon-interactive",
        "--icon-secondary",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-shadow-md",
        "--ion-table-head-height",
        "--ion-table-held-width",
        "--ion-table-sticky-left",
        "--radius-full",
        "--radius-md",
        "--radius-xs",
        "--ring-focus",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-page",
        "--surface-primary-subtle",
        "--text-default",
        "--text-link",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {},
      "propCounts": {
        "own": 0,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "TableRow": {
      "name": "TableRow",
      "source": "src/components/Table.tsx",
      "propsType": "TableRowProps",
      "import": "import { TableRow } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A plain <tr>. Hover is CSS-only; a row is not itself interactive. With `expansion`, it opens to show more under it: a disclosure button in its first cell, and a detail row across every column.",
      "useWhen": [
        "every row, in either TableHead or TableBody"
      ],
      "useInstead": [
        {
          "when": "the whole row should be clickable",
          "use": "Link",
          "why": "put the link or Button in a cell — nesting an interactive role on <tr> is not valid HTML"
        },
        {
          "when": "the extra detail is long, has its own actions, or is read beside the list",
          "use": "SidePanel",
          "why": "an expanded row pushes every row below it down; a panel beside the table keeps the list in place and can hold a form"
        }
      ],
      "slots": {
        "selection": {
          "accepts": "Checkbox props",
          "note": "renders a leading Checkbox cell — a <th scope=\"col\"> select-all inside TableHead, a <td> inside TableBody. Takes the checkbox's own props rather than a boolean, since a selectable row needs `checked`/`onChange` wiring. `useTableSelection`'s `headSelection` and `rowSelection` return exactly these props, tri-state header included"
        },
        "expansion": {
          "accepts": "TableRowExpansion in TableBody, TableHeadExpansion in TableHead",
          "note": "in TableBody, `{ 'aria-label', content, isExpanded?, defaultExpanded?, onExpandedChange? }`: a leading cell with a disclosure button, and when open a second <tr> under the row whose one cell spans every column, counted from the row's own cells. In TableHead, `{ label }`: the header cell over the toggles, its name visually hidden, so the columns line up. Controlled with `isExpanded`, uncontrolled with `defaultExpanded`"
        }
      },
      "a11y": {
        "guarantees": [
          "hover comes from CSS `:hover` plus a `data-hovered` escape hatch, not React Aria — a row takes no focus and fires no click, so there is no keyboard-versus-pointer distinction to track",
          "the selection cell's element follows the section it sits in",
          "an expandable row's toggle is a real <button> with `aria-expanded`, and `aria-controls` pointing at the detail row while it is open",
          "closed points right and open points down — the shape carries the state as well as `aria-expanded`",
          "the detail row spans every column, so it is read as one cell after its row",
          "stripes count real rows only: an open detail takes its row's stripe and selection, and shifts nothing below it"
        ],
        "requires": [
          "every row-selection Checkbox to be labelled — those boxes have no visible label by design, so see Checkbox's own contract",
          "`isSelected` alongside a checked selection checkbox, so the row's state is not carried by colour alone",
          "an `aria-label` on each expansion that names the row — \"Details for run 4821\" — not the action"
        ],
        "notes": [
          "TableRow itself is never named — a <tr> is not an interactive element and takes no accessible name. The naming requirement above belongs to the Checkbox inside it."
        ]
      },
      "antiPatterns": [
        {
          "dont": "hand-wiring the select-all's checked and indeterminate states",
          "do": "`selection={sel.headSelection(pageKeys, label)}` from useTableSelection",
          "why": "the three states and \"a partial header ticks to full\" are easy to get subtly wrong, and every table needs the same logic"
        },
        {
          "dont": "`onClick` on TableRow",
          "why": "it takes no focus, so a keyboard user can never fire it"
        },
        {
          "dont": "`selection={{ checked }}` with no `onChange`",
          "why": "a checkbox that cannot be changed reads as broken rather than as disabled"
        },
        {
          "dont": "`isSelected` without a selection checkbox",
          "why": "the row is tinted with nothing announcing why"
        },
        {
          "dont": "expansion={{ 'aria-label': 'Expand', content }} on every row",
          "do": "expansion={{ 'aria-label': `Details for ${run.id}`, content }}",
          "why": "a column of buttons all named \"Expand\" gives a screen reader no way to tell which row it opens"
        },
        {
          "dont": "a detail row you add yourself, with a colSpan you count by hand",
          "do": "`expansion.content`",
          "why": "the hand count goes stale the day a column is added, and the detail then stops short or pushes the table wider"
        },
        {
          "dont": "onClick on the row to expand it",
          "do": "the toggle `expansion` renders",
          "why": "a row takes no focus, so a keyboard user can never open it; the toggle is a button with its state announced"
        }
      ],
      "stylesheet": "src/styles/table.css",
      "tokens": [
        "--border-default",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-default",
        "--icon-interactive",
        "--icon-secondary",
        "--icon-size-sm",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-shadow-md",
        "--ion-table-head-height",
        "--ion-table-held-width",
        "--ion-table-sticky-left",
        "--radius-full",
        "--radius-md",
        "--radius-xs",
        "--ring-focus",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-page",
        "--surface-primary-subtle",
        "--text-default",
        "--text-link",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "isSelected": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "expansion": {
          "type": "TableRowExpansion | TableHeadExpansion | undefined",
          "required": false,
          "origin": "own",
          "description": "Makes the row expandable — Figma's `Show Expander`. In `<tbody>`, a\n`TableRowExpansion`: a leading toggle cell, and when open, a second row\nunder this one holding `content`. In `<thead>`, a `TableHeadExpansion`:\nthe header cell over the toggles, so the columns still line up."
        },
        "selection": {
          "type": "TableRowSelection | undefined",
          "required": false,
          "origin": "own",
          "description": "Renders a leading `Checkbox` cell — Figma's `Show Selection`. Takes the\ncheckbox's own props directly rather than a boolean, since a selectable\nrow needs `checked`/`onChange` wiring, not just a decorative box.\n\nInside `<thead>` the cell is a `<th scope=\"col\">` (select-all). Inside\n`<tbody>` it is a `<td>`."
        }
      },
      "propCounts": {
        "own": 3,
        "aria": 0,
        "dom": 278,
        "other": 0
      }
    },
    "Tabs": {
      "name": "Tabs",
      "source": "src/components/Tabs.tsx",
      "propsType": "TabsProps",
      "import": "import { Tabs } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A tab list and its panels, built on react-aria's tab collection. Children are TabItem.",
      "useWhen": [
        "one region of the page shows one of several peer views, and only one at a time",
        "the views are peers — no ordering, no progression between them"
      ],
      "useInstead": [
        {
          "when": "the steps must be completed in order",
          "use": "Button",
          "why": "tabs imply peers you may visit in any order; a wizard is a sequence"
        },
        {
          "when": "the sections should all be readable at once, or printable",
          "use": "Divider",
          "why": "tabs hide everything but the active panel, including from Ctrl-F"
        },
        {
          "when": "the options set a value — a view mode, a range, a unit — and no panel belongs to each",
          "use": "SegmentedControl",
          "why": "it looks like the pill type and is a radiogroup: a tab with no panel announces a relationship that does not exist"
        },
        {
          "when": "it is navigation between pages rather than panels within one",
          "use": "NavItem"
        }
      ],
      "composition": {
        "order": [
          "Tabs",
          "TabItem"
        ],
        "note": "TabItem comes from react-stately's collection Item — it takes no props of its own beyond `title` (the tab label) and `key`; its children are the panel.",
        "example": "<Tabs aria-label=\"Account\"><TabItem key=\"profile\" title=\"Profile\">…</TabItem><TabItem key=\"billing\" title=\"Billing\">…</TabItem></Tabs>"
      },
      "variants": {
        "type": {
          "pill": {
            "use": "the default — a filled track, for switching views inside a panel or card"
          },
          "underline": {
            "use": "a rule under the active tab, for page-level sections"
          }
        },
        "size": {
          "sm": {
            "use": "dense panels and toolbars"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "page-level section switching"
          }
        },
        "orientation": {
          "horizontal": {
            "use": "the default"
          },
          "vertical": {
            "use": "a stacked track with up/down arrow keys",
            "caveat": "keyboard and ARIA are complete, but the decoration is not — the underline rule and the pill track's padding are still written for the horizontal axis. Figma has not specified the vertical drawing"
          }
        }
      },
      "a11y": {
        "role": "tablist / tab / tabpanel",
        "guarantees": [
          "roving tabindex, arrow-key navigation and the aria-controls/aria-labelledby pairing between each tab and its panel come from react-aria",
          "the focus ring shows for keyboard focus only, and hover does not latch on touch"
        ],
        "requires": [
          "`aria-label` or `aria-labelledby` on Tabs — the tab list is a named region",
          "a stable `key` on every TabItem"
        ]
      },
      "antiPatterns": [
        {
          "dont": "tabs whose panels the user must read in order",
          "why": "the role announces peers, and nothing stops a user landing on the last one first"
        },
        {
          "dont": "hiding required form fields behind a tab",
          "why": "a validation error in an inactive panel is invisible; the user is told the form is invalid with nothing on screen to fix"
        },
        {
          "dont": "one tab",
          "why": "a tablist with a single tab is chrome with no choice in it"
        },
        {
          "dont": "collecting TabItems into a `React.ReactElement[]` and passing the array as the children",
          "why": "the children are typed `CollectionChildren`: one collection element, an array of them, or a render function. `ReactElement` defaults its props to `unknown`, which is not an item's props, so the array fails to compile. Write the tabs out, or `.map()` them as Tabs' only child"
        }
      ],
      "stylesheet": "src/styles/tabs.css",
      "tokens": [
        "--border-default",
        "--border-primary",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-size-sm",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-tabs-icon-size",
        "--ion-tabs-item-font-size",
        "--ion-tabs-item-gap",
        "--ion-tabs-item-line-height",
        "--ion-tabs-item-padding-x",
        "--ion-tabs-item-padding-y",
        "--ion-tabs-item-size",
        "--radius-full",
        "--radius-md",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-20",
        "--spacing-32",
        "--spacing-4",
        "--spacing-40",
        "--spacing-48",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-hover",
        "--surface-muted",
        "--surface-page",
        "--text-default",
        "--text-disabled",
        "--text-tertiary",
        "--type-body",
        "--type-body-lg",
        "--type-body-lg-line-height",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "type": {
          "type": "TabsType | undefined",
          "required": false,
          "origin": "own",
          "description": "Visual style. Matches the Figma `Type` variant.",
          "values": [
            "pill",
            "underline"
          ],
          "default": "pill"
        },
        "size": {
          "type": "TabsSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant.",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "orientation": {
          "type": "\"vertical\" | \"horizontal\" | undefined",
          "required": false,
          "origin": "own",
          "description": "Drives both the arrow-key axis and the track layout.\n\n`vertical` is keyboard- and ARIA-complete — up/down move between tabs, and\nthe track stacks — but its *decoration* is not: the underline rule and the\npill track's padding are still written for the horizontal axis only. Those\nare Figma's to specify, not this file's to invent. See tabs.css.",
          "values": [
            "vertical",
            "horizontal"
          ],
          "tags": {
            "default": "'horizontal'"
          },
          "default": "horizontal"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "keyboardActivation": {
          "type": "\"manual\" | \"automatic\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether tabs are activated automatically on focus or manually.",
          "values": [
            "manual",
            "automatic"
          ],
          "tags": {
            "default": "'automatic'"
          }
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "other",
          "description": "Whether the TabList is disabled.\nShows that a selection exists, but is not available in that circumstance."
        },
        "selectedKey": {
          "type": "Key | undefined",
          "required": false,
          "origin": "other",
          "description": "The currently selected key in the collection (controlled)."
        },
        "defaultSelectedKey": {
          "type": "Key | undefined",
          "required": false,
          "origin": "other",
          "description": "The initial selected keys in the collection (uncontrolled)."
        },
        "onSelectionChange": {
          "type": "((key: Key) => void) | undefined",
          "required": false,
          "origin": "other",
          "description": "Handler that is called when the selection changes."
        },
        "children": {
          "type": "CollectionChildren<T>",
          "required": true,
          "origin": "aria",
          "description": "The contents of the collection."
        },
        "items": {
          "type": "Iterable<T> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Item objects in the collection."
        },
        "disabledKeys": {
          "type": "Iterable<Key> | undefined",
          "required": false,
          "origin": "aria",
          "description": "The item keys that are disabled. These items cannot be selected, focused, or otherwise\ninteracted with."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The element's unique identifier. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id)."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 9,
        "dom": 0,
        "other": 4
      }
    },
    "Tag": {
      "name": "Tag",
      "source": "../../node_modules/.pnpm/react-stately@3.48.0_react@19.2.8/node_modules/react-stately/dist/types/src/collections/Item.d.ts",
      "propsType": null,
      "import": "import { Tag } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.79.0",
      "summary": "One tag in a TagGroup. Re-exported from react-stately's collection Item — `key` identifies it, children are its label.",
      "useWhen": [
        "a child of TagGroup — it is not usable anywhere else"
      ],
      "useInstead": [
        {
          "when": "it is a status label on its own",
          "use": "Badge"
        }
      ],
      "composition": {
        "order": [
          "TagGroup",
          "Tag"
        ],
        "note": "A collection item, not a rendered component: TagGroup reads it to build the collection, so it never appears in the DOM itself. That is why it exposes no props of its own here — `key` and `textValue` are the collection's API.",
        "example": "<Tag key=\"team:finance\">Team: Finance</Tag>"
      },
      "a11y": {
        "guarantees": [
          "the row, cell and remove-button roles and names are supplied by TagGroup, not by this element"
        ],
        "requires": [
          "a stable `key` — it is what `onRemove` hands back",
          "`textValue` when the label is not plain text, so type-ahead and the remove button's name have words to use"
        ]
      },
      "antiPatterns": [
        {
          "dont": "array indexes as keys in a list that shrinks",
          "why": "removing the first tag renumbers the rest, and the next removal drops the wrong one"
        },
        {
          "dont": "wrapping Tag in a <div> or a fragment inside TagGroup",
          "why": "TagGroup reads the collection from its direct children; a wrapper makes the tag invisible to it"
        }
      ],
      "stylesheet": null,
      "tokens": [],
      "props": {},
      "propCounts": {
        "own": 0,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "TagGroup": {
      "name": "TagGroup",
      "source": "src/components/TagGroup.tsx",
      "propsType": "TagGroupProps",
      "description": "TagGroup — labels a person applied and can take away: active filters, the\nlabels on a record, the recipients of a message.\n\nNOT A BADGE\n\nA Badge reports a state the system decided — Failing, Paid. A Tag is a\nchoice someone made, which is why it is removable and why it is neutral:\ncolouring tags by intent would make \"Status: Failing\" as a filter look like\nthe agent is failing.\n\nWHAT REACT ARIA GIVES IT\n\n`useTagGroup` makes the group one tab stop with arrow keys between tags, a\nremove button per tag named \"Remove <tag>\", and Delete / Backspace to remove\nthe focused one. When a tag goes, focus moves to its neighbour. The one case\nReact Aria leaves open is the last tag: focus would fall to <body>, so the\ngroup takes it instead and reads out `emptyLabel`.\n\nNo selection: a chip that toggles on and off is a SegmentedControl or a\nCheckbox, which already say \"selected\" the way a screen reader expects.",
      "import": "import { TagGroup } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.79.0",
      "summary": "Labels a person applied and can take away: active filters, the labels on a record, recipients. Neutral and removable, unlike Badge.",
      "useWhen": [
        "showing which filters are applied, each removable on its own",
        "the labels or categories someone attached to a record",
        "a set of chosen values — recipients, assignees — shown after they are picked"
      ],
      "useInstead": [
        {
          "when": "it is a status the system reports — Failing, Paid, Draft",
          "use": "Badge"
        },
        {
          "when": "the chips toggle on and off rather than being removed",
          "use": "SegmentedControl"
        },
        {
          "when": "several options can be switched on at once from a fixed list",
          "use": "Checkbox"
        }
      ],
      "composition": {
        "order": [
          "TagGroup",
          "Tag"
        ],
        "note": "Tag is react-stately's collection Item: `key` identifies it and is what `onRemove` hands back, children are its label. Pass `items` and a render function for a list that changes. The children are one of three forms: tags written out, `items` with a render function, or a `.map()` that is the only child. Filters that are on or off are a list you build as data (`items`), not elements pushed into an array.",
        "example": "<TagGroup label=\"Active filters\" onRemove={(keys) => drop(keys)}><Tag key=\"status\">Status: Failing</Tag><Tag key=\"team\">Team: Finance</Tag></TagGroup>"
      },
      "variants": {
        "size": {
          "sm": {
            "use": "the default — filter rows, table toolbars, dense record headers"
          },
          "md": {
            "use": "a group that is the main content of its area, or sits beside md controls"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "the group is one tab stop; arrow keys move between tags",
          "each remove button is named \"Remove\" plus the tag's label, so a screen reader hears which one",
          "Delete or Backspace on a focused tag removes it, and focus moves to its neighbour",
          "removing the last tag moves focus to the group, which reads `emptyLabel`, instead of dropping it to the page"
        ],
        "requires": [
          "a `label` or `aria-label` — it names the group, so \"Remove Finance\" is heard as a filter being removed",
          "`textValue` on a Tag whose children are not plain text",
          "`onRemove` to drop the keys from your own list; the group removes nothing itself"
        ],
        "notes": [
          "No selection. A chip that toggles is a SegmentedControl or a Checkbox, which already announce \"selected\" the way screen readers expect."
        ]
      },
      "antiPatterns": [
        {
          "dont": "using a TagGroup for statuses",
          "why": "tags are neutral and removable; a status is neither — use Badge"
        },
        {
          "dont": "`onRemove` that does not remove the tag",
          "why": "the button says \"Remove\", focus moves on, and the tag is still there — the one thing the control promised did not happen"
        },
        {
          "dont": "a filter row with no way to clear everything at once",
          "why": "removing six filters one by one is six targets; pair the group with a \"Clear filters\" Button"
        },
        {
          "dont": "collecting tags into `const tags: React.ReactElement[] = []` with `push`, then `<TagGroup>{tags}</TagGroup>`",
          "why": "the children are typed `CollectionChildren`: one collection element, an array of them, or a render function. `ReactElement` defaults its props to `unknown`, which is not an item's props, so the array fails to compile. Contracts-only generations of the demo's Agents screen did this on 2 of 3 tries. Build the active filters as data and pass them as `items`, with `(f) => <Tag key={f.id}>…</Tag>` as the children"
        }
      ],
      "stylesheet": "src/styles/tag-group.css",
      "tokens": [
        "--border-disabled",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-default",
        "--icon-disabled",
        "--icon-secondary",
        "--icon-size-2xs",
        "--ion-duration-base",
        "--ion-ease-out",
        "--radius-md",
        "--radius-sm",
        "--radius-xs",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-20",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-6",
        "--spacing-8",
        "--surface-disabled",
        "--surface-hover",
        "--surface-subtle",
        "--text-default",
        "--text-disabled",
        "--text-secondary",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "onRemove": {
          "type": "((keys: Set<Key>) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Called with the keys of the tags to remove — from a tag's × button, or\nDelete / Backspace on a focused tag. Pass it and every tag shows a remove\nbutton; leave it out and the tags are read-only labels. The group does\nnot remove anything itself: drop the keys from your own list."
        },
        "size": {
          "type": "TagGroupSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Figma's `Size`. Set once here, so every tag in a group matches.",
          "values": [
            "sm",
            "md"
          ],
          "default": "sm"
        },
        "emptyLabel": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Shown in place of the tags when there are none — \"No filters applied\".\nWithout it an empty group renders nothing but its label."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The element's unique identifier. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id)."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "children": {
          "type": "CollectionChildren<T>",
          "required": true,
          "origin": "aria",
          "description": "The contents of the collection."
        },
        "description": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "A description for the field. Provides a hint such as specific requirements for what to choose."
        },
        "label": {
          "type": "ReactNode",
          "required": false,
          "origin": "aria",
          "description": "The content to display as the label."
        },
        "items": {
          "type": "Iterable<T> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Item objects in the collection."
        },
        "disabledKeys": {
          "type": "Iterable<Key> | undefined",
          "required": false,
          "origin": "aria",
          "description": "The item keys that are disabled. These items cannot be selected, focused, or otherwise\ninteracted with."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 10,
        "dom": 0,
        "other": 0
      }
    },
    "Textarea": {
      "name": "Textarea",
      "source": "src/components/Textarea.tsx",
      "propsType": "TextareaProps",
      "description": "A multi-line text field.\n\nGeometry from the Figma `Textarea` (1301:334). Every size and state is\nInput's — see textarea.css for why that is the point rather than a shortcut.",
      "import": "import { Textarea } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A multi-line text field. Every size and state is Input's; it wraps and it resizes vertically.",
      "useWhen": [
        "the answer is prose — a description, a note, a message — and may run past one line",
        "the user should be able to see several lines of what they wrote at once"
      ],
      "useInstead": [
        {
          "when": "the value is a single short string — a name, an email, a search term",
          "use": "Input",
          "why": "a one-line answer in a three-line box tells the user to write more than you want"
        },
        {
          "when": "the answer is one of a known set",
          "use": "Select",
          "why": "free text you intend to parse into categories is a Select you have not built yet"
        },
        {
          "when": "the field needs formatting controls or renders markup",
          "use": "Input",
          "why": "a rich-text editor is not in this library, and a textarea styled to look like one will not behave like one"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms and side panels"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "roomy layouts, and the primary field on a page"
          }
        }
      },
      "a11y": {
        "role": "textbox (multiline)",
        "guarantees": [
          "the label, helper text and error message are wired to the control by React Aria's useTextField, so the accessible name and description are correct without the caller doing anything",
          "`errorMessage` replaces the helper text only when `isInvalid` is set, and is announced as the field's description",
          "the focus ring shows for keyboard focus only, and hover does not latch on touch",
          "resize is disabled when the field is, matching what browsers do with a disabled textarea"
        ],
        "requires": [
          "`label`, or an `aria-label` when the field is labelled by something on the page — an unlabelled textarea is announced only as \"edit text\"",
          "a visible error cue that is not the border. THE INVALID BORDER IS 1px AS OF 0.49.0 and differs from a default field by hue alone, so pass `errorMessage` — WCAG 1.4.1 is not satisfied by the border on its own"
        ]
      },
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        }
      ],
      "antiPatterns": [
        {
          "avoid": "setting a fixed `height` in CSS to size the box",
          "instead": "use `rows`. The Figma height is derived from three lines of a bound line-height, so a fixed height pins the box while the text inside it keeps scaling with the type ramp"
        },
        {
          "avoid": "drawing a custom resize grip",
          "instead": "the browser paints one for `resize: vertical` and it cannot be styled or suppressed independently, so a custom grip sits beside it and the field gets two"
        },
        {
          "avoid": "relying on `isInvalid` alone to communicate an error",
          "instead": "pass `errorMessage` too. The border is 1px and the same width as every other state, so colour is the only difference without it"
        }
      ],
      "stylesheet": "src/styles/textarea.css",
      "tokens": [
        "--border-default",
        "--border-disabled",
        "--border-error-strong",
        "--border-focus",
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-regular",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-textarea-border-width",
        "--ion-textarea-font-size",
        "--ion-textarea-line-height",
        "--ion-textarea-padding-x",
        "--ion-textarea-padding-y",
        "--ion-textarea-radius",
        "--radius-md",
        "--radius-sm",
        "--spacing-12",
        "--spacing-16",
        "--spacing-6",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-page",
        "--text-disabled",
        "--text-secondary",
        "--text-tertiary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "size": {
          "type": "TextareaSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "autoCapitalize": {
          "type": "\"none\" | \"off\" | \"on\" | \"sentences\" | \"words\" | \"characters\" | undefined",
          "required": false,
          "origin": "own",
          "description": "Narrower than React's own typing, which also allows any `string` — the same\ncorrection Input carries. `autoCapitalize` is declared on\n`AriaTextFieldOptions` rather than `AriaTextFieldProps`, so the `keyof`\nomission above cannot reach it.",
          "values": [
            "none",
            "off",
            "on",
            "sentences",
            "words",
            "characters"
          ]
        },
        "rows": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Visible rows. Three matches the Figma frame, whose height is derived from\nthree lines of bound line-height rather than a fixed number — so this stays\ncorrect if the type ramp moves.",
          "default": 3
        },
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Field label. Renders the same `.ion-field` wrapper Input uses."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the field."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text when `isInvalid` is set."
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the control itself (`.ion-textarea`)."
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Class names for the `.ion-field` wrapper when a label or helper is shown."
        },
        "aria-activedescendant": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the currently active element when DOM focus is on a composite widget, textbox,\ngroup, or application."
        },
        "aria-autocomplete": {
          "type": "\"inline\" | \"list\" | \"none\" | \"both\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates whether inputting text could trigger display of one or more predictions of the user's\nintended value for an input and specifies how predictions would be presented if they are made.",
          "values": [
            "inline",
            "list",
            "none",
            "both"
          ]
        },
        "aria-haspopup": {
          "type": "boolean | \"true\" | \"false\" | \"menu\" | \"listbox\" | \"tree\" | \"grid\" | \"dialog\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Indicates the availability and type of interactive popup element, such as menu or dialog, that\ncan be triggered by an element."
        },
        "aria-controls": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) whose contents or presence are controlled by the current\nelement."
        },
        "enterKeyHint": {
          "type": "\"done\" | \"enter\" | \"go\" | \"next\" | \"previous\" | \"search\" | \"send\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "An enumerated attribute that defines what action label or icon to preset for the enter key on\nvirtual keyboards. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/enterkeyhint).",
          "values": [
            "done",
            "enter",
            "go",
            "next",
            "previous",
            "search",
            "send"
          ]
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input is disabled."
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input can be selected but not changed by the user."
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether user input is required on the input before form submission."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the input value is invalid."
        },
        "validationState": {
          "type": "ValidationState | undefined",
          "required": false,
          "origin": "aria",
          "values": [
            "valid",
            "invalid"
          ],
          "tags": {
            "deprecated": "Use `isInvalid` instead."
          }
        },
        "validationBehavior": {
          "type": "\"aria\" | \"native\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to use native HTML form validation to prevent form submission\nwhen the value is missing or invalid, or mark the field as required\nor invalid via ARIA.",
          "values": [
            "aria",
            "native"
          ],
          "tags": {
            "default": "'aria'"
          }
        },
        "validate": {
          "type": "((value: string) => ValidationError | true | null | undefined) | undefined",
          "required": false,
          "origin": "aria",
          "description": "A function that returns an error message if a given value is invalid.\nValidation errors are displayed to the user when the form is submitted\nif `validationBehavior=\"native\"`. For realtime validation, use the `isInvalid`\nprop instead."
        },
        "autoFocus": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether the element should receive focus on render."
        },
        "onFocus": {
          "type": "((e: FocusEvent<HTMLTextAreaElement, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element receives focus."
        },
        "onBlur": {
          "type": "((e: FocusEvent<HTMLTextAreaElement, Element>) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element loses focus."
        },
        "onFocusChange": {
          "type": "((isFocused: boolean) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the element's focus status changes."
        },
        "onKeyDown": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is pressed."
        },
        "onKeyUp": {
          "type": "((e: KeyboardEvent) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a key is released."
        },
        "placeholder": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Temporary text that occupies the text input when it is empty."
        },
        "value": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The current value (controlled)."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The default value (uncontrolled)."
        },
        "onChange": {
          "type": "((value: string) => void) | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the value changes."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Defines a string value that labels the current element."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that labels the current element."
        },
        "aria-describedby": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that describes the object."
        },
        "aria-details": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element (or elements) that provide a detailed, extended description for the\nobject."
        },
        "excludeFromTabOrder": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "aria",
          "description": "Whether to exclude the element from the sequential tab order. If true,\nthe element will not be focusable via the keyboard by tabbing. This should\nbe avoided except in rare scenarios where an alternative means of accessing\nthe element or its functionality via the keyboard is available."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The element's unique identifier. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/id)."
        },
        "autoComplete": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Describes the type of autocomplete functionality the input should provide if any. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefautocomplete)."
        },
        "maxLength": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The maximum number of characters supported by the input. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefmaxlength)."
        },
        "minLength": {
          "type": "number | undefined",
          "required": false,
          "origin": "aria",
          "description": "The minimum number of characters required by the input. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefminlength)."
        },
        "pattern": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Regex pattern that the value of the input must match to be valid. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefpattern)."
        },
        "type": {
          "type": "(string & {}) | \"search\" | \"text\" | \"url\" | \"tel\" | \"email\" | \"password\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "The type of input to render. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdeftype).",
          "tags": {
            "default": "'text'"
          }
        },
        "inputMode": {
          "type": "\"none\" | \"search\" | \"text\" | \"url\" | \"tel\" | \"email\" | \"numeric\" | \"decimal\" | undefined",
          "required": false,
          "origin": "aria",
          "description": "Hints at the type of data that might be entered by the user while editing the element or its\ncontents. See\n[MDN](https://html.spec.whatwg.org/multipage/interaction.html#input-modalities:-the-inputmode-attribute).",
          "values": [
            "none",
            "search",
            "text",
            "url",
            "tel",
            "email",
            "numeric",
            "decimal"
          ]
        },
        "autoCorrect": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An attribute that takes as its value a space-separated string that describes what, if any, type\nof autocomplete functionality the input should provide. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#autocomplete)."
        },
        "spellCheck": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "An enumerated attribute that defines whether the element may be checked for spelling errors.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/spellcheck)."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The name of the input element, used when submitting an HTML form. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#htmlattrdefname)."
        },
        "form": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "The `<form>` element to associate the input with.\nThe value of this attribute must be the id of a `<form>` in the same document.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#form)."
        },
        "onCopy": {
          "type": "ClipboardEventHandler<HTMLTextAreaElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user copies text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncopy)."
        },
        "onCut": {
          "type": "ClipboardEventHandler<HTMLTextAreaElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user cuts text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/oncut)."
        },
        "onPaste": {
          "type": "ClipboardEventHandler<HTMLTextAreaElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the user pastes text. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/onpaste)."
        },
        "onCompositionStart": {
          "type": "CompositionEventHandler<HTMLTextAreaElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system starts a new text composition session.\nSee [MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionstart_event)."
        },
        "onCompositionEnd": {
          "type": "CompositionEventHandler<HTMLTextAreaElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a text composition system completes or cancels the current text\ncomposition session. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionend_event)."
        },
        "onCompositionUpdate": {
          "type": "CompositionEventHandler<HTMLTextAreaElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when a new character is received in the current text composition\nsession. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/compositionupdate_event)."
        },
        "onSelect": {
          "type": "ReactEventHandler<HTMLTextAreaElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when text in the input is selected. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/Element/select_event)."
        },
        "onBeforeInput": {
          "type": "FormEventHandler<HTMLTextAreaElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is about to be modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/beforeinput_event)."
        },
        "onInput": {
          "type": "FormEventHandler<HTMLTextAreaElement> | undefined",
          "required": false,
          "origin": "aria",
          "description": "Handler that is called when the input value is modified. See\n[MDN](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event)."
        },
        "aria-errormessage": {
          "type": "string | undefined",
          "required": false,
          "origin": "aria",
          "description": "Identifies the element that provides an error message for the object."
        }
      },
      "propCounts": {
        "own": 9,
        "aria": 48,
        "dom": 251,
        "other": 0
      }
    },
    "ThemeZone": {
      "name": "ThemeZone",
      "source": "src/components/ThemeZone.tsx",
      "propsType": "ThemeZoneProps",
      "description": "ThemeZone — a part of the page in the other theme: a dark header on a\nlight page, a light document preview in a dark app.\n\n`data-theme` on any element re-declares every themed token beneath it,\nlight as well as dark, so the zone's components need nothing of their\nown. The zone adds what the attribute alone does not: text in the theme's\ncolour, native controls and scrollbars in its `color-scheme`, and menus,\npopovers, tooltips and dialogs opened from inside it in its theme rather\nthan the page's.",
      "import": "import { ThemeZone } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A part of the page in the other theme: a dark header on a light page, a light document preview in a dark app. Everything inside takes the zone's theme, including the menus, popovers, tooltips and dialogs opened from it.",
      "useWhen": [
        "one region should stay in one theme whatever the page's — a header that is always dark, a console or log that reads as a terminal",
        "previewing content in the theme it will be seen in — an email or a document that is light even in a dark app"
      ],
      "useInstead": [
        {
          "when": "the whole page should switch theme",
          "use": "`data-theme` on the root element",
          "why": "the root carries the page's theme; a zone is the exception inside it, not the switch"
        },
        {
          "when": "one element should stand out from the page — a callout, a highlighted row",
          "use": "Card",
          "why": "a surface token from the same theme does that; a zone in the other theme is for a region that belongs to it, not for emphasis"
        }
      ],
      "composition": {
        "order": [
          "ThemeZone"
        ],
        "note": "`theme` is required: `dark` or `light`. Without `contents` the zone is a block that paints the theme's page surface (`surface/page`) and text colour; with `contents` it draws no box, for wrapping a component that paints its own surface, like a Header, which stays the grid row or sticky element it was. Every themed token is re-declared on the zone, so the components inside need nothing of their own. Menus, popovers, tooltips and dialogs opened from inside it are portalled out of it, so its `overflow` cannot clip them, into a container in the zone's theme. Overlays the zone does not render, such as a navigation Drawer or command palette mounted by the shell, keep the page's theme. Zones nest. `as` picks the element when the zone is a box.",
        "example": "<ThemeZone theme=\"dark\" contents>\n  <Header brand={…} end={<NotificationsBell />} />\n</ThemeZone>\n\n<ThemeZone theme=\"light\" as=\"section\" aria-label=\"Email preview\">\n  <EmailPreview />\n</ThemeZone>"
      },
      "variants": {
        "theme": {
          "dark": {
            "use": "a region that is dark whatever the page is — a header, a terminal or log"
          },
          "light": {
            "use": "a region that stays light in a dark app — a preview of a document or email as it will be printed or sent"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "any",
          "note": "drawn in the zone's theme; overlays they open are too"
        }
      },
      "a11y": {
        "guarantees": [
          "every themed token is the zone's, so contrast inside it is the zone theme's: the pairs the contrast gate checks in each theme",
          "text, native controls and scrollbars follow the zone: its text colour and `color-scheme` are set on it",
          "an overlay opened inside it stays reachable when a dialog elsewhere hides the rest of the page"
        ],
        "requires": [
          "a name for the landmark — a heading inside it, or a label on it — when `as` makes the zone a `section` or `aside`; as a `div`, the default, it needs none"
        ],
        "notes": [
          "A zone changes colours, not meaning: it adds no role. The zone itself renders on a server, with its theme in the markup."
        ]
      },
      "antiPatterns": [
        {
          "dont": "`data-theme` on an element by hand",
          "do": "ThemeZone",
          "why": "the attribute alone leaves plain text in the page's colour, native controls in the page's scheme, and every menu or tooltip opened inside it in the page's theme"
        },
        {
          "dont": "a zone around a component that sits in a grid or is sticky, without `contents`",
          "do": "`contents`",
          "why": "the zone's box becomes the grid cell, and a sticky child can only stick inside it — the Header scrolls away with the page"
        },
        {
          "dont": "zones for emphasis, scattered through a page",
          "why": "a page switching theme region by region reads as broken; one zone is a region with its own job"
        }
      ],
      "stylesheet": "src/styles/theme-zone.css",
      "tokens": [
        "--surface-page",
        "--text-default"
      ],
      "props": {
        "theme": {
          "type": "Theme",
          "required": true,
          "origin": "own",
          "description": "The theme everything inside takes, whatever the page's is.",
          "values": [
            "light",
            "dark"
          ]
        },
        "contents": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Draw no box: the zone's children lay out as if it were not there — a\ngrid cell stays a grid cell, a sticky header stays sticky — and take its\ntheme all the same. For wrapping a component that paints its own\nsurface. Without it the zone is a block with the theme's page surface\nand text colour."
        },
        "as": {
          "type": "ThemeZoneElement | undefined",
          "required": false,
          "origin": "own",
          "description": "The element, when the zone is a box. Ignored with `contents`.",
          "values": [
            "div",
            "section",
            "nav",
            "header",
            "footer",
            "aside"
          ]
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "TimeField": {
      "name": "TimeField",
      "source": "src/components/TimeField.tsx",
      "propsType": "TimeFieldProps",
      "description": "TimeField — a time of day, typed into segments.\n\nDatePicker's text half, for times: hour, minute and, where the locale uses\none, AM/PM — each a spinbutton that takes digits or arrow keys. The order,\nthe separator and the 12 or 24-hour clock come from the reader's locale, so\n\"2:30 PM\" and \"14:30\" are the same field and the same value.\n\nWHY NOT A TEXT INPUT OR `<input type=\"time\">`\n\nFree text means guessing whether \"2:30\" is afternoon. The native control\ncannot be styled to match the other fields, and Safari's has no picker at\nall. Segments cannot hold a time that does not exist, and every browser\ndraws them the same.\n\nNo dropdown of times. A list of 96 quarter-hours is slower to scan than two\ndigits are to type, and it makes 09:07 impossible. Where only a few times\nare allowed, that is a Select, not a TimeField.",
      "import": "import { TimeField } from 'ionbase-ui';",
      "status": "stable",
      "since": "0.80.0",
      "summary": "A time of day typed into hour, minute and AM/PM segments in the reader's own format, taking and returning a 24-hour HH:MM string.",
      "useWhen": [
        "the user sets a time of day — a schedule, a reminder, the start of a window",
        "any minute is allowed, so a list of choices would be long or would leave times out",
        "beside a DatePicker only when the time is a separate, optional choice — a due date with an optional reminder time"
      ],
      "useInstead": [
        {
          "when": "the value is a date and a time — one moment, such as when a run starts",
          "use": "DateTimePicker",
          "why": "one field holds both and checks a bound such as \"not before now\" against the date and the time together; a TimeField beside a DatePicker leaves that rule to the caller"
        },
        {
          "when": "only a handful of times are allowed — delivery slots, office hours",
          "use": "Select",
          "why": "picking from four slots is a choice from a list, and a field that accepts any minute invites times the product will refuse"
        },
        {
          "when": "the value is a duration — 30 minutes, 2 hours",
          "use": "NumberInput",
          "why": "a duration is an amount, not a time of day; 02:00 as a length reads as two in the morning"
        },
        {
          "when": "the value is a calendar date",
          "use": "DatePicker"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense forms, table filters"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "prominent single-field forms"
          }
        },
        "granularity": {
          "hour": {
            "use": "on-the-hour schedules — the minute segment is not shown"
          },
          "minute": {
            "use": "the default — almost every time of day a person sets"
          },
          "second": {
            "use": "logs and precise triggers; the value becomes HH:MM:SS"
          }
        }
      },
      "a11y": {
        "guarantees": [
          "each segment is a spinbutton that announces its own name, value and bounds — 'hour, 2, minimum 1, maximum 12' — and takes digits or arrow keys",
          "each editable segment is its own tab stop, like DatePicker and the native time input; the separator is not",
          "the 12 or 24-hour clock, the separator and the AM/PM wording come from the reader's locale, so '2:30 PM' and '14:30' are the same field and the same value",
          "the segments sit in one labelled group, so the name is announced once on entry",
          "a time that does not exist cannot be typed",
          "a time outside `minValue`/`maxValue` turns the field invalid and names the bound in the reader's own format, without `isInvalid` — the value is kept, not silently clamped"
        ],
        "requires": [
          "`label`, or `aria-label` when no visible label is rendered",
          "a `description` saying which timezone the time is in, whenever the reader could be in a different one from the thing being scheduled"
        ],
        "notes": [
          "`value` is a wall-clock time with no date and no zone. Keep the timezone as its own field or setting; a time pinned to a `Date` drifts an hour at every daylight-saving change.",
          "The clock icon is decorative. There is no dropdown of times: two digits are faster to type than 96 quarter-hours are to scan, and a list makes 09:07 impossible."
        ]
      },
      "antiPatterns": [
        {
          "dont": "passing a Date, a timestamp or a locale-formatted string such as \"2:30 PM\" as `value`",
          "why": "the value is 24-hour HH:MM. A malformed value throws with the prop name rather than rendering an empty field"
        },
        {
          "dont": "forcing `hourCycle={12}` for everyone",
          "why": "a reader whose locale uses 24 hours has to translate every value, which is the misreading the segments exist to prevent. Leave it to the locale"
        },
        {
          "dont": "a schedule time with no timezone anywhere on the form",
          "why": "\"09:00\" is nine o'clock somewhere; if the reader and the workspace are in different zones, they will each assume their own"
        },
        {
          "dont": "using `minValue` and `maxValue` for a window that crosses midnight",
          "why": "22:00 to 02:00 has a minimum later than its maximum, so every time is refused; validate the window yourself and set `isInvalid`"
        }
      ],
      "stylesheet": "src/styles/time-field.css",
      "tokens": [],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Field label. Required for a usable control — see `a11y.requires`."
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the field when there is no visible `label`."
        },
        "description": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Helper text below the field — \"In the workspace's timezone\"."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Replaces the helper text while the field is invalid — `isInvalid`, or its own bounds check."
        },
        "isInvalid": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isReadOnly": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "isRequired": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "size": {
          "type": "TimeFieldSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Input's `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ]
        },
        "value": {
          "type": "string | null | undefined",
          "required": false,
          "origin": "own",
          "description": "The time as `HH:MM` (or `HH:MM:SS`), 24-hour, whatever the reader sees.\n`null` means no time is chosen. A wall-clock reading — no date, no\ntimezone. A malformed value throws rather than rendering an empty field."
        },
        "defaultValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The initial time, for an uncontrolled field."
        },
        "onChange": {
          "type": "((value: IsoTime | null) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Fires with `HH:MM` (`HH:MM:SS` at second granularity), or `null` when cleared."
        },
        "minValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Earliest allowed time, `HH:MM`. Later is not wrapped past midnight."
        },
        "maxValue": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Latest allowed time, `HH:MM`."
        },
        "granularity": {
          "type": "TimeFieldGranularity | undefined",
          "required": false,
          "origin": "own",
          "description": "Defaults to `minute`. `second` adds a seconds segment and value.",
          "values": [
            "minute",
            "second",
            "hour"
          ]
        },
        "hourCycle": {
          "type": "12 | 24 | undefined",
          "required": false,
          "origin": "own",
          "description": "12 or 24-hour display. Leave it out: the reader's locale already knows,\nand forcing 12-hour on a 24-hour reader is the misreading this field\nexists to prevent. The value is 24-hour either way."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Posts the value under this name, for an uncontrolled form."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "wrapperClassName": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 20,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Timeline": {
      "name": "Timeline",
      "source": "src/components/Timeline.tsx",
      "propsType": "TimelineProps",
      "description": "Timeline — what happened to a record, in order: an audit log, a record's\nhistory, a ticket's activity.\n\nAN ORDERED LIST, because the events happened in an order and a screen\nreader should say \"3 of 12\". It is named by its `aria-label`, and each\nevent reads as one sentence: what happened, who did it, when.\n\nNOT FOR AGENT STEPS. An agent run is AgentActivity: it has a live status\nper step and announces the one in progress. A Timeline is history — every\nevent in it is done, so it has no status and says nothing on its own.\n\nTHE TIME IS A REAL `<time>`. Its `dateTime` is the exact instant, so the\nlabel can be relative (\"2 hours ago\") without losing it. The default label\nis a medium date and a short time; pass `locale` and `timeZone` when it\nrenders on a server, or the server's and the browser's can disagree.\n\nTHE LINE AND DOTS ARE DECORATION. The markers, and an event's icon, are\nhidden from assistive tech; the kind of event is in its title.",
      "import": "import { Timeline } from 'ionbase-ui';",
      "status": "stable",
      "summary": "What happened to a record, in order — an audit log, a record's history, a ticket's activity. An ordered list of TimelineItems, each one sentence: what happened, who did it, and when, in a real `<time>`. History only: every event is done, so there is no status and nothing is announced.",
      "useWhen": [
        "a record's history — who changed what, and when",
        "an audit log a person reads to find out what happened, newest first"
      ],
      "useInstead": [
        {
          "when": "the steps of an agent run",
          "use": "AgentActivity",
          "why": "it has a status per step and announces the one in progress; a Timeline is history and says nothing on its own"
        },
        {
          "when": "things the user has to read or act on — mentions, approvals, alerts",
          "use": "NotificationsPanel",
          "why": "it tracks read and unread and marks them; a Timeline is a record, not an inbox"
        },
        {
          "when": "events the user sorts, filters or selects",
          "use": "Table",
          "why": "a log searched by column, exported or acted on in bulk is a table of events, not a story"
        },
        {
          "when": "the entries are turns in a conversation — what a person asked and what an assistant answered",
          "use": "ChatMessage",
          "why": "a transcript is turns people took, each named by who and when; a Timeline is changes to a record"
        }
      ],
      "composition": {
        "order": [
          "Timeline",
          "TimelineItem"
        ],
        "note": "Name the list with `aria-label` — or `aria-labelledby` pointing at the card or section heading above it. Order the items as they are read, usually newest first. Each item's `title` is what happened, as a sentence; `actor` is who; `timestamp` is when; children are the detail — what it changed from and to. For a long history, show the latest and put a \"Show older\" Button below the list.",
        "example": "<Timeline aria-label=\"History\"><TimelineItem title=\"Paused the agent\" actor=\"Ada Reyes\" timestamp={event.at} /></Timeline>"
      },
      "a11y": {
        "requires": [
          "`aria-label` or `aria-labelledby` — the list's name, the history of what",
          "a `title` that says what happened in words — the icon and the line are not read"
        ],
        "guarantees": [
          "it is an `<ol>`: a screen reader says how many events there are and which one it is on",
          "each event reads in one order — what happened, who, when — then its detail",
          "the time is a `<time>` with the exact instant in `dateTime`, whatever its label says",
          "the line, the markers and any icon are hidden from assistive tech",
          "it renders on a server: no hooks, no effects"
        ],
        "notes": [
          "pass `locale` and `timeZone` to each item when rendering on a server, or its default label can differ from the browser's"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<div className=\"timeline\">{events.map((e) => <div>…</div>)}</div>",
          "do": "<Timeline aria-label=\"History\">{events.map((e) => <TimelineItem … />)}</Timeline>",
          "why": "a column of divs is not a list: a screen reader cannot say how many events there are or skip past them"
        },
        {
          "dont": "a green dot for \"Approved\" and a red one for \"Rejected\", with the title \"Status changed\"",
          "do": "the title \"Approved the refund\" or \"Rejected the refund\"",
          "why": "the dots are hidden from assistive tech and fail WCAG 1.4.1 for everyone else; what happened belongs in the words"
        },
        {
          "dont": "<span>2 hours ago</span>",
          "do": "<TimelineItem timestamp={at} timestampLabel=\"2 hours ago\" … />",
          "why": "the relative label keeps the exact instant in `dateTime`, which a bare span throws away"
        }
      ],
      "stylesheet": "src/styles/timeline.css",
      "tokens": [
        "--border-default",
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-semibold",
        "--icon-secondary",
        "--icon-size-xs",
        "--ion-timeline-marker",
        "--radius-full",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-24",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--text-default",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "TimelineItems, in the order they are read — usually newest first."
        }
      },
      "propCounts": {
        "own": 1,
        "aria": 0,
        "dom": 280,
        "other": 0
      }
    },
    "TimelineItem": {
      "name": "TimelineItem",
      "source": "src/components/Timeline.tsx",
      "propsType": "TimelineItemProps",
      "import": "import { TimelineItem } from 'ionbase-ui';",
      "status": "stable",
      "summary": "One event in a Timeline: what happened, who did it, when — then the detail. An `<li>` with a decorative marker.",
      "useWhen": [
        "one event in a Timeline"
      ],
      "a11y": {
        "requires": [
          "a Timeline parent — an `<li>` outside a list is invalid",
          "a `title` in words: the icon is hidden, so it cannot be the only sign of what kind of event it was"
        ],
        "guarantees": [
          "the time is a `<time>` whose `dateTime` is the exact instant",
          "`timestampLabel` changes only the words shown, never the instant",
          "the separator between actor and time, the marker and the icon are hidden from assistive tech"
        ]
      },
      "antiPatterns": [
        {
          "dont": "title=\"purpose\" with the change as the detail",
          "do": "title=\"Changed the purpose\", and the old and new text as the detail",
          "why": "the title is what a screen reader hears first and what a person scans; a field name alone does not say what happened"
        }
      ],
      "stylesheet": "src/styles/timeline.css",
      "tokens": [
        "--border-default",
        "--border-strong",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-semibold",
        "--icon-secondary",
        "--icon-size-xs",
        "--ion-timeline-marker",
        "--radius-full",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-24",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--text-default",
        "--text-secondary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "title": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "What happened, as a sentence in the reader's language: \"Paused the agent\"."
        },
        "actor": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Who did it — a person's name, \"System\", an agent."
        },
        "timestamp": {
          "type": "string | Date",
          "required": true,
          "origin": "own",
          "description": "When it happened. Rendered in a `<time>` whose `dateTime` is the exact\ninstant, whatever the label says."
        },
        "timestampLabel": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "The text shown for the time, in place of the default date and time —\n\"2 hours ago\", or a date the app formats itself."
        },
        "locale": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Locale for the default label. Pass it when rendering on a server."
        },
        "timeZone": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Time zone for the default label, e.g. \"Europe/Berlin\"."
        },
        "icon": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "A decorative mark in the dot, for the kind of event. Hidden from assistive tech."
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "More about it — what changed from what, a reason, a link to the record."
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Toast": {
      "name": "Toast",
      "source": "src/components/Toast.tsx",
      "propsType": "ToastProps",
      "description": "Toast — Figma `Toast` (820:1655).\n\nNeutral chrome on `surface/raised`; the intent is carried by the icon alone.\nA tinted panel floating over unknown content competes with whatever is\nbehind it, and makes Toast and Alert indistinguishable at a glance.\n\nAUTO-DISMISS PAUSES ON HOVER AND FOCUS. A toast that keeps counting down\nwhile being read or while its action has keyboard focus takes the action\naway mid-reach — WCAG 2.2.1 asks for exactly this. The timer restarts rather\nthan resumes, which is the forgiving direction.",
      "import": "import { Toast } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A transient notification on neutral chrome. Intent is carried by the icon, not by a tinted panel. Show one with `const { toast, dismiss } = useToast()` inside a ToastProvider: `toast({ intent, title, message, action, duration })` queues it and returns its id, and `dismiss(id)` removes it. You rarely render `<Toast>` yourself: the provider renders the queue.",
      "useWhen": [
        "confirming that something the user just did succeeded or failed",
        "the message is short, transient, and not required to complete a task"
      ],
      "useInstead": [
        {
          "when": "the message must stay until the condition is resolved",
          "use": "Alert",
          "why": "a toast disappears, and anything the user must act on should not"
        },
        {
          "when": "the message belongs to one field",
          "use": "Input",
          "why": "Input's error text sits with the field it is about"
        },
        {
          "when": "you need a decision from the user",
          "use": "Modal",
          "why": "a toast offers one action and takes it away on a timer"
        }
      ],
      "variants": {
        "intent": {
          "neutral": {
            "use": "a plain fact with no status attached"
          },
          "primary": {
            "use": "brand-flavoured confirmations"
          },
          "success": {
            "use": "the action completed"
          },
          "warning": {
            "use": "it completed with a caveat worth knowing"
          },
          "error": {
            "use": "it failed — pair with a way to retry"
          },
          "information": {
            "use": "the default — a neutral update"
          }
        }
      },
      "slots": {
        "title": {
          "accepts": "text"
        },
        "message": {
          "accepts": "text"
        },
        "action": {
          "accepts": "{ label, onPress }",
          "note": "exactly one action. A second choice belongs in a Modal, which will still be there"
        }
      },
      "a11y": {
        "guarantees": [
          "auto-dismiss pauses on hover and on focus, and restarts rather than resumes — WCAG 2.2.1, and the forgiving direction",
          "announcement comes from ToastProvider's live region, which is always present rather than appearing with its content"
        ],
        "requires": [
          "rendering inside a ToastProvider — a Toast on its own is not in a live region",
          "`duration={null}` when the message carries an action the user genuinely needs"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a toast for an error the user must fix",
          "why": "it vanishes; the fix does not"
        },
        {
          "dont": "two actions in one toast",
          "why": "a timed choice is not a choice"
        },
        {
          "dont": "stacking several toasts for one operation",
          "why": "the stack covers the page and the last one is the only one read"
        },
        {
          "dont": "calling `useToast().show(...)` or `useToast().success(...)`",
          "why": "neither exists. `useToast()` returns `{ toast, dismiss }`: call `toast({ intent: 'success', title })`"
        }
      ],
      "stylesheet": "src/styles/toast.css",
      "tokens": [
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-error",
        "--icon-information",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-md",
        "--icon-success",
        "--icon-warning",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-shadow-lg",
        "--ion-toast-icon",
        "--radius-md",
        "--radius-sm",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-32",
        "--spacing-4",
        "--surface-hover",
        "--surface-raised",
        "--text-default",
        "--text-link",
        "--text-link-hover",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "onDismiss": {
          "type": "(id: string) => void",
          "required": true,
          "origin": "own"
        },
        "id": {
          "type": "string",
          "required": true,
          "origin": "own"
        },
        "intent": {
          "type": "ToastIntent | undefined",
          "required": false,
          "origin": "own",
          "values": [
            "neutral",
            "primary",
            "success",
            "warning",
            "error",
            "information"
          ],
          "default": "information"
        },
        "title": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "message": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "action": {
          "type": "{ label: string; onPress: () => void; } | undefined",
          "required": false,
          "origin": "own",
          "description": "One action. A second choice belongs in a dialog, which will still be there."
        },
        "duration": {
          "type": "number | null | undefined",
          "required": false,
          "origin": "own",
          "description": "Milliseconds before auto-dismiss. `null` keeps it until dismissed.",
          "default": 5000
        },
        "dismissLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "default": "Dismiss"
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "ToastProvider": {
      "name": "ToastProvider",
      "source": "src/components/Toast.tsx",
      "propsType": "ToastProviderProps",
      "description": "Renders the queue and supplies `useToast`.\n\nThe live region is the CONTAINER, declared once and always present. A region\nthat appears at the same moment as its content is not reliably announced —\nassistive tech has to be watching the node before the text lands in it.",
      "import": "import { ToastProvider } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Renders the toast queue and supplies the `useToast` hook. Mount once, near the app root.",
      "useWhen": [
        "the app shows toasts anywhere — this is the only way to get `useToast`"
      ],
      "useInstead": [
        {
          "when": "you only need one persistent in-page message",
          "use": "Alert",
          "why": "no provider, no queue, and it stays put"
        }
      ],
      "variants": {
        "placement": {
          "top-left": {
            "use": "rare — for RTL layouts or where the top-right is occupied"
          },
          "top-right": {
            "use": "the common desktop choice, out of the way of primary content"
          },
          "bottom-left": {
            "use": "keeps toasts clear of a right-hand panel"
          },
          "bottom-right": {
            "use": "close to where a save or submit action usually sits"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "any",
          "note": "your app — the provider wraps it. Anywhere inside, `const { toast, dismiss } = useToast()`: `toast(options)` takes Toast's own props (`intent`, `title`, `message`, `action`, `duration`, `dismissLabel`), queues one and returns its id; `dismiss(id)` removes it. Outside a ToastProvider, useToast throws."
        }
      },
      "a11y": {
        "guarantees": [
          "the live region is the container, declared once and always present — a region that appears at the same moment as its content is not reliably announced",
          "`limit` drops the oldest toasts, so the stack can never cover the page"
        ],
        "requires": [
          "mounting exactly one ToastProvider — a second live region competes with the first",
          "a `label` that is unique among the page's landmarks when the app has its own section called Notifications — the region is a landmark, and two with one name fail axe `landmark-unique`"
        ]
      },
      "antiPatterns": [
        {
          "dont": "mounting a ToastProvider per route or per page",
          "why": "toasts queued before the switch are unmounted mid-announcement, and two live regions announce over each other"
        },
        {
          "dont": "placing it inside a Modal",
          "why": "the toasts are removed from the tree when the modal closes, and hidden from assistive tech while it is open"
        },
        {
          "dont": "raising `limit` to keep everything",
          "why": "an unbounded stack covers the content the toasts are about"
        },
        {
          "dont": "calling `useToast().show(...)` or `useToast().success(...)`",
          "why": "neither exists. `useToast()` returns `{ toast, dismiss }`: call `toast({ intent: 'success', title })`"
        }
      ],
      "stylesheet": "src/styles/toast.css",
      "tokens": [
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--icon-error",
        "--icon-information",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-md",
        "--icon-success",
        "--icon-warning",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-shadow-lg",
        "--ion-toast-icon",
        "--radius-md",
        "--radius-sm",
        "--ring-focus",
        "--spacing-12",
        "--spacing-16",
        "--spacing-32",
        "--spacing-4",
        "--surface-hover",
        "--surface-raised",
        "--text-default",
        "--text-link",
        "--text-link-hover",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "placement": {
          "type": "ToastPlacement | undefined",
          "required": false,
          "origin": "own",
          "description": "Where the stack sits. Not a Figma variant — position is the queue's concern.",
          "values": [
            "top-left",
            "top-right",
            "bottom-left",
            "bottom-right"
          ]
        },
        "limit": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Oldest are dropped past this. Prevents an unbounded stack covering the page."
        },
        "label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The live region's accessible name. The region is a landmark, and landmark\nnames must be unique on a page: an app with its own section called\n\"Notifications\" fails axe `landmark-unique` on every page that has one\nunless this is changed."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Toggle": {
      "name": "Toggle",
      "source": "src/components/Toggle.tsx",
      "propsType": "ToggleProps",
      "description": "Toggle is a checkbox with `role=\"switch\"`.\n\nNot a button with aria-pressed: a switch is a form value, and the checkbox\ngives form association and `:checked` for free. `role=\"switch\"` changes only\nhow it is announced — \"on/off\" rather than \"checked/unchecked\" — which is\nwhat Figma's On/Off states describe.\n\nThe visual difference from Checkbox is entirely in CSS; this component and\nCheckbox are near-identical by design rather than by accident, and are kept\napart because Figma models them as separate components with separate size\nramps.",
      "import": "import { Toggle } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A switch — a checkbox with role=\"switch\", announced as on/off.",
      "useWhen": [
        "the change takes effect immediately: a setting, a feature flag, a preference"
      ],
      "useInstead": [
        {
          "when": "the value is collected and submitted with a form",
          "use": "Checkbox",
          "why": "a switch reads as taking effect now; a checkbox reads as a value being gathered"
        },
        {
          "when": "there are more than two states",
          "use": "RadioGroup"
        }
      ],
      "variants": {
        "size": {
          "sm": {
            "use": "dense settings rows and table filters"
          },
          "md": {
            "use": "the default"
          },
          "lg": {
            "use": "touch targets and prominent single settings"
          }
        },
        "intent": {
          "brand": {
            "use": "the default"
          },
          "neutral": {
            "use": "a setting with no positive or negative reading — display and layout preferences"
          },
          "danger": {
            "use": "turning on something destructive or hard to undo"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "text",
          "note": "the visible label; omit for a bare switch and supply `aria-label`"
        }
      },
      "a11y": {
        "role": "switch",
        "guarantees": [
          "a real <input type=\"checkbox\"> underneath, so form association, label clicking and `:checked` come from the platform",
          "`role=\"switch\"` changes only the announcement — \"on/off\" rather than \"checked/unchecked\""
        ],
        "requires": [
          "`children`, or `aria-label` when there is no visible label"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Toggle with a Save button beside it",
          "why": "a switch says the change is already applied; if it needs saving it is a Checkbox"
        },
        {
          "dont": "labelling a Toggle \"Enable dark mode?\" as a question",
          "why": "a switch label names the thing being switched, not a question about it"
        },
        {
          "dont": "a button with `aria-pressed` for a setting",
          "why": "a switch is a form value; the checkbox gives form association and `:checked` for free"
        },
        {
          "dont": "<Toggle isSelected={on} onChange={(isSelected) => setOn(isSelected)} />",
          "do": "<Toggle isSelected={on} onSelectionChange={setOn} />",
          "why": "as Checkbox: `isSelected` pairs with `onSelectionChange` and `checked` pairs with `onChange(event)`. Toggle extends the input element, so `onChange` keeps the DOM signature it has always had."
        }
      ],
      "deprecated": [
        {
          "prop": "disabled",
          "replacement": "isDisabled"
        }
      ],
      "stylesheet": "src/styles/toggle.css",
      "tokens": [
        "--border-disabled",
        "--border-focus",
        "--border-strong",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--ion-duration-base",
        "--ion-ease-in-out",
        "--ion-ease-out",
        "--ion-shadow-raised-flush-lg",
        "--ion-shadow-raised-flush-sm",
        "--ion-shadow-raised-flush-xs",
        "--ion-shadow-shadow-xs",
        "--ion-toggle-font-size",
        "--ion-toggle-gap",
        "--ion-toggle-inset",
        "--ion-toggle-line-height",
        "--ion-toggle-on",
        "--ion-toggle-raised",
        "--ion-toggle-thumb",
        "--ion-toggle-track-h",
        "--ion-toggle-track-w",
        "--radius-full",
        "--spacing-12",
        "--spacing-16",
        "--spacing-2",
        "--spacing-20",
        "--spacing-24",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-error",
        "--surface-inverse",
        "--surface-muted",
        "--surface-primary",
        "--text-disabled",
        "--text-secondary",
        "--type-body",
        "--type-body-line-height",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "size": {
          "type": "ToggleSize | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Size` variant: Small, Medium, Large.",
          "values": [
            "sm",
            "md",
            "lg"
          ],
          "default": "md"
        },
        "intent": {
          "type": "ToggleIntent | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Color` variant: Brand, Neutral, Danger.",
          "values": [
            "neutral",
            "danger",
            "brand"
          ],
          "default": "brand"
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether the toggle is disabled."
        },
        "disabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "tags": {
            "deprecated": "Use `isDisabled`. Accepted as an alias for one minor version."
          }
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own"
        },
        "isSelected": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "React Aria's name for `checked`. Wins when both are passed."
        },
        "onSelectionChange": {
          "type": "((isSelected: boolean) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Receives the new selection state rather than the change event."
        }
      },
      "propCounts": {
        "own": 7,
        "aria": 0,
        "dom": 304,
        "other": 0
      }
    },
    "Toggletip": {
      "name": "Toggletip",
      "source": "src/components/Toggletip.tsx",
      "propsType": "ToggletipProps",
      "description": "Toggletip — an \"ⓘ\" that opens an explanation on click, with room for a link.\n\nWHY NOT TOOLTIP, AND WHY NOT POPOVER\n\nA Tooltip opens on hover, closes on pointer-out and cannot be focused, so a\nlink in one is unreachable — and a hover-only hint is invisible on touch.\nA Popover is a dialog: it takes focus, traps it, and hides the rest of the\npage from assistive technology, which is a lot of ceremony for a sentence of\nhelp beside a label. A toggletip sits between: opened on purpose, it stays\nopen, and it is part of the page.\n\nHOW IT IS BUILT\n\n  - The bubble is rendered right after the button in the DOM, not portalled,\n    so the next Tab from the button lands on a link inside it. It is\n    positioned with `useOverlayPosition`, which measures against the\n    bubble's own containing block, so it still flips and stays on screen.\n  - Focus stays on the button when it opens. The bubble is a live region\n    that is always in the page — empty while closed — so its text is read\n    out on open without focus having to move. The same reason TableBatchBar\n    stays mounted: a live region inserted with its message is silent.\n  - It closes on Escape (focus back to the button), on a press outside, and\n    when focus leaves the button and bubble together. It never traps focus\n    and never hides the page — it is not modal.",
      "import": "import { Toggletip } from 'ionbase-ui';",
      "status": "stable",
      "summary": "An \"ⓘ\" button that opens a short explanation on click and keeps it open, so it can hold a link. Not modal, not hover.",
      "useWhen": [
        "a field label or a setting needs a sentence of explanation that most people will not need",
        "the explanation links onward — \"How retention works\" — which a Tooltip cannot hold"
      ],
      "useInstead": [
        {
          "when": "naming an icon-only control, or a hint with nothing to click in it",
          "use": "Tooltip",
          "why": "a tooltip needs no press to read; a toggletip for a one-word hint makes people click to learn what hovering would have told them"
        },
        {
          "when": "the content is a task — a form, a choice, anything more than a sentence and a link",
          "use": "Popover",
          "why": "that is a dialog: it takes focus, traps it and has a title; a toggletip is part of the page"
        },
        {
          "when": "the information is needed to fill the field in",
          "use": "Input",
          "why": "`description` is always visible; hiding required guidance behind an ⓘ makes people find it by failing first"
        },
        {
          "when": "the explanation is that content was generated by AI",
          "use": "AILabel",
          "why": "the mark is the button, and it requires saying what was generated and from what"
        }
      ],
      "composition": {
        "order": [
          "Toggletip",
          "Link"
        ],
        "note": "Place it right after the label text it explains. The children are a sentence or two and at most one Link or Button. Name it for its subject — `aria-label=\"About log retention\"`.",
        "example": "<>Keep run logs for <Toggletip aria-label=\"About log retention\">Logs past the period are deleted every night. <Link href=\"/docs/retention\">How retention works</Link></Toggletip></>"
      },
      "variants": {
        "placement": {
          "top": {
            "use": "the default — above the label, clear of the field below"
          },
          "bottom": {
            "use": "near the top of the viewport; it also flips here on its own"
          },
          "left": {
            "use": "beside a label at the end of a row"
          },
          "right": {
            "use": "beside a label at the start of a row"
          }
        },
        "size": {
          "sm": {
            "use": "beside body-sm text — field labels, table headers"
          },
          "md": {
            "use": "beside body text — headings of settings rows"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "text, Link, Button",
          "note": "the explanation; read out on open"
        }
      },
      "a11y": {
        "role": "button with aria-expanded and aria-controls, controlling a status region",
        "guarantees": [
          "opens on click, Enter or Space — never on hover, so it works the same on touch",
          "the bubble follows the button in the DOM, so the next Tab reaches a link inside it",
          "the bubble is a `role=\"status\"` live region that is in the page before it opens, so its text is read out on open while focus stays on the button",
          "Escape closes it and returns focus to the button; inside a Modal, the first Escape closes only the toggletip",
          "a press outside, or focus leaving the button and bubble together, closes it",
          "not modal: no focus trap, nothing on the page hidden from assistive technology",
          "a 24px target at both sizes, pulled into the line so the label is no taller for having one"
        ],
        "requires": [
          "`aria-label` naming what it explains — \"About log retention\", not \"Info\" or \"More information\""
        ],
        "notes": [
          "The bubble is rendered inline, not portalled — that is what keeps it in the Tab order. It is clipped only by an ancestor that is BOTH positioned and clipping (`position` other than static, with `overflow` hidden, auto or scroll): plain `overflow: hidden` does not clip an absolutely positioned descendant whose containing block is higher up. Table's scroll container is exactly that pair, so keep toggletips out of table headers; the demo's smoke test hit-tests the bubble's corners to catch the case.",
          "Inside a <label> or <legend> the button's name becomes part of that label: a RadioGroup reads \"Keep run logs for, About log retention\". Keep `aria-label` short and led by the subject so the addition informs rather than repeats."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Tooltip with a link in it",
          "do": "<Toggletip aria-label=\"About …\">…<Link>…</Link></Toggletip>",
          "why": "the tooltip closes as the pointer moves toward the link, and a keyboard user can never reach it"
        },
        {
          "dont": "`aria-label=\"Info\"` on every toggletip on the page",
          "why": "a list of buttons all called \"Info\" tells a screen reader user nothing about which is which"
        },
        {
          "dont": "a Toggletip holding a form or several actions",
          "do": "Popover",
          "why": "focus is not managed into the bubble, so a task inside it is easy to lose"
        },
        {
          "dont": "a Toggletip in a Table header",
          "why": "Table's scroll container is positioned and clips, so the bubble is cut off at the table's edge; explain the column above the table instead"
        }
      ],
      "stylesheet": "src/styles/toggletip.css",
      "tokens": [
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-regular",
        "--icon-default",
        "--icon-secondary",
        "--icon-size-md",
        "--icon-size-sm",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-shadow-lg",
        "--ion-toggletip-glyph",
        "--radius-full",
        "--radius-md",
        "--spacing-12",
        "--spacing-16",
        "--spacing-24",
        "--spacing-4",
        "--surface-hover",
        "--surface-raised",
        "--text-default",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "aria-label": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "Names the button for what it explains — \"About log retention\", not\n\"Info\". Required: the button holds nothing but a glyph."
        },
        "children": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The explanation. Text, and at most a Link or a Button."
        },
        "placement": {
          "type": "ToggletipPlacement | undefined",
          "required": false,
          "origin": "own",
          "description": "Where the bubble sits. Flips when there is no room.",
          "values": [
            "top",
            "bottom",
            "left",
            "right"
          ]
        },
        "size": {
          "type": "ToggletipSize | undefined",
          "required": false,
          "origin": "own",
          "description": "The button: 16px glyph beside body-sm text, 20px beside body.",
          "values": [
            "sm",
            "md"
          ]
        },
        "isOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "defaultOpen": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own"
        },
        "onOpenChange": {
          "type": "((isOpen: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 8,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "Toolbar": {
      "name": "Toolbar",
      "source": "src/components/Toolbar.tsx",
      "propsType": "ToolbarProps",
      "description": "Toolbar — a row of controls that act on one thing, reached as one stop.\n\nReact Aria's `useToolbar` gives it `role=\"toolbar\"` and `aria-orientation`,\narrow keys between its controls (mirrored in right-to-left), Tab that leaves\nthe whole toolbar rather than walking every button, and focus returned to the\ncontrol used last when Tab brings the user back. A toolbar inside a toolbar\nbecomes a `group`, so nesting one for grouping is safe.\n\nEvery control keeps its own tab stop in the DOM — React Aria moves focus to\nthe first or last control on Tab and lets the browser take it from there —\nso nothing here rewrites a child's `tabIndex`, and a control that mounts\nlater (the bulk actions appearing on selection) needs no registration.\n\nWHAT IT IS NOT FOR\n\nA filter bar. Tab leaves a toolbar in one press, so a search field inside one\nstrands the filters after it: Tab skips them and ← → belong to the caret.\nThat is WAI-ARIA's own caution about text fields in toolbars. A table's\nsearch and filters stay ordinary tab stops; the toolbar is the row of\nactions — the batch bar, a record's actions, an editor's formatting.",
      "import": "import { Toolbar } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A row of controls that act on one thing, reached as one tab stop: arrow keys between the controls, Tab out in one press, and back to the control used last.",
      "useWhen": [
        "a set of actions on one selection or one record — the batch bar over a table, a run's Resume/Pause/Stop, an editor's formatting",
        "the row has enough controls that tabbing through each one costs a keyboard user more than it gives"
      ],
      "useInstead": [
        {
          "when": "the row holds a search field or filters",
          "use": "Stack",
          "why": "a `direction=\"row\" wrap` Stack keeps every control its own tab stop — Tab leaves a toolbar in one press and ← → belong to the caret, so a text field inside strands every filter after it (WAI-ARIA's own caution)"
        },
        {
          "when": "two or three actions in a dialog, drawer or form footer",
          "use": "ButtonGroup",
          "why": "a few actions are expected to be a tab stop each; ButtonGroup gives them the gap, the order and a narrow-screen behaviour without the arrow-key model"
        },
        {
          "when": "two or three actions at the top of a page",
          "use": "PageHeader",
          "why": "its `actions` slot is a row of ordinary tab stops, which for a few buttons is what a keyboard user expects"
        },
        {
          "when": "the controls are one choice among options",
          "use": "SegmentedControl",
          "why": "that is a radio group with a selected value; a toolbar holds independent actions"
        }
      ],
      "composition": {
        "order": [
          "Toolbar",
          "Button",
          "Divider",
          "MenuTrigger"
        ],
        "note": "Buttons in the order they are read, a vertical Divider between groups, and a MenuTrigger with an icon-only Button last for the overflow. Name it with `aria-label` for what it acts on — \"Bulk actions\", not \"Toolbar\".",
        "example": "<Toolbar aria-label=\"Bulk actions\"><Button size=\"sm\" variant=\"secondary\">Pause</Button><Divider orientation=\"vertical\" /><Button size=\"sm\" variant=\"destructive\">Delete 3</Button></Toolbar>"
      },
      "variants": {
        "orientation": {
          "horizontal": {
            "use": "the default — ← → move between controls; wraps rather than overflowing"
          },
          "vertical": {
            "use": "a side rail of tools — ↑ ↓ move between controls"
          }
        }
      },
      "slots": {
        "children": {
          "accepts": "Button, MenuTrigger, SegmentedControl, Divider, Toolbar",
          "note": "a nested Toolbar becomes a named `group`, and the outer toolbar keeps the arrow keys"
        }
      },
      "a11y": {
        "role": "toolbar, with aria-orientation",
        "guarantees": [
          "React Aria's `useToolbar`: arrow keys move between controls, skipping disabled ones and separators, and are mirrored in right-to-left",
          "Tab leaves the whole toolbar in one press, and Shift+Tab or Tab back in returns to the control used last",
          "a text field, native select, combobox, slider or radio inside keeps its own arrow keys — the caret moves and the value changes instead of focus jumping",
          "↓ still reaches a MenuTrigger in a horizontal toolbar, so an overflow menu opens as it does anywhere else"
        ],
        "requires": [
          "`aria-label` or `aria-labelledby` naming what the toolbar acts on"
        ]
      },
      "antiPatterns": [
        {
          "dont": "a table's search field and filters inside a Toolbar",
          "why": "Tab from the search field leaves the toolbar, so the filters after it can only be reached by Shift+Tab and arrows"
        },
        {
          "dont": "a Toolbar with one button",
          "why": "a single stop gains nothing from arrow keys, and screen readers announce a toolbar the user has to learn"
        },
        {
          "dont": "an unnamed Toolbar",
          "why": "\"toolbar\" alone does not say what the buttons act on, and pages with two cannot be told apart"
        }
      ],
      "stylesheet": "src/styles/toolbar.css",
      "tokens": [
        "--spacing-4",
        "--spacing-8"
      ],
      "props": {
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the toolbar — \"Bulk actions\", \"Formatting\". Required, or `aria-labelledby`.",
          "tags": {
            "see": "aria-labelledby."
          }
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Identifies the element (or elements) that labels the current element.",
          "tags": {
            "see": "aria-describedby."
          }
        },
        "orientation": {
          "type": "ToolbarOrientation | undefined",
          "required": false,
          "origin": "own",
          "description": "Which arrow keys move between controls. Horizontal: ← →; vertical: ↑ ↓.",
          "values": [
            "vertical",
            "horizontal"
          ],
          "default": "horizontal"
        },
        "children": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Buttons, MenuTriggers, a SegmentedControl, and a vertical Divider between groups."
        }
      },
      "propCounts": {
        "own": 4,
        "aria": 0,
        "dom": 274,
        "other": 0
      }
    },
    "ToolCall": {
      "name": "ToolCall",
      "source": "src/components/ToolCall.tsx",
      "propsType": "ToolCallProps",
      "description": "ToolCall — the evidence behind one step of an agent run.\n\nAgentActivityStep says what the agent did in the user's language. ToolCall\nis where the person checking that account goes next: which tool, with what\narguments, and what came back. The title is still plain language and still\nrequired, so the technical detail supplements the account rather than\nreplacing it.\n\nCOLLAPSED BY DEFAULT, EXCEPT THE FAILURE\n\nA run makes dozens of calls, and a thread of expanded JSON is unreadable.\nBut `errorMessage` renders outside the disclosure: evidence that takes a\nclick to reach is evidence nobody reads, and a failure is the one thing in\nthe call a reader must not miss.\n\nTHE DETAILS ARE UNMOUNTED WHEN CLOSED — unlike Accordion, deliberately.\nAccordion keeps collapsed panels for the form state inside them; a tool call\nholds no user state, and its payloads can be megabytes of JSON multiplied by\nevery call in the thread.\n\nA CALL WITH NOTHING TO SHOW IS NOT A BUTTON. With no input and no output the\nheader renders as text: a disclosure that discloses nothing announces as\nexpandable and does nothing.\n\nTHE PAYLOADS ARE FOCUSABLE. Each is a scroll container capped in height, and\na scroll container a keyboard user cannot focus is one they cannot scroll.\n\nIt does not announce status changes. AgentActivity owns the run's live\nregion; a second one here would talk over it.",
      "import": "import { ToolCall } from 'ionbase-ui';",
      "status": "stable",
      "summary": "The evidence behind one step of an agent run: which tool, with what input, and what came back — collapsed by default, with any failure shown without a click.",
      "useWhen": [
        "a person may need to check how an agent reached a result — which search it ran, which record it wrote",
        "beneath or beside an AgentActivityStep, for the steps that called a tool",
        "debugging or audit views where the arguments matter as much as the outcome"
      ],
      "useInstead": [
        {
          "when": "the reader needs to know what the agent did, not how",
          "use": "AgentActivityStep",
          "why": "the plain-language log is the account; ToolCall is the evidence for it, and a thread of tool calls alone is a log nobody but an engineer can read"
        },
        {
          "when": "the call has not happened yet and needs permission",
          "use": "ApprovalGate",
          "why": "ToolCall reports a call; it does not ask whether to make one"
        }
      ],
      "variants": {
        "status": {
          "pending": {
            "use": "the default — queued, not started"
          },
          "active": {
            "use": "running now"
          },
          "done": {
            "use": "returned successfully"
          },
          "failed": {
            "use": "errored. Pass `errorMessage`, which shows without expanding"
          },
          "skipped": {
            "use": "not run — rejected at an ApprovalGate, or a cached result was used"
          }
        }
      },
      "slots": {
        "errorMessage": {
          "accepts": "text",
          "note": "what went wrong, in words — not a stack trace"
        }
      },
      "a11y": {
        "guarantees": [
          "the header is a disclosure button with aria-expanded; with no input and no output it is plain text instead, because a disclosure that discloses nothing is a dead control",
          "status is rendered as visually hidden text inside the header, and the glyph differs in shape per status",
          "each payload is a focusable, named scroll region, so a keyboard user can scroll a capped payload",
          "errorMessage renders outside the disclosure, so a failure is never hidden behind a click"
        ],
        "notes": [
          "it does not announce status changes — AgentActivity owns the run's live region, and a second one would talk over it",
          "the details unmount when collapsed, unlike Accordion: a tool call holds no user state and payloads can be large"
        ]
      },
      "antiPatterns": [
        {
          "dont": "title=\"search_invoices\"",
          "do": "title=\"Searched the invoice archive\" name=\"search_invoices\"",
          "why": "the function name describes the implementation; the title is what tells a reader whether the step was the right one"
        },
        {
          "dont": "passing input or output with credentials, tokens or personal data still in it",
          "why": "it renders exactly what it is given, to anyone who can see the thread"
        },
        {
          "dont": "defaultExpanded on every call in a run",
          "why": "a thread of open JSON buries the answer; open the one the reader is likely to check, or none"
        },
        {
          "dont": "status=\"failed\" with no errorMessage",
          "why": "a failure the reader cannot diagnose is just a red mark"
        }
      ],
      "stylesheet": "src/styles/tool-call.css",
      "tokens": [
        "--border-error-subtle",
        "--border-focus",
        "--border-subtle",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-mono",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-error",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-sm",
        "--icon-success",
        "--icon-tertiary",
        "--ion-duration-base",
        "--ion-duration-cycle",
        "--ion-ease-linear",
        "--ion-ease-out",
        "--radius-md",
        "--radius-sm",
        "--spacing-12",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-error-subtle",
        "--surface-hover",
        "--surface-muted",
        "--text-default",
        "--text-error",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "title": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "What the call did, in the user's language — \"Searched the invoice archive\".\nRequired: the tool's function name is not a description of the work."
        },
        "name": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The tool's technical name, shown in monospace beside the title."
        },
        "status": {
          "type": "AgentActivityStatus | undefined",
          "required": false,
          "origin": "own",
          "description": "The same vocabulary as AgentActivityStep, so the two never disagree.",
          "values": [
            "pending",
            "active",
            "done",
            "failed",
            "skipped"
          ]
        },
        "input": {
          "type": "unknown",
          "required": false,
          "origin": "own",
          "description": "What the agent passed in. Objects and arrays render as formatted JSON,\nstrings as they are. Redact secrets before passing it — this renders\nexactly what it is given."
        },
        "output": {
          "type": "unknown",
          "required": false,
          "origin": "own",
          "description": "What came back. Rendered as `input` is."
        },
        "errorMessage": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Why a `failed` call failed. Shown under the header whether or not the\ndetails are expanded — a failure that takes a click to read is not read."
        },
        "durationMs": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "How long the call took, in milliseconds."
        },
        "defaultExpanded": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Uncontrolled: whether the details start open."
        },
        "isExpanded": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Controlled: whether the details are open. Pass `onExpandedChange` with it."
        },
        "onExpandedChange": {
          "type": "((isExpanded: boolean) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "statusLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "The status in words, as AgentActivityStep's. Pass the translation for `status`."
        },
        "labels": {
          "type": "ToolCallLabels | undefined",
          "required": false,
          "origin": "own",
          "description": "Every other string the call renders, for translation. English by default."
        }
      },
      "propCounts": {
        "own": 12,
        "aria": 0,
        "dom": 277,
        "other": 0
      }
    },
    "Tooltip": {
      "name": "Tooltip",
      "source": "src/components/Tooltip.tsx",
      "propsType": "TooltipProps",
      "description": "Tooltip — Figma `Tooltip` (801:1568).\n\nWraps its trigger rather than taking a ref, so the common case is one\nelement deep: `<Tooltip label=\"...\"><Button/></Tooltip>`.\n\nFOCUS OPENS IT, NOT JUST HOVER. A hover-only tooltip is invisible to keyboard\nand switch users, and `useTooltipTrigger` wires both plus Escape to dismiss.\nIt also enforces one open tooltip at a time and a shared warmup, so moving\nalong a row of icon buttons does not flash a tooltip per button.\n\nThe trigger is cloned with the interaction props and a ref, which means it\nmust forward both. Every component in this library does; a bare `<div>` does\ntoo. A function component that drops its ref will render, but the tooltip\nwill have nothing to position against.",
      "import": "import { Tooltip } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A text-only hint attached to a trigger. Opens on hover and on focus.",
      "useWhen": [
        "naming an icon-only control",
        "adding a short clarification that is genuinely optional to read"
      ],
      "useInstead": [
        {
          "when": "the hint needs a link, or has to work on touch",
          "use": "Toggletip",
          "why": "it opens on a press and stays open, and its link is the next Tab stop"
        },
        {
          "when": "the content is a task — a field, a choice, several actions",
          "use": "Popover",
          "why": "a tooltip cannot be focused, cannot be scrolled, and closes the moment the pointer leaves"
        },
        {
          "when": "the information is required to complete the field",
          "use": "Input",
          "why": "Input's helper text is always visible; a tooltip is not discoverable on touch"
        },
        {
          "when": "the hint is the rest of a cut line of text",
          "use": "TruncatedText",
          "why": "it shows the tooltip only when the text is cut, makes the line a tab stop, and keeps the tooltip from being read twice"
        }
      ],
      "variants": {
        "placement": {
          "top": {
            "use": "above the trigger — the default reading position"
          },
          "bottom": {
            "use": "below the trigger, for something near the top of the viewport"
          },
          "left": {
            "use": "to the left of the trigger"
          },
          "right": {
            "use": "to the right of the trigger"
          }
        }
      },
      "slots": {
        "label": {
          "accepts": "text",
          "note": "the hint. Text only"
        },
        "title": {
          "accepts": "text",
          "note": "optional heading above the label"
        },
        "children": {
          "accepts": "Button",
          "note": "the trigger. Must forward both a ref and DOM props"
        }
      },
      "a11y": {
        "role": "tooltip",
        "guarantees": [
          "focus opens it, not only hover — a hover-only tooltip is invisible to keyboard and switch users",
          "Escape dismisses it",
          "one tooltip open at a time with a shared warmup, so moving along a row of icon buttons does not flash one per button",
          "`placement` flips to the opposite side when there is no room",
          "it is the trigger's accessible description, unless `describesTrigger={false}` — for a label that repeats the trigger's own text, as TruncatedText's does"
        ],
        "requires": [
          "a trigger that is already labelled for icon-only controls — Tooltip supplies the hint, not the trigger's name"
        ]
      },
      "antiPatterns": [
        {
          "dont": "putting a link or button inside `label`",
          "why": "it cannot be reached — the tooltip closes when the pointer leaves the trigger"
        },
        {
          "dont": "a tooltip as the only place information appears",
          "why": "it is undiscoverable on touch, where there is no hover"
        },
        {
          "dont": "a tooltip on a disabled Button",
          "why": "a disabled button takes no focus and often no pointer events, so the hint never appears for a keyboard user"
        }
      ],
      "stylesheet": "src/styles/tooltip.css",
      "tokens": [
        "--border-width-default",
        "--font-family-sans",
        "--font-weight-medium",
        "--font-weight-regular",
        "--ion-shadow-shadow-md",
        "--ion-tooltip-arrow",
        "--radius-sm",
        "--spacing-12",
        "--spacing-4",
        "--spacing-8",
        "--surface-inverse",
        "--text-inverse",
        "--type-body-sm",
        "--type-body-sm-line-height"
      ],
      "props": {
        "label": {
          "type": "React.ReactNode",
          "required": true,
          "origin": "own",
          "description": "The hint. A tooltip is text only — anything interactive or focusable\nbelongs in a popover, because a tooltip cannot be focused, cannot be\nscrolled, and closes the moment the pointer leaves."
        },
        "title": {
          "type": "React.ReactNode",
          "required": false,
          "origin": "own",
          "description": "Optional heading above the label."
        },
        "placement": {
          "type": "TooltipPlacement | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches the Figma `Placement` variant, and names where the TOOLTIP sits —\nnot where the arrow points. `top` is above the trigger, with the arrow on\nthe tooltip's bottom edge.\n\nTreated as a preference, not a guarantee: it flips to the opposite side\nwhen there is no room, which is the behaviour Figma cannot draw.",
          "values": [
            "top",
            "bottom",
            "left",
            "right"
          ]
        },
        "delay": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Milliseconds before it opens on hover. Focus always opens immediately."
        },
        "isDisabled": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Disable the tooltip without unmounting the trigger."
        },
        "describesTrigger": {
          "type": "boolean | undefined",
          "required": false,
          "origin": "own",
          "description": "Whether the tooltip is the trigger's accessible description. Default\ntrue. Set false only when the label repeats text the trigger already\ncontains — the full text of a truncated line — so a screen reader, which\nreads the whole text anyway, does not hear it twice."
        },
        "children": {
          "type": "React.ReactElement<unknown, string | React.JSXElementConstructor<any>>",
          "required": true,
          "origin": "own",
          "description": "The element the tooltip describes. Must accept a ref and DOM props."
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "ref": {
          "type": "React.Ref<HTMLElement> | undefined",
          "required": false,
          "origin": "own",
          "description": "Passed to the trigger with any other props, merged with the tooltip's\nown — so a Tooltip can itself be the child of a Popover or MenuTrigger,\nwhich clones its child with a ref and trigger props."
        }
      },
      "propCounts": {
        "own": 9,
        "aria": 0,
        "dom": 275,
        "other": 0
      }
    },
    "Tour": {
      "name": "Tour",
      "source": "src/components/Tour.tsx",
      "propsType": "TourProps",
      "description": "Tour — Coachmarks in a sequence: one at a time, each pointing at its\ntarget, with where the reader is (\"2 of 4\"), Back, Next, and Done on the\nlast. The close button and Escape end it; focus goes back to what started\nit, once, at the end.\n\nSTEPS ARE DATA\n\nA step names its target by `id`, so a tour is a list anyone can write\nwithout threading refs through the page. A target that is not on the page\nwhen the tour starts — removed, or hidden at a phone's width — is skipped,\nand the count is of the steps that remain: \"3 of 3\", never \"3 of 4\" with a\nstep that points at nothing.\n\nFINISHED IS NOT DISMISSED\n\n`onComplete` fires for Done on the last step only. Ended early, the tour\ncalls `onOpenChange(false)` alone, so a product can offer it again to\nsomeone who stopped halfway and not to someone who saw it through.",
      "import": "import { Tour } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Coachmarks in a sequence — one at a time, each pointing at its target, with \"2 of 4\", Back, Next, and Done on the last. Steps are data, naming their targets by id; a target not on the page is skipped.",
      "useWhen": [
        "a first look round a page someone asked to be shown — \"Take the tour\"",
        "several places matter in an order, and each is best explained beside it",
        "the product wants to know who saw it through: `onComplete` fires for Done only"
      ],
      "useInstead": [
        {
          "when": "one place, one thing to say",
          "use": "Coachmark",
          "why": "a tour of one has no count and no Back; a Coachmark says the same with less"
        },
        {
          "when": "the page itself is new to everyone and needs no pointing — an empty workspace",
          "use": "EmptyState",
          "why": "an empty page explains itself in place; a tour pointing at nothing yet is five dialogs for one sentence"
        },
        {
          "when": "telling people something changed without their asking",
          "use": "Banner",
          "why": "a tour takes focus at every step; opened unasked it takes over the page"
        }
      ],
      "composition": {
        "order": [
          "Tour"
        ],
        "note": "Start it from a button — `isOpen` from its press — and end it through `onOpenChange(false)`. Each step is `{ target: id, title, body, placement? }`. Remember a finished tour in `onComplete`, and offer it again to someone who ended it early. Every string is in `labels`; `progress` receives the current step and the total as numbers.",
        "example": "<Tour isOpen={touring} onOpenChange={setTouring} onComplete={markSeen} steps={[{ target: 'overview-range', title: 'Pick the period', body: 'Every figure follows the dates chosen here.' }]} />"
      },
      "a11y": {
        "requires": [
          "start it from a person's action, never on page load: every step takes focus",
          "an `id` on every target, and a `title` on every step — it names that step's dialog"
        ],
        "guarantees": [
          "one step at a time, each a Coachmark: a non-modal dialog named by its title, focused when it appears so each step is read out",
          "the count, Back and Next / Done sit in each step's footer; there is no Back on the first step, and one step alone has no count",
          "a target not on the page when the tour starts — removed, or not drawn at this width — is skipped, and the count is of the steps that remain",
          "Done on the last step calls `onComplete` and ends the tour; the close button (`labels.close`, \"End tour\") and Escape end it without completing it",
          "focus goes back to what started the tour once, when it ends, not between steps",
          "started again, it begins at the first step",
          "`onStepChange` receives the step's index in `steps`, so a skipped step never appears in it"
        ],
        "notes": [
          "The page is measured once, when the tour starts: a target that appears mid-tour is not added, so the count never changes under the reader."
        ]
      },
      "antiPatterns": [
        {
          "dont": "<Tour isOpen={!seen} … /> on first visit",
          "do": "a \"Take the tour\" button, with `seen` deciding only whether to offer it",
          "why": "a tour that opens itself takes focus from whoever arrived to do something else"
        },
        {
          "dont": "steps pointing at elements that exist only after a click in an earlier step",
          "do": "steps over what is on the page when the tour starts",
          "why": "the page is measured when the tour starts; a target that was not there is skipped, and the step with it"
        },
        {
          "dont": "marking the tour seen in `onOpenChange(false)`",
          "do": "`onComplete`",
          "why": "Escape on step one is not the same as reading it through; only one of them should stop the offer"
        }
      ],
      "stylesheet": null,
      "tokens": [],
      "props": {
        "steps": {
          "type": "TourStep[]",
          "required": true,
          "origin": "own",
          "description": "The steps, in order. A step whose target is not on the page is skipped."
        },
        "isOpen": {
          "type": "boolean",
          "required": true,
          "origin": "own",
          "description": "Running or not. Start it from a button — see Coachmark on why."
        },
        "onOpenChange": {
          "type": "(isOpen: boolean) => void",
          "required": true,
          "origin": "own",
          "description": "`false` when it ends: Done, the close button or Escape."
        },
        "onComplete": {
          "type": "(() => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "Done on the last step — finished, not abandoned. Remember it here."
        },
        "onStepChange": {
          "type": "((index: number) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "The step shown, by its index in `steps`."
        },
        "labels": {
          "type": "TourLabels | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 6,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "TreeGrid": {
      "name": "TreeGrid",
      "source": "src/components/TreeGrid.tsx",
      "propsType": "TreeGridProps",
      "description": "TreeGrid — rows that open to show the rows under them, in columns compared\ndown: a cost broken down by agent and run, a budget by team and person, a\nfolder listing with size and owner.\n\nHOW IT IS BUILT\n\n  - React Aria's table hooks over its tree-table state\n    (`UNSTABLE_useTreeGridState`): a `<table>` with `role=\"treegrid\"`.\n    Only open rows are rendered, flat, each with `aria-level`,\n    `aria-posinset`, `aria-setsize`, and `aria-expanded` when it has\n    children.\n  - One tab stop. ↑ ↓ move between rows, or between cells in a column; →\n    opens a closed row and, on an open one or a leaf, moves into its cells;\n    ← moves back out to the row, closes an open row, and on a closed one\n    moves to its parent. Home and End go to the first and last row, and\n    typing jumps to a row by its first column.\n  - The first column holds the tree and names the row: each row is indented\n    by its level, and a chevron, out of the tab order and named \"Expand\" or\n    \"Collapse\" in the user's locale, opens it with a pointer.\n  - It draws as a Table does — the same density, header, rules and\n    selected tint — and its selection is Table's: a Checkbox per row and a\n    select-all in the header. Selection does not cascade: a parent is a row\n    of its own.",
      "import": "import { TreeGrid } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Rows that open to show the rows under them, in columns compared down — spend by agent and then by task, a budget by team and then by person. The WAI-ARIA treegrid, drawn as a Table.",
      "useWhen": [
        "the records nest — a total and the parts it is made of — and each level has the same columns to compare",
        "the reader starts from the totals and opens only the branch that matters",
        "a TreeView would do but each row has figures beside its name — size, owner, cost"
      ],
      "useInstead": [
        {
          "when": "the hierarchy has one column — names only",
          "use": "TreeView",
          "why": "a tree grid's header and columns are overhead with nothing to compare down them"
        },
        {
          "when": "the rows are flat, with nothing under them",
          "use": "Table",
          "why": "a Table sorts, resizes, holds its header and first column, and needs no expand keys"
        },
        {
          "when": "a row opens to show more about that one record — its payload, its log",
          "use": "Table",
          "why": "that is a TableRow's `detail`: one record's own content, where a tree grid opens more rows of the same kind"
        },
        {
          "when": "the levels are sections of a page — groups of settings, a FAQ",
          "use": "Accordion",
          "why": "content under headings is not rows in columns"
        }
      ],
      "composition": {
        "order": [
          "TreeGrid"
        ],
        "note": "Pass the rows as `items`, each with an `id` unique in the whole tree and its `children`; pass `columns`, each with a `header` and a `cell(item)`. The first column holds the tree — the indent and the chevron — and is the row's name, so make it the name. `align: 'end'` for figures. Open the path to what matters with `defaultExpandedKeys`.",
        "example": "<TreeGrid aria-label=\"Spend by agent\" items={spend} columns={[{ id: 'name', header: 'Agent', cell: (r) => r.name }, { id: 'cost', header: 'Cost', cell: (r) => r.cost, align: 'end' }]} defaultExpandedKeys={['support']} onAction={openAgent} />"
      },
      "variants": {
        "density": {
          "compact": {
            "use": "a long breakdown scanned by eye, where more rows on screen matter more than room"
          },
          "default": {
            "use": "the default"
          },
          "relaxed": {
            "use": "a short breakdown where each row deserves attention"
          }
        },
        "selectionMode": {
          "none": {
            "use": "the default: rows are read and opened, and Enter or a press is `onAction`"
          },
          "single": {
            "use": "one row is the subject of what is beside the grid — a detail panel, a chart"
          },
          "multiple": {
            "use": "rows are picked for a bulk action; a Checkbox per row and a select-all in the header"
          }
        }
      },
      "slots": {
        "columns": {
          "accepts": "any",
          "note": "each column's `cell(item)` is the row's content in that column. The first column's cell names the row: keep it text, or give the item a `textValue`"
        },
        "renderEmptyState": {
          "accepts": "EmptyState",
          "note": "shown in one row across every column when `items` is empty; the grid keeps its name and header"
        }
      },
      "a11y": {
        "requires": [
          "`aria-label` or `aria-labelledby` — the grid's name, such as \"Spend by agent\"",
          "a unique `id` on every item across the whole tree — expansion and selection are held by id",
          "a first column whose cell is text, or a `textValue` on each item — it is the row's name and what typeahead matches"
        ],
        "guarantees": [
          "a `<table>` with `role=\"treegrid\"`: only open rows are rendered, flat, each with `aria-level`, `aria-posinset` and `aria-setsize` among its siblings, and `aria-expanded` only when it has rows under it",
          "one tab stop: ↑ ↓ move between rows, and between cells in a column; → opens a closed row and, on an open one or a leaf, moves into its cells and across them; ← moves back out to the row, closes an open row, and on a closed one or a leaf moves to its parent; Home and End go to the first and last row; typing jumps to a row by its first column",
          "each row is named by its first column's text alone — not the chevron's name with it",
          "each parent's chevron is a button named \"Expand\" or \"Collapse\" and the row's name, in the user's locale, out of the tab order; a press opens the row and puts the grid's focus on it",
          "`multiple` gives each row a Checkbox named \"Select\" and the row's name, and the header a \"Select All\" that is indeterminate when some rows are picked; a click on the drawn box changes it once",
          "select-all is every row that can be selected, open or not, and `onSelectionChange` always gets a Set of ids",
          "selection does not cascade: a parent is a row of its own",
          "a disabled item cannot be selected; it can still be focused, opened and activated, so the rows under it stay reachable",
          "every level indents by spacing/20 and a leaf keeps the chevron's width, so labels at one level line up; in right-to-left the indent is from the right, the chevron points left, and ← opens",
          "the focus ring is drawn inside the focused row or cell; in forced colours a selected row is Highlight, and its ring, chevron and checked box take HighlightText"
        ],
        "notes": [
          "It draws as a Table does — the same container, density, header, rules and selected tint — but it is not composed of TableRow and TableCell: the rows come from `items`, so the keyboard model can know the tree.",
          "No sorting and no column resizing. Sort `items` yourself, within each level, before passing them; a tree whose levels need resizable columns is a Table's job once flattened.",
          "Built on React Aria's tree-table state, which 3.50 ships as `UNSTABLE_useTreeGridState` behind a flag. TreeGrid turns the flag on; it gates that hook and nothing else."
        ]
      },
      "antiPatterns": [
        {
          "dont": "a Table whose rows are indented by hand, with a chevron button in the first cell",
          "do": "<TreeGrid items={…} columns={…} />",
          "why": "indentation is not structure: a screen reader hears a flat table with no levels, and the rows under a closed one are still there to land on"
        },
        {
          "dont": "a Table of TableRows, each with a `detail` that holds another Table",
          "do": "<TreeGrid> with the child rows as `children`",
          "why": "a nested table is a second grid with its own header and tab stop, and its columns do not line up with the parent's"
        },
        {
          "dont": "ids that repeat under different parents — two \"run-1\" rows under two agents",
          "do": "ids from the path or the record — \"support/run-1\", \"run_4821\"",
          "why": "expansion and selection are held by id, so two rows with one id open and select together"
        },
        {
          "dont": "a first column of icons or badges, with the name in the second",
          "do": "the name first",
          "why": "the first column holds the tree and names the row; a row named by an icon is announced as nothing"
        }
      ],
      "stylesheet": "src/styles/tree-grid.css",
      "tokens": [
        "--border-focus",
        "--border-width-thick",
        "--icon-default",
        "--icon-secondary",
        "--icon-size-sm",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-tree-grid-indent",
        "--ion-tree-grid-level",
        "--radius-xs",
        "--spacing-20",
        "--spacing-24",
        "--spacing-4",
        "--surface-hover",
        "--type-body-sm-line-height"
      ],
      "props": {
        "columns": {
          "type": "readonly TreeGridColumn<T>[]",
          "required": true,
          "origin": "own",
          "description": "The columns, in order. The first holds the tree — the indent, the\nchevron — and names each row."
        },
        "items": {
          "type": "readonly T[]",
          "required": true,
          "origin": "own"
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the grid. Required unless `aria-labelledby` is given."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "expandedKeys": {
          "type": "Iterable<string> | undefined",
          "required": false,
          "origin": "own",
          "description": "Rows that are open. Controlled."
        },
        "defaultExpandedKeys": {
          "type": "Iterable<string> | undefined",
          "required": false,
          "origin": "own",
          "description": "Rows open at first."
        },
        "onExpandedChange": {
          "type": "((keys: Set<string>) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "selectionMode": {
          "type": "TreeGridSelectionMode | undefined",
          "required": false,
          "origin": "own",
          "description": "`none` (default). `single`: Space or a press selects one row.\n`multiple`: a Checkbox in each row and one in the header for every row.",
          "values": [
            "multiple",
            "single",
            "none"
          ]
        },
        "selectedKeys": {
          "type": "Iterable<string> | undefined",
          "required": false,
          "origin": "own"
        },
        "defaultSelectedKeys": {
          "type": "Iterable<string> | undefined",
          "required": false,
          "origin": "own"
        },
        "onSelectionChange": {
          "type": "((keys: Set<string>) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "onAction": {
          "type": "((key: string) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "A row was activated — Enter, or a press when nothing is selectable."
        },
        "density": {
          "type": "TreeGridDensity | undefined",
          "required": false,
          "origin": "own",
          "description": "Matches Table's density: vertical padding only.",
          "values": [
            "default",
            "compact",
            "relaxed"
          ]
        },
        "renderEmptyState": {
          "type": "(() => React.ReactNode) | undefined",
          "required": false,
          "origin": "own",
          "description": "Shown, in a row of its own, when `items` is empty."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 16,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "TreeView": {
      "name": "TreeView",
      "source": "src/components/TreeView.tsx",
      "propsType": "TreeViewProps",
      "description": "TreeView — a hierarchy you open level by level: folders, an org chart,\nnested permissions.\n\nNOT FOR NAVIGATION. A site's own pages, nested, are Sidebar sections: links\nwith `aria-current`, reached by Tab. A tree is one tab stop that the arrow\nkeys move inside, which is right for data and wrong for a menu of pages.\n\nHOW IT IS BUILT\n\n  - React Aria's `useTree` and `useTreeItem`, over `useTreeState`: a\n    `treegrid` of flat rows, each with `aria-level`, `aria-expanded` and\n    its position among its siblings. One tab stop; ↑ ↓ move between visible\n    rows, → opens a row, ← closes it or moves to its parent, Home and End\n    go to the ends, and typing jumps to a label. A second → on an open row\n    stays put: in a treegrid → moves into the row's cells, not its children\n    — ↓ is the way down.\n  - The chevron is a button out of the tab order, named \"Expand\" or\n    \"Collapse\" in the user's locale, so a pointer can open a row that a\n    press would select.\n  - Only open rows are rendered. A closed branch's children are not in the\n    DOM, and the keyboard cannot land on them.\n  - `multiple` draws a check in each row — MultiSelect's box, for the same\n    reason: \"pick several\" has to look like it. Selection does not cascade:\n    a parent is an item of its own, and ticking it ticks only it.",
      "import": "import { TreeView } from 'ionbase-ui';",
      "status": "stable",
      "summary": "A hierarchy opened level by level — folders, an org chart, nested categories. One tab stop, arrow keys inside, only open rows rendered; selectable one or many.",
      "useWhen": [
        "the data is a hierarchy of unknown depth and the user needs to see where an item sits in it — a folder of knowledge files, teams within departments",
        "people pick one node, or several, from that hierarchy — a destination folder, the sources an agent may read",
        "the hierarchy is too large to show open, so levels open on demand"
      ],
      "useInstead": [
        {
          "when": "the hierarchy is the app's own pages — navigation",
          "use": "Sidebar",
          "why": "navigation is links reached by Tab, with `aria-current`. A tree is one tab stop with arrow keys inside, which hides every page from Tab and is announced as data, not as navigation"
        },
        {
          "when": "there are two levels and the second is short — sections of settings, FAQ groups",
          "use": "Accordion",
          "why": "a tree's keyboard model is overhead for a list of headings with content under each"
        },
        {
          "when": "the rows have columns — size, owner, modified",
          "use": "TreeGrid",
          "why": "TreeView is a single-column treegrid; nested rows with columns to compare down are a TreeGrid"
        },
        {
          "when": "the items are flat — records to open or pick, with no nesting",
          "use": "List",
          "why": "a tree's levels and expand keys are overhead when there is nothing to open"
        },
        {
          "when": "picking several values from a flat list",
          "use": "MultiSelect"
        }
      ],
      "a11y": {
        "requires": [
          "`aria-label` or `aria-labelledby` — the tree's name, such as \"Knowledge files\"",
          "a unique `id` on every item across the whole tree — keys are how selection and expansion are held",
          "a string `label`, or `textValue` when the label is not text — typeahead matches it and it is the row's name"
        ],
        "guarantees": [
          "a `treegrid` of flat rows, each with `aria-level`, `aria-posinset` and `aria-setsize` among its siblings, and `aria-expanded` only on rows that have children",
          "one tab stop: ↑ and ↓ move between visible rows, → opens a row, ← closes it or moves to its parent, Home and End go to the ends, and typing jumps to a matching label",
          "only open rows are rendered, so a closed branch cannot be landed on",
          "each parent's chevron is a button named \"Expand\" or \"Collapse\" in the user's locale, out of the tab order, and opens the row without selecting it",
          "`selectionMode=\"multiple\"` sets `aria-multiselectable` and draws a check in every row; the row's `aria-selected` is the announcement",
          "a disabled item is skipped by the arrow keys and cannot be selected",
          "`description` is read as the row's description",
          "every level indents by spacing/20, from the right in a right-to-left locale, where ← opens",
          "rows are at least 32px tall with a 24px chevron target; the keyboard focus ring is drawn on the row"
        ],
        "notes": [
          "→ on a row that is already open stays on it: in a treegrid → moves into a row's own cells, not its children. ↓ goes on to the first child",
          "selection does not cascade: selecting a parent selects that item only. A permissions tree where a parent means \"all of these\" needs the caller to expand the selection",
          "`onAction` fires on Enter, and on a press when nothing is selectable — for \"open this\": show the file, go to the folder. With `selectionMode=\"none\"` and no `onAction`, a press on a parent opens or closes it"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<TreeView items={pages} onAction={navigate} /> for the app's own sections",
          "do": "<Sidebar> with a SidebarSection per group",
          "why": "pages in a tree are hidden from Tab and read as data; Sidebar's rows are links"
        },
        {
          "dont": "a tree of nested Accordions or indented Checkboxes",
          "do": "<TreeView selectionMode=\"multiple\" items={…} />",
          "why": "nested accordions are a tab stop per heading, and indented checkboxes carry no level — a screen reader hears a flat list"
        },
        {
          "dont": "ids that repeat under different parents — two \"docs\" folders with id \"docs\"",
          "do": "ids from the path or the record — \"policies/docs\", \"fld_812\"",
          "why": "expansion and selection are held by id, so two rows with one id open, close and select together"
        },
        {
          "dont": "rendering every level open to show the whole hierarchy",
          "do": "`defaultExpandedKeys` for the path to what matters, the rest closed",
          "why": "a tree opened in full is a long flat list with indentation, and loses the one thing a tree gives: seeing the shape before the detail"
        }
      ],
      "stylesheet": "src/styles/tree-view.css",
      "tokens": [
        "--border-disabled",
        "--border-focus",
        "--border-primary-strong",
        "--border-stronger",
        "--border-width-default",
        "--border-width-thick",
        "--font-family-sans",
        "--font-weight-medium",
        "--icon-default",
        "--icon-disabled",
        "--icon-on-color",
        "--icon-primary",
        "--icon-secondary",
        "--icon-size-sm",
        "--ion-duration-base",
        "--ion-ease-out",
        "--ion-shadow-raised-flush-xs",
        "--ion-tree-view-indent",
        "--ion-tree-view-level",
        "--radius-md",
        "--radius-sm",
        "--radius-xs",
        "--spacing-16",
        "--spacing-2",
        "--spacing-20",
        "--spacing-24",
        "--spacing-32",
        "--spacing-4",
        "--spacing-8",
        "--surface-default",
        "--surface-disabled",
        "--surface-hover",
        "--surface-primary",
        "--surface-selected",
        "--surface-selected-hover",
        "--text-default",
        "--text-disabled",
        "--text-secondary",
        "--text-tertiary",
        "--type-body-sm",
        "--type-body-sm-line-height",
        "--type-caption",
        "--type-caption-line-height"
      ],
      "props": {
        "items": {
          "type": "readonly TreeViewItem[]",
          "required": true,
          "origin": "own"
        },
        "aria-label": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "Names the tree. Required unless `aria-labelledby` is given."
        },
        "aria-labelledby": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "expandedKeys": {
          "type": "Iterable<string> | undefined",
          "required": false,
          "origin": "own",
          "description": "Rows that are open. Controlled."
        },
        "defaultExpandedKeys": {
          "type": "Iterable<string> | undefined",
          "required": false,
          "origin": "own",
          "description": "Rows open at first."
        },
        "onExpandedChange": {
          "type": "((keys: Set<string>) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "selectionMode": {
          "type": "TreeViewSelectionMode | undefined",
          "required": false,
          "origin": "own",
          "description": "`none` (default): a row press opens or closes it, or fires `onAction`.\n`single` / `multiple`: a press selects, and `multiple` draws a check.",
          "values": [
            "multiple",
            "single",
            "none"
          ]
        },
        "selectedKeys": {
          "type": "Iterable<string> | undefined",
          "required": false,
          "origin": "own"
        },
        "defaultSelectedKeys": {
          "type": "Iterable<string> | undefined",
          "required": false,
          "origin": "own"
        },
        "onSelectionChange": {
          "type": "((keys: Set<string>) => void) | undefined",
          "required": false,
          "origin": "own"
        },
        "onAction": {
          "type": "((key: string) => void) | undefined",
          "required": false,
          "origin": "own",
          "description": "A row was activated — Enter, or a press when nothing is selectable. For\n\"open this\": navigating to a folder, showing a record."
        },
        "id": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 13,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    },
    "TruncatedText": {
      "name": "TruncatedText",
      "source": "src/components/TruncatedText.tsx",
      "propsType": "TruncatedTextProps",
      "description": "TruncatedText — long text cut to its space, with the whole of it still\nreachable.\n\nNOT HOVER ONLY. A `title` attribute, or a tooltip on hover, leaves the rest\nof the text to mouse users. Here a cut line is also a tab stop that opens\nthe same tooltip on focus, and a cut paragraph has a real Show more button.\n\nONLY WHEN IT IS CUT. It measures itself, and while the text fits it is\nplain text: no tab stop, no tooltip, no button. A narrower window can cut\nit later; it measures again when its size changes.\n\nTHE WHOLE TEXT IS ALWAYS THERE. It is cut by CSS, not by dropping\ncharacters, so a screen reader reads all of it, find-in-page finds it, and\na copy copies it. The tooltip repeats text already read, so it is kept out\nof the accessible description rather than read twice.\n\nIT INHERITS THE FONT. It sits inside whatever sets the type — a caption, a\ntable cell — and takes its size, weight and colour.",
      "import": "import { TruncatedText } from 'ionbase-ui';",
      "status": "stable",
      "summary": "Long text cut to its space — a line in a table cell, a paragraph under a heading — with the whole of it still reachable: a cut line is a tab stop with a tooltip on hover and focus, a cut paragraph has a Show more button. While the text fits it is plain text. The whole text is always in the DOM, so a screen reader reads all of it.",
      "useWhen": [
        "text of any length lands in a space of fixed size — a description in a table row, a name in a card, a summary in a list",
        "the text is written by users or agents, so no one can promise it is short"
      ],
      "useInstead": [
        {
          "when": "the text is short by construction — a status, a label you wrote",
          "use": "plain text",
          "why": "a label that is cut was written for a space it does not fit; shorten it instead"
        },
        {
          "when": "an ID or key the user needs whole",
          "use": "CopyButton",
          "why": "an ID is copied, not read; cutting it hides the part that tells two apart"
        },
        {
          "when": "a long block of code or output",
          "use": "CodeSnippet",
          "why": "it cuts by lines with Show more too, and keeps the code's spacing and a copy button"
        }
      ],
      "composition": {
        "order": [
          "TruncatedText"
        ],
        "note": "`children` is the text, as a string. It takes the font of whatever it is in — put a caption class on it or on its parent. It never widens what it sits in: in a table cell, a grid or a flex row it shrinks to the space it is given and is cut there. `lines` above 1 clamps by line count; `overflow=\"expand\"` swaps the tooltip for Show more, which a paragraph needs.",
        "example": "<TableCell><TruncatedText className=\"ion-text-caption\">{agent.purpose}</TruncatedText></TableCell>"
      },
      "variants": {
        "overflow": {
          "tooltip": {
            "use": "the default — a short line in a narrow place: the cut line is a tab stop, and the tooltip shows the whole text on hover and focus"
          },
          "expand": {
            "use": "a paragraph, with `lines` above 1 — Show more opens it in place, since a tooltip is too small to hold it"
          }
        }
      },
      "a11y": {
        "requires": [
          "plain text as `children` — nothing interactive inside a line that may be cut"
        ],
        "guarantees": [
          "the whole text is in the DOM, cut by CSS: a screen reader reads all of it, find-in-page finds it, a copy copies it",
          "a cut line is a tab stop, and focus opens the same tooltip as hover; Escape closes it",
          "the tooltip is not the text's description — it repeats what is already read, so a screen reader does not hear it twice",
          "Show more is a real button, with aria-expanded and aria-controls pointing at the text",
          "text that fits is plain text: no tab stop, no tooltip, no button; it measures again when its size changes"
        ],
        "notes": [
          "a `title` attribute is hover-only and never reaches a keyboard — this replaces it",
          "the focus ring is drawn inside the text's box, so a table's scroll container cannot clip it",
          "it never widens its container: a table column, a grid track or a flex item shrinks to its share and cuts it"
        ]
      },
      "antiPatterns": [
        {
          "dont": "<span title={purpose} className=\"truncate\">{purpose}</span>",
          "do": "<TruncatedText>{purpose}</TruncatedText>",
          "why": "a title appears on hover only, after a delay; a keyboard or touch user never gets the rest of the text"
        },
        {
          "dont": "{purpose.slice(0, 40) + '…'}",
          "do": "<TruncatedText>{purpose}</TruncatedText>",
          "why": "the rest is gone from the page: a screen reader, find-in-page and a copy all get the stub"
        },
        {
          "dont": "<TruncatedText overflow=\"tooltip\" lines={4}>{longDescription}</TruncatedText>",
          "do": "<TruncatedText overflow=\"expand\" lines={4}>{longDescription}</TruncatedText>",
          "why": "a tooltip holds a sentence; a paragraph in one is unreadable and closes when the pointer leaves"
        }
      ],
      "stylesheet": "src/styles/truncated-text.css",
      "tokens": [
        "--border-focus",
        "--border-width-thick",
        "--ion-truncated-lines",
        "--radius-xs",
        "--spacing-4"
      ],
      "props": {
        "children": {
          "type": "string",
          "required": true,
          "origin": "own",
          "description": "The whole text. Plain text only: it is what the tooltip shows, and a\nlink or a button inside a cut line would be cut with it."
        },
        "lines": {
          "type": "number | undefined",
          "required": false,
          "origin": "own",
          "description": "Lines shown before the text is cut. Default 1."
        },
        "overflow": {
          "type": "TruncatedTextOverflow | undefined",
          "required": false,
          "origin": "own",
          "description": "How the rest is reached once the text is cut.\n\n`tooltip` — the cut text becomes a tab stop and shows the whole text on\nhover and on focus. For short text in a narrow place: a table cell, a\ncard's subtitle.\n\n`expand` — a Show more button below it opens the text in place. For a\nparagraph, which a tooltip is too small to hold.",
          "values": [
            "tooltip",
            "expand"
          ]
        },
        "showMoreLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "`expand` only. Default \"Show more\"."
        },
        "showLessLabel": {
          "type": "string | undefined",
          "required": false,
          "origin": "own",
          "description": "`expand` only. Default \"Show less\"."
        },
        "placement": {
          "type": "TooltipPlacement | undefined",
          "required": false,
          "origin": "own",
          "description": "`tooltip` only: where the tooltip sits. Default `top`.",
          "values": [
            "top",
            "bottom",
            "left",
            "right"
          ]
        },
        "className": {
          "type": "string | undefined",
          "required": false,
          "origin": "own"
        }
      },
      "propCounts": {
        "own": 7,
        "aria": 0,
        "dom": 0,
        "other": 0
      }
    }
  }
}
