{
  "note": "Tattva components, generated from the documentation registry at build time. Import every component from the package index, never from deep paths.",
  "version": 1,
  "site": "https://lkb00.github.io/tattva/",
  "count": 228,
  "components": [
    {
      "id": "button",
      "name": "Button",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "btn",
        "call to action",
        "cta"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "loading",
          "note": "Pass busy: a spinner takes the place of the label at the same width, presses are ignored, aria-busy is set and busyLabel (default \"Working\") is read. It keeps focus and stays in the Tab order.",
          "source": "documented"
        },
        {
          "name": "disabled",
          "note": "Pass the native disabled attribute; it looks dimmed, ignores clicks and is skipped by keyboard.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The background shifts to a darker or tinted shade when the pointer is over it.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears when you reach it with the keyboard.",
          "source": "documented"
        },
        {
          "name": "pressed",
          "note": "While held down it sinks to --press-scale in --dur-instant and comes back in --dur-fast when let go. Off under reduced motion.",
          "source": "documented"
        }
      ],
      "summary": "A rounded button with a text label. It comes in five styles and three sizes, and can hold an icon.",
      "description": "Button is how people take an action. Its style shows how important the action is. Primary is dark and bold. Lime is a bright highlight for the one most positive action. Secondary has an outline. Ghost has no border. Danger is for actions that delete things. It sinks a little when pressed. Set busy while work runs after a press, and a spinner takes the place of the label. It never sends a form unless you ask it to.",
      "purpose": "The main way to take an action. Its style shows how much the action matters, so one strong button can lead a screen while quieter ones sit beside it.",
      "useFor": [
        "An action with a short verb label, like Send, Save or Regenerate.",
        "The one main action on a screen: use the primary or lime style.",
        "A second, quieter action next to the main one: use secondary or ghost.",
        "Deleting something, or anything that cannot be undone: use danger."
      ],
      "notFor": [
        {
          "text": "An action shown by an icon alone",
          "use": "icon-button"
        },
        {
          "text": "A setting that turns on or off right away",
          "use": "switch"
        },
        {
          "text": "Picking one option from a small set",
          "use": "segmented-control"
        }
      ],
      "whenToUse": [
        "Use one primary button per screen, for the action you most want people to take.",
        "Use lime for one positive highlight, with dark text. Lime is only ever a background.",
        "Use secondary for the second action beside a primary one. Use ghost for quiet toolbar actions.",
        "Start labels with a verb that says what happens, like \"Regenerate\" or \"Delete chat\".",
        "Use busy, not disabled, while a press is being handled, so focus stays on the button."
      ],
      "avoid": [
        "Do not use the danger style unless something is deleted or cannot be undone.",
        "Do not put two primary buttons side by side.",
        "Do not make a button amber. Amber is only for when a person has to act.",
        "Do not use an icon alone in a Button. For icon-only buttons, use IconButton with a label."
      ],
      "accessibility": [
        "It is a real button, so the Tab key reaches it and Enter and Space press it.",
        "A clear outline shows when someone tabs to it.",
        "A disabled button looks faded, cannot be pressed and is skipped by the Tab key.",
        "While busy it sets aria-busy, keeps focus and stays in the Tab order, and screen readers hear busyLabel after the label, which stays in the page but cannot be seen. Presses do nothing until busy is off.",
        "On press it sinks to --press-scale in --dur-instant and comes back in --dur-fast. IconButton has the same press.",
        "The press is off, and color changes are instant, for people who turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Write a label that says what happens, such as \"Delete chat\", not \"OK\".",
        "Set type to \"submit\" when the button should send a form. It does not by default.",
        "If you put only an icon inside, add a name for screen readers, or use IconButton instead.",
        "While busy, set busyLabel to what is happening, such as \"Saving\". Turn busy off when the work ends, and show the result or the error on the page."
      ],
      "onPhone": [
        "On a touch screen every button keeps the size it is drawn at and has an invisible tap area at least 44px tall and wide, so sm and md stay small but are easy to tap.",
        "The label never wraps, so a long label can run past a narrow container. Keep labels short or shorten them on a phone.",
        "Hover colours do nothing on a phone, so the pressed state is the only feedback: the button sinks a little under the finger and comes back when it lifts."
      ],
      "importPath": "import { Button } from \"./index\"",
      "sourcePath": "src/atoms/Button.tsx",
      "props": [
        {
          "name": "variant",
          "type": "\"primary\" | \"lime\" | \"secondary\" | \"ghost\" | \"danger\"",
          "required": false,
          "default": "\"primary\"",
          "description": "Visual weight of the button. Exported as ButtonVariant."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "default": "\"md\"",
          "description": "Height and padding: 32px, 36px or 44px. Exported as ButtonSize."
        },
        {
          "name": "leading",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Content rendered before the label, usually an icon."
        },
        {
          "name": "trailing",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Content rendered after the label."
        },
        {
          "name": "busy",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Work is running after a press. The label gives way to a spinner without changing the width, presses are ignored, and the button keeps focus and stays in the Tab order. Sets aria-busy."
        },
        {
          "name": "busyLabel",
          "type": "string",
          "required": false,
          "default": "\"Working\"",
          "description": "Read by screen readers while busy, for example \"Saving\"."
        },
        {
          "name": "type",
          "type": "\"button\" | \"submit\" | \"reset\"",
          "required": false,
          "default": "\"button\"",
          "description": "Native button type. Set to \"submit\" explicitly inside forms."
        },
        {
          "name": "ref",
          "type": "Ref<HTMLButtonElement>",
          "required": false,
          "default": null,
          "description": "The button element, for moving focus to it, for example onto the safe choice of a confirmation."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Merged after the variant and size classes, so it can override them."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The label."
        },
        {
          "name": "...rest",
          "type": "ButtonHTMLAttributes<HTMLButtonElement>",
          "required": false,
          "default": null,
          "description": "Passed to the underlying <button>, including onClick, disabled and aria-* attributes."
        }
      ],
      "variantsHint": {
        "variant": [
          "primary",
          "lime",
          "secondary",
          "ghost",
          "danger"
        ],
        "size": [
          "sm",
          "md",
          "lg"
        ],
        "type": [
          "button",
          "submit",
          "reset"
        ]
      },
      "tokens": [
        "--accent",
        "--accent-hover",
        "--on-accent",
        "--lime",
        "--lime-hover",
        "--on-lime",
        "--surface",
        "--bg",
        "--border",
        "--border-strong",
        "--hover",
        "--fg",
        "--fg-muted",
        "--danger",
        "--dur-instant",
        "--dur-fast",
        "--press-scale",
        "--focus-ring",
        "--radius-control"
      ],
      "related": [
        "icon-button",
        "badge"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Variants",
          "description": "Primary is the default. Use it once per screen. Lime is a small bright highlight with dark text. Ghost suits quiet actions in toolbars.",
          "code": "<div className=\"flex flex-wrap items-center gap-3\">\n  <Button variant=\"primary\">Primary</Button>\n  <Button variant=\"lime\">Lime</Button>\n  <Button variant=\"secondary\">Secondary</Button>\n  <Button variant=\"ghost\">Ghost</Button>\n  <Button variant=\"danger\">Delete chat</Button>\n</div>"
        },
        {
          "title": "Sizes",
          "description": "Small, medium and large. Medium is the default.",
          "code": "<div className=\"flex flex-wrap items-center gap-3\">\n  <Button size=\"sm\">Small</Button>\n  <Button size=\"md\">Medium</Button>\n  <Button size=\"lg\">Large</Button>\n</div>"
        },
        {
          "title": "With icons",
          "description": "An icon can sit before or after the label.",
          "code": "<div className=\"flex flex-wrap items-center gap-3\">\n  <Button leading={<PlusIcon width={14} height={14} />}>New chat</Button>\n  <Button variant=\"secondary\" trailing={<RefreshIcon width={14} height={14} />}>Regenerate</Button>\n  <Button variant=\"lime\" leading={<SparkleIcon width={14} height={14} />}>Ask AI</Button>\n</div>"
        },
        {
          "title": "Busy while saving",
          "description": "Press it and it shows a spinner for a moment. The button keeps its width, ignores more presses and keeps focus, so nothing jumps and a second press does not save twice.",
          "code": "function Example() {\n  const [busy, setBusy] = useState(false);\n  const save = () => {\n    setBusy(true);\n    window.setTimeout(() => setBusy(false), 1500);\n  };\n  return <Button busy={busy} busyLabel=\"Saving\" onClick={save}>Save changes</Button>;\n}"
        },
        {
          "title": "Disabled",
          "description": "A disabled button looks faded and cannot be pressed.",
          "code": "<div className=\"flex flex-wrap items-center gap-3\">\n  <Button disabled>Send</Button>\n  <Button variant=\"secondary\" disabled>Cancel</Button>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-button/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/button.md"
    },
    {
      "id": "icon-button",
      "name": "IconButton",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "icon only button",
        "action icon"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the active prop.",
          "source": "prop"
        }
      ],
      "summary": "A round button that shows only an icon. It always has a name, and it can show an on or off state.",
      "description": "IconButton is for actions shown by a small picture alone, like copy, regenerate, send and stop. It must have a label. The label is read by screen readers and shown as a tooltip on hover. Set active to make it an on-or-off button with a soft highlight.",
      "purpose": "A small round button for actions people know by their icon, such as copy, send or stop. There are no words on it, so it always takes a name that screen readers read and a mouse shows as a tooltip.",
      "useFor": [
        "Toolbar actions under a reply, like copy and regenerate.",
        "The send and stop buttons in a message box.",
        "An on-or-off action, like marking a reply as good: set active."
      ],
      "notFor": [
        {
          "text": "An action whose icon people may not recognise",
          "use": "button"
        },
        {
          "text": "A setting that stays on until someone turns it off",
          "use": "switch"
        }
      ],
      "whenToUse": [
        "Always give it a label that names the action, like \"Copy\" or \"Stop generating\".",
        "Use lime for the send button and primary for stop.",
        "Use active for real on-or-off buttons, and keep the label the same when the state changes.",
        "Use a slightly smaller icon in the small size."
      ],
      "avoid": [
        "Do not use IconButton when the icon is unclear. Use a Button with text.",
        "Do not use active to mark the page someone is on.",
        "Do not put text inside. The button is a fixed square.",
        "Do not make it amber. Amber is only for when a person has to act."
      ],
      "accessibility": [
        "Screen readers read the label as the button's name.",
        "The same label shows as a tooltip when a mouse hovers over it.",
        "When active is set, screen readers say whether it is pressed.",
        "It is a real button, so the Tab key reaches it, Enter and Space press it, and a clear outline shows on focus.",
        "A disabled button cannot be pressed. The lime send button stays visible but faded."
      ],
      "accessibilityYouDo": [
        "Give it a label that names the action, such as \"Stop generating\".",
        "Keep the label the same when active changes. Screen readers already say whether it is pressed.",
        "Leave active out for plain actions, so screen readers do not treat them as on-or-off buttons."
      ],
      "onPhone": [
        "On a touch screen the button keeps its size and has a tap area at least 44px tall and wide, even at size sm.",
        "The name only shows as a hover tooltip, which a phone never shows, so the icon must be clear without it.",
        "Leave room between neighbouring icon buttons. Each tap area is 44px wide, so buttons packed close together share the space between them."
      ],
      "importPath": "import { IconButton } from \"./index\"",
      "sourcePath": "src/atoms/Button.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name. Applied as aria-label and as the title tooltip."
        },
        {
          "name": "variant",
          "type": "\"ghost\" | \"secondary\" | \"primary\" | \"lime\"",
          "required": false,
          "default": "\"ghost\"",
          "description": "Visual style. There is no danger variant."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "default": "\"md\"",
          "description": "Square size, 28px (sm) or 36px (md)."
        },
        {
          "name": "active",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Sets aria-pressed and applies the soft accent background. Leave undefined for a plain action button."
        },
        {
          "name": "type",
          "type": "\"button\" | \"submit\" | \"reset\"",
          "required": false,
          "default": "\"button\"",
          "description": "Native button type."
        },
        {
          "name": "ref",
          "type": "Ref<HTMLButtonElement>",
          "required": false,
          "default": null,
          "description": "The button element, for moving focus to it."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Merged last, so it can override the defaults."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The icon, typically an icon from lib/icons at 14 to 16px."
        },
        {
          "name": "...rest",
          "type": "ButtonHTMLAttributes<HTMLButtonElement>",
          "required": false,
          "default": null,
          "description": "Passed to the underlying <button>."
        }
      ],
      "variantsHint": {
        "variant": [
          "ghost",
          "secondary",
          "primary",
          "lime"
        ],
        "size": [
          "sm",
          "md"
        ],
        "type": [
          "button",
          "submit",
          "reset"
        ]
      },
      "tokens": [
        "--accent",
        "--accent-hover",
        "--accent-soft",
        "--accent-fg",
        "--on-accent",
        "--lime",
        "--lime-hover",
        "--on-lime",
        "--surface",
        "--bg",
        "--border",
        "--hover",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--dur-fast",
        "--focus-ring",
        "--radius-control"
      ],
      "related": [
        "button",
        "switch"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Variants and sizes",
          "description": "Ghost is the default for message toolbars. There are medium and small sizes.",
          "code": "<div className=\"flex flex-wrap items-center gap-3\">\n  <IconButton label=\"Copy\"><CopyIcon width={16} height={16} /></IconButton>\n  <IconButton label=\"Regenerate\" variant=\"secondary\"><RefreshIcon width={16} height={16} /></IconButton>\n  <IconButton label=\"Search\" variant=\"primary\"><SearchIcon width={16} height={16} /></IconButton>\n  <IconButton label=\"New\" variant=\"lime\"><PlusIcon width={16} height={16} /></IconButton>\n  <IconButton label=\"Copy\" size=\"sm\"><CopyIcon width={14} height={14} /></IconButton>\n</div>"
        },
        {
          "title": "Send and stop",
          "description": "The send button is lime. When it is disabled it stays visible but faded, so the message box does not look empty.",
          "code": "<div className=\"flex items-center gap-3\">\n  <IconButton label=\"Send message\" variant=\"lime\"><SendIcon width={16} height={16} /></IconButton>\n  <IconButton label=\"Send message\" variant=\"lime\" disabled><SendIcon width={16} height={16} /></IconButton>\n  <IconButton label=\"Stop generating\" variant=\"primary\"><StopIcon width={16} height={16} /></IconButton>\n</div>"
        },
        {
          "title": "Toggle with active",
          "description": "Set active to show a pressed state. The button gets a soft highlight, and screen readers say it is pressed.",
          "code": "function Example() {\n  const [liked, setLiked] = useState(false);\n  return (\n    <IconButton label=\"Good response\" active={liked} onClick={() => setLiked((v) => !v)}>\n      <ThumbUpIcon width={16} height={16} />\n    </IconButton>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-icon-button/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/icon-button.md"
    },
    {
      "id": "badge",
      "name": "Badge",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "tag",
        "label",
        "pill",
        "status badge",
        "lozenge"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A small label that shows a status, a count or a category.",
      "description": "Badge is a small tag for short status text. It comes in several colors that match meaning, like green for success. It cannot be pressed, so it works inside headings, lists and tables. Keep the text to a word or two.",
      "purpose": "A word or two of status, count or category in a small pill. Its color backs up the meaning, and it cannot be pressed, so it fits inside headings, rows and tables.",
      "useFor": [
        "A status beside a title, like Draft or Failed.",
        "A count or a category in a list row.",
        "A small \"Beta\" or \"New\" tag next to a feature name."
      ],
      "notFor": [
        {
          "text": "Marking content made by AI",
          "use": "ai-badge"
        },
        {
          "text": "Showing that a person has to act",
          "use": "attention-dot"
        },
        {
          "text": "Something people should press",
          "use": "button"
        }
      ],
      "whenToUse": [
        "Use the neutral color for plain details. Save colors for states that mean something.",
        "Keep the label to one or two words.",
        "Use the accent color sparingly. Lime is a small highlight, not a background for body text.",
        "Always add words to a colored badge, so meaning does not depend on color."
      ],
      "avoid": [
        "Do not use the warning color to say a person must act. Use AttentionDot for that.",
        "Do not make a Badge pressable. Use a Button or a link.",
        "Do not put long sentences in it. They do not fit.",
        "Do not make the text lime."
      ],
      "accessibility": [
        "Screen readers read its text as part of the sentence around it.",
        "Each tone pairs a soft background with matching text that is easy to read.",
        "It is plain text, so the Tab key skips it."
      ],
      "accessibilityYouDo": [
        "Write the status in words. Color alone must not carry the meaning.",
        "Use the lime tone when the badge sits on an image, so the text stays readable."
      ],
      "onPhone": [
        "Badge is not tappable and does not change on a phone.",
        "Its text is 12px on touch screens.",
        "The pill has no width limit and does not truncate, so keep the text to a word or two."
      ],
      "importPath": "import { Badge } from \"./index\"",
      "sourcePath": "src/atoms/Badge.tsx",
      "props": [
        {
          "name": "tone",
          "type": "\"neutral\" | \"accent\" | \"lime\" | \"success\" | \"warning\" | \"danger\" | \"info\" | \"unsure\" | \"celebrate\"",
          "required": false,
          "default": "\"neutral\"",
          "description": "Colour role. Exported as Tone. Use lime, the opaque version of accent, when the badge sits on an image. unsure is a calm neutral for \"not fully sure\" or \"partly done\". celebrate is reserved for a goal fully reached, one place per product."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Merged after the base classes."
        },
        {
          "name": "...rest",
          "type": "HTMLAttributes<HTMLSpanElement>",
          "required": false,
          "default": null,
          "description": "Passed to the <span>, including children, id and aria-* attributes."
        }
      ],
      "variantsHint": {
        "tone": [
          "neutral",
          "accent",
          "lime",
          "success",
          "warning",
          "danger",
          "info",
          "unsure",
          "celebrate"
        ]
      },
      "tokens": [
        "--surface-sunken",
        "--fg-muted",
        "--border",
        "--lime",
        "--fg",
        "--success-soft",
        "--success-fg",
        "--warning-soft",
        "--warning-fg",
        "--danger-soft",
        "--danger-fg",
        "--info-soft",
        "--info-fg",
        "--radius-control"
      ],
      "related": [
        "ai-badge",
        "attention-dot",
        "section-label"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Tones",
          "description": "Neutral is the default. Accent is the lime highlight. The other colors match their meaning.",
          "code": "<div className=\"flex flex-wrap items-center gap-2\">\n  <Badge>Neutral</Badge>\n  <Badge tone=\"accent\">Accent</Badge>\n  <Badge tone=\"success\">Success</Badge>\n  <Badge tone=\"warning\">Warning</Badge>\n  <Badge tone=\"danger\">Danger</Badge>\n  <Badge tone=\"info\">Info</Badge>\n</div>"
        },
        {
          "title": "With an icon",
          "description": "An icon and text sit side by side.",
          "code": "<Badge tone=\"info\"><SparkleIcon width={10} height={10} />Beta</Badge>"
        },
        {
          "title": "Not sure, and a goal reached",
          "description": "Unsure is a calm neutral for \"not fully sure\" or \"partly done\". It is not a warning. Celebrate is for one thing only: a goal fully reached.",
          "code": "<div className=\"flex flex-wrap items-center gap-2\">\n  <Badge tone=\"unsure\">Medium confidence</Badge>\n  <Badge tone=\"unsure\">Partly refunded</Badge>\n  <Badge tone=\"celebrate\">Refunded in full</Badge>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-badge/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/badge.md"
    },
    {
      "id": "avatar",
      "name": "Avatar",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "profile picture",
        "user image",
        "persona"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A round picture that shows who wrote a message: a letter for a person, a dark circle with a lime spark for the assistant.",
      "description": "Avatar shows who wrote a message. A person gets the first letter of their name in a light circle. The assistant gets a dark circle with a lime sparkle. Lime fills a circle nowhere else. Screen readers read it as an image with a name.",
      "purpose": "Shows who wrote a message. A person gets the first letter of their name; the assistant gets a dark circle with a lime spark, so its turns are easy to spot at a glance.",
      "useFor": [
        "Beside each assistant reply in a conversation.",
        "Next to a person's name in a list or a header.",
        "In tight rows such as a chat list: use the small size."
      ],
      "notFor": [
        {
          "text": "Several people shown together",
          "use": "avatar-stack"
        },
        {
          "text": "Showing a status or that something needs a person",
          "use": "attention-dot"
        },
        {
          "text": "Showing whether a voice assistant is listening or speaking",
          "use": "ai-presence"
        }
      ],
      "whenToUse": [
        "Use the person's real name so the letter and the screen reader name are right.",
        "Use small in tight lists and medium beside messages.",
        "Use the assistant style wherever the assistant speaks, so people recognise it.",
        "Keep the fixed sizes so avatars stay round."
      ],
      "avoid": [
        "Do not use lime for a person. A lime spark on a circle is only for the assistant.",
        "Do not expect a full name to show. Only the first letter appears.",
        "Do not use Avatar to show status. Use AttentionDot or Badge.",
        "Do not change the colors of the assistant avatar."
      ],
      "accessibility": [
        "Screen readers read it as an image named \"Assistant\" or the person's name.",
        "The letter and the sparkle are part of that image, so they are not read out on their own.",
        "It cannot be pressed, and the Tab key skips it."
      ],
      "accessibilityYouDo": [
        "Pass the person's real name, so the letter and the spoken name are right. Without it, screen readers hear \"You\".",
        "Wrap it in a button or link yourself if it should open something."
      ],
      "onPhone": [
        "Avatar is a fixed 24px or 32px circle and does not change on a phone.",
        "It is not a control. If it opens a profile or menu, wrap it in a button, which gets a 44px target on touch screens."
      ],
      "importPath": "import { Avatar } from \"./index\"",
      "sourcePath": "src/atoms/Avatar.tsx",
      "props": [
        {
          "name": "kind",
          "type": "\"user\" | \"ai\"",
          "required": true,
          "default": null,
          "description": "Selects the person or assistant treatment."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "default": "\"You\"",
          "description": "Used for the initial and for the accessible name of a user avatar. Ignored when kind is \"ai\"."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "default": "\"md\"",
          "description": "24px (sm) or 32px (md)."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Merged after the base classes."
        }
      ],
      "variantsHint": {
        "kind": [
          "user",
          "ai"
        ],
        "size": [
          "sm",
          "md"
        ]
      },
      "tokens": [
        "--code",
        "--lime",
        "--border",
        "--border-strong",
        "--surface-sunken",
        "--fg-muted",
        "--font-serif"
      ],
      "related": [
        "ai-badge",
        "badge"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "User and assistant",
          "description": "The kind picks the look. The name only matters for a person.",
          "code": "<div className=\"flex items-center gap-3\">\n  <Avatar kind=\"user\" name=\"Lokesh\" />\n  <Avatar kind=\"ai\" />\n</div>"
        },
        {
          "title": "Sizes",
          "description": "Small and medium. The sparkle shrinks with it.",
          "code": "<div className=\"flex items-center gap-3\">\n  <Avatar kind=\"user\" name=\"Mira\" size=\"sm\" />\n  <Avatar kind=\"user\" name=\"Mira\" size=\"md\" />\n  <Avatar kind=\"ai\" size=\"sm\" />\n  <Avatar kind=\"ai\" size=\"md\" />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-avatar/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/avatar.md"
    },
    {
      "id": "spinner",
      "name": "Spinner",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "loader",
        "loading indicator",
        "progress circle",
        "activity indicator"
      ],
      "category": "states",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A small spinning ring that shows something is loading.",
      "description": "Spinner is a small ring that spins while something loads. Use it for short waits inside a button or a row. If you know what the content will look like, use Skeleton instead.",
      "purpose": "A small spinning ring for a short wait when you cannot show what is coming. It is marked as a status, so screen readers can find what is loading.",
      "useFor": [
        "A short wait inside a button, like Saving.",
        "A row or small area that is loading and has no known shape.",
        "Next to status text for a step that takes a while."
      ],
      "notFor": [
        {
          "text": "Content whose shape you already know",
          "use": "skeleton"
        },
        {
          "text": "The wait before the assistant's first words",
          "use": "typing-indicator"
        },
        {
          "text": "Naming the step the assistant is on",
          "use": "shimmer-text"
        }
      ],
      "whenToUse": [
        "Use it for waits of a second or more when nothing better fits.",
        "Give it a specific label like \"Saving\" when more than one can be on screen.",
        "Add visible text when the wait belongs to a button or a row.",
        "Match its size to the text beside it."
      ],
      "avoid": [
        "Do not use a Spinner when a Skeleton could show the shape of the content.",
        "Do not use it while the assistant writes a reply. Use TypingIndicator before the reply starts and ShimmerText for status text.",
        "Do not show many spinners in one list. Show one for the whole area.",
        "Do not make it lime or amber."
      ],
      "accessibility": [
        "It is marked as a status and named by its label, \"Loading\" by default.",
        "The ring stops spinning for people who turn off motion in their system settings.",
        "It cannot be pressed, and the Tab key skips it."
      ],
      "accessibilityYouDo": [
        "Give it a specific label, like \"Saving\", when the default \"Loading\" is too vague or more than one is on screen.",
        "Show one spinner for an area, not one per item, so screen readers do not meet the same status many times."
      ],
      "onPhone": [
        "Spinner is a fixed pixel size and looks the same on a phone.",
        "It is not tappable, so give people another way to cancel a long wait."
      ],
      "importPath": "import { Spinner } from \"./index\"",
      "sourcePath": "src/atoms/Spinner.tsx",
      "props": [
        {
          "name": "size",
          "type": "number",
          "required": false,
          "default": "16",
          "description": "Width and height in pixels."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Loading\"",
          "description": "Accessible name announced for the status."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg-muted"
      ],
      "related": [
        "skeleton",
        "typing-indicator",
        "shimmer-text"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Sizes",
          "description": "Four sizes, from small to large.",
          "code": "<div className=\"flex items-center gap-4\">\n  <Spinner size={14} />\n  <Spinner />\n  <Spinner size={24} />\n  <Spinner size={32} />\n</div>"
        },
        {
          "title": "Inside a button",
          "description": "Place it before a button's label to show something is happening. The label should say what.",
          "code": "<Button variant=\"secondary\" disabled leading={<Spinner size={14} label=\"Saving\" />}>\n  Saving\n</Button>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-spinner/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/spinner.md"
    },
    {
      "id": "skeleton",
      "name": "Skeleton",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "placeholder",
        "shimmer",
        "loading placeholder",
        "ghost element"
      ],
      "category": "states",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A shimmering gray block that holds the place of content while it loads.",
      "description": "Skeleton is a gray block with a soft moving shine. It has no size of its own, so you set its width and height. Use several to sketch the shape of what is about to appear.",
      "purpose": "A gray block with a soft moving shine that holds the space of content still loading. Several together sketch the shape of what is coming, so the page does not jump when it arrives.",
      "useFor": [
        "Lines of text that are loading: vary the widths.",
        "A list row with a picture and text, before the data arrives.",
        "Cards or images whose size you already know."
      ],
      "notFor": [
        {
          "text": "A short wait inside a button",
          "use": "spinner"
        },
        {
          "text": "The wait before the assistant replies",
          "use": "typing-indicator"
        },
        {
          "text": "A load that failed",
          "use": "error-state"
        }
      ],
      "whenToUse": [
        "Match the size and position of the real content, so the page does not jump.",
        "Vary line lengths so it looks like real text.",
        "Use a round block for pictures of people and gently rounded blocks for text and images.",
        "Replace the whole group with real content at once.",
        "Set a width and height on each block. Without them it has no size and shows nothing."
      ],
      "avoid": [
        "Do not leave blocks on screen after something fails. Show an error message instead.",
        "Do not use it inside a button. Use Spinner.",
        "Do not put text inside a Skeleton. Screen readers cannot see it.",
        "Do not use a Skeleton and a Spinner for the same area."
      ],
      "accessibility": [
        "Screen readers skip it.",
        "The shine stops for people who turn off motion in their system settings.",
        "It cannot be pressed, and the Tab key skips it."
      ],
      "accessibilityYouDo": [
        "Tell screen readers the area is loading, for example with a hidden status message. The blocks themselves are skipped."
      ],
      "onPhone": [
        "Skeleton has no size of its own. It fills the width and height you give it, so use classes that fit a narrow screen.",
        "The shimmer runs on a phone unless the person has asked for less motion."
      ],
      "importPath": "import { Skeleton } from \"./index\"",
      "sourcePath": "src/atoms/Skeleton.tsx",
      "props": [
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Sets the size and any radius override. Without a height and width the block collapses to zero."
        },
        {
          "name": "...rest",
          "type": "HTMLAttributes<HTMLDivElement>",
          "required": false,
          "default": null,
          "description": "Passed to the <div>."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken",
        "--border",
        "--animate-shimmer"
      ],
      "related": [
        "spinner",
        "shimmer-text",
        "typing-indicator"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Text lines",
          "description": "Make the lines different lengths so it looks like a paragraph.",
          "code": "<div className=\"flex w-72 flex-col gap-2\">\n  <Skeleton className=\"h-3 w-full\" />\n  <Skeleton className=\"h-3 w-11/12\" />\n  <Skeleton className=\"h-3 w-2/3\" />\n</div>"
        },
        {
          "title": "Message row",
          "description": "A round block for a picture, with two lines of text beside it.",
          "code": "<div className=\"flex w-72 items-start gap-3\">\n  <Skeleton className=\"size-8 rounded-full\" />\n  <div className=\"flex flex-1 flex-col gap-2\">\n    <Skeleton className=\"h-3 w-24\" />\n    <Skeleton className=\"h-3 w-full\" />\n  </div>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-skeleton/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/skeleton.md"
    },
    {
      "id": "switch",
      "name": "Switch",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "toggle",
        "toggle switch",
        "on off"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the checked prop.",
          "source": "prop"
        },
        {
          "name": "disabled",
          "note": "Set with the disabled prop.",
          "source": "prop"
        }
      ],
      "summary": "An on-off switch with a name for screen readers. It can be disabled and can carry a hidden explanation.",
      "description": "Switch turns a setting on or off right away, like turning web search on. Your code keeps track of whether it is on. When on, the track is dark. When off, it is gray. The knob slides across. The area you can press is larger than it looks, so it is easy to hit.",
      "purpose": "Turns one setting on or off the moment it is pressed, with no save step. The knob position and the track color both show the state, so color is not the only clue.",
      "useFor": [
        "A setting that applies right away, like web search or memory.",
        "A row in a settings list, with the setting's name beside it.",
        "A setting that is locked: disable it and give the reason."
      ],
      "notFor": [
        {
          "text": "An action, like sending or deleting",
          "use": "button"
        },
        {
          "text": "A choice between more than two options",
          "use": "segmented-control"
        },
        {
          "text": "An icon action that shows a pressed state",
          "use": "icon-button"
        }
      ],
      "whenToUse": [
        "Use it for settings that apply right away, with no save step.",
        "Show a visible label next to it and give the switch the same name.",
        "Name the setting, not the action. Write \"Web search\", not \"Turn on web search\".",
        "Keep the on-or-off value in your own code and pass it back as checked."
      ],
      "avoid": [
        "Do not use a Switch for a choice that needs a confirm step. Use a checkbox or a form.",
        "Do not use it for an action. Use a Button.",
        "Do not make the on state amber. The track is dark when on.",
        "Do not disable a switch without saying why. Give the reason in the explanation or nearby text.",
        "Do not rely on the hidden explanation for visible help text. Only screen readers hear it, so show any help text yourself."
      ],
      "accessibility": [
        "Screen readers call it a switch and say whether it is on or off.",
        "It is a real button, so the Tab key reaches it and Enter and Space flip it.",
        "The description is read after the name and state.",
        "The pressable area is bigger than the track, and bigger still on touch screens.",
        "A disabled switch is skipped by the Tab key, and screen readers call it unavailable."
      ],
      "accessibilityYouDo": [
        "Give it a label that names the setting, such as \"Web search\". A visible label beside it does not name it.",
        "When it is disabled, say why in description and in visible text nearby."
      ],
      "onPhone": [
        "On a touch screen the switch gets a hit area of at least 44px by 44px, while the track stays 20px tall.",
        "With a visible label, tapping the label also toggles the switch.",
        "Put the switch and its label on one row with room to spare, so two switches never sit closer than 44px."
      ],
      "importPath": "import { Switch } from \"./index\"",
      "sourcePath": "src/atoms/Switch.tsx",
      "props": [
        {
          "name": "checked",
          "type": "boolean",
          "required": true,
          "default": null,
          "description": "Current state. The component is fully controlled."
        },
        {
          "name": "onChange",
          "type": "(v: boolean) => void",
          "required": true,
          "default": null,
          "description": "Called with the opposite of checked when the switch is activated."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name, applied as aria-label. When children are given, the children are the name instead and label is not used."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Visible label beside the switch. Pressing it flips the switch, and it becomes the accessible name."
        },
        {
          "name": "labelPosition",
          "type": "\"start\" | \"end\"",
          "required": false,
          "default": "\"end\"",
          "description": "Which side of the switch the children sit on. Only matters when children are given."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Dims the switch, removes it from the tab order and blocks onChange."
        },
        {
          "name": "description",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra context announced after the name. Rendered as screen reader text and linked with aria-describedby."
        },
        {
          "name": "id",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id on the button, for a visible label that uses htmlFor."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the button. The hit area is the button, so margins and alignment apply to it."
        }
      ],
      "variantsHint": {
        "labelPosition": [
          "start",
          "end"
        ]
      },
      "tokens": [
        "--accent",
        "--border-strong",
        "--dur-fast",
        "--focus-ring"
      ],
      "related": [
        "icon-button",
        "button"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Controlled switch",
          "description": "Your code keeps the on-or-off value and updates it when the switch is pressed.",
          "code": "function Example() {\n  const [on, setOn] = useState(true);\n  return <Switch checked={on} onChange={setOn} label=\"Web search\" />;\n}"
        },
        {
          "title": "In a settings row",
          "description": "The switch has no words of its own. Put a visible label beside it and give it the same name.",
          "code": "function Example() {\n  const [search, setSearch] = useState(true);\n  const [memory, setMemory] = useState(false);\n  return (\n    <div className=\"flex w-72 flex-col gap-3 text-body leading-5 text-fg\">\n      <div className=\"flex items-center justify-between\">\n        <span>Web search</span>\n        <Switch checked={search} onChange={setSearch} label=\"Web search\" />\n      </div>\n      <div className=\"flex items-center justify-between\">\n        <span>Memory</span>\n        <Switch checked={memory} onChange={setMemory} label=\"Memory\" />\n      </div>\n    </div>\n  );\n}"
        },
        {
          "title": "Disabled with a description",
          "description": "A disabled switch looks faded and cannot be changed. The explanation is read out after the name, so say why the setting is unavailable.",
          "code": "<div className=\"flex w-72 items-center justify-between text-body leading-5 text-fg\">\n  <label htmlFor=\"memory-switch\">Memory</label>\n  <Switch id=\"memory-switch\" checked={false} onChange={() => {}} label=\"Memory\"\n    disabled description=\"Turned off by your workspace admin.\" />\n</div>"
        },
        {
          "title": "With a visible label",
          "description": "Pass the words as children. They sit beside the switch, pressing them flips it, and a screen reader uses them as the name.",
          "code": "function Example() {\n  const [on, setOn] = useState(true);\n  return <Switch checked={on} onChange={setOn} label=\"Web search\">Web search</Switch>;\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-switch/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/switch.md"
    },
    {
      "id": "section-label",
      "name": "SectionLabel",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "overline",
        "eyebrow",
        "subheader",
        "list subheader",
        "group label"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A tiny capital-letter label that names a group in a list, with room for a small action.",
      "description": "SectionLabel is a small label in capital letters. It names a group of items. Make it a heading if it should show up in the page outline. At the end of the row you can add a count or a short link. It carries no content of its own.",
      "purpose": "A tiny capital-letter label that names a group of items, such as Today or Pinned. It stays quiet so the items stand out, and it has room at the end for a count or one small action.",
      "useFor": [
        "Grouping chats by date in a side list.",
        "Naming a section in a tray or a settings panel.",
        "A group name with a count or a Clear button at the end."
      ],
      "notFor": [
        {
          "text": "A page or card title"
        },
        {
          "text": "A status on a single item",
          "use": "badge"
        }
      ],
      "whenToUse": [
        "Use short words like \"Pinned\" or \"Last 7 days\". It shows in capitals by itself, so write it normally.",
        "Put it right above the group it names.",
        "Use the end of the row for one small control or a count.",
        "Make it a heading only when it is a real section title."
      ],
      "avoid": [
        "Do not use it as a page or card title. Use the large headings.",
        "Do not type labels in capitals. The style does it.",
        "Do not put long sentences in it.",
        "Do not put several actions at the end of the row."
      ],
      "accessibility": [
        "It is plain text by default, so it does not add a heading to the page outline.",
        "Screen readers read the text as you typed it. The capitals are only a style."
      ],
      "accessibilityYouDo": [
        "Set as to a heading level when the label should appear in the page outline, and pick the level that fits.",
        "Give whatever you put in action its own name, such as a button label."
      ],
      "onPhone": [
        "The label and the action sit on one row. The label can wrap if the action takes the space.",
        "The label text is 12px on touch screens.",
        "If the action is a button, it keeps its size on a touch screen and has a 44px tap area, so the row does not get taller. A small link needs the tap class to get the same tap area."
      ],
      "importPath": "import { SectionLabel } from \"./index\"",
      "sourcePath": "src/atoms/SectionLabel.tsx",
      "props": [
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The label text."
        },
        {
          "name": "action",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Content aligned to the end of the row, wrapped in a muted text-small leading-4 span."
        },
        {
          "name": "as",
          "type": "\"p\" | \"h2\" | \"h3\" | \"h4\"",
          "required": false,
          "default": "\"p\"",
          "description": "Element to render. A label is plain text by default. Use a heading level only when it belongs in the page outline."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Merged onto the outer flex row."
        }
      ],
      "variantsHint": {
        "as": [
          "p",
          "h2",
          "h3",
          "h4"
        ]
      },
      "tokens": [
        "--fg-muted"
      ],
      "related": [
        "badge",
        "ai-disclosure"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Label only",
          "description": "A plain group name above a list.",
          "code": "<div className=\"w-64\">\n  <SectionLabel>Today</SectionLabel>\n</div>"
        },
        {
          "title": "With an action",
          "description": "Add a button or link at the end of the row.",
          "code": "<div className=\"w-64\">\n  <SectionLabel action={<Button variant=\"ghost\" size=\"sm\">Clear</Button>}>Recent chats</SectionLabel>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-section-label/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/section-label.md"
    },
    {
      "id": "attention-dot",
      "name": "AttentionDot",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "notification dot",
        "badge dot",
        "unread indicator",
        "indicator"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A small amber dot that means a person has to do something.",
      "description": "AttentionDot is a tiny amber circle. It is the only sign that something needs a person. Examples are a request for approval, a step that failed and needs a decision, or a reply that blocks progress. Always put it next to words that say what is needed. Amber is never for decoration or general warnings.",
      "purpose": "A tiny amber dot that means a person has to do something before work can go on. Amber is kept for this alone, so the dot always stands out.",
      "useFor": [
        "A request waiting for approval.",
        "A chat or task in a list that is blocked on the person.",
        "A failed step that needs a decision."
      ],
      "notFor": [
        {
          "text": "General warnings, or problems the system will fix by itself",
          "use": "callout"
        },
        {
          "text": "A full list of everything waiting on the person",
          "use": "approval-inbox"
        }
      ],
      "whenToUse": [
        "Use it only when a person must act before work can go on.",
        "Put it next to words that say what is needed.",
        "Use a specific label like \"Approval needed\" when the default is too vague.",
        "Remove it as soon as the person has acted."
      ],
      "avoid": [
        "Do not use it for problems the system can fix on its own, or for general warnings.",
        "Do not use it alone. It must come with words.",
        "Do not change its color or add another amber element beside it.",
        "Do not use it as a decorative bullet."
      ],
      "accessibility": [
        "Screen readers read it as an image named \"Needs attention\", or your label.",
        "It does not move, so motion settings do not affect it.",
        "It cannot be pressed, and the Tab key skips it."
      ],
      "accessibilityYouDo": [
        "Put words next to it that say what is needed.",
        "Give it a specific label, such as \"Approval needed\", when the default \"Needs attention\" is too vague.",
        "Remove it as soon as the person has acted."
      ],
      "onPhone": [
        "The dot is a fixed 6px circle and is not tappable, on a phone or anywhere else.",
        "Place it on or next to a control that has the 44px target, and do not rely on the dot alone to be seen at arm's length."
      ],
      "importPath": "import { AttentionDot } from \"./index\"",
      "sourcePath": "src/atoms/AttentionDot.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Needs attention\"",
          "description": "Accessible name for the dot, applied as aria-label."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--attention"
      ],
      "related": [
        "badge",
        "ai-badge"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Beside text",
          "description": "The dot comes with a line saying what the person has to do.",
          "code": "<div className=\"flex items-center gap-2 text-body leading-5 text-fg\">\n  <AttentionDot label=\"Approval needed\" />\n  <span>The assistant wants to run a command on your computer</span>\n</div>"
        },
        {
          "title": "In a list row",
          "description": "A chat in a list that is waiting on you. The dot is the marker. The words say why.",
          "code": "<div className=\"flex w-72 items-center justify-between rounded-card border border-line bg-surface px-3 py-2 text-body leading-5 text-fg\">\n  <span>Quarterly report</span>\n  <span className=\"flex items-center gap-2 text-small leading-4 text-fg-muted\">\n    <AttentionDot />\n    Needs your input\n  </span>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-attention-dot/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/attention-dot.md"
    },
    {
      "id": "typing-indicator",
      "name": "TypingIndicator",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "typing dots",
        "is typing",
        "loading dots"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "Three pulsing dots that show the assistant is getting ready to reply.",
      "description": "TypingIndicator is three small dots that pulse one after another. Show it where the reply will appear, from the moment someone sends a message until the first words show up. Then replace it with the reply. Screen readers announce its label.",
      "purpose": "Three pulsing dots in the spot where the reply will appear. They fill the gap between sending a message and the first words, so people know the assistant heard them.",
      "useFor": [
        "The moment after a message is sent, until the first words arrive.",
        "Beside the assistant's avatar, where the reply will start."
      ],
      "notFor": [
        {
          "text": "A step you can name, like searching the web",
          "use": "shimmer-text"
        },
        {
          "text": "Loading that is not the assistant replying",
          "use": "spinner"
        },
        {
          "text": "A reply that has started to appear",
          "use": "message"
        }
      ],
      "whenToUse": [
        "Show it only until the first words appear.",
        "Put it in the same spot where the reply will appear.",
        "Change the label when the assistant is doing something specific.",
        "Use ShimmerText beside it when you can name the step, like a search."
      ],
      "avoid": [
        "Do not keep it on screen once the reply starts appearing.",
        "Do not use it for loading that is not the assistant. Use Spinner or Skeleton.",
        "Do not show several in the same conversation.",
        "Do not make the dots lime or amber."
      ],
      "accessibility": [
        "It is marked as a status and named by its label, \"Assistant is thinking\" by default.",
        "The dots have no text, so screen readers get only the label.",
        "The dots stop pulsing for people who turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Change the label when the assistant is doing something specific."
      ],
      "onPhone": [
        "TypingIndicator is three small dots and looks the same on a phone.",
        "It is not tappable. Put it in the message list where the next reply will start, so it does not push content when the reply arrives."
      ],
      "importPath": "import { TypingIndicator } from \"./index\"",
      "sourcePath": "src/atoms/TypingIndicator.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Assistant is thinking\"",
          "description": "Accessible name announced for the status."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg-subtle",
        "--animate-pulse-dot"
      ],
      "related": [
        "shimmer-text",
        "spinner",
        "skeleton"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Default",
          "description": "The default label is \"Assistant is thinking\".",
          "code": "<TypingIndicator />"
        },
        {
          "title": "Where the reply will appear",
          "description": "Placed at the start of the coming reply, under the person's message, so nothing jumps when text arrives.",
          "code": "<div className=\"w-full max-w-md space-y-4\">\n  <Message role=\"user\">Can you check my order?</Message>\n  <TypingIndicator label=\"The assistant is writing\" />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-typing-indicator/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/typing-indicator.md"
    },
    {
      "id": "shimmer-text",
      "name": "ShimmerText",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "shimmer",
        "loading text",
        "status text"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "Status text with a moving shine, for steps like \"Searching the web…\".",
      "description": "ShimmerText is a line of text with a shine that sweeps across it. Use it to show what the assistant is doing right now. When the step ends, replace it with the result.",
      "purpose": "One line of status text with a shine sweeping across it. The shine says the work is still going; the words say what the assistant is doing.",
      "useFor": [
        "A step in progress, like \"Searching the web…\".",
        "Next to a small spinner when a step takes a while.",
        "A status line that you replace with the result when the step ends."
      ],
      "notFor": [
        {
          "text": "The wait before you know what the assistant is doing",
          "use": "typing-indicator"
        },
        {
          "text": "A list of the steps the assistant has taken",
          "use": "step-timeline"
        },
        {
          "text": "Body text or headings"
        }
      ],
      "whenToUse": [
        "Write a short phrase that names the step, like \"Reading 4 files…\".",
        "Replace it with the finished text when the step is done.",
        "Show one step at a time.",
        "Keep it short enough for one line."
      ],
      "avoid": [
        "Do not use it for body text or headings.",
        "Do not leave it moving after the work has stopped.",
        "Only plain text works inside it, not other elements.",
        "Do not change its text color. The shine needs the default."
      ],
      "accessibility": [
        "It is real text, so screen readers read it and people can select it.",
        "The shine stops for people who turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Put it in an area that announces changes if people must hear each step. It does not announce itself.",
        "Never put the only copy of something important here. Part of the text is light and hard to read."
      ],
      "onPhone": [
        "ShimmerText is inline text at 14px and wraps with the text around it on a narrow screen.",
        "The shimmer runs on a phone. It is a status line, so keep it short."
      ],
      "importPath": "import { ShimmerText } from \"./index\"",
      "sourcePath": "src/atoms/ShimmerText.tsx",
      "props": [
        {
          "name": "children",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The status text. Only strings are accepted."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Merged last. Use it to change size or spacing."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg-subtle",
        "--fg",
        "--animate-shimmer"
      ],
      "related": [
        "typing-indicator",
        "spinner",
        "skeleton"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Status step",
          "description": "One line saying what the assistant is doing.",
          "code": "<ShimmerText>Searching the web…</ShimmerText>"
        },
        {
          "title": "With a spinner",
          "description": "Add a small Spinner when the step takes a while.",
          "code": "<div className=\"flex items-center gap-2\">\n  <Spinner size={14} label=\"Reading files\" />\n  <ShimmerText>Reading 4 files…</ShimmerText>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-shimmer-text/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/shimmer-text.md"
    },
    {
      "id": "hero-heading",
      "name": "HeroHeading",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "hero",
        "headline",
        "welcome heading"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "typing",
          "note": "Pass type (on by default) to reveal the text one character at a time; it shows in full at once when the user prefers reduced motion.",
          "source": "documented"
        }
      ],
      "summary": "A big centered headline that types itself out once, then stays still.",
      "description": "HeroHeading is the big greeting on an empty chat. It types out one letter at a time. If you turn typing off, or the person has turned off motion, the full text shows at once.",
      "purpose": "The big greeting on an empty chat. It types itself out once and then stays still, so the empty screen feels alive without moving forever.",
      "useFor": [
        "The opening line of an empty chat.",
        "A greeting on a start page: turn typing off if people see it on every visit."
      ],
      "notFor": [
        {
          "text": "Naming a group of items in a list",
          "use": "section-label"
        },
        {
          "text": "Titles inside cards or sections"
        }
      ],
      "whenToUse": [
        "Use it once per screen, as the opening line of an empty chat.",
        "Keep it to one short sentence so it fits on two lines on a phone.",
        "Turn typing off when it would replay on every visit.",
        "Speak to the person in a single sentence."
      ],
      "avoid": [
        "Do not use it for section titles or inside cards.",
        "Do not change the text quickly. Each change types out again.",
        "Do not add another moving element next to it.",
        "Do not make screen readers announce it again. They already get the full sentence."
      ],
      "accessibility": [
        "Screen readers hear the whole sentence at once, not each letter as it types.",
        "People who turn off motion see the full text right away.",
        "It is a second-level heading."
      ],
      "accessibilityYouDo": [
        "Check that a second-level heading fits your page outline. It is always one."
      ],
      "onPhone": [
        "The heading is centred and balanced, and it is smaller below the sm breakpoint so it wraps on a phone.",
        "Text is typed in letter by letter, and only the typed part is drawn, so on a narrow screen the heading grows taller line by line as it types. Reserve space for it if the content below should not move.",
        "Under reduced motion it shows all at once."
      ],
      "importPath": "import { HeroHeading } from \"./index\"",
      "sourcePath": "src/atoms/HeroHeading.tsx",
      "props": [
        {
          "name": "text",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The headline. Changing it restarts the typing animation."
        },
        {
          "name": "type",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "When false, the whole text renders at once with no animation."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg",
        "--font-serif"
      ],
      "related": [
        "section-label",
        "ai-disclosure"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Typed",
          "description": "The default. The text types out when the page opens and whenever the text changes.",
          "code": "<HeroHeading text=\"What can I help you with?\" />"
        },
        {
          "title": "Static",
          "description": "Turn typing off to show the full text at once, for example on a page people visit often.",
          "code": "<HeroHeading text=\"Good evening, Lokesh\" type={false} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-hero-heading/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/hero-heading.md"
    },
    {
      "id": "citation-marker",
      "name": "CitationMarker",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "inline citation",
        "footnote",
        "reference marker",
        "citation"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "The number fills with the accent colour when the pointer is over it.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears when you reach the link with the keyboard.",
          "source": "documented"
        }
      ],
      "summary": "A small numbered link that points to a source, placed after a claim in a reply.",
      "description": "CitationMarker is a small numbered tag raised slightly like a footnote. It links to the source you give it, or to a matching spot on the same page. Hovering highlights it. Place it right after the claim it supports.",
      "purpose": "A small raised number after a claim that links to its source, like a footnote. It keeps the sentence readable while letting people check where a fact came from.",
      "useFor": [
        "After a claim in a reply that comes from a source.",
        "Linking to a matching entry in a source list on the same page."
      ],
      "notFor": [
        {
          "text": "Showing a source's details when people point at it",
          "use": "citation-hover-card"
        },
        {
          "text": "The full list of sources under a reply",
          "use": "source-list"
        },
        {
          "text": "A label that stands outside a sentence",
          "use": "badge"
        }
      ],
      "whenToUse": [
        "Number markers in the order the sources first appear.",
        "Give each a title, so the tooltip and screen readers say what the source is.",
        "Give each marker a real link, or give your source list matching anchors.",
        "Place the marker right after the claim."
      ],
      "avoid": [
        "Do not use it for footnotes that are not links.",
        "Do not reuse a number for different sources in one message.",
        "Do not use it outside a sentence. Use Badge for a standalone label.",
        "Do not rely on hover to show the source. People on touch screens never see it."
      ],
      "accessibility": [
        "It is a real link, so the Tab key reaches it and Enter follows it.",
        "Screen readers hear \"Source 1\" followed by the title.",
        "The title also shows as a tooltip when a mouse hovers over it.",
        "A clear outline shows when someone tabs to it."
      ],
      "accessibilityYouDo": [
        "Give each marker a title, so screen readers hear what the source is, not only its number.",
        "Leave some space around markers in dense text. They are small to tap."
      ],
      "onPhone": [
        "The marker is only 16px tall, but on touch screens it gets an invisible tap area about 8px larger on each side.",
        "Its number is 12px on touch screens.",
        "The tooltip with the source title needs a hover, so a phone only sees the number. Show the source name in the list below the text."
      ],
      "importPath": "import { CitationMarker } from \"./index\"",
      "sourcePath": "src/atoms/CitationMarker.tsx",
      "props": [
        {
          "name": "n",
          "type": "number",
          "required": true,
          "default": null,
          "description": "The number shown in the chip and used in the default anchor and label."
        },
        {
          "name": "href",
          "type": "string",
          "required": false,
          "default": "`#source-${n}`",
          "description": "Link target. Defaults to an in-page anchor named for the number."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Source title. Shown as the native tooltip and appended to the accessible name."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--accent-soft",
        "--accent-fg",
        "--accent",
        "--on-accent"
      ],
      "related": [
        "badge",
        "ai-badge"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "In a sentence",
          "description": "Markers sit right after the text they support.",
          "code": "<p className=\"max-w-sm text-body leading-5 text-fg\">\n  Revenue grew 12% year on year<CitationMarker n={1} title=\"Annual report\" href=\"#annual-report\" />, driven\n  by subscriptions<CitationMarker n={2} title=\"Q3 earnings call\" href=\"#earnings-call\" />.\n</p>"
        },
        {
          "title": "Default target",
          "description": "With no link given, it jumps to the matching source on the same page.",
          "code": "<CitationMarker n={3} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-citation-marker/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/citation-marker.md"
    },
    {
      "id": "ai-badge",
      "name": "AIBadge",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "ai tag",
        "ai chip"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "solid",
          "note": "Pass solid for a filled lime badge instead of the soft one.",
          "source": "documented"
        }
      ],
      "summary": "A lime-tinted badge with a sparkle that marks content made by AI.",
      "description": "AIBadge is a Badge with a sparkle and a short label. Use it on generated images, summaries and drafts, so people can tell AI work from human work. It says \"AI-generated\" by default. You can change the words.",
      "purpose": "A badge with a sparkle that marks one piece of content as made by AI. It sits on the item itself, so people can tell AI work from human work at a glance.",
      "useFor": [
        "A generated image, summary or draft.",
        "A card whose text the assistant wrote.",
        "Content on a photo or a busy background: turn on solid."
      ],
      "notFor": [
        {
          "text": "A note about the whole conversation",
          "use": "ai-disclosure"
        },
        {
          "text": "AI content that needs an explanation, or an undo after editing",
          "use": "ai-label"
        },
        {
          "text": "Content a person wrote"
        }
      ],
      "whenToUse": [
        "Put it on anything the AI wrote or made, like summaries or images.",
        "Keep the label to two words or fewer.",
        "Place it close to the content it describes, like a corner of a card.",
        "Use the same words for the same kind of content everywhere."
      ],
      "avoid": [
        "Do not use it on content a person wrote.",
        "Do not use it as a general highlight. The sparkle means AI.",
        "Do not swap it for a plain Badge in the accent color.",
        "Do not use it in place of AIDisclosure when the whole conversation needs a caution."
      ],
      "accessibility": [
        "Screen readers read the label as part of the text around it.",
        "Screen readers skip the sparkle.",
        "It cannot be pressed, and the Tab key skips it."
      ],
      "accessibilityYouDo": [
        "Use a label that says what the content is, such as \"AI summary\".",
        "Turn on solid on images or busy backgrounds, so the text stays readable."
      ],
      "onPhone": [
        "AIBadge is not tappable and does not change on a phone.",
        "Its text is 12px on touch screens.",
        "Set solid when the badge sits on an image, as on any screen size."
      ],
      "importPath": "import { AIBadge } from \"./index\"",
      "sourcePath": "src/atoms/AIBadge.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"AI-generated\"",
          "description": "Text shown next to the sparkle."
        },
        {
          "name": "solid",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Use the opaque lime badge. Set it when the badge sits on an image or another busy background so the text keeps its contrast."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--lime",
        "--fg"
      ],
      "related": [
        "badge",
        "ai-disclosure",
        "avatar"
      ],
      "composedOf": [
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Default",
          "description": "The standard label for content made by AI.",
          "code": "<AIBadge />"
        },
        {
          "title": "Custom label",
          "description": "Change the words to describe the kind of content.",
          "code": "<div className=\"flex flex-wrap items-center gap-2\">\n  <AIBadge label=\"AI summary\" />\n  <AIBadge label=\"Draft\" />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-ai-badge/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/ai-badge.md"
    },
    {
      "id": "ai-disclosure",
      "name": "AIDisclosure",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "ai notice",
        "ai disclaimer",
        "transparency notice"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A small centered note reminding people to check the assistant's answers.",
      "description": "AIDisclosure is one line of small, light text. It sits below the message box or the conversation. It says \"AI can make mistakes. Check important information.\" You can replace the words. It has no icon or border.",
      "purpose": "One small, quiet line that reminds people the assistant can be wrong. It sits once under the message box, so it is always there without competing with the conversation.",
      "useFor": [
        "Under the message box in a chat.",
        "At the end of a generated answer or report.",
        "A caution for a sensitive topic, such as medication: change the words."
      ],
      "notFor": [
        {
          "text": "Marking one item as made by AI",
          "use": "ai-badge"
        },
        {
          "text": "A warning the person must read or act on",
          "use": "callout"
        },
        {
          "text": "Legal terms people must agree to"
        }
      ],
      "whenToUse": [
        "Show it once, under the message box or at the end of the conversation.",
        "Keep custom text to one short sentence.",
        "State plainly what the person should check.",
        "Keep it light so it stays quiet."
      ],
      "avoid": [
        "Do not repeat it under every message. Use AIBadge to mark single items.",
        "Do not make it an amber warning. Amber is only for when a person has to act.",
        "Do not use it for legal terms that need a link or a click to agree.",
        "Do not write long paragraphs. It is small and centered."
      ],
      "accessibility": [
        "Screen readers read it as a normal paragraph, in page order.",
        "It is not announced when its words change."
      ],
      "accessibilityYouDo": [
        "Keep critical instructions out of it. It is the lightest text in the system and easy to miss.",
        "If you add a link, give it words that say where it goes."
      ],
      "onPhone": [
        "The line is centred 12px text that wraps on a narrow screen.",
        "It is not tappable. Keep it visible near the input, not behind a tap."
      ],
      "importPath": "import { AIDisclosure } from \"./index\"",
      "sourcePath": "src/atoms/AIDisclosure.tsx",
      "props": [
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Replaces the default message when provided."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg-subtle"
      ],
      "related": [
        "ai-badge",
        "section-label"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Default",
          "description": "The built-in message.",
          "code": "<AIDisclosure />"
        },
        {
          "title": "Custom message",
          "description": "Change the words for a specific part of your product.",
          "code": "<AIDisclosure>Answers about medication are not medical advice.</AIDisclosure>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-ai-disclosure/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/ai-disclosure.md"
    },
    {
      "id": "avatar-stack",
      "name": "AvatarStack",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "avatar group",
        "facepile",
        "avatar list"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "overflow",
          "note": "When total is larger than the people shown (or max cuts the list), a plus disc and a count appear.",
          "source": "documented"
        },
        {
          "name": "compact",
          "note": "Pass compact to shorten large counts, for example 1.2K.",
          "source": "documented"
        }
      ],
      "summary": "A few overlapping avatars, a plus, then how many more people there are.",
      "description": "AvatarStack shows that people are here or took part, without listing everyone. It draws the first few avatars, a plus, then the number of others. Use it for viewers of a page, people in a shared chat, or who voted. The picture is decoration; screen readers hear one sentence with the names and the count.",
      "purpose": "Shows that people are here or took part, without listing them all. The avatars overlap to stay small, and the number says how many more there are.",
      "useFor": [
        "Who is viewing a page or document right now.",
        "Who is in a shared chat.",
        "Who voted, reacted or took part in something."
      ],
      "notFor": [
        {
          "text": "A single person",
          "use": "avatar"
        },
        {
          "text": "A full list of names that people need to read or pick from"
        }
      ],
      "whenToUse": [
        "Use real counts only. If you cannot count it, do not show it.",
        "Put the people the viewer knows first.",
        "Name what is counted with label, for example \"voted\" or \"viewing now\".",
        "Keep it to three faces. More faces add noise, not trust."
      ],
      "avoid": [
        "Do not invent or round up numbers to look popular.",
        "Do not make the stack the only way to see who is there. Link to the full list when people need it.",
        "Do not use it for one person. Use Avatar.",
        "Do not colour the plus or the number. Lime belongs to the assistant."
      ],
      "accessibility": [
        "Screen readers hear one sentence with the names and the count, for example \"Lokesh, Mira, Asha and 1,245 others\". The drawn avatars and number are skipped.",
        "The assistant is read as \"Assistant\" in that sentence.",
        "The number uses equal-width digits, so it does not jump when it changes.",
        "A ring in the page color separates overlapping avatars in light and dark mode."
      ],
      "accessibilityYouDo": [
        "Wrap it in a link or button if it should open the full list. On its own it cannot be pressed.",
        "Set total to everyone counted, so the sentence screen readers hear gives the right number.",
        "Pick a label that reads well after the number, such as \"viewers\" or \"others\"."
      ],
      "onPhone": [
        "Stays inline and keeps its size, so it fits beside a title or in a row on a narrow screen.",
        "It is not a tap target. A button around it gets a 44px tap area on touch screens, but a link around it needs the tap class or its own padding.",
        "Names and the count are read from a hidden sentence, so nothing depends on hover."
      ],
      "importPath": "import { AvatarStack } from \"./index\"",
      "sourcePath": "src/molecules/AvatarStack.tsx",
      "props": [
        {
          "name": "people",
          "type": "{ name: string; kind?: \"user\" | \"ai\" }[]",
          "required": true,
          "default": null,
          "description": "People to show, most relevant first. Only the first max are drawn."
        },
        {
          "name": "total",
          "type": "number",
          "required": false,
          "default": "people.length",
          "description": "Everyone counted, including the ones drawn. The number shown is the rest."
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "default": "3",
          "description": "How many avatars to draw before the plus."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"others\"",
          "description": "Word after the number, for example \"voted\" or \"viewing\"."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "default": "\"md\"",
          "description": "24px or 32px avatars."
        },
        {
          "name": "compact",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Show 1.2k instead of 1,248."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Merged after the base classes."
        }
      ],
      "variantsHint": {
        "size": [
          "sm",
          "md"
        ]
      },
      "tokens": [
        "--bg",
        "--border",
        "--surface",
        "--fg",
        "--fg-muted"
      ],
      "related": [
        "avatar",
        "badge"
      ],
      "composedOf": [
        "avatar"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Three people and the rest",
          "description": "The default: three avatars, a plus, and how many others.",
          "code": "<AvatarStack\n  people={[{ name: \"Lokesh\" }, { name: \"Mira\" }, { name: \"Asha\" }]}\n  total={1248}\n/>"
        },
        {
          "title": "Short numbers and a custom word",
          "description": "Use compact for big counts, and name what is being counted.",
          "code": "<AvatarStack\n  people={[{ name: \"Ravi\" }, { name: \"Noor\" }, { name: \"Sam\" }]}\n  total={23400}\n  compact\n  label=\"voted\"\n/>"
        },
        {
          "title": "With the assistant, small",
          "description": "In a shared chat the assistant can be one of the faces. Small fits beside a title or in a list row.",
          "code": "<AvatarStack\n  size=\"sm\"\n  people={[{ name: \"Assistant\", kind: \"ai\" }, { name: \"Lokesh\" }, { name: \"Mira\" }]}\n  total={7}\n  label=\"in this chat\"\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-avatar-stack/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/avatar-stack.md"
    },
    {
      "id": "callout",
      "name": "Callout",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "alert",
        "banner",
        "notice",
        "inline message",
        "admonition",
        "message bar"
      ],
      "category": "states",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "danger",
          "note": "Set tone to danger to announce it as an alert; the other tones announce it as a status.",
          "source": "documented"
        }
      ],
      "summary": "A message box inside the page, with a color for its mood, an optional title and an optional button.",
      "description": "Callout is the base for every notice shown inside the page. It is a soft rounded box with an icon, an optional title, some text and an optional button. It has four moods: info, success, warning and danger. ErrorState, RefusalNotice and RateLimitNotice are built on it, so try those first if one fits.",
      "purpose": "A notice that sits inside the page instead of popping up over it. The soft color sets the mood, while the icon and the words carry the meaning.",
      "useFor": [
        "A note about the page or the app, such as a saved draft or an unstable connection.",
        "A warning someone should read before going on.",
        "A short message with one button that fixes or follows up on it."
      ],
      "notFor": [
        {
          "text": "A failure the person can try again",
          "use": "error-state"
        },
        {
          "text": "A request the assistant will not do",
          "use": "refusal-notice"
        },
        {
          "text": "A usage limit with a reset time",
          "use": "rate-limit-notice"
        }
      ],
      "whenToUse": [
        "Give every callout a title or a clear first sentence, so color is not the only clue.",
        "Add one button for the next step the reader can take, like Retry or Upgrade.",
        "Use danger only for things that failed. Use info for limits and neutral notes.",
        "Keep the text to one or two sentences."
      ],
      "avoid": [
        "Do not stack several callouts. Combine them or show the most important one.",
        "Do not use warning as decoration. It is amber, which tells people to take a look.",
        "Do not put long forms or lists inside. Link to a fuller page instead.",
        "Do not use neutral or accent colors. Only the four moods work here."
      ],
      "accessibility": [
        "When it appears, screen readers announce danger right away and the other moods politely.",
        "The icon is hidden from screen readers.",
        "Info and success show a circle icon, warning and danger a triangle, so color is never the only clue."
      ],
      "accessibilityYouDo": [
        "Write a title or text that says what happened, because the icon is not read out.",
        "Name the button for what it does, such as \"Retry upload\".",
        "Use the danger mood only for problems that need attention now, because screen readers interrupt for it."
      ],
      "onPhone": [
        "Fills the width of its container, and the message wraps beside the icon.",
        "On a phone the action drops to its own line under the message, so the message keeps its full width.",
        "The action button keeps its size and has a 44px tap area on touch screens."
      ],
      "importPath": "import { Callout } from \"./index\"",
      "sourcePath": "src/molecules/Callout.tsx",
      "props": [
        {
          "name": "tone",
          "type": "\"info\" | \"success\" | \"warning\" | \"danger\"",
          "required": false,
          "default": "\"info\"",
          "description": "Colour family of the surface and text. Info and success use the info icon, warning and danger use the alert icon."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Short bold heading above the body."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Body content. Rendered at 90% opacity under the title."
        },
        {
          "name": "action",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Trailing slot, usually a small Button."
        }
      ],
      "variantsHint": {
        "tone": [
          "info",
          "success",
          "warning",
          "danger"
        ]
      },
      "tokens": [
        "--info-soft",
        "--info-fg",
        "--success-soft",
        "--success-fg",
        "--warning-soft",
        "--warning-fg",
        "--danger-soft",
        "--danger-fg",
        "--radius-card"
      ],
      "related": [
        "error-state",
        "refusal-notice",
        "rate-limit-notice"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Informational",
          "description": "The default mood. Use it for neutral notes that ask nothing of the reader.",
          "code": "<Callout title=\"Draft saved\">Your changes are saved and will sync when you are back online.</Callout>"
        },
        {
          "title": "All tones",
          "description": "Success, warning and danger look alike apart from color. Screen readers announce danger more urgently.",
          "code": "<div className=\"space-y-3\">\n  <Callout tone=\"success\" title=\"Export complete\">The file is ready to download.</Callout>\n  <Callout tone=\"warning\" title=\"Large file\">Uploads over 20 MB may take a minute.</Callout>\n  <Callout tone=\"danger\" title=\"Upload failed\">The connection dropped before the file finished.</Callout>\n</div>"
        },
        {
          "title": "With an action",
          "description": "Add a button at the end of the box.",
          "code": "<Callout tone=\"warning\" title=\"Connection unstable\" action={<Button size=\"sm\" variant=\"secondary\">Retry</Button>}>\n  Responses may be delayed.\n</Callout>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-callout/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/callout.md"
    },
    {
      "id": "collapsible",
      "name": "Collapsible",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "disclosure",
        "expander",
        "details",
        "show more",
        "expand collapse"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open or closed",
          "note": "Set with the defaultOpen prop.",
          "source": "prop"
        },
        {
          "name": "opening",
          "note": "The content grows open and fades in over --dur-base on --ease-arrive. It is clipped only while the height moves. Instant under reduced motion.",
          "source": "documented"
        },
        {
          "name": "closing",
          "note": "The content shrinks and fades in --dur-fast, then is hidden from everyone with the hidden attribute. Instant under reduced motion.",
          "source": "documented"
        }
      ],
      "summary": "A heading with an arrow that shows or hides extra content when pressed.",
      "description": "Collapsible is a full-width heading with an arrow. Pressing it opens a hidden section, which grows open and closes a little faster. It starts closed unless you say otherwise. ThinkingBlock and ToolCallCard are built on it. Use it for extra detail most people do not need at first.",
      "purpose": "Hides detail that most people do not need at first behind a heading they can press. The arrow turns to show whether it is open.",
      "useFor": [
        "Advanced options under a simpler form.",
        "Long detail, such as logs or notes, that would crowd the page.",
        "Your own expandable section when ThinkingBlock or ToolCallCard do not fit."
      ],
      "notFor": [
        {
          "text": "The assistant's reasoning",
          "use": "thinking-block"
        },
        {
          "text": "One step the assistant took, with its status",
          "use": "tool-call-card"
        },
        {
          "text": "Content that most people need to read"
        }
      ],
      "whenToUse": [
        "Make the heading say what is inside, like Sources or Full output.",
        "Use it to tuck away extra detail that would make the page long.",
        "Add space inside the hidden section yourself."
      ],
      "avoid": [
        "Do not hide anything a person must act on.",
        "Do not put buttons inside the heading. The whole heading is one button.",
        "Do not stack many of them when tabs or a table would be clearer."
      ],
      "accessibility": [
        "The heading is a real button. Screen readers say whether the section is open or closed.",
        "Enter and Space open and close it, and the keyboard stays on the heading.",
        "When closed, the hidden section is hidden from everyone, so screen readers and the Tab key skip it.",
        "The arrow is hidden from screen readers.",
        "The content grows open in --dur-base and closes in --dur-fast, on --ease-arrive. It is clipped only while the height moves, so focus rings inside are not cut off once it is open.",
        "When people turn off motion in their system settings, it opens and closes at once."
      ],
      "accessibilityYouDo": [
        "Write heading words that make sense alone, because the arrow is not read out.",
        "Do not put another button or link inside the heading: the heading is already a button."
      ],
      "onPhone": [
        "The whole header row is the button, so the tap target is the full width. On touch screens its tap area is at least 44px tall.",
        "It opens by tap and has no hover state, so it works the same on a phone.",
        "The content grows open in place and pushes the page down, with no scrolling area of its own."
      ],
      "importPath": "import { Collapsible } from \"./index\"",
      "sourcePath": "src/molecules/Collapsible.tsx",
      "props": [
        {
          "name": "header",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Content of the toggle button, shown next to the chevron."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Panel content. Stays mounted and is hidden with the hidden attribute once it has finished closing."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Initial open state when the part keeps its own state."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Controls whether it is open. Use with onOpenChange; without it the part keeps its own state."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called with the new value when the header is pressed."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": null,
          "description": "Wraps the header button in a heading of this level, so screen reader users can jump between sections."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Class applied to the outer wrapper."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--fg-subtle",
        "--dur-fast",
        "--dur-base",
        "--ease-arrive"
      ],
      "related": [
        "thinking-block",
        "tool-call-card"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Closed by default",
          "description": "The heading can hold more than words, like a count or a badge.",
          "code": "<Collapsible header={<span className=\"text-body leading-5 font-medium\">Advanced options</span>}>\n  <p className=\"mt-2 pl-6 text-body leading-5 text-fg-muted\">These settings apply to new chats only.</p>\n</Collapsible>"
        },
        {
          "title": "Open by default",
          "description": "Start it open when the content is the main reason people came.",
          "code": "<Collapsible defaultOpen header={<span className=\"text-body leading-5 font-medium\">Details</span>}>\n  <p className=\"mt-2 pl-6 text-body leading-5 text-fg-muted\">Created on 4 March by the workspace owner.</p>\n</Collapsible>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-collapsible/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/collapsible.md"
    },
    {
      "id": "tray-row",
      "name": "TrayRow",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "list item",
        "sidebar item",
        "nav item"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "The row gets a soft background and shows its trailing text, such as Open.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears and the trailing text shows when you reach the row with the keyboard.",
          "source": "documented"
        },
        {
          "name": "attention",
          "note": "Pass attention to swap the icon for a small attention dot.",
          "source": "documented"
        },
        {
          "name": "truncated",
          "note": "On wider screens a long detail is cut off with an ellipsis.",
          "source": "documented"
        }
      ],
      "summary": "A compact list row with an icon, a title, a detail line and a hint that shows on hover.",
      "description": "TrayRow is one pressable row for tight lists, like a task tray or recent items. Place it inside a list. When attention is on, the icon becomes an amber dot and the detail text gets darker. That marks the row that needs a person.",
      "purpose": "A compact row that can be pressed, for tight lists. The amber dot is the one mark that says this row is waiting on a person.",
      "useFor": [
        "Items in a task tray or a list of recent things.",
        "A row that opens something when pressed.",
        "Showing which item is waiting on the person: turn on attention."
      ],
      "notFor": [
        {
          "text": "A to-do item that shows status and is not pressed",
          "use": "checklist-row"
        },
        {
          "text": "A tag that shows what the chat is about",
          "use": "context-chip"
        }
      ],
      "whenToUse": [
        "Put rows inside a list.",
        "Mark only rows that truly wait on a person.",
        "Write a detail that says the status or the next step.",
        "Keep titles short so the detail has room."
      ],
      "avoid": [
        "Do not mark most rows. The amber dot loses meaning when it is common.",
        "Do not hide essential actions in the hint. It only shows on hover or keyboard focus.",
        "Do not use it outside a list."
      ],
      "accessibility": [
        "The row is a real button. The Tab key reaches it, and Enter or Space presses it.",
        "The amber dot is read as \"Needs attention\".",
        "The hint at the end shows on hover and on keyboard focus, and always shows on touch screens.",
        "On touch screens the row keeps its size and has a tap area at least 44px tall."
      ],
      "accessibilityYouDo": [
        "Put rows inside a list (ul or ol). Each row is already a list item.",
        "Give the list a name for screen readers if the page has more than one.",
        "When attention is on, say why in the detail text."
      ],
      "onPhone": [
        "Under 640px wide the row has rounded corners, the title sits above the detail, and the detail wraps instead of being cut off.",
        "From 640px wide the title and detail share one line and the detail is truncated.",
        "Rows keep their size and have a tap area at least 44px tall on touch screens, and the trailing text is always visible there instead of only on hover."
      ],
      "importPath": "import { TrayRow } from \"./index\"",
      "sourcePath": "src/molecules/TrayRow.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Primary label, medium weight."
        },
        {
          "name": "detail",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Secondary text that truncates when space runs out."
        },
        {
          "name": "attention",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Shows the amber dot instead of the icon and sets the detail text to the foreground colour."
        },
        {
          "name": "icon",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Leading icon. Ignored when attention is true."
        },
        {
          "name": "trailing",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Small text or node at the right edge. Hidden until the row is hovered or focused."
        },
        {
          "name": "onClick",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Click handler for the row button."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-hover",
        "--fg",
        "--fg-subtle",
        "--attention",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "checklist-row",
        "context-chip"
      ],
      "composedOf": [
        "attention-dot"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Plain rows",
          "description": "Icons are optional. The hint at the end shows on hover or when the row is reached by keyboard.",
          "code": "<ul className=\"w-full max-w-sm space-y-0.5\">\n  <TrayRow icon={<FileIcon width={14} height={14} />} title=\"Meeting notes\" detail=\"Edited today\" trailing=\"Open\" />\n  <TrayRow icon={<FileIcon width={14} height={14} />} title=\"Roadmap draft\" detail=\"Edited yesterday\" trailing=\"Open\" />\n</ul>"
        },
        {
          "title": "Needs attention",
          "description": "Mark the row where a person has to decide something. The detail should say what.",
          "code": "<ul className=\"w-full max-w-sm space-y-0.5\">\n  <TrayRow attention title=\"Weekly digest\" detail=\"Waiting for your approval\" trailing=\"Review\" />\n  <TrayRow icon={<FileIcon width={14} height={14} />} title=\"Invoice batch\" detail=\"Sent\" />\n</ul>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-tray-row/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/tray-row.md"
    },
    {
      "id": "context-chip",
      "name": "ContextChip",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "context pill",
        "context chip",
        "mention chip",
        "attached context"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "When onClick is set, the pill gets a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "When onClick or onClear is set, those buttons show a focus ring on keyboard focus.",
          "source": "documented"
        },
        {
          "name": "clearable",
          "note": "Pass onClear to add a small clear button inside a shared rounded background.",
          "source": "documented"
        }
      ],
      "summary": "A small tag near the message box that shows what the chat is about.",
      "description": "ContextChip shows what a chat is about. It can be a button that opens a list of choices. It can be a label with a remove button. Or it can be a plain label.",
      "purpose": "Shows what the chat is about, right beside the message box. It can open choices, be removed, or simply sit there as a label.",
      "useFor": [
        "Showing the project, file or folder the chat is limited to.",
        "Letting a person change that scope: give it onClick to open choices.",
        "Letting a person drop the scope: give it onClear."
      ],
      "notFor": [
        {
          "text": "Ready-made questions to send",
          "use": "suggestion-chips"
        },
        {
          "text": "A file attached to one message",
          "use": "attachment-chip"
        },
        {
          "text": "A status or category label",
          "use": "badge"
        }
      ],
      "whenToUse": [
        "Make it pressable when the topic can be changed. Add a remove button when it can be removed.",
        "Keep labels to a few words, like a file name.",
        "Put tags in one row near the message box."
      ],
      "avoid": [
        "Do not make a tag both pressable and removable unless both make sense.",
        "Do not use it for tags or filters in a table. It is only for the message box.",
        "Do not use very long file names. Shorten them first."
      ],
      "accessibility": [
        "With onClick it is a real button. Without it, it is plain text and the Tab key skips it.",
        "The remove button is a real button named \"Clear context\".",
        "The arrow and the remove cross are hidden from screen readers. The label is read.",
        "After the tag is cleared, keyboard focus moves to what is left of the pill, else the next control, else the message box. Pass focusAfterClear to choose another place."
      ],
      "accessibilityYouDo": [
        "When the pill opens choices, move focus into them, because the choices are yours.",
        "When there is more than one removable tag, give each a clearLabel that says what it clears, such as \"Clear project Atlas\".",
        "Pass an icon that is hidden from screen readers, so only the label is read."
      ],
      "onPhone": [
        "On touch screens the pill stays 28px tall. Its clear button, and the pill itself when it is a button, keep their size and have a 44px tap area.",
        "It has no hover-only parts: the picker opens on tap and the clear button is always shown.",
        "The label has no truncation, so keep it short for a narrow screen."
      ],
      "importPath": "import { ContextChip } from \"./index\"",
      "sourcePath": "src/molecules/ContextChip.tsx",
      "props": [
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Label text."
        },
        {
          "name": "icon",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Leading icon in the subtle foreground colour."
        },
        {
          "name": "onClick",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Turns the pill into a button and adds a chevron. Use it to open a picker."
        },
        {
          "name": "onClear",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Adds a clear button after the pill. The pill itself then has no border."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the pill body."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--border",
        "--surface",
        "--surface-hover",
        "--fg",
        "--fg-subtle",
        "--radius-control"
      ],
      "related": [
        "suggestion-chips",
        "tray-row"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Picker and removable",
          "description": "The first tag opens a list of choices. The second is something attached that the person can remove.",
          "code": "<div className=\"flex flex-wrap items-center gap-2\">\n  <ContextChip icon={<GlobeIcon width={14} height={14} />} onClick={() => {}}>All sources</ContextChip>\n  <ContextChip icon={<FileIcon width={14} height={14} />} onClear={() => {}}>Q3 planning.pdf</ContextChip>\n</div>"
        },
        {
          "title": "Static label",
          "description": "With neither, it is a plain label.",
          "code": "<ContextChip icon={<PaperclipIcon width={14} height={14} />}>2 files attached</ContextChip>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-context-chip/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/context-chip.md"
    },
    {
      "id": "suggestion-chips",
      "name": "SuggestionChips",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "suggestions",
        "suggested prompts",
        "quick replies",
        "prompt starters",
        "chips"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "A chip gets a stronger border and a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears on a chip when you reach it with the keyboard.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "Pass an empty suggestions array and no chips are shown.",
          "source": "documented"
        }
      ],
      "summary": "A row of ready-made questions a person can pick.",
      "description": "SuggestionChips shows a few ready-made questions as small buttons. When a person picks one, your app gets the exact words and decides whether to fill the message box or send it. Use it on an empty chat and after a reply to make the next step easy.",
      "purpose": "Gives a person a ready-made next step instead of a blank box. Each suggestion is a short button holding the exact words.",
      "useFor": [
        "An empty chat, to show what the assistant can do.",
        "After a reply, to offer likely follow-up questions."
      ],
      "notFor": [
        {
          "text": "A whole first screen with starting ideas and what the assistant can do",
          "use": "starter-gallery"
        },
        {
          "text": "Showing what the chat is about",
          "use": "context-chip"
        },
        {
          "text": "Picking one setting from a few options",
          "use": "segmented-control"
        }
      ],
      "whenToUse": [
        "Offer three to five suggestions, worded the way the person would say them.",
        "Make each one a complete request that makes sense on its own.",
        "Change them as the conversation moves on."
      ],
      "avoid": [
        "Do not show suggestions while a reply is still appearing. They compete with it.",
        "Do not repeat the same suggestion twice.",
        "Do not use long sentences. A paragraph inside a small button is hard to read."
      ],
      "accessibility": [
        "The list is named \"Suggested prompts\", and screen readers hear how many suggestions there are.",
        "Each suggestion is a real button that reads its own words.",
        "The Tab key reaches each suggestion, and Enter or Space picks it."
      ],
      "accessibilityYouDo": [
        "Move the keyboard to the message box, or wherever fits, after a pick.",
        "Give every suggestion different words. They are told apart only by their words."
      ],
      "onPhone": [
        "Chips wrap onto new rows instead of scrolling sideways.",
        "Each chip keeps its size and has a 44px tap area on touch screens.",
        "A tap calls onPick, and no part of it needs hover."
      ],
      "importPath": "import { SuggestionChips } from \"./index\"",
      "sourcePath": "src/molecules/SuggestionChips.tsx",
      "props": [
        {
          "name": "suggestions",
          "type": "string[]",
          "required": true,
          "default": null,
          "description": "Prompt texts to show. Each string is also used as its key, so keep them unique."
        },
        {
          "name": "onPick",
          "type": "(s: string) => void",
          "required": true,
          "default": null,
          "description": "Called with the chosen suggestion."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--border",
        "--border-strong",
        "--surface",
        "--surface-sunken",
        "--fg-muted",
        "--fg",
        "--radius-control"
      ],
      "related": [
        "context-chip"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Pick a prompt",
          "description": "It does not remember the choice. Here the demo keeps it.",
          "code": "const [picked, setPicked] = useState<string | null>(null);\n\n<SuggestionChips\n  suggestions={[\"Summarise this report\", \"Draft a reply\", \"List open questions\"]}\n  onPick={setPicked}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-suggestion-chips/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/suggestion-chips.md"
    },
    {
      "id": "message-actions",
      "name": "MessageActions",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "message toolbar",
        "copy retry",
        "response actions",
        "actions"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "copied",
          "note": "After a successful copy the icon becomes a check and the label reads Copied for about 1.5 seconds.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "Each icon button gets a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Each icon button shows a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "Copy and retry buttons that sit under an assistant reply.",
      "description": "MessageActions is a small row of icon buttons under a reply. Copy saves the text so it can be pasted. It shows a check and \"Copied\" for a moment. The Regenerate button only appears if you turn it on. It is made for the bottom of a Message.",
      "purpose": "Small icon buttons under a reply for what people do after reading it: copy it, or ask for a new one. Icons keep the row quiet so it does not compete with the answer.",
      "useFor": [
        "Under every finished assistant reply.",
        "Offering a fresh answer: pass onRegenerate to show the second button."
      ],
      "notFor": [
        {
          "text": "Rating whether a reply was good",
          "use": "feedback-bar"
        },
        {
          "text": "Copying a piece of code inside a reply",
          "use": "code-block"
        },
        {
          "text": "Moving between versions of a reply",
          "use": "version-pager"
        }
      ],
      "whenToUse": [
        "Put it at the bottom of an assistant Message once the reply is finished.",
        "Give it the plain text of the answer.",
        "Add Regenerate only when a new try is possible."
      ],
      "avoid": [
        "Do not show it on a person's messages or while the reply is still appearing.",
        "Do not add more than a few actions. Put extras in a menu.",
        "Do not assume copying always works. If the browser blocks it, nothing is shown."
      ],
      "accessibility": [
        "Both buttons have names that screen readers read: \"Copy response\" and \"Regenerate response\".",
        "After copying, a check shows and the name changes to \"Copied\" for a moment.",
        "Both buttons work with the keyboard, and their names also show as a tooltip on hover.",
        "The icons are hidden from screen readers."
      ],
      "accessibilityYouDo": [
        "Add your own announcement if people must know the copy worked. Screen readers may not read the name change.",
        "Tell people when copying fails. The button stays silent when the clipboard is blocked."
      ],
      "onPhone": [
        "The copy and regenerate buttons stay 28px square on touch screens and have a 44px tap area.",
        "The buttons have a title for hover, which a phone does not show, so the label is read by screen readers only.",
        "Copy needs clipboard access from the browser. If it is blocked, nothing happens and no error is shown."
      ],
      "importPath": "import { MessageActions } from \"./index\"",
      "sourcePath": "src/molecules/MessageActions.tsx",
      "props": [
        {
          "name": "text",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Plain text written to the clipboard when Copy is pressed."
        },
        {
          "name": "onRegenerate",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "When set, adds a Regenerate response button."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg-subtle",
        "--surface-hover",
        "--dur-fast"
      ],
      "related": [
        "feedback-bar",
        "message"
      ],
      "composedOf": [
        "icon-button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Copy only",
          "description": "Without the retry option, only Copy shows.",
          "code": "<MessageActions text=\"Here is the summary you asked for.\" />"
        },
        {
          "title": "Copy and regenerate",
          "description": "",
          "code": "<MessageActions text=\"Here is the summary you asked for.\" onRegenerate={() => {}} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-message-actions/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/message-actions.md"
    },
    {
      "id": "code-block",
      "name": "CodeBlock",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "code snippet",
        "syntax highlighter",
        "pre",
        "code viewer"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "copied",
          "note": "After a successful copy the Copy button shows a check and the word Copied for about 1.5 seconds.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The Copy button and the scrollable code area each show a focus ring on keyboard focus.",
          "source": "documented"
        },
        {
          "name": "scrolling",
          "note": "A line wider than the block scrolls sideways instead of wrapping.",
          "source": "documented"
        }
      ],
      "summary": "A dark box for code, with the language name and a Copy button.",
      "description": "CodeBlock shows code exactly as written. A bar on top holds the language name and a Copy button. The button briefly says Copied. Long lines scroll sideways instead of wrapping. It does not color the code.",
      "purpose": "Shows code exactly as written, on a dark panel so it stands apart from the words around it. Long lines scroll sideways instead of wrapping, so nothing is broken up.",
      "useFor": [
        "Code, commands or settings inside a reply.",
        "Any text that people must copy exactly."
      ],
      "notFor": [
        {
          "text": "A short name or value inside a sentence"
        },
        {
          "text": "Changes between two versions of a file",
          "use": "diff-view"
        },
        {
          "text": "Formatted text with headings and lists",
          "use": "markdown"
        }
      ],
      "whenToUse": [
        "Set the language so people know what they are copying.",
        "Use it for multi-line code and commands in replies.",
        "Remove blank lines at the end of the code."
      ],
      "avoid": [
        "Do not use it for a single word in a sentence. Use inline code styling.",
        "Do not expect long lines to wrap. They scroll sideways.",
        "Do not paste in secrets like passwords. Anything shown can be copied."
      ],
      "accessibility": [
        "The box is a figure, and the bar with the language name is its caption.",
        "Copy is a real button with visible words. Screen readers politely hear \"Copied\" when it works.",
        "The Tab key can reach the code area, so keyboard users can scroll long lines with the arrow keys.",
        "The icons are hidden from screen readers."
      ],
      "accessibilityYouDo": [
        "Explain what the code does in the words around it. The language name alone is not enough.",
        "Tell people when copying fails. The button stays silent when the clipboard is blocked."
      ],
      "onPhone": [
        "Long lines scroll sideways inside the block and do not wrap, so the page itself does not scroll sideways.",
        "The Copy button keeps its size and has a 44px tap area on touch screens, so the header row does not get taller.",
        "If the browser blocks clipboard access, Copy does nothing and shows no error."
      ],
      "importPath": "import { CodeBlock } from \"./index\"",
      "sourcePath": "src/molecules/CodeBlock.tsx",
      "props": [
        {
          "name": "code",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Source text shown in the block and copied to the clipboard."
        },
        {
          "name": "language",
          "type": "string",
          "required": false,
          "default": "\"text\"",
          "description": "Label shown in the caption. It does not trigger highlighting."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--code-bg",
        "--code-fg",
        "--code-muted",
        "--border",
        "--radius-card"
      ],
      "related": [
        "message",
        "tool-call-card"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With a language",
          "description": "The language is only a name shown on top.",
          "code": "<CodeBlock language=\"ts\" code={sampleCode} />"
        },
        {
          "title": "Default label",
          "description": "With no language, the name reads text.",
          "code": "<CodeBlock code=\"npm install @tattva/ui\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-code-block/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/code-block.md"
    },
    {
      "id": "message",
      "name": "Message",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "chat bubble",
        "chat message",
        "response",
        "turn"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "streaming",
          "note": "Set with the streaming prop.",
          "source": "prop"
        }
      ],
      "summary": "One turn in a conversation: a bubble for the person, open text across the full width for the assistant.",
      "description": "Message shows one turn. A person's messages sit on the right in a soft bubble. The assistant's messages are open text across the full width, with no mark beside them, so long answers are easy to read and short ones do not leave a column of marks down the thread. Screen readers hear \"Assistant\" before each reply. Set mark to top to show a small AI mark and the name above a reply. While a reply is still appearing, a blinking cursor shows and screen readers stay quiet. When it finishes, they hear one short \"Response complete\" note. The bottom area holds actions, feedback or sources.",
      "purpose": "One turn in a chat. The person's words sit in a bubble on the right; the assistant's reply is open text across the full width, so long answers and code have room.",
      "useFor": [
        "Each turn of a conversation, from the person or the assistant.",
        "A reply that is still being written: turn on streaming.",
        "A finished reply with actions, feedback or sources underneath."
      ],
      "notFor": [
        {
          "text": "A whole conversation that should be read out as it grows",
          "use": "message-list"
        },
        {
          "text": "Notes from the app itself, such as limits or errors",
          "use": "callout"
        },
        {
          "text": "A refusal or a safety stop",
          "use": "refusal-notice"
        }
      ],
      "whenToUse": [
        "Keep the assistant's text out of a bubble so long text and code have room.",
        "Put actions and feedback at the bottom, and only after the reply ends.",
        "Wrap the messages in an area that announces new replies if your app should read them out.",
        "Show the AI mark once, on the first reply, or not at all. The thread already says who is speaking."
      ],
      "avoid": [
        "Do not put the assistant's text in a bubble. The layout needs it open.",
        "Do not put a mark beside every reply. It pushes the text in and fills the thread with repeated marks.",
        "Do not add a bottom area to a person's message. It is ignored.",
        "Remember to mark the reply as finished, or the blinking cursor stays."
      ],
      "accessibility": [
        "Screen readers stay quiet while words arrive, then say \"Response complete\" once.",
        "The blinking cursor is hidden from screen readers.",
        "Screen readers hear the name (\"Assistant\" by default) before every reply, whether or not the mark shows.",
        "The rise-in motion stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Set streaming to false when the reply ends. Until then the cursor keeps blinking and nothing is announced.",
        "Put conversations in MessageList, so screen readers hear new replies as they arrive.",
        "Give images and charts inside a reply their own text alternatives.",
        "When more than one assistant or agent speaks, give each its name and set mark to top, so people can see who said what."
      ],
      "onPhone": [
        "The user bubble is at most 80% of the width, and the assistant text takes the full width.",
        "The assistant text can shrink to the width of the screen and wraps. Wide children such as tables or code must scroll inside themselves."
      ],
      "importPath": "import { Message } from \"./index\"",
      "sourcePath": "src/molecules/Message.tsx",
      "props": [
        {
          "name": "variant",
          "type": "\"bubble\" | \"plain\"",
          "required": false,
          "default": "\"bubble\"",
          "description": "How the person's own turn looks. bubble is a right-aligned bubble. plain is left-aligned serif text with no box, like a line in a letter. It only changes user messages."
        },
        {
          "name": "mark",
          "type": "\"none\" | \"top\"",
          "required": false,
          "default": "\"none\"",
          "description": "Assistant only. none shows no mark: the reply uses the full width. top shows a small AI mark and the name above the reply. Use it once per thread, or on every reply when more than one assistant speaks."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "default": "\"Assistant\"",
          "description": "Assistant only. Shown with the mark, and read by screen readers before every reply."
        },
        {
          "name": "showAvatar",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Deprecated: use mark. true shows the mark above the reply."
        },
        {
          "name": "role",
          "type": "\"user\" | \"assistant\"",
          "required": true,
          "default": null,
          "description": "Chooses the layout. User renders a bubble, assistant renders open text across the full width."
        },
        {
          "name": "streaming",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Assistant only. Shows a blinking caret, sets aria-busy and sets aria-live=\"off\" on the text while tokens arrive."
        },
        {
          "name": "completeLabel",
          "type": "string",
          "required": false,
          "default": "\"Response complete\"",
          "description": "Assistant only. Announced once through a polite status region when streaming changes from true to false."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Message content."
        },
        {
          "name": "footer",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Assistant only. Slot under the content for MessageActions, FeedbackBar or sources."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the outer wrapper."
        }
      ],
      "variantsHint": {
        "variant": [
          "bubble",
          "plain"
        ],
        "mark": [
          "none",
          "top"
        ],
        "role": [
          "user",
          "assistant"
        ]
      },
      "tokens": [
        "--surface-sunken",
        "--fg",
        "--fg-muted",
        "--lime",
        "--on-lime",
        "--radius-card"
      ],
      "related": [
        "message-actions",
        "feedback-bar",
        "thinking-block"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "User and assistant",
          "description": "The person's bubble never fills the whole width. The assistant has no bubble.",
          "code": "<div className=\"w-full max-w-xl space-y-6\">\n  <Message role=\"user\">Can you summarize the meeting?</Message>\n  <Message role=\"assistant\">\n    <p>The team agreed to ship on Friday and review the open risks on Wednesday.</p>\n  </Message>\n</div>"
        },
        {
          "title": "Streaming",
          "description": "While words are still appearing, a blinking cursor shows and screen readers do not read each new word.",
          "code": "<Message role=\"assistant\" streaming>\n  <p>Looking at the notes now, the main decision was</p>\n</Message>"
        },
        {
          "title": "With a footer",
          "description": "Add actions and feedback at the bottom once the reply is finished.",
          "code": "<Message\n  role=\"assistant\"\n  footer={\n    <div className=\"space-y-2\">\n      <MessageActions text=\"The team agreed to ship on Friday.\" onRegenerate={() => {}} />\n      <ConfidenceIndicator level=\"high\" />\n    </div>\n  }\n>\n  <p>The team agreed to ship on Friday.</p>\n</Message>"
        },
        {
          "title": "Plain turns, with the mark once",
          "description": "The person's turns are serif text with no bubble. The first reply shows the AI mark and name above it; the rest need no mark, because the thread already says who is speaking.",
          "code": "<div className=\"flex w-full max-w-xl flex-col gap-5\">\n  <Message role=\"user\" variant=\"plain\">My refund from Flipkart is stuck.</Message>\n  <Message role=\"assistant\" mark=\"top\">I read your message and found the order.</Message>\n  <Message role=\"user\" variant=\"plain\">Yes, that one.</Message>\n  <Message role=\"assistant\">Is the amount ₹3,499?</Message>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-message/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/message.md"
    },
    {
      "id": "thinking-block",
      "name": "ThinkingBlock",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "reasoning",
        "chain of thought",
        "thoughts",
        "thinking",
        "reasoning trace"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the active prop.",
          "source": "prop"
        }
      ],
      "summary": "A closed note of the assistant's thinking. It shimmers while it works and shows how long it took when done.",
      "description": "ThinkingBlock starts closed. While the assistant is working, it says Thinking with a shimmer. After that it says how many seconds it thought. Opening it shows the thinking in light text with a line on the left.",
      "purpose": "Keeps the assistant's reasoning out of the way but within reach. It starts closed, shimmers while the assistant works, then says how long it took.",
      "useFor": [
        "Reasoning the assistant shows before its answer.",
        "Letting people check how an answer was reached, if they want to."
      ],
      "notFor": [
        {
          "text": "One step the assistant took, such as a search",
          "use": "tool-call-card"
        },
        {
          "text": "The answer itself",
          "use": "message"
        },
        {
          "text": "A simple waiting signal with nothing to open",
          "use": "typing-indicator"
        }
      ],
      "whenToUse": [
        "Show it above the answer, so the answer stays the main thing.",
        "Switch to the finished look with the real time taken.",
        "Keep it closed so people choose to see the detail.",
        "Pass seconds once thinking ends. Without it, the heading says 0s."
      ],
      "avoid": [
        "Do not open it by default. It has no setting for that.",
        "Do not present the thinking as a checked source.",
        "Do not show a made-up time. Leave the block out instead."
      ],
      "accessibility": [
        "The heading is a real button. Screen readers say whether it is open or closed.",
        "\"Thinking…\" and the time it took are real text that screen readers read. The shimmer is only a visual effect.",
        "The shimmer stops when people turn off motion in their system settings.",
        "When closed, the reasoning is skipped by screen readers."
      ],
      "accessibilityYouDo": [
        "Announce when thinking ends if screen reader users need to know. The part does not."
      ],
      "onPhone": [
        "The header is a full-width button with a tap area at least 44px tall on touch screens.",
        "The opened text sits behind a left rule and wraps to the screen width.",
        "It is closed by default, so it takes one line on a phone until someone taps it."
      ],
      "importPath": "import { ThinkingBlock } from \"./index\"",
      "sourcePath": "src/molecules/ThinkingBlock.tsx",
      "props": [
        {
          "name": "active",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "When true the header shimmers and reads Thinking."
        },
        {
          "name": "seconds",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Elapsed seconds shown when not active. Falls back to 0."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Reasoning text shown when expanded."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg-subtle",
        "--fg-muted",
        "--border"
      ],
      "related": [
        "tool-call-card",
        "collapsible",
        "message"
      ],
      "composedOf": [
        "collapsible",
        "shimmer-text"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Active",
          "description": "Use while the assistant is still thinking.",
          "code": "<ThinkingBlock active>Comparing the two schedules against the team calendar.</ThinkingBlock>"
        },
        {
          "title": "Finished",
          "description": "When done, give the time taken. It shows 0 seconds if you leave it out.",
          "code": "<ThinkingBlock seconds={8}>Comparing the two schedules against the team calendar.</ThinkingBlock>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-thinking-block/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/thinking-block.md"
    },
    {
      "id": "tool-call-card",
      "name": "ToolCallCard",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "tool call",
        "tool use",
        "function call",
        "tool"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        }
      ],
      "summary": "A card that shows one step the assistant took, with its status, how long it took, and what went in and out.",
      "description": "ToolCallCard shows one step the assistant took. The top line gives the step name, a short summary, how long it took and a status tag. Opening it shows what was sent and what came back. The icon is a tool while waiting, a spinner while running, a check when done and an alert if it failed. A failure also tints the border.",
      "purpose": "Makes one step the assistant took visible and easy to check. The top line says what it is and how it went; what went in and came out stays folded until someone wants it.",
      "useFor": [
        "A search, file read or other action the assistant ran.",
        "A step that failed, with what was sent and what came back.",
        "A step that is still running."
      ],
      "notFor": [
        {
          "text": "The assistant's reasoning",
          "use": "thinking-block"
        },
        {
          "text": "A list of many steps in a task",
          "use": "step-timeline"
        },
        {
          "text": "An action that needs the person's approval first",
          "use": "approval-prompt"
        }
      ],
      "whenToUse": [
        "Write a summary anyone would understand.",
        "Update the same card as the step moves from waiting to running to done.",
        "Show the time taken once the step finishes.",
        "When a step fails, show what went wrong."
      ],
      "avoid": [
        "Do not put secrets or personal data in what was sent. It shows as plain text.",
        "Do not use it to ask for approval. It reports. It does not ask.",
        "Do not leave a failed step without a note on what to do next."
      ],
      "accessibility": [
        "The top line is a real button. Screen readers say whether it is open or closed.",
        "Status is written as a word (Queued, Running, Done or Failed), not shown by icon and color alone.",
        "While running, the spinner is read as \"Loading\". The other icons are hidden from screen readers.",
        "The Tab key can reach the input box, so long lines can be scrolled with the keyboard."
      ],
      "accessibilityYouDo": [
        "Announce status changes yourself if they matter, for example from an area that screen readers watch for updates.",
        "Write the summary in plain words, such as Searching \"q3 revenue\", so the step makes sense without opening it."
      ],
      "onPhone": [
        "The name and the summary are each cut to one line with an ellipsis, and the full text is not shown anywhere else on the card.",
        "The input and output blocks both scroll sideways, and long unbroken strings in the output wrap.",
        "The whole header is one tap target that opens the details, with a tap area at least 44px tall on touch screens."
      ],
      "importPath": "import { ToolCallCard } from \"./index\"",
      "sourcePath": "src/molecules/ToolCallCard.tsx",
      "props": [
        {
          "name": "name",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Tool identifier, shown in a monospace face."
        },
        {
          "name": "status",
          "type": "\"pending\" | \"running\" | \"success\" | \"error\"",
          "required": true,
          "default": null,
          "description": "Drives the icon, the badge label (Queued, Running, Done, Failed) and the badge tone."
        },
        {
          "name": "summary",
          "type": "string",
          "required": false,
          "default": null,
          "description": "One-line human description of what the tool is doing."
        },
        {
          "name": "input",
          "type": "unknown",
          "required": false,
          "default": null,
          "description": "Any value. Shown as indented JSON in the expanded panel when it is not undefined."
        },
        {
          "name": "output",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Result shown in the expanded panel when it is not null or undefined."
        },
        {
          "name": "durationMs",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Elapsed time in milliseconds, shown in seconds with one decimal. Hidden while running."
        }
      ],
      "variantsHint": {
        "status": [
          "pending",
          "running",
          "success",
          "error"
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--danger",
        "--success",
        "--surface-sunken",
        "--fg-muted",
        "--fg-subtle",
        "--radius-card"
      ],
      "related": [
        "thinking-block",
        "collapsible",
        "code-block"
      ],
      "composedOf": [
        "collapsible",
        "badge",
        "spinner"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Pending and running",
          "description": "A waiting step says Queued. A running step shows a spinner and no time yet.",
          "code": "<div className=\"w-full max-w-md space-y-2\">\n  <ToolCallCard name=\"Search the web\" status=\"pending\" summary=\"Waiting to start\" />\n  <ToolCallCard name=\"Search the web\" status=\"running\" summary='Searching \"last quarter revenue\"' />\n</div>"
        },
        {
          "title": "Success with details",
          "description": "Open the card to see what was sent and what came back.",
          "code": "<ToolCallCard\n  name=\"Search the web\"\n  status=\"success\"\n  summary='Searched \"last quarter revenue\"'\n  durationMs={1840}\n  input={{ query: \"last quarter revenue\", limit: 5 }}\n  output=\"5 results found\"\n/>"
        },
        {
          "title": "Error",
          "description": "The red tag, alert icon and tinted border make a failed step easy to spot.",
          "code": "<ToolCallCard\n  name=\"Open a web page\"\n  status=\"error\"\n  summary=\"The page took too long to respond\"\n  durationMs={30000}\n  input={{ url: \"https://example.com/report\" }}\n  output=\"No answer after 30 seconds\"\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-tool-call-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/tool-call-card.md"
    },
    {
      "id": "checklist-row",
      "name": "ChecklistRow",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "task row",
        "todo item"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "attention",
          "note": "Set attention on the item to show a dot beside the status and use the stronger text colour.",
          "source": "documented"
        }
      ],
      "summary": "A list row with a title, an optional tag and a status on the right. An amber dot marks items that need a person.",
      "description": "ChecklistRow shows one item from a to-do list. The title and an optional tag sit on the left. The status sits on the right in light text. If the item needs a person, the status is darker and has an amber dot. A thin line separates rows.",
      "purpose": "One item in a to-do list, with its status at the end of the row. Only items that need a person get the amber dot and darker status, so they stand out.",
      "useFor": [
        "Steps in a task that the assistant or a team is working through.",
        "A list where a few items wait on the person and most do not."
      ],
      "notFor": [
        {
          "text": "A row that opens something when pressed",
          "use": "tray-row"
        },
        {
          "text": "A whole list with finished work folded away",
          "use": "checklist"
        },
        {
          "text": "A step the assistant is running right now",
          "use": "tool-call-card"
        }
      ],
      "whenToUse": [
        "Put rows inside a list.",
        "Use the amber dot only when a person has to act, and say what in the status.",
        "Use the tag for a small label, like a type or priority."
      ],
      "avoid": [
        "Do not use the amber dot for progress or priority. It means a person must act.",
        "Do not combine a long title with a tag. The title gets cut short.",
        "Do not expect it to be pressable. It has no checkbox."
      ],
      "accessibility": [
        "Each row is a list item, so inside a list screen readers hear how many items there are.",
        "The amber dot is read as \"Needs attention\".",
        "Status is written in words, not shown by color alone."
      ],
      "accessibilityYouDo": [
        "Put rows in a list (ul or ol) and give the list a name for screen readers.",
        "Give every item its own id.",
        "When attention is on, say why in the status.",
        "Give anything pressable that you put in the title or tag its own name."
      ],
      "onPhone": [
        "The row fills the width. The title takes the space left by the status on the right.",
        "It is not tappable. If a row should open something, wrap it in a button of your own, which gets a 44px tap area, or a link with the tap class.",
        "Rows have 12px of padding above and below, and no minimum height."
      ],
      "importPath": "import { ChecklistRow } from \"./index\"",
      "sourcePath": "src/molecules/ChecklistRow.tsx",
      "props": [
        {
          "name": "item",
          "type": "ChecklistItem",
          "required": true,
          "default": null,
          "description": "Object with id (string, required), title (ReactNode, required), badge (ReactNode), status (string) and attention (boolean)."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--border",
        "--fg",
        "--fg-subtle",
        "--attention"
      ],
      "related": [
        "tray-row"
      ],
      "composedOf": [
        "attention-dot"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Mixed statuses",
          "description": "Only the row that needs a person gets the dot. The others stay quiet.",
          "code": "<ul className=\"w-full max-w-md\">\n  <ChecklistRow item={{ id: \"1\", title: \"Update the brand sheet\", status: \"Done\" }} />\n  <ChecklistRow item={{ id: \"2\", title: \"Review the contract\", status: \"Waiting for your sign-off\", attention: true }} />\n  <ChecklistRow item={{ id: \"3\", title: \"Send the weekly note\", badge: <Badge>Draft</Badge>, status: \"In progress\" }} />\n</ul>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-checklist-row/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/checklist-row.md"
    },
    {
      "id": "confidence-indicator",
      "name": "ConfidenceIndicator",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "confidence score",
        "certainty",
        "uncertainty indicator"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "low",
          "note": "Set level to low for one bar, a warning badge and a verify-before-relying note.",
          "source": "documented"
        },
        {
          "name": "medium",
          "note": "Set level to medium for two bars and a neutral badge.",
          "source": "documented"
        },
        {
          "name": "high",
          "note": "Set level to high for three bars and a success badge.",
          "source": "documented"
        }
      ],
      "summary": "Three bars and a label that show how sure the assistant is about an answer.",
      "description": "ConfidenceIndicator pairs three small bars with a label: Low, Medium or High confidence. It never relies on color alone. More bars fill as confidence rises, and screen readers get a sentence that explains the level. High is green and medium is gray. Low is amber, because a person should double-check the answer. Use it beside replies that are not always reliable.",
      "purpose": "Tells people how far to trust an answer. Bars, a word and a sentence all say the same thing, so nobody needs to see color to read it.",
      "useFor": [
        "Beside a reply or fact that is not always reliable.",
        "Next to an extracted value that a person may need to check."
      ],
      "notFor": [
        {
          "text": "The model's own rating of itself, or any number you have not measured (see the Trust and safety guide)"
        },
        {
          "text": "How much of something is used",
          "use": "meter"
        },
        {
          "text": "Saying that content was made by AI",
          "use": "ai-label"
        }
      ],
      "whenToUse": [
        "Show it near the answer it describes, like at the bottom of a message.",
        "Base the level on something real: how well the sources support the answer, how much of the question was covered, or a success rate you measured on real cases. Write down what each level means for your product.",
        "When confidence is low, suggest a next step, like checking sources.",
        "Leave it out when you have no real signal. No label is better than one that guesses."
      ],
      "avoid": [
        "Do not drive it from the model's own rating of itself (\"I am 90% sure\"). Models are often sure and wrong.",
        "Do not show a percentage you have not measured; a number looks more exact than it is.",
        "Do not put it on every answer as decoration.",
        "Do not drop the words to save space. They are the main clue.",
        "Do not add levels beyond low, medium and high."
      ],
      "accessibility": [
        "The bars are hidden from screen readers. They read the label, such as \"High confidence\".",
        "A hidden sentence explains the level, such as \"Low confidence. Verify before relying on this.\" Setting showText to false removes it.",
        "The same sentence shows as a tooltip on hover.",
        "The bars' grow-in motion stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Keep showText on unless the same explanation is already written nearby.",
        "Place it right next to the answer it rates, so it is clear what it refers to."
      ],
      "onPhone": [
        "It stays on one line: bars, then the badge.",
        "The longer sentence, such as \"Verify before relying on this\", is only a hover tooltip and a screen reader line, so on a phone the bars and the badge word carry the meaning.",
        "The badge text is at least 12px on touch screens."
      ],
      "importPath": "import { ConfidenceIndicator } from \"./index\"",
      "sourcePath": "src/molecules/ConfidenceIndicator.tsx",
      "props": [
        {
          "name": "level",
          "type": "\"low\" | \"medium\" | \"high\"",
          "required": true,
          "default": null,
          "description": "Confidence level. Controls the bars, the badge tone and the label."
        },
        {
          "name": "showText",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Includes a visually hidden sentence that explains the level, for example Low confidence. Verify before relying on this."
        }
      ],
      "variantsHint": {
        "level": [
          "low",
          "medium",
          "high"
        ]
      },
      "tokens": [
        "--attention",
        "--attention-soft",
        "--attention-fg",
        "--success",
        "--success-soft",
        "--fg-subtle",
        "--border-strong"
      ],
      "related": [
        "callout"
      ],
      "composedOf": [
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "All levels",
          "description": "Low fills one bar, medium two and high three.",
          "code": "<div className=\"flex flex-col items-start gap-3\">\n  <ConfidenceIndicator level=\"low\" />\n  <ConfidenceIndicator level=\"medium\" />\n  <ConfidenceIndicator level=\"high\" />\n</div>"
        },
        {
          "title": "Without the hidden explanation",
          "description": "Turn off the hidden explanation for screen readers. The tooltip still shows on hover.",
          "code": "<ConfidenceIndicator level=\"medium\" showText={false} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-confidence-indicator/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/confidence-indicator.md"
    },
    {
      "id": "feedback-bar",
      "name": "FeedbackBar",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "thumbs up down",
        "rating",
        "feedback",
        "like dislike"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "reason picker",
          "note": "After a thumbs down, a row of reasons and a Skip button appears below the icons.",
          "source": "documented"
        },
        {
          "name": "submitted",
          "note": "After a rating is sent a thank-you message appears and further clicks are ignored until resetKey changes.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The thumb buttons and reason buttons show a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "Thumbs up and down buttons, with a short list of reasons after a thumbs down.",
      "description": "FeedbackBar asks how a reply was. A thumbs up is sent right away. A thumbs down shows a row of reasons and a Skip button. Picking a reason sends it with the rating. Skip sends just the rating. After that, the chosen thumb stays pressed and a thank-you appears beside it. Change resetKey when a new message arrives to start over.",
      "purpose": "A quick way to say whether a reply helped. A thumbs down asks why with one tap, and Skip keeps the reason optional.",
      "useFor": [
        "Under a finished assistant reply.",
        "Collecting a reason when a reply was wrong, unhelpful or unsafe."
      ],
      "notFor": [
        {
          "text": "Copying or retrying a reply",
          "use": "message-actions"
        },
        {
          "text": "A detailed written report or survey"
        }
      ],
      "whenToUse": [
        "Show it after a reply is finished, next to the message actions.",
        "Keep the thank-you short and plain.",
        "Save the answer in onSubmit. The part only hands it to you.",
        "Change resetKey when a new message arrives, so the bar starts fresh."
      ],
      "avoid": [
        "Do not force a reason. Keep Skip so people can send just the rating.",
        "Do not reuse one bar for several messages without a resetKey. It will not clear itself.",
        "Do not use it for general surveys. It rates one reply."
      ],
      "accessibility": [
        "The thumbs are named \"Good response\" and \"Bad response\", and screen readers say which one is pressed.",
        "After a rating is sent, the chosen thumb stays pressed and pressing either does nothing.",
        "The reasons form a group named \"What went wrong?\". Each reason and Skip is a real button the keyboard can reach.",
        "The thank-you line is announced politely when it appears."
      ],
      "accessibilityYouDo": [
        "Keep it directly under the reply it rates. Every bar uses the same button names, so position tells screen reader users which reply they are rating."
      ],
      "onPhone": [
        "The thumb buttons stay 28px square on touch screens and have a 44px tap area.",
        "After a thumbs down the reasons wrap onto new rows. Each keeps its size and has a 44px tap area on touch screens.",
        "Nothing needs hover, and the thank-you line is shown beside the thumbs."
      ],
      "importPath": "import { FeedbackBar } from \"./index\"",
      "sourcePath": "src/molecules/FeedbackBar.tsx",
      "props": [
        {
          "name": "onSubmit",
          "type": "(rating: \"up\" | \"down\", reason?: string) => void",
          "required": true,
          "default": null,
          "description": "Called once per message. Thumbs up sends only the rating. A reason button sends rating down and the reason text. Skip sends rating down with no reason."
        },
        {
          "name": "resetKey",
          "type": "unknown",
          "required": false,
          "default": null,
          "description": "Any value, such as the message id. When it changes, the bar clears its rating and returns to the thumbs. It does not reset on first render."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg-muted",
        "--accent-soft",
        "--accent-fg",
        "--border"
      ],
      "related": [
        "message-actions",
        "message"
      ],
      "composedOf": [
        "button",
        "icon-button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Interactive",
          "description": "Try thumbs down to see the reasons. The line below shows what was sent.",
          "code": "<FeedbackBar onSubmit={(rating, reason) => console.log(rating, reason)} />"
        },
        {
          "title": "Reset for a new message",
          "description": "Send feedback, then choose Next message. The bar starts fresh for each new message.",
          "code": "<FeedbackBar resetKey={message.id} onSubmit={(rating, reason) => send(message.id, rating, reason)} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-feedback-bar/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/feedback-bar.md"
    },
    {
      "id": "source-card",
      "name": "SourceCard",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "source",
        "reference card",
        "link card",
        "citation card"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "The card gets a stronger border and a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears when you reach the card link with the keyboard.",
          "source": "documented"
        },
        {
          "name": "truncated",
          "note": "A long title is cut to one line and a long snippet to two lines.",
          "source": "documented"
        }
      ],
      "summary": "A numbered link card for one source.",
      "description": "SourceCard is a link in a box. It has a numbered lime-tinted marker, a title, the website name and an optional short excerpt. The matching CitationMarker jumps to it. It opens in a new tab. Put several in a numbered list.",
      "purpose": "One numbered source that an answer drew on. The number matches the citation in the text, so people can check where a claim came from.",
      "useFor": [
        "The list of sources under a reply.",
        "A place for citation markers in the text to jump to."
      ],
      "notFor": [
        {
          "text": "The marker inside the sentence itself",
          "use": "citation-marker"
        },
        {
          "text": "A whole set of sources laid out in columns",
          "use": "source-list"
        }
      ],
      "whenToUse": [
        "Put cards in a numbered list so they match the small numbers in the text.",
        "Keep numbers steady and matching the CitationMarkers.",
        "Add an excerpt when the title alone is unclear."
      ],
      "avoid": [
        "Do not use lime for text. Here it only fills the small number circle.",
        "Do not leave the website blank. It tells people where the link goes.",
        "Do not use it outside a list."
      ],
      "accessibility": [
        "The whole card is one link. Screen readers read the number, title, website and excerpt in order.",
        "Long titles and excerpts are cut short on screen, but screen readers still read all of it.",
        "Each card carries an anchor for its number, so citation markers can jump to it.",
        "The rise-in motion stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Put cards in a numbered list (ol), so screen readers hear the count.",
        "Say in the nearby text that sources open in a new tab.",
        "Give every source its own number, matching its citation marker."
      ],
      "onPhone": [
        "The whole card is one link that opens in a new tab, so the tap target is the full card.",
        "The title is cut to one line and the snippet to two lines, so every card stays short.",
        "Render it inside a list that sets the columns. The card itself fills the width it is given."
      ],
      "importPath": "import { SourceCard } from \"./index\"",
      "sourcePath": "src/molecules/SourceCard.tsx",
      "props": [
        {
          "name": "source",
          "type": "Source",
          "required": true,
          "default": null,
          "description": "Object with id (number), title (string), url (string), domain (string) and snippet (string, optional). The id forms the li anchor source-{id}."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--lime",
        "--border",
        "--border-strong",
        "--surface",
        "--surface-sunken",
        "--fg-subtle",
        "--radius-card"
      ],
      "related": [
        "message",
        "confidence-indicator"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Source list",
          "description": "The numbers match the small numbers in the text.",
          "code": "<ol className=\"grid w-full max-w-xl gap-2 sm:grid-cols-2\">\n  <SourceCard source={{ id: 1, title: \"Quarterly results\", url: \"https://example.com/results\", domain: \"example.com\", snippet: \"Revenue grew in every region compared with last year.\" }} />\n  <SourceCard source={{ id: 2, title: \"Market overview\", url: \"https://example.org/market\", domain: \"example.org\" }} />\n</ol>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-source-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/source-card.md"
    },
    {
      "id": "error-state",
      "name": "ErrorState",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "error page",
        "error message",
        "failure state",
        "retry"
      ],
      "category": "states",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "retry",
          "note": "Pass onRetry to show a Try again button next to the message.",
          "source": "documented"
        }
      ],
      "summary": "A red message for a problem that can be fixed, with an optional Try again button.",
      "description": "ErrorState is a red Callout titled \"Something went wrong\". You give it a message. You can also add a Try again button. Use it when something failed and trying again is safe.",
      "purpose": "Says that something failed, that it can be fixed, and offers to try again. It is a red Callout with that wording ready to use.",
      "useFor": [
        "A request that failed and is safe to try again.",
        "A part of the page that could not load."
      ],
      "notFor": [
        {
          "text": "A request the assistant will not do",
          "use": "refusal-notice"
        },
        {
          "text": "A usage limit",
          "use": "rate-limit-notice"
        },
        {
          "text": "A whole empty area with a drawing",
          "use": "empty-state"
        }
      ],
      "whenToUse": [
        "Say what happened in plain words, and whether the person's work was kept.",
        "Add Try again only when it can work.",
        "Use a specific title when you know the cause."
      ],
      "avoid": [
        "Do not use it for refusals or usage limits. Use RefusalNotice or RateLimitNotice.",
        "Do not show error codes or technical details as the message.",
        "Do not blame the person. Describe what went wrong on our side."
      ],
      "accessibility": [
        "Screen readers announce it right away when it appears.",
        "Try again is a real button with visible words.",
        "An alert icon and the words come with the red, so color is not the only clue."
      ],
      "accessibilityYouDo": [
        "Write a message that says what failed and whether the person's work is safe.",
        "Move the keyboard to a sensible spot after a retry works or fails again."
      ],
      "onPhone": [
        "The message wraps, and on a phone the Try again button drops under it so the text keeps its full width.",
        "Try again keeps its size and has a 44px tap area on touch screens.",
        "It fills the width of its container."
      ],
      "importPath": "import { ErrorState } from \"./index\"",
      "sourcePath": "src/molecules/ErrorState.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Something went wrong\"",
          "description": "Heading of the callout."
        },
        {
          "name": "message",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "What happened, and whether the person's work is safe."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Adds a Try again button when provided."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--danger-soft",
        "--danger-fg"
      ],
      "related": [
        "callout",
        "refusal-notice",
        "rate-limit-notice"
      ],
      "composedOf": [
        "callout",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With retry",
          "description": "Say whether the person's work is safe and offer a next step.",
          "code": "<ErrorState message=\"We could not load your conversation. Your draft is safe.\" onRetry={() => {}} />"
        },
        {
          "title": "Custom title, no retry",
          "description": "Leave out Try again when trying again will not help.",
          "code": "<ErrorState title=\"File not supported\" message=\"Upload a PDF or a plain text file instead.\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-error-state/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/error-state.md"
    },
    {
      "id": "refusal-notice",
      "name": "RefusalNotice",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "refusal",
        "declined request",
        "content policy message"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "alternative",
          "note": "Pass alternative to add a second line suggesting what to do instead.",
          "source": "documented"
        }
      ],
      "summary": "A calm notice for a request the assistant will not do, with an optional alternative.",
      "description": "RefusalNotice is a Callout titled \"I can't help with that.\" It explains why and can offer something else instead. A refusal is not an error, so it is not red or amber.",
      "purpose": "Explains calmly that the assistant will not do something. It uses the info color, not red or amber, because a refusal is not an error.",
      "useFor": [
        "A request the assistant declines, with the reason.",
        "Offering an allowed alternative next to the refusal."
      ],
      "notFor": [
        {
          "text": "Something that broke and can be tried again",
          "use": "error-state"
        },
        {
          "text": "A usage limit",
          "use": "rate-limit-notice"
        }
      ],
      "whenToUse": [
        "Say calmly, in one sentence, what you can't do.",
        "Offer something else whenever you can.",
        "Keep the calm tone. The colors are chosen on purpose."
      ],
      "avoid": [
        "Do not use red or amber. A refusal is not a failure.",
        "Do not lecture or repeat the request back at length.",
        "Do not use it when something broke. Use ErrorState."
      ],
      "accessibility": [
        "Screen readers announce it politely when it appears.",
        "The title \"I can't help with that\" is written in words. The icon is hidden from screen readers."
      ],
      "accessibilityYouDo": [
        "Write a reason that names the boundary, because the title is always the same.",
        "Give any link or button in the alternative a clear name."
      ],
      "onPhone": [
        "The reason and the alternative wrap to the screen width.",
        "It fills the width of its container.",
        "It has no buttons of its own. If you pass an alternative with a link, give the link the tap class so it has a 44px tap area."
      ],
      "importPath": "import { RefusalNotice } from \"./index\"",
      "sourcePath": "src/molecules/RefusalNotice.tsx",
      "props": [
        {
          "name": "reason",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Short explanation of the boundary."
        },
        {
          "name": "alternative",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Optional paragraph offering what can be done instead."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--info-soft",
        "--info-fg"
      ],
      "related": [
        "error-state",
        "rate-limit-notice",
        "callout"
      ],
      "composedOf": [
        "callout"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Reason and alternative",
          "description": "Offer something useful the assistant can do instead.",
          "code": "<RefusalNotice\n  reason=\"I can't write messages that pretend to be a real person.\"\n  alternative=\"I can write a clearly labeled template you can edit.\"\n/>"
        },
        {
          "title": "Reason only",
          "description": "",
          "code": "<RefusalNotice reason=\"That is something I can't help with.\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-refusal-notice/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/refusal-notice.md"
    },
    {
      "id": "rate-limit-notice",
      "name": "RateLimitNotice",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "quota exceeded",
        "usage limit",
        "too many requests",
        "throttling message"
      ],
      "category": "states",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "upgrade",
          "note": "Pass onUpgrade to show an Upgrade button next to the message.",
          "source": "documented"
        }
      ],
      "summary": "An amber message that says when a usage limit resets, with an optional Upgrade button.",
      "description": "RateLimitNotice is a warning Callout titled \"You've reached the usage limit.\" The text says when the limit resets, like \"Limits reset in 2 hours.\" You can add an Upgrade button. The wording is fixed, so give it a short time like 2 hours.",
      "purpose": "Tells people they reached a usage limit and when it resets. Amber marks that they must wait or act before going on.",
      "useFor": [
        "A person has used up their messages or credits for now.",
        "Offering a way to upgrade: pass onUpgrade."
      ],
      "notFor": [
        {
          "text": "How much has been used before the limit is reached",
          "use": "usage-meter"
        },
        {
          "text": "A failure that can be tried again right away",
          "use": "error-state"
        }
      ],
      "whenToUse": [
        "Give the real time until the limit resets.",
        "Offer Upgrade only if it applies to the person's plan.",
        "Place it where the blocked action would have been.",
        "Disable the blocked action, or explain it next to the action, so people do not run into the limit again."
      ],
      "avoid": [
        "Do not show it before the limit is reached. Use a quieter hint.",
        "Do not use a full date. Give a short time.",
        "Do not use it when the service is down. Use ErrorState."
      ],
      "accessibility": [
        "Screen readers announce it politely when it appears.",
        "Upgrade is a real button with visible words.",
        "The icon is hidden from screen readers. The title and the reset time carry the meaning."
      ],
      "accessibilityYouDo": [
        "Pass a short time, such as \"2 hours\", because it is read inside \"Limits reset in …\"."
      ],
      "onPhone": [
        "The message wraps, and on a phone the Upgrade button drops under it so the text keeps its full width.",
        "Upgrade keeps its size and has a 44px tap area on touch screens.",
        "It fills the width of its container."
      ],
      "importPath": "import { RateLimitNotice } from \"./index\"",
      "sourcePath": "src/molecules/RateLimitNotice.tsx",
      "props": [
        {
          "name": "resetsIn",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Human duration inserted into the sentence Limits reset in {resetsIn}."
        },
        {
          "name": "onUpgrade",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Adds an Upgrade button when provided."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--warning-soft",
        "--warning-fg"
      ],
      "related": [
        "error-state",
        "refusal-notice",
        "callout"
      ],
      "composedOf": [
        "callout",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With upgrade",
          "description": "The Upgrade button is a small primary button.",
          "code": "<RateLimitNotice resetsIn=\"2 hours\" onUpgrade={() => {}} />"
        },
        {
          "title": "Wait only",
          "description": "",
          "code": "<RateLimitNotice resetsIn=\"45 minutes\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-rate-limit-notice/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/rate-limit-notice.md"
    },
    {
      "id": "variant-tile",
      "name": "VariantTile",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "variation",
        "option tile",
        "image tile"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the selected prop.",
          "source": "prop"
        }
      ],
      "summary": "A square tile for one AI-made option that a person can pick.",
      "description": "VariantTile shows one of several AI-made options as a square you can pick. Put tiles in a VariantGrid so the arrow keys move between them. An AI badge always sits at the top left. The chosen tile gets a thick dark border and a check mark at the bottom right, so color is not the only clue.",
      "purpose": "One AI-made option shown as a square, so options can be compared side by side. The chosen one gets a check mark and a dark border, not only a color change.",
      "useFor": [
        "Picking one of several generated images, layouts or drafts.",
        "Inside VariantGrid, which adds arrow key movement between tiles."
      ],
      "notFor": [
        {
          "text": "A whole set of options to choose from",
          "use": "variant-grid"
        },
        {
          "text": "Picking several images at once",
          "use": "media-grid"
        },
        {
          "text": "A few text options with no picture",
          "use": "segmented-control"
        }
      ],
      "whenToUse": [
        "Use VariantGrid, or wrap tiles in a group that screen readers know is a set of choices, with a name.",
        "Give each option a clear name, like Option B, sand block.",
        "Let the content fill the whole square, like a cropped image."
      ],
      "avoid": [
        "Do not allow picking more than one. Only one tile can be chosen.",
        "Do not remove the AI badge from AI-made content.",
        "Do not use it for navigation. It is for choosing between AI-made options."
      ],
      "accessibility": [
        "Each tile is a button that screen readers treat as a choice. They read its label and whether it is selected.",
        "A chosen tile shows a check mark and a thicker border, not just color.",
        "The rise-in motion stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Use VariantGrid, or build the group yourself: set tabIndex to -1 on tiles that are not chosen and move between tiles with the arrow keys.",
        "Give each tile a label that says how it differs, such as \"Option A, warm gradient\".",
        "Say that the option is AI-made in the label or the nearby text if that matters. The label replaces the tile's content, so the badge is not read out."
      ],
      "onPhone": [
        "Each tile is a square that fills the width its grid gives it, and has a tap area of at least 44px on touch screens.",
        "Selection is a tap, and the check mark shows without hover.",
        "Choose the number of columns in the grid, since the tile size comes from it."
      ],
      "importPath": "import { VariantTile } from \"./index\"",
      "sourcePath": "src/molecules/VariantTile.tsx",
      "props": [
        {
          "name": "variant",
          "type": "Variant",
          "required": true,
          "default": null,
          "description": "Object with id (string), node (ReactNode shown inside the tile) and label (string used as the accessible name)."
        },
        {
          "name": "selected",
          "type": "boolean",
          "required": true,
          "default": null,
          "description": "Whether this tile is the chosen option."
        },
        {
          "name": "onSelect",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Called with variant.id when the tile is pressed."
        },
        {
          "name": "tabIndex",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Set to -1 on tiles that are not the tab stop of a roving group. VariantGrid does this for you. Leave it unset for a standalone tile, which then stays a tab stop."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg",
        "--bg",
        "--border",
        "--border-strong",
        "--shadow-md",
        "--radius-card"
      ],
      "related": [
        "variant-grid",
        "source-card"
      ],
      "composedOf": [
        "ai-badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Choose one",
          "description": "Here the demo keeps track of the choice. Without VariantGrid, arrow keys do not move between tiles, so the Tab key stops on each one.",
          "code": "const [selected, setSelected] = useState(\"a\");\n\n<div role=\"radiogroup\" aria-label=\"Generated options\" className=\"grid w-full max-w-md grid-cols-3 gap-3\">\n  {variants.map((v) => (\n    <VariantTile key={v.id} variant={v} selected={selected === v.id} onSelect={setSelected} />\n  ))}\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-variant-tile/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/variant-tile.md"
    },
    {
      "id": "tray",
      "name": "Tray",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "sidebar list",
        "navigation list",
        "panel list"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A soft panel with a short list of things to look at, and an optional message box under it.",
      "description": "Tray shows a few rows under a small heading. Use it on a landing view to show what needs a person. A message box can sit at the bottom, lined up with the list. Keep the list short. It does not scroll.",
      "purpose": "A soft panel that gathers the few things a person should look at now. The message box can sit underneath, so the list and the input share one edge.",
      "useFor": [
        "A short list of tasks that need a person, on a landing screen.",
        "A few suggested starting points with a message box under them.",
        "A small group of open items that all fit without scrolling."
      ],
      "notFor": [
        {
          "text": "A long list that has to scroll"
        },
        {
          "text": "Past chats in the side panel",
          "use": "conversation-list"
        },
        {
          "text": "Progress of a running task, with finished work folded away",
          "use": "checklist"
        }
      ],
      "whenToUse": [
        "Keep the list to a few rows so it can be read at a glance.",
        "Put the message box in the footer so it lines up with the list.",
        "Mark a row as needing attention only when a person has to act.",
        "Give the panel a heading so screen readers can name it."
      ],
      "avoid": [
        "Do not use it for long lists. Use a full list page instead.",
        "Do not add a border or a second panel inside it.",
        "Only put list rows inside it. Other content will not display correctly.",
        "Do not mark every row. When everything is amber, nothing stands out."
      ],
      "accessibility": [
        "With a label, screen readers can find the panel by that name.",
        "Rows sit in a real list, so screen readers say how many items there are.",
        "The label is plain text, so it does not add a level to the page outline."
      ],
      "accessibilityYouDo": [
        "Give it a label unless a nearby heading already names the list.",
        "Put only list rows, such as TrayRow, inside it.",
        "Give each row that needs a person a detail that says what is needed, not just the amber dot."
      ],
      "onPhone": [
        "Fills the width of its container, and the rows inside it keep their size and have a tap area at least 44px tall on a touch screen.",
        "Each row stacks its title above its detail on a phone, and the detail wraps instead of being cut off.",
        "A row's trailing text, which shows only on hover on a desktop, is always visible on a touch screen."
      ],
      "importPath": "import { Tray } from \"./index\"",
      "sourcePath": "src/organisms/Tray.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Small uppercase heading rendered with SectionLabel. Also becomes the aria-label of the section."
        },
        {
          "name": "action",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Right-aligned text beside the label, such as a count or a link. Only rendered when label is set."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The rows. Rendered inside a ul, so pass li elements such as TrayRow."
        },
        {
          "name": "footer",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Rendered below the list, inside the sand surface. Intended for a Composer."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes merged onto the section, for width or spacing."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken (bg-sunken)",
        "--shadow-sm",
        "--fg-muted",
        "--attention (via TrayRow)",
        "--radius-card"
      ],
      "related": [
        "composer",
        "checklist",
        "conversation-list"
      ],
      "composedOf": [
        "section-label"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Rows that need attention",
          "description": "A row marked as needing attention gets an amber dot. Use it only when a person has to act.",
          "code": "<div className=\"max-w-xl\">\n  <Tray label=\"Needs you\" action=\"4 running →\">\n    <TrayRow attention title=\"Quarterly report\" detail=\"Approval pending for 6 days\" trailing=\"Open →\" />\n    <TrayRow attention title=\"Customer survey\" detail=\"Missing 2 answers, due in 2 days\" trailing=\"Open →\" />\n    <TrayRow icon={<FileIcon />} title=\"Launch plan\" detail=\"On track\" trailing=\"Open →\" />\n  </Tray>\n</div>"
        },
        {
          "title": "With a message box underneath",
          "description": "The message box sits inside the panel, lined up with the list.",
          "code": "<div className=\"max-w-xl\">\n  <Tray\n    label=\"Needs you\"\n    footer={\n      <Composer\n        placeholder=\"Give me a task or question to work on…\"\n        onSend={() => {}}\n        tools={<ContextChip icon={<UsersIcon width={14} height={14} />} onClick={() => {}}>All projects</ContextChip>}\n      />\n    }\n  >\n    <TrayRow attention title=\"Quarterly report\" detail=\"Approval pending for 6 days\" trailing=\"Open →\" />\n  </Tray>\n</div>"
        },
        {
          "title": "Without a heading",
          "description": "Leave the heading out for a plain list. Screen readers then get no name for it, so only do this when a nearby heading explains the list.",
          "code": "<div className=\"max-w-xl\">\n  <Tray>\n    <TrayRow icon={<SearchIcon />} title=\"Find missing documents\" />\n    <TrayRow icon={<ToolIcon />} title=\"Reconcile invoices\" />\n  </Tray>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-tray/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/tray.md"
    },
    {
      "id": "message-list",
      "name": "MessageList",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "thread",
        "chat thread",
        "conversation",
        "message thread"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A column that holds a conversation and reads out new messages.",
      "description": "MessageList holds the messages of one conversation in a centered column with even spacing. Screen readers read out each new message as it arrives. A reply that is still being written stays quiet until it is done, then says so once.",
      "purpose": "The column that holds one conversation. It reads new turns out to screen readers as they are added, so people who cannot see the screen can follow along.",
      "useFor": [
        "The messages of one conversation, in order.",
        "A thread where replies arrive while the person waits.",
        "A page with more than one conversation, each with its own name."
      ],
      "notFor": [
        {
          "text": "A single turn on its own",
          "use": "message"
        },
        {
          "text": "The box where people type",
          "use": "composer"
        },
        {
          "text": "A list of past chats",
          "use": "conversation-list"
        }
      ],
      "whenToUse": [
        "Put each message directly inside it so each new one is read out.",
        "Mark the reply as still being written while its words arrive.",
        "Give each conversation its own name when a page shows more than one.",
        "Place it inside ThreadTemplate, which already sets a good reading width."
      ],
      "avoid": [
        "Do not put the message box inside it. Everything added there is read out.",
        "Do not rebuild the list for every new word. Update the last message instead.",
        "Do not add other read-out areas inside it."
      ],
      "accessibility": [
        "Screen readers hear each new message as it is added, without being cut off.",
        "Changes to messages already on screen are not read out again.",
        "It is named \"Conversation\" for screen readers unless you give it another name.",
        "It has no keyboard controls of its own."
      ],
      "accessibilityYouDo": [
        "Put each Message directly inside it, so each new one is read out.",
        "Set streaming on the reply that is still being written, and turn it off when it ends.",
        "Give each list its own label when a page shows more than one conversation.",
        "Keep the message box and other read-out areas outside it."
      ],
      "onPhone": [
        "Fills the width of its container up to 768px, so on a phone it is the full width with a 24px gap between messages.",
        "It does not scroll or pin itself. Your scrolling parent must handle the on-screen keyboard and the bottom safe area."
      ],
      "importPath": "import { MessageList } from \"./index\"",
      "sourcePath": "src/organisms/MessageList.tsx",
      "props": [
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The messages, in order. Usually Message components."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Conversation\"",
          "description": "Accessible name of the log region. Change it when more than one thread is on screen."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "max-w-3xl reading width",
        "gap-6 spacing"
      ],
      "related": [
        "composer",
        "markdown",
        "source-list"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A short conversation",
          "description": "Your messages and the assistant's replies stack in one column.",
          "code": "<MessageList>\n  <Message role=\"user\">Summarise the meeting notes.</Message>\n  <Message role=\"assistant\">The team agreed to ship on Friday and review metrics next week.</Message>\n</MessageList>"
        },
        {
          "title": "A reply that is still being written",
          "description": "Give the conversation a name for screen readers. A reply that is still being written shows a blinking cursor.",
          "code": "<MessageList label=\"Revenue analysis\">\n  <Message role=\"user\">What drove growth?</Message>\n  <Message role=\"assistant\" streaming>Revenue grew mostly because of enterprise renewals</Message>\n</MessageList>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-message-list/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/message-list.md"
    },
    {
      "id": "composer",
      "name": "Composer",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "chat input",
        "prompt input",
        "message input",
        "chat box",
        "prompt box"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "streaming",
          "note": "Set with the generating prop.",
          "source": "prop"
        },
        {
          "name": "disabled",
          "note": "Set with the disabled prop.",
          "source": "prop"
        }
      ],
      "summary": "The box where people type a message, with attach, tools and send or stop.",
      "description": "Composer is where people type to the assistant. Enter sends. Shift and Enter start a new line. It never sends while someone is still choosing a character with an input method. While the assistant is replying, Send turns into Stop. The box grows as you type, then scrolls. Show the attach button only if your app can take files.",
      "purpose": "The one place people type to the assistant. Send turns into Stop while a reply is being written, so the main button always does what matters right now.",
      "useFor": [
        "The message box at the bottom of a conversation.",
        "A message box inside a Tray on a landing screen.",
        "A chat where people may need to stop a long reply."
      ],
      "notFor": [
        {
          "text": "Describing something to make, such as an image, where Enter should add a line",
          "use": "prompt-box"
        },
        {
          "text": "A short one-line search or filter field"
        }
      ],
      "whenToUse": [
        "Keep one Composer at the bottom of the conversation, as wide as the messages.",
        "Always let people stop a reply while it is being written.",
        "Put choices such as project or model beside Send.",
        "Set the limit to the real one so the counter is honest.",
        "Pass onAttach only when your app can really take files. The button appears whenever it is set.",
        "Pass onStop whenever you set generating, so Stop really stops the reply."
      ],
      "avoid": [
        "Do not show the attach button if your app cannot take files. A button that does nothing is confusing.",
        "Do not show more than one Composer in the same conversation.",
        "Do not add a second send button. Enter and the green button already cover it.",
        "Do not use the green Send button for anything else. Green marks the one main send action."
      ],
      "accessibility": [
        "The text box is named \"Message\" for screen readers.",
        "Enter sends and Shift and Enter add a new line. Enter never sends while someone is still choosing a character with an input method.",
        "The buttons are named \"Attach file\", \"Send message\" and \"Stop generating\".",
        "Send stays off until there is text to send.",
        "Near the limit a character count appears, and screen readers hear it politely."
      ],
      "accessibilityYouDo": [
        "Show the sent message or an error after onSend, so people know what happened.",
        "Give anything you put in tools its own name for screen readers."
      ],
      "onPhone": [
        "The text box is 16px on a touch screen so the page does not zoom on focus, and it grows up to 200px tall as the person types.",
        "The attach, tools and send controls wrap onto their own line when they do not fit. Each has a 44px tap area on a touch screen.",
        "The phone keyboard is asked to show a send key. Enter sends and Shift+Enter adds a new line, which is hard to reach on a phone, so people cannot easily write a second line.",
        "It does no keyboard or safe-area handling of its own, so keep it above the on-screen keyboard and the bottom safe area yourself."
      ],
      "importPath": "import { Composer } from \"./index\"",
      "sourcePath": "src/organisms/Composer.tsx",
      "props": [
        {
          "name": "placeholders",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Example messages that take turns as the placeholder while the box is empty, so a blank field shows what kind of thing to type. With reduced motion the first one stays still. Overrides placeholder."
        },
        {
          "name": "placeholderInterval",
          "type": "number",
          "required": false,
          "default": "4000",
          "description": "Milliseconds each example stays."
        },
        {
          "name": "onSend",
          "type": "(text: string) => void",
          "required": true,
          "default": null,
          "description": "Called with the trimmed text on Enter or Send. The field is cleared afterwards."
        },
        {
          "name": "generating",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "When true the send button becomes Stop, and submitting is blocked."
        },
        {
          "name": "onStop",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called when Stop is pressed while generating."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "default": "\"Message the assistant…\"",
          "description": "Placeholder text of the textarea."
        },
        {
          "name": "maxLength",
          "type": "number",
          "required": false,
          "default": "4000",
          "description": "Hard limit on the textarea. A counter appears once the text passes 90% of it."
        },
        {
          "name": "onAttach",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called when Attach file is pressed. The attach button renders only when this is provided."
        },
        {
          "name": "tools",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Extra controls rendered between the attach button, when present, and the send button."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Disables the textarea and Send and dims the whole composer."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--border-strong",
        "--surface",
        "--shadow-sm / --shadow-md",
        "--fg-subtle",
        "--warning-fg (counter)",
        "--lime (Send)",
        "--radius-field"
      ],
      "related": [
        "prompt-box",
        "tray",
        "message-list"
      ],
      "composedOf": [
        "icon-button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Default",
          "description": "Send stays off until something is typed. Try Enter, then Shift and Enter.",
          "code": "<div className=\"max-w-xl\">\n  <Composer onSend={(text) => console.log(text)} />\n</div>"
        },
        {
          "title": "With an attach button",
          "description": "The Attach file button shows only when files are allowed. Press it to see what happens.",
          "code": "<div className=\"max-w-xl\">\n  <Composer onAttach={() => openFilePicker()} onSend={(text) => console.log(text)} />\n</div>"
        },
        {
          "title": "While a reply is being written",
          "description": "The green Send button turns into Stop. Enter does nothing until the reply is done.",
          "code": "<div className=\"max-w-xl\">\n  <Composer generating onStop={() => {}} onSend={() => {}} />\n</div>"
        },
        {
          "title": "With a choice beside Send",
          "description": "Add choices such as a project picker. They sit to the left of Send.",
          "code": "<div className=\"max-w-xl\">\n  <Composer\n    placeholder=\"Ask about your projects…\"\n    onSend={() => {}}\n    tools={<ContextChip icon={<UsersIcon width={14} height={14} />} onClick={() => {}}>All projects</ContextChip>}\n  />\n</div>"
        },
        {
          "title": "Near the character limit",
          "description": "The limit here is 40 characters. Type more than 36 to see the counter.",
          "code": "<div className=\"max-w-xl\">\n  <Composer maxLength={40} placeholder=\"Type more than 36 characters…\" onSend={() => {}} />\n</div>"
        },
        {
          "title": "Rotating examples",
          "description": "An empty box shows one example at a time instead of a row of example chips. With reduced motion, the first example stays.",
          "code": "<Composer\n  onSend={(text) => console.log(text)}\n  placeholders={[\n    \"My order was cancelled but no refund came\",\n    \"UPI payment failed and the money is gone\",\n    \"The flight was cancelled and I want my money back\",\n  ]}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-composer/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/composer.md"
    },
    {
      "id": "conversation-list",
      "name": "ConversationList",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "chat history",
        "conversation history",
        "threads list",
        "sidebar history"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "current",
          "note": "The item whose id matches activeId gets a highlighted background and aria-current set to page.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "An item gets a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears on an item or the New chat button on keyboard focus.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "With no items, only the New chat button is shown.",
          "source": "documented"
        },
        {
          "name": "row menu",
          "note": "With onRename or onDelete, each row gets a More menu (drawn on the page, so a scrolling side bar cannot clip it).",
          "source": "documented"
        },
        {
          "name": "confirming delete",
          "note": "Choosing Delete swaps the row for \"Delete this chat?\" with Keep it and Delete side by side; focus lands on Keep it and Escape cancels.",
          "source": "documented"
        }
      ],
      "summary": "Past chats grouped by day, with a New chat button, for the side bar.",
      "description": "ConversationList shows a New chat button and the titles of past chats under small group names. Groups appear in the order they are given. Chats with no group go under Recent. The open chat is highlighted. Your app keeps track of which chat is open.",
      "purpose": "The history of past chats for the side panel. A New chat button sits on top, so starting fresh is always one press away.",
      "useFor": [
        "Past chats in the AppShell side panel, grouped by day.",
        "Switching between chats, with the open one highlighted.",
        "Starting a new chat from the side panel."
      ],
      "notFor": [
        {
          "text": "The main site menu or links to other parts of the app"
        },
        {
          "text": "The messages inside one chat",
          "use": "message-list"
        }
      ],
      "whenToUse": [
        "Use groups such as Today and Yesterday, newest first.",
        "Keep titles short. Long ones wrap onto a second line and make the list harder to scan.",
        "Place it in the side bar of AppShell, which sets the width.",
        "Keep the highlight matching the chat that is open."
      ],
      "avoid": [
        "Do not use it as the main site menu. It lists chats only.",
        "Do not expect it to sort. It keeps the order you give it.",
        "Do not add extra actions inside a row. Each row is one button."
      ],
      "accessibility": [
        "It is a navigation area named \"Conversations\".",
        "Each chat is a real button, so Tab, Enter and Space work.",
        "The open chat is marked as current for screen readers and gets a background, so color is not the only clue.",
        "Each group of chats is its own list, so screen readers say how many chats it holds."
      ],
      "accessibilityYouDo": [
        "Keep activeId in step with the chat that is open, so the right one is announced as current.",
        "Write titles that tell chats apart. The title is all a screen reader hears for each row.",
        "Use one per page. Its navigation name, \"Conversations\", is fixed."
      ],
      "onPhone": [
        "Each row title is a full-width button with a tap area at least 44px tall on a touch screen, and long titles wrap instead of being cut off.",
        "The row menu button grows from 32px to 44px on a touch screen, and the menu opens as a bottom sheet.",
        "Delete asks in place with Keep and Delete side by side, each with a 44px tap area."
      ],
      "importPath": "import { ConversationList } from \"./index\"",
      "sourcePath": "src/organisms/ConversationList.tsx",
      "props": [
        {
          "name": "items",
          "type": "ConversationItem[]",
          "required": true,
          "default": null,
          "description": "Conversations to show. ConversationItem is { id: string; title: string; group?: string }."
        },
        {
          "name": "activeId",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id of the open conversation. It receives aria-current and the active style."
        },
        {
          "name": "onSelect",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Called with the id when an item is clicked."
        },
        {
          "name": "onNew",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called when New chat is pressed."
        },
        {
          "name": "onRename",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "When given, each row gets a More menu with Rename. Your code asks for the new name."
        },
        {
          "name": "onDelete",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "When given, each row gets Delete in its More menu. Pressing it asks once, in place, with Keep it as easy to press as Delete and focus on Keep it. Called only after the person confirms."
        },
        {
          "name": "labels",
          "type": "{ delete?: string; keep?: string; confirm?: (title: string) => string }",
          "required": false,
          "default": null,
          "description": "The words of the in-place confirmation."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-hover (bg-hover)",
        "--fg-muted",
        "--fg-subtle",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "tray",
        "app-shell"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Grouped history",
          "description": "Each chat has a group name. Groups show in the order they first appear.",
          "code": "const [active, setActive] = useState(\"2\");\n\n<div className=\"w-65\">\n  <ConversationList\n    activeId={active}\n    onSelect={setActive}\n    onNew={() => {}}\n    items={[\n      { id: \"1\", title: \"Q3 revenue analysis\", group: \"Today\" },\n      { id: \"2\", title: \"Onboarding email draft\", group: \"Today\" },\n      { id: \"3\", title: \"SQL help\", group: \"Yesterday\" },\n      { id: \"4\", title: \"Meeting notes summary\", group: \"Yesterday\" },\n    ]}\n  />\n</div>"
        },
        {
          "title": "Chats with no groups",
          "description": "Chats with no group all go under Recent.",
          "code": "const [active, setActive] = useState(\"a\");\n\n<div className=\"w-65\">\n  <ConversationList\n    activeId={active}\n    onSelect={setActive}\n    onNew={() => {}}\n    items={[\n      { id: \"a\", title: \"Trip planning\" },\n      { id: \"b\", title: \"Contract summary\" },\n    ]}\n  />\n</div>"
        },
        {
          "title": "Rename and delete from a row menu",
          "description": "Each row gets a More menu. Delete asks once, right in the row, and Keep it is as easy to press as Delete. The menu is drawn on the page itself, so a scrolling side bar cannot cut it off.",
          "code": "<ConversationList\n  activeId={active}\n  onSelect={setActive}\n  onNew={newChat}\n  items={items}\n  onRename={(id) => askForName(id)}\n  onDelete={(id) => remove(id)}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-conversation-list/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/conversation-list.md"
    },
    {
      "id": "markdown",
      "name": "Markdown",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "rich text",
        "markdown renderer",
        "formatted text"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "unclosed code fence",
          "note": "A code fence with no closing line still renders as a code block, so partly streamed text does not break.",
          "source": "documented"
        },
        {
          "name": "unsafe link",
          "note": "A link whose address is not http, https or mailto is shown as plain text instead of a link.",
          "source": "documented"
        },
        {
          "name": "wide table",
          "note": "A table wider than the column scrolls sideways and can be focused with the keyboard.",
          "source": "documented"
        }
      ],
      "summary": "Turns the assistant's formatted text into headings, lists, links and tables, even while it is still being written.",
      "description": "Markdown shows formatted text safely. It handles headings, bold, italic, links, lists, quotes, tables, dividers and code. Lists can go three levels deep. A code block that is not finished yet still shows as code while the reply is being written. Markers like [1] become small source numbers. Only web and email links work.",
      "purpose": "Turns the assistant's formatted text into real headings, lists, tables and code. It builds the page safely and copes with text that stops halfway, so it can show a reply while it is still arriving.",
      "useFor": [
        "The body of an assistant reply.",
        "A reply that is still being written: pass all the text so far each time.",
        "A draft document shown inside a CanvasPanel."
      ],
      "notFor": [
        {
          "text": "A code sample on its own, outside a reply",
          "use": "code-block"
        },
        {
          "text": "Text that people need to edit"
        }
      ],
      "whenToUse": [
        "Put it in a container that sets text size and spacing.",
        "While the reply is being written, give it all the text so far each time.",
        "Show a SourceList under the reply so the [1] markers point somewhere.",
        "Use it for assistant replies, where the content is not known ahead of time."
      ],
      "avoid": [
        "Do not expect images, web code, checkbox lists or lists deeper than three levels. Deeper items stay at level three.",
        "Do not add web code to the text. It is shown as plain text.",
        "Do not use it to edit documents. It only displays text.",
        "Do not rely on it for the page's heading structure. Its headings sit below the page title."
      ],
      "accessibility": [
        "Headings are marked as headings, one level below the page title.",
        "Lists are real lists, so screen readers say how many items there are and how deep they go.",
        "Table columns have headers, and a wide table can be reached with Tab and scrolled sideways.",
        "Source markers like [1] are links that screen readers hear as \"Source 1\".",
        "Only web and email links become links, and they open in a new tab. The text cannot add scripts to the page."
      ],
      "accessibilityYouDo": [
        "Put it inside a Message with streaming on while the reply arrives, so screen readers are not interrupted by every word.",
        "Show a SourceList on the same page, so source markers lead somewhere.",
        "Ask for link text that makes sense on its own, not \"click here\"."
      ],
      "onPhone": [
        "Tables and code blocks scroll sideways inside their own box, so a wide one never pushes the page wider.",
        "Source markers like [1] get a larger invisible tap area on a touch screen.",
        "Paragraphs wrap long unbroken words and addresses, so they do not push the page wider."
      ],
      "importPath": "import { Markdown } from \"./index\"",
      "sourcePath": "src/organisms/Markdown.tsx",
      "props": [
        {
          "name": "children",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The Markdown source. Partial text is fine. Re-render with the growing string while streaming."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken (code, table head)",
        "--border (tables, rules)",
        "--accent-fg (links)",
        "--accent-soft-border (blockquote)",
        "--fg-muted",
        "--code-bg (via CodeBlock)",
        "--radius-card"
      ],
      "related": [
        "source-list",
        "message-list"
      ],
      "composedOf": [
        "citation-marker",
        "code-block"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A rich reply",
          "description": "Shows a heading, bold and italic text, a link, a source number, a table, a quote, a list, a divider and code.",
          "code": "const RICH = `## Quarterly summary\n\nRevenue grew **12%** …`;\n\n<div className=\"max-w-xl space-y-4 text-body-lg leading-7\">\n  <Markdown>{RICH}</Markdown>\n</div>"
        },
        {
          "title": "Nested lists",
          "description": "Indent each level. Numbered and bulleted lists can mix. Three levels is the most.",
          "code": "const NESTED = `Plan for the release:\n\n1. Prepare the build\n   - Update the changelog\n   - Bump versions\n     1. Core package\n     2. Docs site\n2. Ship it\n   - Tag the commit\n- Notify the team`;\n\n<div className=\"max-w-xl space-y-4 text-body-lg leading-7\">\n  <Markdown>{NESTED}</Markdown>\n</div>"
        },
        {
          "title": "Code that is still being written",
          "description": "The end of the code has not arrived yet. It still shows as code, not as stray symbols.",
          "code": "const STREAMING = \"Here is a small helper:\\n\\n```ts\\nexport function clamp(...) {\\n  return Math.min(...);\";\n\n<div className=\"max-w-xl space-y-4 text-body-lg leading-7\">\n  <Markdown>{STREAMING}</Markdown>\n</div>"
        },
        {
          "title": "A risky link becomes plain text",
          "description": "Links that could run code are not clickable. Only their words stay.",
          "code": "<div className=\"max-w-xl space-y-4 text-body-lg leading-7\">\n  <Markdown>{\"Open [the safe page](https://example.com) or [this one](javascript:alert(1)).\"}</Markdown>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-markdown/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/markdown.md"
    },
    {
      "id": "step-timeline",
      "name": "StepTimeline",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "stepper",
        "steps",
        "progress steps",
        "activity timeline",
        "wizard steps"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "done",
          "note": "A step with state done shows a green check and a green connector line.",
          "source": "documented"
        },
        {
          "name": "active",
          "note": "A step with state active shows a spinner and is marked as the current step.",
          "source": "documented"
        },
        {
          "name": "todo",
          "note": "A step with state todo shows its number and dimmed title.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "A step with state failed shows a red cross.",
          "source": "documented"
        }
      ],
      "summary": "A list of steps in a task, showing which are done, in progress, waiting or failed.",
      "description": "StepTimeline shows the stages of a task in order, joined by a thin line. Done steps get a green check. The current step shows a spinner. Failed steps show a cross. Waiting steps show their number. Use it when order matters and people want to know where things stand.",
      "purpose": "Shows the stages of a task in order, so people see at a glance what is done, what is happening now and what failed.",
      "useFor": [
        "A task whose steps must happen in order.",
        "Showing where a running workflow stands.",
        "Showing which step failed, with later steps still waiting."
      ],
      "notFor": [
        {
          "text": "Open items with no set order",
          "use": "checklist"
        },
        {
          "text": "Events with dates, such as a history of changes",
          "use": "timeline"
        },
        {
          "text": "The details of one tool the assistant used",
          "use": "tool-call-card"
        }
      ],
      "whenToUse": [
        "Mark only one step as in progress at a time.",
        "Use the detail line to say who or what is being waited on.",
        "Keep titles short, starting with an action word.",
        "Say why a step failed in its detail line, so people do not have to look elsewhere."
      ],
      "avoid": [
        "Do not use it for to-dos with no order. Use Checklist.",
        "Do not use it for dozens of steps. It cannot fold or scroll.",
        "Do not use amber here. Amber is for rows that need a person, in Checklist and ApprovalPrompt."
      ],
      "accessibility": [
        "It is a numbered list named \"Task progress\".",
        "The step in progress is marked as the current step for screen readers.",
        "Each step has hidden text with its state (done, active, todo or failed), so meaning does not rest on color or icon.",
        "The spinner on the current step is read as \"Loading\"."
      ],
      "accessibilityYouDo": [
        "Mark only one step as active at a time.",
        "Put the reason for a failure in the step's detail. The hidden text only says \"failed\".",
        "Tell people when a step finishes or fails, for example with a short status message. The list does not announce changes."
      ],
      "onPhone": [
        "Steps stack in one column, and titles and details wrap inside the space beside the marker.",
        "Nothing in it is tappable, so there are no touch targets to size. Detail text is 12px."
      ],
      "importPath": "import { StepTimeline } from \"./index\"",
      "sourcePath": "src/organisms/StepTimeline.tsx",
      "props": [
        {
          "name": "steps",
          "type": "Step[]",
          "required": true,
          "default": null,
          "description": "Ordered steps. Step is { id: string; title: string; detail?: string; state: \"done\" | \"active\" | \"todo\" | \"failed\" }."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--success",
        "--danger",
        "--accent",
        "--border / --border-strong",
        "--fg-muted / --fg-subtle"
      ],
      "related": [
        "checklist",
        "approval-prompt"
      ],
      "composedOf": [
        "spinner"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "In progress",
          "description": "One step done, one in progress, one waiting.",
          "code": "<StepTimeline steps={[\n  { id: \"a\", title: \"Find missing documents\", detail: \"2 people\", state: \"done\" },\n  { id: \"b\", title: \"Send reminders\", detail: \"Waiting for approval\", state: \"active\" },\n  { id: \"c\", title: \"Confirm receipt\", state: \"todo\" },\n]} />"
        },
        {
          "title": "A failed step",
          "description": "A failed step gets a red cross and says what went wrong. Later steps keep waiting.",
          "code": "<StepTimeline steps={[\n  { id: \"a\", title: \"Fetch invoices\", state: \"done\" },\n  { id: \"b\", title: \"Match payments\", detail: \"The bank export could not be read\", state: \"failed\" },\n  { id: \"c\", title: \"Send summary\", state: \"todo\" },\n]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-step-timeline/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/step-timeline.md"
    },
    {
      "id": "approval-prompt",
      "name": "ApprovalPrompt",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "confirmation",
        "approve deny",
        "human in the loop",
        "confirm action"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "low risk",
          "note": "Set risk to low for a quiet border and an optional Always allow button when onAlways is passed.",
          "source": "documented"
        },
        {
          "name": "medium or high risk",
          "note": "Set risk to medium or high for a warning-tinted box and a coloured risk badge.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The Approve, Deny and Always allow buttons show a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "A pause that asks a person before the assistant does something important.",
      "description": "ApprovalPrompt stops the assistant before an action and says exactly what will happen. It shows a risk label, the details, and equal Approve and Deny buttons. Low risk looks neutral. Medium and high risk use amber, because a person has to decide. High risk gets a red label. Always allow shows only for low risk. Use it before anything that sends, deletes, spends or shares.",
      "purpose": "Stops the assistant before an action that matters and says exactly what will happen. Approve and Deny sit side by side, so saying no is as easy as saying yes.",
      "useFor": [
        "Before the assistant sends, deletes, spends or shares something.",
        "An action that cannot be undone: set risk to high.",
        "A read-only action people may want to allow every time: set risk to low and pass onAlways."
      ],
      "notFor": [
        {
          "text": "Harmless actions that change nothing"
        },
        {
          "text": "Choosing how much the assistant may do on its own",
          "use": "permission-mode-switcher"
        },
        {
          "text": "A question the assistant needs answered before it can go on",
          "use": "agent-question-card"
        }
      ],
      "whenToUse": [
        "Name the real person, file or amount, so no one has to guess.",
        "Make Deny as easy to find as Approve.",
        "Set the risk by what the action does: can it be undone, and who does it reach.",
        "Offer Always allow only for low-risk actions that just look."
      ],
      "avoid": [
        "Do not ask about harmless actions. Too many questions teach people to click through.",
        "Do not use vague words like This action may have effects.",
        "Do not pre-select Approve for high-risk actions.",
        "Do not expect Always allow on medium or high risk. It never shows."
      ],
      "accessibility": [
        "Screen readers announce it as an alert dialog named \"Approval needed\" followed by the action.",
        "The risk level is written in words, such as \"high risk\", so color is not the only clue.",
        "Approve, Deny and Always allow are real buttons that work with Tab, Enter and Space.",
        "Always allow never shows for medium or high risk, even if you pass onAlways."
      ],
      "accessibilityYouDo": [
        "Move focus to the prompt when it appears, for example to Deny, or show it where the person is already working. It does not move focus by itself.",
        "Tell people the result of their choice, for example with a short status message.",
        "Write the detail with the real names, files and amounts."
      ],
      "onPhone": [
        "The action, risk badge and details fill the width, and the Approve and Deny buttons wrap onto a second line if they do not fit.",
        "Every button has a 44px tap area on a touch screen, and Deny is the same size as Approve.",
        "It sits in the page and does not open as a sheet, so place it where the person will see it without scrolling."
      ],
      "importPath": "import { ApprovalPrompt } from \"./index\"",
      "sourcePath": "src/organisms/ApprovalPrompt.tsx",
      "props": [
        {
          "name": "action",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Short title of what the agent wants to do. Also used in the dialog's accessible name."
        },
        {
          "name": "detail",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Exactly what will happen, in plain language with real values, not placeholders."
        },
        {
          "name": "risk",
          "type": "\"low\" | \"medium\" | \"high\"",
          "required": false,
          "default": "\"medium\"",
          "description": "Low uses the neutral sunken surface and a neutral badge. Medium uses the attention surface and a warning badge. High uses the attention surface and a danger badge. The level is always written in the badge text. Also decides whether Always allow can show."
        },
        {
          "name": "onApprove",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called when Approve is pressed."
        },
        {
          "name": "onDeny",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called when Deny is pressed."
        },
        {
          "name": "onAlways",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Adds an Always allow ghost button. Rendered only when risk is low."
        }
      ],
      "variantsHint": {
        "risk": [
          "low",
          "medium",
          "high"
        ]
      },
      "tokens": [
        "--warning / --warning-soft / --warning-fg (medium and high surface)",
        "--surface-sunken (low surface)",
        "--danger-soft / --danger-fg (high risk badge)",
        "--fg-muted",
        "--radius-card"
      ],
      "related": [
        "checklist",
        "step-timeline"
      ],
      "composedOf": [
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "High risk",
          "description": "An action that cannot be undone, in amber with a red label. There is no Always allow.",
          "code": "<ApprovalPrompt\n  risk=\"high\"\n  action=\"Delete 14 archived projects\"\n  detail={<>This permanently removes <b>14 projects</b> from the workspace <b>Acme Studio</b>. It cannot be undone.</>}\n  onApprove={() => {}}\n  onDeny={() => {}}\n/>"
        },
        {
          "title": "Medium risk (the default)",
          "description": "An action others will see, in amber. Medium is used when no level is given.",
          "code": "<ApprovalPrompt\n  action=\"Send a reminder to Dana Whitfield\"\n  detail={<>An email from <b>team@example.com</b> will be sent now.</>}\n  onApprove={() => {}}\n  onDeny={() => {}}\n/>"
        },
        {
          "title": "Low risk with Always allow",
          "description": "An action that only looks, in a neutral color. It can offer Always allow so people are not asked again. Click a button to see the choice.",
          "code": "<ApprovalPrompt\n  risk=\"low\"\n  action=\"Read the shared reports folder\"\n  detail={<>The assistant will list file names in <b>/reports/2026</b>. Nothing is changed.</>}\n  onApprove={() => {}}\n  onDeny={() => {}}\n  onAlways={() => {}}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-approval-prompt/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/approval-prompt.md"
    },
    {
      "id": "checklist",
      "name": "Checklist",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "todo list",
        "task list",
        "plan steps"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "done summary",
          "note": "Pass doneSummary to add a final row with a check and the summary text.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "With no items and no doneSummary, only the top border is drawn.",
          "source": "documented"
        }
      ],
      "summary": "A list of what is still open, with finished work folded into one line.",
      "description": "Checklist shows open items as rows, each with an optional tag and status. Rows that need a person get an amber dot. Finished work is folded into one summary line so the list stays short. Use it for a task in progress, such as people who still owe a document.",
      "purpose": "Shows what is still open in a running task and folds finished work into one line, so the list stays short and the open items stand out.",
      "useFor": [
        "People or items that still owe something, such as missing documents.",
        "A task in progress where only a few items need a person.",
        "Open work with a one-line summary of what is already done."
      ],
      "notFor": [
        {
          "text": "Steps that must happen in a set order",
          "use": "step-timeline"
        },
        {
          "text": "A short list of things to open from a landing screen",
          "use": "tray"
        }
      ],
      "whenToUse": [
        "List open items first and fold finished ones into the summary line.",
        "Mark a row only when a person has to act.",
        "Write status as a short fact with a time, such as No reply in 5 days.",
        "Use the tag for a category, not for status."
      ],
      "avoid": [
        "Do not list every finished item. The summary line is there to avoid that.",
        "Do not use amber for items that are only late or still being handled by the assistant.",
        "Do not use it for steps that must happen in order. Use StepTimeline."
      ],
      "accessibility": [
        "It is a real list, so screen readers say how many items there are.",
        "The amber dot is read as \"Needs attention\", next to the status words.",
        "The check mark in the summary line is hidden from screen readers. The words carry the meaning."
      ],
      "accessibilityYouDo": [
        "Write a status that says what is needed, such as \"No reply in 5 days\".",
        "Put a real button or link in the title if a row should open something. Rows do nothing on their own.",
        "Mark a row as needing attention only when a person has to act."
      ],
      "onPhone": [
        "Each row runs the full width, with the title on the left and the status on the right.",
        "The rows are not tappable, so there are no touch targets to size. Status text is 12px."
      ],
      "importPath": "import { Checklist } from \"./index\"",
      "sourcePath": "src/organisms/Checklist.tsx",
      "props": [
        {
          "name": "items",
          "type": "ChecklistItem[]",
          "required": true,
          "default": null,
          "description": "Open items. ChecklistItem is { id: string; title: ReactNode; badge?: ReactNode; status?: string; attention?: boolean }."
        },
        {
          "name": "doneSummary",
          "type": "string",
          "required": false,
          "default": null,
          "description": "One line summarising completed work. Shown as a final row with a check mark."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes merged onto the list."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--border (top and row rules)",
        "--border-strong (summary check)",
        "--attention (dot)",
        "--fg-muted / --fg-subtle"
      ],
      "related": [
        "step-timeline",
        "approval-prompt",
        "tray"
      ],
      "composedOf": [
        "checklist-row",
        "attention-dot"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Open items with a summary",
          "description": "The first row needs a person, so it has an amber dot. Finished work is one line at the end.",
          "code": "<Checklist\n  doneSummary=\"Received from Atlas, Birch and Cedar\"\n  items={[\n    { id: \"1\", title: <span className=\"font-medium\">Dana Whitfield</span>, badge: <Badge tone=\"accent\">Form A</Badge>, status: \"No reply in 5 days, due in 2\", attention: true },\n    { id: \"2\", title: <span className=\"font-medium\">Owen Park</span>, badge: <Badge tone=\"accent\">Form A</Badge>, status: \"Reminder sent yesterday\" },\n  ]}\n/>"
        },
        {
          "title": "Nothing needs a person",
          "description": "When nothing needs a person, no amber appears.",
          "code": "<Checklist items={[\n  { id: \"1\", title: \"Draft agenda\", status: \"In progress\" },\n  { id: \"2\", title: \"Book the room\", status: \"Queued\" },\n]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-checklist/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/checklist.md"
    },
    {
      "id": "source-list",
      "name": "SourceList",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "sources",
        "references",
        "bibliography",
        "citations list"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "The numbered sources behind an answer, shown in two columns.",
      "description": "SourceList shows a Sources heading and one card per source. Each card is a link that opens in a new tab. The numbers match the [1] markers in the reply. Place it under a reply that backs up its claims.",
      "purpose": "Shows where an answer came from: it lists the sources behind a reply as numbered cards, so people can check a claim. The numbers match the [1] markers in the text.",
      "useFor": [
        "Under a finished reply that cites its sources.",
        "Research answers where people may want to read the original.",
        "Next to Markdown, whose [1] markers jump to the matching card."
      ],
      "notFor": [
        {
          "text": "One source shown when someone hovers a marker",
          "use": "citation-hover-card"
        },
        {
          "text": "A single source on its own",
          "use": "source-card"
        }
      ],
      "whenToUse": [
        "Use numbers that match the [n] markers in the reply.",
        "Use real page titles and websites so people can judge a source at a glance.",
        "Add a short quote when it shows why the source backs up the claim.",
        "Show it only after the reply is finished, so nothing jumps while people read."
      ],
      "avoid": [
        "Do not show a source the reply never mentions.",
        "Do not reuse numbers across replies on one page. Links would point to the wrong source.",
        "Do not use links you have not checked. Cards open whatever link you give them."
      ],
      "accessibility": [
        "It is an area named \"Sources\" that holds a numbered list.",
        "Each card is one link, read as its number, title, website and quote.",
        "Each card can be jumped to from a matching [1] marker in the reply."
      ],
      "accessibilityYouDo": [
        "Use ids that match the [n] markers in the reply, and keep them unique on the page.",
        "Say that links open in a new tab if that matters to your users. Screen readers are not told."
      ],
      "onPhone": [
        "Sources show in one column on a phone and in two columns from 600px wide.",
        "Each source is a link that opens in a new tab, and its title is cut to one line and its snippet to two.",
        "A source card is taller than 44px, so the whole card is an easy tap target."
      ],
      "importPath": "import { SourceList } from \"./index\"",
      "sourcePath": "src/organisms/SourceList.tsx",
      "props": [
        {
          "name": "sources",
          "type": "Source[]",
          "required": true,
          "default": null,
          "description": "Sources to list. Source is { id: number; title: string; url: string; domain: string; snippet?: string }."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes merged onto the section."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border / --border-strong",
        "--surface-sunken (hover)",
        "--lime (number badge fill)",
        "--fg-muted / --fg-subtle"
      ],
      "related": [
        "markdown",
        "message-list"
      ],
      "composedOf": [
        "source-card"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Three sources",
          "description": "The short quote is optional. Cards sit in two columns on wider screens.",
          "code": "<div className=\"max-w-2xl\">\n  <SourceList sources={[\n    { id: 1, title: \"Q3 2026 earnings report\", url: \"https://example.com/q3\", domain: \"investor.example.com\", snippet: \"Total revenue increased 12% quarter over quarter.\" },\n    { id: 2, title: \"Customer metrics update\", url: \"https://example.com/metrics\", domain: \"example.com\" },\n    { id: 3, title: \"Market overview\", url: \"https://example.com/market\", domain: \"research.example.com\", snippet: \"Enterprise demand remained steady through the quarter.\" },\n  ]} />\n</div>"
        },
        {
          "title": "Beside the reply it supports",
          "description": "The [1] and [2] in the text point to the first and second source below.",
          "code": "<div className=\"max-w-2xl space-y-4 text-body-lg leading-7\">\n  <Markdown>{\"Revenue grew 12% quarter over quarter.[1] Churn held steady.[2]\"}</Markdown>\n  <SourceList sources={[\n    { id: 1, title: \"Q3 2026 earnings report\", url: \"https://example.com/q3\", domain: \"investor.example.com\" },\n    { id: 2, title: \"Customer metrics update\", url: \"https://example.com/metrics\", domain: \"example.com\" },\n  ]} />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-source-list/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/source-list.md"
    },
    {
      "id": "prompt-box",
      "name": "PromptBox",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "prompt input",
        "generation prompt",
        "prompt field"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "loading",
          "note": "Set with the busy prop.",
          "source": "prop"
        }
      ],
      "summary": "A box for describing what you want made, with a Generate button and optional examples.",
      "description": "PromptBox is for tools that make things, such as images. It has a three-line text box, a character count and a green Generate button. While it works, the button is off and shows Generating. Example ideas appear as small buttons that fill the box when clicked. Use it when the result is a set of new things, not a chat reply.",
      "purpose": "A roomy box for describing something to make, such as an image or a draft. Enter adds a line instead of sending, because these descriptions are often long and reworked before each run.",
      "useFor": [
        "The input of a tool that makes images, designs or drafts.",
        "Offering a few example ideas that fill the box in one click.",
        "A Generate step that takes a while: set busy so it cannot be pressed twice."
      ],
      "notFor": [
        {
          "text": "Chatting back and forth with the assistant",
          "use": "composer"
        },
        {
          "text": "Fine settings such as size, style or seed",
          "use": "parameter-panel"
        }
      ],
      "whenToUse": [
        "Use it when each submit makes something new.",
        "Offer two or three examples so new people see what to write.",
        "Keep it in the working state the whole time, so people cannot submit twice.",
        "Pair it with VariantGrid and CanvasPanel in a SplitCanvasTemplate.",
        "Set busy back to false when the run ends, or Generate stays off."
      ],
      "avoid": [
        "Do not use it for chat. Use Composer, which has Enter and Stop.",
        "Do not show a PromptBox and a Composer on the same screen.",
        "Do not use long sentences as examples. They do not fit in small buttons."
      ],
      "accessibility": [
        "It is a real form, and the text box is named \"Prompt\" for screen readers.",
        "Generate is a real button. It stays off while the box is empty or busy.",
        "While busy, the button text changes to \"Generating…\" and its spinner is read as \"Loading\".",
        "Example ideas are real buttons, so they work from the keyboard.",
        "Screen readers hear when generating starts and when it ends."
      ],
      "accessibilityYouDo": [
        "Set doneMessage to say where the results are, such as \"Done. 4 images below.\""
      ],
      "onPhone": [
        "The text box is 16px on a touch screen so the page does not zoom on focus. It stays three rows tall and does not grow.",
        "Enter adds a new line, so people send with the Generate button, which has a 44px tap area on a touch screen.",
        "The example chips wrap onto more lines. Each keeps its size and has a 44px tap area on a touch screen."
      ],
      "importPath": "import { PromptBox } from \"./index\"",
      "sourcePath": "src/organisms/PromptBox.tsx",
      "props": [
        {
          "name": "onGenerate",
          "type": "(prompt: string) => void",
          "required": true,
          "default": null,
          "description": "Called with the trimmed prompt on submit. Not called when empty or busy."
        },
        {
          "name": "busy",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Disables Generate and swaps its label and icon for a spinner with Generating…"
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "default": "\"Describe what you want to create…\"",
          "description": "Placeholder text for the textarea."
        },
        {
          "name": "examples",
          "type": "string[]",
          "required": false,
          "default": "[]",
          "description": "Prompts offered as Try pills. Clicking one replaces the textarea content."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--border-strong",
        "--surface",
        "--shadow-sm",
        "--lime (Generate)",
        "--fg-subtle",
        "--surface-hover",
        "--radius-control",
        "--radius-field"
      ],
      "related": [
        "composer",
        "variant-grid",
        "canvas-panel"
      ],
      "composedOf": [
        "button",
        "spinner"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With example ideas",
          "description": "Click an example to fill the box, then Generate. The demo shows Generating for 1.5 seconds.",
          "code": "const [busy, setBusy] = useState(false);\n\n<div className=\"max-w-xl\">\n  <PromptBox\n    busy={busy}\n    examples={[\"Product launch banner\", \"Quiet desk at sunrise\"]}\n    onGenerate={() => { setBusy(true); setTimeout(() => setBusy(false), 1500); }}\n  />\n</div>"
        },
        {
          "title": "While it works",
          "description": "The button is off, so a second request cannot start. The text can still be edited.",
          "code": "<div className=\"max-w-xl\">\n  <PromptBox busy onGenerate={() => {}} />\n</div>"
        },
        {
          "title": "Custom placeholder",
          "description": "Change the hint text to match what is being made.",
          "code": "<div className=\"max-w-xl\">\n  <PromptBox placeholder=\"Describe the icon you need…\" onGenerate={() => {}} />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-prompt-box/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/prompt-box.md"
    },
    {
      "id": "variant-grid",
      "name": "VariantGrid",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "variations grid",
        "results grid",
        "image grid"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "The tile whose id matches selectedId is checked and is the one tab stop.",
          "source": "documented"
        },
        {
          "name": "loading",
          "note": "Pass loadingCount to append that many shimmering placeholder tiles.",
          "source": "documented"
        },
        {
          "name": "keyboard",
          "note": "Arrow keys, Home and End move focus between tiles and select the one reached.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "With no variants, only loading placeholders (if any) are drawn.",
          "source": "documented"
        }
      ],
      "summary": "A set of made options where you pick one, with empty slots held for ones still coming.",
      "description": "VariantGrid shows options as square tiles, two across or four on wider screens. You can pick exactly one, and the pick shows a check mark as well as a border. Tab goes to the picked tile and arrow keys move and pick. Loading slots keep the layout from jumping while more options arrive.",
      "purpose": "Shows options the assistant made as square tiles and lets people pick one. Empty slots hold space for options still on the way, so tiles do not jump around.",
      "useFor": [
        "Picking one of several generated images or designs.",
        "A round of options that arrive one by one: set loadingCount.",
        "Comparing options side by side before opening one in a canvas."
      ],
      "notFor": [
        {
          "text": "Picking several options at once"
        },
        {
          "text": "Media that people look at but do not choose between",
          "use": "media-grid"
        },
        {
          "text": "Buttons to vary, upscale or remix one option",
          "use": "variation-actions"
        }
      ],
      "whenToUse": [
        "Give each option its own name.",
        "Hold space for options that are still arriving.",
        "Make the content fill the square tile.",
        "Mark made images with the AI label. VariantTile adds it for you.",
        "Update selectedId in onSelect, since arrow keys pick as they move."
      ],
      "avoid": [
        "Do not use it to pick several. It allows one choice.",
        "Do not put buttons inside a tile. The whole tile is the button.",
        "Do not rely on border color alone to show the pick. The check mark covers it."
      ],
      "accessibility": [
        "It is a group of choices named \"Generated variants\". Each tile has its own name, and screen readers say which one is picked.",
        "Only one tile is a Tab stop. Arrow keys move and pick, wrapping at the ends, and Home and End jump to the first and last.",
        "The pick shows a check mark and a thicker border, not color alone.",
        "Loading slots are hidden from screen readers.",
        "Screen readers hear when options start generating and when new ones arrive, such as \"4 options ready\"."
      ],
      "accessibilityYouDo": [
        "Give each variant a label that tells it apart, not just a letter.",
        "Set label, loadingMessage and readyMessage when you translate."
      ],
      "onPhone": [
        "Tiles show in two columns on a phone and in four columns from 600px wide, and each tile stays square.",
        "Tiles are tapped to select, and the selected tile shows a check mark, so selection does not depend on hover.",
        "Loading slots are the same square size, so the grid does not jump when the options arrive."
      ],
      "importPath": "import { VariantGrid } from \"./index\"",
      "sourcePath": "src/organisms/VariantGrid.tsx",
      "props": [
        {
          "name": "variants",
          "type": "Variant[]",
          "required": true,
          "default": null,
          "description": "Options to show. Variant is { id: string; node: ReactNode; label: string }. The node fills a square tile."
        },
        {
          "name": "selectedId",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id of the chosen variant. Leave undefined for no selection."
        },
        {
          "name": "onSelect",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Called with the id when a tile is clicked."
        },
        {
          "name": "loadingCount",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Number of skeleton squares appended after the variants."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg (selected border)",
        "--border / --border-strong",
        "--shadow-md",
        "--surface-sunken (skeleton)",
        "rounded-2xl tiles"
      ],
      "related": [
        "prompt-box",
        "canvas-panel"
      ],
      "composedOf": [
        "variant-tile",
        "skeleton"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Four options, one picked",
          "description": "Each option has a name for screen readers. Tab to the picked tile, then use the arrow keys, Home and End.",
          "code": "const [sel, setSel] = useState(\"a\");\n\n<VariantGrid\n  selectedId={sel}\n  onSelect={setSel}\n  variants={[\n    { id: \"a\", label: \"Variant A\", node: <div className=\"size-full bg-fg\" /> },\n    { id: \"b\", label: \"Variant B\", node: <div className=\"size-full bg-lime\" /> },\n    { id: \"c\", label: \"Variant C\", node: <div className=\"size-full bg-sunken\" /> },\n    { id: \"d\", label: \"Variant D\", node: <div className=\"size-full bg-line-strong\" /> },\n  ]}\n/>"
        },
        {
          "title": "More options on the way",
          "description": "Two options are ready and two slots are held, so the grid keeps its size.",
          "code": "<VariantGrid\n  selectedId=\"a\"\n  onSelect={() => {}}\n  loadingCount={2}\n  variants={[\n    { id: \"a\", label: \"Variant A\", node: <div className=\"size-full bg-fg\" /> },\n    { id: \"b\", label: \"Variant B\", node: <div className=\"size-full bg-lime\" /> },\n  ]}\n/>"
        },
        {
          "title": "Nothing picked yet",
          "description": "Leave the pick empty until the person chooses.",
          "code": "<VariantGrid\n  onSelect={() => {}}\n  variants={[\n    { id: \"a\", label: \"Variant A\", node: <div className=\"size-full bg-fg\" /> },\n    { id: \"b\", label: \"Variant B\", node: <div className=\"size-full bg-lime\" /> },\n  ]}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-variant-grid/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/variant-grid.md"
    },
    {
      "id": "canvas-panel",
      "name": "CanvasPanel",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "canvas",
        "artboard",
        "workspace"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "first or last version",
          "note": "The previous arrow is disabled on version 1 and the next arrow on the last version.",
          "source": "documented"
        },
        {
          "name": "single version",
          "note": "When versions is 1 or version is not set, the version switcher is hidden.",
          "source": "documented"
        }
      ],
      "summary": "A side panel for a document, code or design that stays after the chat message.",
      "description": "CanvasPanel is a titled panel that scrolls. It holds a document, code or a design. Its top bar shows the title and, if there is more than one version, Previous and Next buttons with a v of n label. It can also show Regenerate, Close and your own buttons. Use it beside the conversation in SplitCanvasTemplate.",
      "purpose": "A side panel for work that lasts longer than one message, such as a document, code or a design. It keeps versions, so people can go back while the chat moves on.",
      "useFor": [
        "A draft document or code that the assistant keeps editing.",
        "Moving between versions of the same piece of work.",
        "The right side of SplitCanvasTemplate."
      ],
      "notFor": [
        {
          "text": "A reply that only matters in the conversation",
          "use": "message"
        },
        {
          "text": "A code snippet inside a reply",
          "use": "code-block"
        }
      ],
      "whenToUse": [
        "Give the panel a height. It fills it and scrolls its own content.",
        "Use it for content that stays after the message that made it.",
        "Start version numbers at 1, and update the content when the version changes.",
        "Mark made content with an AI label in your own buttons area."
      ],
      "avoid": [
        "Do not use it for one-off messages. Replies belong in the conversation.",
        "Do not put the main controls for making things inside it. They belong in the main column.",
        "Do not give a version count above 1 without saying which version is shown, or the buttons stay hidden."
      ],
      "accessibility": [
        "It is an area named by its title, and the title is also a heading.",
        "The version buttons sit in a group named \"Version history\", are named \"Previous version\" and \"Next version\", and turn off at the ends.",
        "Regenerate and Close are icon buttons named \"Regenerate\" and \"Close panel\".",
        "When the version changes, screen readers hear it, such as \"Version 2 of 3\".",
        "The content area can be reached with Tab and scrolled with the arrow keys.",
        "After Close, keyboard focus returns to what had focus when the panel opened, such as the button that opened it. Pass focusAfterClose to choose another place."
      ],
      "accessibilityYouDo": [
        "Give any buttons you pass in actions their own names."
      ],
      "onPhone": [
        "It is a side panel with no phone layout of its own, so on a phone you must give it the full screen or a sheet yourself.",
        "It fills the height of its parent and scrolls its content inside, so the parent needs a set height.",
        "The title is cut to one line and the version, regenerate and close controls wrap below it. Each control has a 44px tap area on a touch screen."
      ],
      "importPath": "import { CanvasPanel } from \"./index\"",
      "sourcePath": "src/organisms/CanvasPanel.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Artifact name. Shown truncated in the header and used as the panel's aria-label."
        },
        {
          "name": "version",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Current version, 1-based. Needed for the version switcher to appear."
        },
        {
          "name": "versions",
          "type": "number",
          "required": false,
          "default": "1",
          "description": "Total number of versions. The switcher shows only when this is greater than 1."
        },
        {
          "name": "onVersionChange",
          "type": "(v: number) => void",
          "required": false,
          "default": null,
          "description": "Called with the new version number from the previous and next buttons."
        },
        {
          "name": "onClose",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows a Close panel icon button when provided."
        },
        {
          "name": "onRegenerate",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows a Regenerate icon button when provided."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The artifact. Rendered in a scrolling body with 16px padding."
        },
        {
          "name": "actions",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Extra header controls, such as an AI badge or an Export button."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border (line)",
        "--shadow-md",
        "--fg-muted",
        "--radius-card"
      ],
      "related": [
        "prompt-box",
        "variant-grid",
        "split-canvas-template"
      ],
      "composedOf": [
        "button",
        "icon-button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Versioned document",
          "description": "With more than one version, Previous and Next appear and turn off at the ends.",
          "code": "const [v, setV] = useState(2);\n\n<div className=\"h-75 max-w-xl\">\n  <CanvasPanel title=\"Launch announcement.md\" version={v} versions={3} onVersionChange={setV} onRegenerate={() => {}} onClose={() => {}}>\n    <p className=\"text-body leading-5\">Draft text for version {v}.</p>\n  </CanvasPanel>\n</div>"
        },
        {
          "title": "Title only",
          "description": "With no versions, Regenerate or Close, the top bar shows only the title.",
          "code": "<div className=\"h-50 max-w-xl\">\n  <CanvasPanel title=\"Release notes\">\n    <p className=\"text-body leading-5\">Static content.</p>\n  </CanvasPanel>\n</div>"
        },
        {
          "title": "Your own buttons",
          "description": "Your buttons sit after Regenerate and before Close.",
          "code": "<div className=\"h-50 max-w-xl\">\n  <CanvasPanel title=\"Summary.md\" actions={<Button size=\"sm\" variant=\"secondary\">Export</Button>}>\n    <p className=\"text-body leading-5\">Panel body.</p>\n  </CanvasPanel>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-canvas-panel/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/canvas-panel.md"
    },
    {
      "id": "app-shell",
      "name": "AppShell",
      "level": "template",
      "since": "0.5",
      "aka": [
        "layout",
        "app layout",
        "sidebar layout",
        "scaffold",
        "frame"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "narrow screen",
          "note": "Below the medium breakpoint the sidebar rail is hidden and only the main area shows.",
          "source": "documented"
        }
      ],
      "summary": "The outer frame of a screen: a narrow side panel and one main area.",
      "description": "AppShell is the frame around every screen. It puts an optional side panel on the left and one main area on the right, and fills the height it is given. On small screens a Menu button opens the side panel in a sheet, so it stays reachable. Keep navigation in the side panel so the main area stays free for the work.",
      "purpose": "The frame around every screen: a narrow side panel for navigation and one main area for the work. The side panel stays narrow so it never competes with the main area.",
      "useFor": [
        "The outer frame of any app screen.",
        "A screen with past chats or navigation in a side panel.",
        "A full-width screen with no side panel: leave rail out."
      ],
      "notFor": [
        {
          "text": "Splitting the main area into chat and work side by side",
          "use": "split-canvas-template"
        },
        {
          "text": "Spacing content inside a section",
          "use": "stack"
        }
      ],
      "whenToUse": [
        "Put navigation and the user card in the side panel so the main area stays calm.",
        "Give the frame a space with a set height.",
        "Put a template (HomeTemplate, ThreadTemplate) inside it.",
        "Check how it looks without the side panel, since it is hidden on small screens."
      ],
      "avoid": [
        "Do not put important actions only in the side panel. It is hidden on narrow screens.",
        "Do not add a third column. Use SplitCanvasTemplate inside the main area instead.",
        "Do not put an AppShell inside another AppShell."
      ],
      "accessibility": [
        "The side panel is an area named \"Sidebar\" and the main area is the page's main area, so screen reader users can jump between them.",
        "On small screens the side panel is hidden from sight and from screen readers.",
        "Inside a docs preview the two areas become plain boxes, so the host page keeps one main area."
      ],
      "accessibilityYouDo": [
        "Give people another way to reach navigation on small screens, since the side panel is hidden there.",
        "Use one AppShell per page. Do not put one inside another.",
        "Put a template such as ThreadTemplate inside it, which adds the page's top heading."
      ],
      "onPhone": [
        "Below the md breakpoint the sidebar moves into a side sheet that opens from a Menu button above the main area. Use menuLabel to rename that button.",
        "The main area takes the full width and can shrink without overflowing.",
        "The shell is as tall as its parent, so give the parent a height; use the dynamic viewport height so phone browser bars are counted."
      ],
      "importPath": "import { AppShell } from \"./index\"",
      "sourcePath": "src/templates/AppShell.tsx",
      "props": [
        {
          "name": "rail",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Left sidebar content such as a conversation list and user card. Rendered in an aside with 272px width from md up. Below md it opens from a Menu button in a left side sheet."
        },
        {
          "name": "menuLabel",
          "type": "string",
          "required": false,
          "default": "Menu",
          "description": "Name of the button that opens the rail on a phone."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The main column. Rendered inside main as a flex column, so children can use flex-1 and min-h-0."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Merged onto the root. Use it to fix a height such as h-160 for previews. By default the shell fills its container."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--bg",
        "--fg",
        "w-68 rail width"
      ],
      "related": [
        "home-template",
        "thread-template",
        "split-canvas-template"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Side panel and main area",
          "description": "The frame fills the space around it, so give that space a height. Make the window narrow and the side panel disappears.",
          "code": "<div className=\"h-105 overflow-hidden rounded-card border border-line\">\n  <AppShell rail={<ConversationList items={railItems} activeId=\"1\" onSelect={() => {}} onNew={() => {}} />}>\n    <div className=\"p-6 text-body leading-5 text-fg-muted\">Main content goes here.</div>\n  </AppShell>\n</div>"
        },
        {
          "title": "Without a side panel",
          "description": "With no side panel, the main area takes the full width. The studio page uses this form.",
          "code": "<div className=\"h-105 overflow-hidden rounded-card border border-line\">\n  <AppShell>\n    <div className=\"p-6 text-body leading-5 text-fg-muted\">Full-width main column.</div>\n  </AppShell>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-app-shell/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/app-shell.md"
    },
    {
      "id": "home-template",
      "name": "HomeTemplate",
      "level": "template",
      "since": "0.5",
      "aka": [
        "home page layout",
        "landing layout"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "The first screen of a new conversation: one greeting and one tray, centered.",
      "description": "HomeTemplate centers a heading, one block of content and an optional note in a narrow column. Use it for a new conversation, where nothing should compete with the greeting. The content block answers the headline, usually a Tray with a Composer at the bottom.",
      "purpose": "The screen before a conversation starts. One greeting and one block of content sit in a narrow centered column, so nothing competes with getting started.",
      "useFor": [
        "A new chat, before the first message.",
        "A landing screen whose greeting is answered by a Tray with a message box.",
        "Showing the AI notice right under the starting point."
      ],
      "notFor": [
        {
          "text": "A conversation that has already started",
          "use": "thread-template"
        },
        {
          "text": "A screen with several sections or dashboards"
        }
      ],
      "whenToUse": [
        "Use it only for a new conversation, before the first message.",
        "Keep one heading and one block of content.",
        "Put the Composer inside the Tray so they share one width.",
        "Place it inside AppShell, which gives it the height to center in."
      ],
      "avoid": [
        "Do not add more sections below the tray. Anything extra competes with the greeting.",
        "Do not use it once a conversation has started. Switch to ThreadTemplate.",
        "Do not make it wider. The narrow column keeps lines easy to read."
      ],
      "accessibility": [
        "The title bar holds the page's top heading, \"New chat\" unless you change it.",
        "The content scrolls when it is taller than the screen.",
        "Reading order matches what people see: title, greeting, content, note."
      ],
      "accessibilityYouDo": [
        "Use HeroHeading for the greeting, so screen readers can find it as a heading.",
        "Set header to name the screen if \"New chat\" is not right.",
        "Decide which control gets focus first, usually the message box."
      ],
      "onPhone": [
        "The content sits at the top on a phone and is centred vertically from the md breakpoint up.",
        "Side padding is 1rem and the content column is capped at 48rem, so it fills the screen on a phone.",
        "The area scrolls on its own when the content is taller than the screen."
      ],
      "importPath": "import { HomeTemplate } from \"./index\"",
      "sourcePath": "src/templates/HomeTemplate.tsx",
      "props": [
        {
          "name": "heading",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The greeting. Use HeroHeading."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The block that answers the headline, usually a Tray with its Composer as footer."
        },
        {
          "name": "footnote",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "A quiet line under the content, for example AIDisclosure."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "max-w-read",
        "space-y-8",
        "px-4 py-10"
      ],
      "related": [
        "app-shell",
        "thread-template",
        "tray"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Greeting, tray and AI notice",
          "description": "A greeting, a tray with one row and a message box, and the AI notice below.",
          "code": "<div className=\"h-105 overflow-hidden rounded-card border border-line\">\n  <AppShell>\n    <HomeTemplate heading={<HeroHeading text=\"Where should we start?\" type={false} />} footnote={<AIDisclosure />}>\n      <Tray label=\"Needs you\" footer={<Composer onSend={() => {}} />}>\n        <TrayRow attention title=\"Quarterly report\" detail=\"Approval pending\" trailing=\"Open →\" />\n      </Tray>\n    </HomeTemplate>\n  </AppShell>\n</div>"
        },
        {
          "title": "Empty placeholders",
          "description": "The same layout with plain placeholders, showing what goes where.",
          "code": "<div className=\"h-105 overflow-hidden rounded-card border border-line\">\n  <AppShell>\n    <HomeTemplate heading={<h2 className=\"text-center text-title-md\">Heading slot</h2>} footnote={<p className=\"text-center text-small leading-4 text-fg-subtle\">Footnote slot</p>}>\n      <div className=\"flex h-24 items-center justify-center rounded-card border border-dashed border-line-strong text-small leading-4 text-fg-subtle\">Children slot</div>\n    </HomeTemplate>\n  </AppShell>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-home-template/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/home-template.md"
    },
    {
      "id": "thread-template",
      "name": "ThreadTemplate",
      "level": "template",
      "since": "0.5",
      "aka": [
        "chat layout",
        "chat page layout"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A conversation that scrolls, with the message box fixed at the bottom.",
      "description": "ThreadTemplate has an optional title row, a scrolling conversation and a message box that stays at the bottom. The conversation and message box share one width, so text and input line up. It needs a parent that stacks things top to bottom, such as the main area of AppShell.",
      "purpose": "A conversation that scrolls, with the message box fixed at the bottom. Both share one width, so the text people read and the box they type in line up.",
      "useFor": [
        "Any chat once the first message is sent.",
        "A running workflow shown as a conversation, with checklists and approval requests.",
        "A screen with a title bar and controls such as Share."
      ],
      "notFor": [
        {
          "text": "A new chat before the first message",
          "use": "home-template"
        },
        {
          "text": "Work that should stay in view beside the chat",
          "use": "split-canvas-template"
        }
      ],
      "whenToUse": [
        "Place it directly inside AppShell so it fills the main area.",
        "Wrap messages in MessageList so screen readers announce new ones.",
        "Use it for workflows too. It can hold a checklist, steps and approval requests.",
        "Use the note for the AI notice so it stays close to the input."
      ],
      "avoid": [
        "Do not put it in a space with no set height. The conversation cannot scroll and the message box will not stay at the bottom.",
        "Do not put the message box inside the conversation. It would scroll away.",
        "Do not give the conversation a different width. The shared width keeps text and input lined up."
      ],
      "accessibility": [
        "The title bar holds the page's top heading.",
        "The message box sits outside the scrolling area, so it never scrolls away.",
        "Reading order goes title, conversation, message box, note."
      ],
      "accessibilityYouDo": [
        "Wrap messages in MessageList, so screen readers hear new ones. The template announces nothing.",
        "Pass a header. Without one, the page's top heading is empty.",
        "Place it inside AppShell, which supplies the main area."
      ],
      "onPhone": [
        "Side padding is 1rem on a phone and 1.5rem from the sm breakpoint, and the column is capped at 48rem.",
        "The messages scroll in their own area while the composer stays at the bottom.",
        "The composer area adds the bottom safe area to its padding, so it clears the home bar on a phone."
      ],
      "importPath": "import { ThreadTemplate } from \"./index\"",
      "sourcePath": "src/templates/ThreadTemplate.tsx",
      "props": [
        {
          "name": "header",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Optional title row above the thread. Rendered in a 56px header element."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The conversation or workflow body. Scrolls inside a max-w-3xl column."
        },
        {
          "name": "composer",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The input, pinned to the bottom in the same column width as the body."
        },
        {
          "name": "footnote",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Rendered under the composer, for example AIDisclosure."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "max-w-3xl",
        "--fg-muted (header)",
        "px-4 sm:px-6 gutters"
      ],
      "related": [
        "app-shell",
        "home-template",
        "split-canvas-template",
        "message-list",
        "composer"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A conversation with a message box",
          "description": "The title sits above the conversation. Messages scroll while the message box stays put.",
          "code": "<div className=\"h-105 overflow-hidden rounded-card border border-line\">\n  <AppShell>\n    <ThreadTemplate header=\"Onboarding email draft\" composer={<Composer onSend={() => {}} />} footnote={<AIDisclosure />}>\n      <MessageList>\n        <Message role=\"user\">Draft a welcome email for new customers.</Message>\n        <Message role=\"assistant\">Here is a short draft you can adapt.</Message>\n      </MessageList>\n    </ThreadTemplate>\n  </AppShell>\n</div>"
        },
        {
          "title": "Empty placeholders",
          "description": "With no title, the content starts at the top. Anything can fill the conversation, not only messages.",
          "code": "<div className=\"h-105 overflow-hidden rounded-card border border-line\">\n  <AppShell>\n    <ThreadTemplate composer={<div className=\"flex h-12 items-center justify-center rounded-card border border-dashed border-line-strong text-small leading-4 text-fg-subtle\">Composer slot</div>}>\n      <div className=\"flex h-24 items-center justify-center rounded-card border border-dashed border-line-strong text-small leading-4 text-fg-subtle\">Thread body</div>\n    </ThreadTemplate>\n  </AppShell>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-thread-template/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/thread-template.md"
    },
    {
      "id": "split-canvas-template",
      "name": "SplitCanvasTemplate",
      "level": "template",
      "since": "0.5",
      "aka": [
        "split layout",
        "canvas layout",
        "two pane layout"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "Chat or controls on the left, and the thing you are making on the right.",
      "description": "SplitCanvasTemplate splits the main area into two columns: a left side for chat or controls and a right side for the work. On smaller screens they stack, with the work below. The left side scrolls on its own. Use it when the result should stay in view while the person keeps working.",
      "purpose": "Splits the main area in two: chat or controls on the left, the thing being made on the right. The work stays in view while the person keeps changing it.",
      "useFor": [
        "Editing a document or code with the assistant beside it.",
        "A studio with the prompt and options on the left and the chosen draft on the right.",
        "Any screen where the result must stay visible while people work."
      ],
      "notFor": [
        {
          "text": "A plain chat with no lasting result",
          "use": "thread-template"
        },
        {
          "text": "A new chat before the first message",
          "use": "home-template"
        }
      ],
      "whenToUse": [
        "Put a CanvasPanel on the right so it fills the height.",
        "Use it when the result should stay in view while the person makes changes.",
        "Keep the controls for making things on the left.",
        "Check the stacked layout on small screens, where the canvas comes after the left side."
      ],
      "avoid": [
        "Do not use it for plain chat. ThreadTemplate is the right frame.",
        "Do not put lasting content on the left and short-lived content on the canvas. The canvas is for the thing being made.",
        "Do not set fixed heights on the sides. The template handles overflow itself."
      ],
      "accessibility": [
        "The two sides are named areas, \"Conversation\" and \"Canvas\" by default.",
        "The left side comes first in reading order, which matches the stacked layout on small screens.",
        "The title bar holds the page's top heading."
      ],
      "accessibilityYouDo": [
        "Rename the sides with primaryLabel and canvasLabel when they hold something other than a chat and a canvas.",
        "Pass a header. Without one, the page's top heading is empty.",
        "Tell people when the canvas changes because of something they did on the left. The template does not."
      ],
      "onPhone": [
        "Below the lg breakpoint the conversation and the canvas stack in one column, conversation first, and the page scrolls as one.",
        "The stacked canvas is at least 22.5rem tall; from lg up the two sit side by side and scroll separately.",
        "Side padding is 1rem on a phone and 1.5rem from sm."
      ],
      "importPath": "import { SplitCanvasTemplate } from \"./index\"",
      "sourcePath": "src/templates/SplitCanvasTemplate.tsx",
      "props": [
        {
          "name": "primary",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Left column: the conversation or controls. Wrapped in a section named by primaryLabel (default Conversation) that scrolls and spaces its children by 24px."
        },
        {
          "name": "canvas",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Right column: the artifact that outlives a single message, usually CanvasPanel. Wrapped in a section named by canvasLabel (default Canvas). Stacks under primary on narrow screens."
        },
        {
          "name": "primaryLabel",
          "type": "string",
          "required": false,
          "default": "\"Conversation\"",
          "description": "Accessible name for the left region. Change it when the column holds controls rather than a chat."
        },
        {
          "name": "canvasLabel",
          "type": "string",
          "required": false,
          "default": "\"Canvas\"",
          "description": "Accessible name for the right region."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "gap-4 / p-4",
        "grid-cols 5fr / 6fr at lg",
        "min-h-90 canvas on small screens"
      ],
      "related": [
        "app-shell",
        "thread-template",
        "canvas-panel"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A prompt and a canvas",
          "description": "A document on the right. On a narrow screen the two sides stack.",
          "code": "<div className=\"h-105 overflow-hidden rounded-card border border-line\">\n  <AppShell>\n    <SplitCanvasTemplate\n      primary={<Composer onSend={() => {}} />}\n      canvas={\n        <CanvasPanel title=\"Launch announcement.md\">\n          <Markdown>{\"# Launch announcement\\n\\nA calmer way to build with AI.\"}</Markdown>\n        </CanvasPanel>\n      }\n    />\n  </AppShell>\n</div>"
        },
        {
          "title": "Empty placeholders",
          "description": "Each side has a name for screen readers. The defaults are Conversation on the left and Canvas on the right, and both can be renamed.",
          "code": "<div className=\"h-105 overflow-hidden rounded-card border border-line\">\n  <AppShell>\n    <SplitCanvasTemplate\n      primary={<div className=\"flex h-24 items-center justify-center rounded-card border border-dashed border-line-strong text-small leading-4 text-fg-subtle\">Primary slot</div>}\n      canvas={<div className=\"flex h-full min-h-24 items-center justify-center rounded-card border border-dashed border-line-strong text-small leading-4 text-fg-subtle\">Canvas slot</div>}\n    />\n  </AppShell>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-split-canvas-template/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/split-canvas-template.md"
    },
    {
      "id": "home-page",
      "name": "HomePage",
      "level": "page",
      "since": "0.5",
      "aka": [
        "landing page",
        "start page"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "The first screen: a headline backed up by the list of tasks right below it.",
      "description": "HomePage is what a person sees before any conversation starts. The headline says how many tasks need them, and the tray below lists those tasks with amber dots. The message box sits in the same tray, with a small tag that shows what the assistant can see. Nothing else is on screen.",
      "purpose": "A worked example of the first screen. The headline says how many tasks need the person, and the tray right below lists them, so the claim and the proof sit together.",
      "useFor": [
        "A starting point to copy for your own landing screen.",
        "Seeing how AppShell, HomeTemplate, Tray and Composer fit together.",
        "Checking where attention dots and the AI notice go."
      ],
      "notFor": [
        {
          "text": "Dropping into a product as it is, since it holds sample data"
        },
        {
          "text": "A landing screen built with your own content",
          "use": "home-template"
        }
      ],
      "whenToUse": [
        "Copy it as a starting point and replace the sample rows with real tasks.",
        "Keep the headline true to the tray. The count in the heading should match the rows below.",
        "Use amber only for rows that need a person.",
        "Keep the message box at the bottom of the tray."
      ],
      "avoid": [
        "Do not add navigation cards or promo blocks around the tray.",
        "Do not list tasks that need nothing from the person under \"Needs you\".",
        "Do not drop the page into your app as it is. It contains sample data."
      ],
      "accessibility": [
        "Screen readers find a side panel named \"Sidebar\" and one main area.",
        "The headline types itself out once, shows whole when people turn off motion in their system settings, and screen readers always get the full text.",
        "Rows that need a person show an amber dot, read as \"Needs attention\", beside written detail.",
        "Rows are buttons. In the message box, Enter sends and Shift and Enter add a new line."
      ],
      "accessibilityYouDo": [
        "Give each real task a detail that says what is needed, not just the amber dot.",
        "Give people a way to reach the side panel on small screens."
      ],
      "onPhone": [
        "The sidebar opens from a Menu button in a side sheet on a phone.",
        "The greeting, the task list and the composer stack in one column.",
        "The page is 45rem tall at most and never taller than the phone screen height."
      ],
      "importPath": "import { HomePage } from \"./index\"",
      "sourcePath": "src/pages/HomePage.tsx",
      "props": [
        {
          "name": "(none)",
          "type": "-",
          "required": false,
          "default": null,
          "description": "This page takes no props. It is a reference composition with sample content, meant to be copied and adapted."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--bg",
        "--surface-sunken (tray)",
        "--attention (dots)",
        "--lime (send)"
      ],
      "related": [
        "thread-page",
        "workflow-page",
        "studio-page"
      ],
      "composedOf": [
        "app-shell",
        "home-template",
        "hero-heading",
        "ai-disclosure",
        "tray-row",
        "context-chip",
        "tray",
        "composer"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Full page",
          "description": "The whole screen at a fixed height. The headline types itself out once.",
          "code": "<HomePage />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-home-page/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/home-page.md"
    },
    {
      "id": "thread-page",
      "name": "ThreadPage",
      "level": "page",
      "since": "0.5",
      "aka": [
        "chat page",
        "conversation page"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "streaming",
          "note": "Sending a message streams the reply, shows Stop on the composer and holds back sources and feedback until it finishes.",
          "source": "documented"
        }
      ],
      "summary": "A full answer with the assistant's thinking, the steps it took, sources and feedback.",
      "description": "ThreadPage shows one full reply from the assistant. The reply appears as it is written, after a thinking section and a card showing the step it took. It ends with how sure the assistant is, its sources, message actions and a feedback bar. Those only appear once the reply is finished, so the page does not jump while people read. Suggestion chips offer follow-ups, and the send button becomes Stop while the reply is being written.",
      "purpose": "A worked example of one full answer: thinking, a step the assistant took, the reply as it is written, then sources and feedback. The extras wait until the reply ends, so the page does not jump.",
      "useFor": [
        "A starting point for a research or question-and-answer screen.",
        "Seeing the order of thinking, steps, answer, sources and feedback.",
        "Seeing how Stop, streaming text and follow-up suggestions work together."
      ],
      "notFor": [
        {
          "text": "A long task with approvals and steps",
          "use": "workflow-page"
        },
        {
          "text": "A tool that makes images or drafts",
          "use": "studio-page"
        }
      ],
      "whenToUse": [
        "Copy the order: thinking, steps taken, answer, then how sure it is, sources and actions.",
        "Show the extras only when the reply is finished.",
        "Put source numbers in the reply so they link to the source cards.",
        "Show follow-up suggestions after the last reply, and hide them while a reply is being written."
      ],
      "avoid": [
        "Do not show feedback or actions while the reply is still being written.",
        "Do not put the message box inside the scrolling conversation. ThreadTemplate keeps it at the bottom.",
        "Do not use the sample reply in your real product. Swap in your own."
      ],
      "accessibility": [
        "New messages are read out politely. The reply being written is marked busy, then says \"Response complete\" once.",
        "The blinking cursor is hidden from screen readers.",
        "Before words arrive, the typing indicator is read as \"Assistant is thinking\".",
        "Sources are a numbered list of links, and the [1] markers in the reply link to them.",
        "Stop replaces Send while the reply is written, and works from the keyboard."
      ],
      "accessibilityYouDo": [
        "Keep streaming on the reply in step with your real stream, and turn it off when it ends."
      ],
      "onPhone": [
        "The sidebar opens from a Menu button in a side sheet on a phone, and the page shows the conversation.",
        "Messages, sources and the composer fit one column, and the composer stays at the bottom while the messages scroll.",
        "The page is 45rem tall at most and never taller than the phone screen height. The composer area clears the bottom safe area."
      ],
      "importPath": "import { ThreadPage } from \"./index\"",
      "sourcePath": "src/pages/ThreadPage.tsx",
      "props": [
        {
          "name": "(none)",
          "type": "-",
          "required": false,
          "default": null,
          "description": "This page takes no props. It is a reference composition with sample content, meant to be copied and adapted."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken (user bubble)",
        "--lime",
        "--success (confidence)",
        "--border"
      ],
      "related": [
        "home-page",
        "workflow-page",
        "studio-page"
      ],
      "composedOf": [
        "app-shell",
        "thread-template",
        "ai-disclosure",
        "typing-indicator",
        "message",
        "message-actions",
        "thinking-block",
        "tool-call-card",
        "feedback-bar",
        "confidence-indicator",
        "suggestion-chips",
        "composer",
        "markdown",
        "message-list",
        "source-list"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Full page",
          "description": "The reply is written when the page loads. Press Stop to interrupt it, or send a message to start another.",
          "code": "<ThreadPage />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-thread-page/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/thread-page.md"
    },
    {
      "id": "workflow-page",
      "name": "WorkflowPage",
      "level": "page",
      "since": "0.5",
      "aka": [
        "agent page",
        "workflow"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "decided",
          "note": "After Approve or Deny is chosen, a line below the prompt states the decision.",
          "source": "documented"
        }
      ],
      "summary": "A task in progress, shown as a conversation: heading, checklist, approval request and steps.",
      "description": "WorkflowPage shows a long task as a conversation. A heading and a status line open the page. A checklist shows who still owes a reply, with finished work folded into one line. An approval request holds the next action until a person says yes. A step list shows where the task is overall. The message box stays at the bottom, so a person can still ask a question while the task waits.",
      "purpose": "A worked example of a long task shown as a conversation. Open items, the decision that needs a person and overall progress stack in one column, and the message box stays for questions.",
      "useFor": [
        "A starting point for tasks that wait on people, such as chasing documents.",
        "Seeing how Checklist, ApprovalPrompt and StepTimeline fit in a thread.",
        "Showing how to confirm a decision with a short message."
      ],
      "notFor": [
        {
          "text": "A plain question and answer",
          "use": "thread-page"
        },
        {
          "text": "One list of everything waiting for the person across tasks",
          "use": "approval-inbox"
        }
      ],
      "whenToUse": [
        "Use the conversation layout so follow-up questions stay possible.",
        "Put the request that needs a person where it is easy to spot, between the checklist and the step list.",
        "Say what will be sent, to whom and when in the approval request.",
        "Confirm the outcome of a decision with a message, as the page does.",
        "Keep the status line under the heading in step with the checklist."
      ],
      "avoid": [
        "Do not mark work in progress as needing attention. Amber means a person has to act.",
        "Do not list finished items one by one. Fold them into one summary line.",
        "Do not ask for approval without a Deny option."
      ],
      "accessibility": [
        "The approval request is announced as an alert dialog named after its action.",
        "After a choice, a status message says \"Decision: Approved\" or \"Decision: Denied\", and screen readers hear it.",
        "Rows that need attention show a dot, read as \"Needs attention\", with written status.",
        "The step list tells screen readers which step is current and the state of each step."
      ],
      "accessibilityYouDo": [
        "Move focus to the approval request if it appears after the page has loaded. The page does not do this.",
        "Tell people when a step changes state. The step list does not announce it."
      ],
      "onPhone": [
        "The sidebar opens from a Menu button in a side sheet on a phone, and the page shows the workflow in one column.",
        "The approval buttons keep their size and have a 44px tap area on touch screens. The checklist rows are not tappable.",
        "The page is 45rem tall at most and never taller than the phone screen height. The composer area clears the bottom safe area."
      ],
      "importPath": "import { WorkflowPage } from \"./index\"",
      "sourcePath": "src/pages/WorkflowPage.tsx",
      "props": [
        {
          "name": "(none)",
          "type": "-",
          "required": false,
          "default": null,
          "description": "This page takes no props. It is a reference composition with sample content, meant to be copied and adapted."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--attention / --warning-soft",
        "--success (steps)",
        "--border (checklist rules)",
        "font-serif heading"
      ],
      "related": [
        "home-page",
        "thread-page",
        "studio-page"
      ],
      "composedOf": [
        "app-shell",
        "thread-template",
        "ai-disclosure",
        "badge",
        "context-chip",
        "checklist",
        "approval-prompt",
        "step-timeline",
        "composer"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Full page",
          "description": "Approve or deny the reminder to see the decision message appear under the request.",
          "code": "<WorkflowPage />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-workflow-page/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/workflow-page.md"
    },
    {
      "id": "studio-page",
      "name": "StudioPage",
      "level": "page",
      "since": "0.5",
      "aka": [
        "studio",
        "creative workspace"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "generating",
          "note": "Pressing Generate shows two options plus two shimmering placeholder tiles for about two seconds.",
          "source": "documented"
        }
      ],
      "summary": "A creative studio: write a prompt and see options on the left, the one you pick on the right.",
      "description": "StudioPage is for tools that make something, not just reply in chat. The left side has a prompt box and a grid of options. Placeholder tiles keep the grid steady while options load. The right side shows the chosen draft, with its version history. Only a narrow icon rail sits at the edge, so the two columns get almost the full width.",
      "purpose": "A worked example of a tool that makes things. The prompt and options sit on the left and the chosen draft with its versions on the right, so people compare and refine without losing their place.",
      "useFor": [
        "A starting point for an image, design or writing studio.",
        "Seeing how PromptBox, VariantGrid and CanvasPanel fit in a SplitCanvasTemplate.",
        "Seeing loading slots and a version switcher work together."
      ],
      "notFor": [
        {
          "text": "A plain question and answer",
          "use": "thread-page"
        },
        {
          "text": "A long task with approvals",
          "use": "workflow-page"
        }
      ],
      "whenToUse": [
        "Use it when people refine something and compare options.",
        "Reserve placeholder tiles so the grid keeps its layout while options load.",
        "Keep the draft on the canvas, where it stays visible while the prompt changes.",
        "Label AI-made content with the AI badge."
      ],
      "avoid": [
        "Do not use it for plain questions and answers. Use the thread page.",
        "Do not hide version history. People expect to go back after making new options.",
        "Do not show more than a few options per round. The grid is made for four."
      ],
      "accessibility": [
        "The two sides are named \"Make options\" and \"Draft\" for screen readers.",
        "The options are a group of choices with one Tab stop. Arrow keys move and pick, and the picked one shows a check mark.",
        "While busy, the Generate button reads \"Generating…\".",
        "The version buttons are named \"Previous version\" and \"Next version\", and the draft also says which version is shown in text.",
        "On narrow screens the two sides stack, with the draft after the options."
      ],
      "accessibilityYouDo": [
        "Announce when new options are ready. The grid and the prompt box do not.",
        "Give each option a label that describes it, not just a letter.",
        "Announce version changes if people need to hear them."
      ],
      "onPhone": [
        "The sidebar opens from a Menu button in a side sheet on a phone, and below the lg breakpoint the options and the draft stack in one column that scrolls as one.",
        "The draft panel is at least 22.5rem tall when stacked, so the user scrolls down past the options to reach it.",
        "The page is 45rem tall at most and never taller than the phone screen height."
      ],
      "importPath": "import { StudioPage } from \"./index\"",
      "sourcePath": "src/pages/StudioPage.tsx",
      "props": [
        {
          "name": "(none)",
          "type": "-",
          "required": false,
          "default": null,
          "description": "This page takes no props. It is a reference composition with sample content, meant to be copied and adapted."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--lime (Generate)",
        "--shadow-md (canvas)"
      ],
      "related": [
        "home-page",
        "thread-page",
        "workflow-page"
      ],
      "composedOf": [
        "app-shell",
        "split-canvas-template",
        "prompt-box",
        "variant-grid",
        "canvas-panel",
        "markdown",
        "ai-badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Full page",
          "description": "Click Generate to see the busy state and placeholder tiles, then switch versions at the top of the canvas.",
          "code": "<StudioPage />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-studio-page/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/studio-page.md"
    },
    {
      "id": "model-picker",
      "name": "ModelPicker",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "model selector",
        "model switcher",
        "model dropdown"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open",
          "note": "Pressing the trigger opens a panel of model options; choosing one closes it.",
          "source": "documented"
        },
        {
          "name": "option disabled",
          "note": "Set disabled on a model, or list its id in limit.limitedIds, to dim it and make it unselectable.",
          "source": "documented"
        },
        {
          "name": "limit notice",
          "note": "Pass limit to show a note at the top of the panel saying why some models are unavailable and which one is used instead.",
          "source": "documented"
        },
        {
          "name": "routed",
          "note": "When the chosen model has auto set and routedId is passed, the trigger shows via the routed model and the panel says who answered.",
          "source": "documented"
        },
        {
          "name": "keyboard",
          "note": "Arrow keys, Home and End move between enabled models and select the one reached.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The trigger and the model options show a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "A small button that opens a list for choosing how the assistant answers, with optional thinking time and limit messages.",
      "description": "ModelPicker shows the current choice on a small button. Opening it lists the options, each with a name, a one-line description and an optional tag. An optional switch below sets how long the assistant thinks. When Auto is picked, the button can show which option actually answered. When a limit is reached, those options are turned off, a line explains why and names the one used instead.",
      "purpose": "A compact button that opens a short list of ways the assistant can answer, each with a one-line description. It stays honest about what happened: it names the option that answered when Auto chose, and says why an option is off when a limit is reached.",
      "useFor": [
        "Letting people choose between a few answer modes, such as Fast and Thinking.",
        "Showing which option actually answered when Auto is picked.",
        "Explaining that a limit was reached and naming the option used instead.",
        "Setting how long the assistant thinks, with the optional switch."
      ],
      "notFor": [
        {
          "text": "Turning tools such as web search on or off",
          "use": "tool-menu"
        },
        {
          "text": "Two or three choices that should always be visible",
          "use": "segmented-control"
        },
        {
          "text": "A usage limit that needs its own message",
          "use": "rate-limit-notice"
        }
      ],
      "whenToUse": [
        "Write a description that says when to choose each option.",
        "Whenever Auto is picked, show which option answered.",
        "Name the replacement when a limit is reached.",
        "Keep option names short enough to fit the button."
      ],
      "avoid": [
        "Do not hide an option behind Auto with no way to pick it directly.",
        "Do not remove turned-off options. Show them with the reason.",
        "Do not use it for settings that are not a single choice. Use Switch or checkboxes."
      ],
      "accessibility": [
        "The options are a group of single choices named by the label, and screen readers hear which one is picked.",
        "Arrow keys move and pick, Home and End jump to the ends, and options that are off are skipped. Tab stops on one option only.",
        "The button says the label before the current choice and tells screen readers whether the list is open. On open, the cursor goes to the picked option, and Escape closes the list and returns to the button.",
        "Options that are off stay visible, with the reason written above the list.",
        "The list opens on the other side if it would run off the screen, and stays inside the screen edges."
      ],
      "accessibilityYouDo": [
        "Write a label that names what is being chosen, such as Model or Mode. Screen readers use it for the button and the list.",
        "When a limit is reached, write the reason in plain words and say when it resets, if you know."
      ],
      "onPhone": [
        "The trigger keeps its 32px height and has a 44px tap area on touch screens. It shortens a long model name with an ellipsis.",
        "On a phone the panel opens as a bottom sheet with a backdrop. On wider screens it is an anchored popover 20rem wide, never wider than the screen minus 2rem.",
        "Each model row and the thinking time options have a 44px tap area on touch screens."
      ],
      "importPath": "import { ModelPicker } from \"./index\"",
      "sourcePath": "src/molecules/ModelPicker.tsx",
      "props": [
        {
          "name": "models",
          "type": "ModelOption[]",
          "required": true,
          "default": null,
          "description": "Options with id, name, optional description, badge, auto and disabled."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Selected id. Omit for uncontrolled use."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Initial id when uncontrolled. Defaults to the first model."
        },
        {
          "name": "onChange",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when an enabled option is chosen."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Model\"",
          "description": "Accessible name for the trigger, the panel and the radio group."
        },
        {
          "name": "routedId",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id of the model that answered. Shown only while the selected option has auto set."
        },
        {
          "name": "routedText",
          "type": "(modelName: string) => string",
          "required": false,
          "default": "Answered by {name}",
          "description": "Builds the line that names the answering model."
        },
        {
          "name": "effortOptions",
          "type": "SegmentOption<string>[]",
          "required": false,
          "default": null,
          "description": "Choices for the thinking time control. Omit to hide it."
        },
        {
          "name": "effortValue",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Selected effort. Omit for uncontrolled use."
        },
        {
          "name": "defaultEffort",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Initial effort when uncontrolled. Defaults to the first option."
        },
        {
          "name": "onEffortChange",
          "type": "(value: string) => void",
          "required": false,
          "default": null,
          "description": "Called when the effort changes."
        },
        {
          "name": "effortLabel",
          "type": "string",
          "required": false,
          "default": "\"Thinking time\"",
          "description": "Visible label and group name for the effort control."
        },
        {
          "name": "limit",
          "type": "ModelLimit",
          "required": false,
          "default": null,
          "description": "Puts the picker in the limit-reached state: reason, limitedIds and fallbackId."
        },
        {
          "name": "fallbackText",
          "type": "(modelName: string) => string",
          "required": false,
          "default": "Continuing on {name}.",
          "description": "Builds the line that names the fallback model."
        },
        {
          "name": "align",
          "type": "\"start\" | \"end\"",
          "required": false,
          "default": "\"start\"",
          "description": "Horizontal alignment of the panel."
        },
        {
          "name": "side",
          "type": "\"bottom\" | \"top\"",
          "required": false,
          "default": "\"bottom\"",
          "description": "Side of the trigger the panel opens on."
        },
        {
          "name": "triggerExtra",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Content placed after the model name on the chip."
        }
      ],
      "variantsHint": {
        "align": [
          "start",
          "end"
        ],
        "side": [
          "bottom",
          "top"
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--surface-raised",
        "--border",
        "--fg-muted",
        "--shadow-lg",
        "--radius-control"
      ],
      "related": [
        "segmented-control",
        "popover",
        "rate-limit-notice"
      ],
      "composedOf": [
        "popover",
        "segmented-control",
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Modes with thinking time",
          "description": "An Auto option, two fixed options and a Standard or Extended switch.",
          "code": "<ModelPicker\n  models={[\n    { id: \"auto\", name: \"Auto\", description: \"Chooses a model for each question.\", badge: \"Auto\", auto: true },\n    { id: \"fast\", name: \"Fast\", description: \"Quick answers for everyday questions.\" },\n    { id: \"thinking\", name: \"Thinking\", description: \"Works through harder problems before it answers.\" },\n  ]}\n  effortOptions={[{ value: \"standard\", label: \"Standard\" }, { value: \"extended\", label: \"Extended\" }]}\n/>"
        },
        {
          "title": "Auto picks for you",
          "description": "Auto is picked and Thinking answered. The button says so and the list repeats it.",
          "code": "<ModelPicker models={models} defaultValue=\"auto\" routedId=\"thinking\" />"
        },
        {
          "title": "Usage limit reached",
          "description": "Thinking is turned off, the reason is shown and the replacement is named. Fast can still be picked.",
          "code": "<ModelPicker\n  models={models}\n  defaultValue=\"fast\"\n  limit={{ reason: \"Weekly limit reached for Thinking.\", limitedIds: [\"thinking\"], fallbackId: \"fast\" }}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-model-picker/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/model-picker.md"
    },
    {
      "id": "tool-chip",
      "name": "ToolChip",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "tool toggle",
        "enabled tool"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "removable",
          "note": "Pass onRemove to show a small remove button on the right.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The remove button gets a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The remove button shows a focus ring on keyboard focus.",
          "source": "documented"
        },
        {
          "name": "truncated",
          "note": "A long label is cut off with an ellipsis.",
          "source": "documented"
        }
      ],
      "summary": "A small tag in the message box showing a tool that is on, with a Remove button.",
      "description": "ToolChip shows one tool that is turned on, such as Web search. It has an optional icon, the tool name and a Remove button. The button is named Remove plus the tool name, so a row of tags is clear to a screen reader. Leave out the remove option for a tool that is locked on.",
      "purpose": "Shows one tool that is switched on, such as Web search, right by the message box. People can see what the assistant will use and remove it in one step.",
      "useFor": [
        "Each tool that is turned on for the next message.",
        "A tool that is locked on, shown without a Remove button."
      ],
      "notFor": [
        {
          "text": "Files or images attached to a message",
          "use": "attachment-chip"
        },
        {
          "text": "Choosing which tools to turn on",
          "use": "tool-menu"
        },
        {
          "text": "Showing what the chat is about",
          "use": "context-chip"
        }
      ],
      "whenToUse": [
        "Show a tag for every tool that is on, next to the text box.",
        "Use the same icon as the tool has in ToolMenu.",
        "Keep labels to one or two words."
      ],
      "avoid": [
        "Do not use it for attached files. Use AttachmentChip.",
        "Do not show a tag for a tool that is off.",
        "Do not take away the button unless there is another way to turn the tool off."
      ],
      "accessibility": [
        "The Remove button is a real button named after the tool, such as \"Remove Web search\".",
        "The icon is hidden from screen readers. The tool name carries the meaning.",
        "A clear outline shows when the Remove button has keyboard focus.",
        "After Remove, keyboard focus moves to the next tag, else the previous one, else the message box, so it never falls to the top of the page. Pass focusAfterRemove to choose another place."
      ],
      "accessibilityYouDo": [
        "Set removeLabel when you translate, so the button name matches the language of the page."
      ],
      "onPhone": [
        "The chip is at least 32px tall and shortens a long label with an ellipsis, so it never grows wider than its container.",
        "On touch screens the remove button keeps its size and has a 44px tap area, so the chip stays the same size."
      ],
      "importPath": "import { ToolChip } from \"./index\"",
      "sourcePath": "src/molecules/ToolChip.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Tool name. Also used in the remove button name."
        },
        {
          "name": "icon",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Leading icon. Decorative."
        },
        {
          "name": "onRemove",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows the remove button and handles its press."
        },
        {
          "name": "removeLabel",
          "type": "string",
          "required": false,
          "default": "Remove {label}",
          "description": "Overrides the remove button name."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the chip."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken",
        "--border",
        "--fg",
        "--fg-muted",
        "--surface-hover",
        "--radius-control"
      ],
      "related": [
        "tool-menu",
        "context-chip",
        "attachment-chip"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Tags you can remove",
          "description": "Pressing Remove takes the tag away.",
          "code": "const [tools, setTools] = useState([\"Web search\", \"Deep research\"]);\n\n<div className=\"flex flex-wrap gap-2\">\n  {tools.map((t) => (\n    <ToolChip key={t} label={t} icon={<GlobeIcon width={14} height={14} />}\n      onRemove={() => setTools((l) => l.filter((x) => x !== t))} />\n  ))}\n  {tools.length === 0 && <span className=\"text-small leading-4 text-fg-muted\">No tools on.</span>}\n</div>"
        },
        {
          "title": "A tag that stays",
          "description": "With no remove option there is no button. Use it when the tool is locked on.",
          "code": "<ToolChip label=\"Web search\" icon={<GlobeIcon width={14} height={14} />} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-tool-chip/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/tool-chip.md"
    },
    {
      "id": "tool-menu",
      "name": "ToolMenu",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "tools menu",
        "plugins menu",
        "tool picker"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open",
          "note": "Pressing the plus button opens a menu with Attach, Tools and Connectors groups.",
          "source": "documented"
        },
        {
          "name": "item disabled",
          "note": "Set disabled on an item to dim it and stop clicks.",
          "source": "documented"
        },
        {
          "name": "tool on",
          "note": "A tool with checked true shows a check mark and stays in the menu when pressed.",
          "source": "documented"
        },
        {
          "name": "connector status",
          "note": "Set status on a connector to show short text such as Connected on its right.",
          "source": "documented"
        },
        {
          "name": "empty group",
          "note": "A group with no items is left out of the menu.",
          "source": "documented"
        }
      ],
      "summary": "The plus menu by the message box, with ways to attach things, tools you can switch on, and connected apps.",
      "description": "ToolMenu is a plus button that opens a menu with three sections. Attach and Connectors items do something and close the menu. Tools have a check mark and stay open when toggled, so several can be on. The cursor moves to the first item when it opens, and arrow keys, Home and End move between items. Tab closes the menu. If the menu would run off the screen, it opens on the other side.",
      "purpose": "The plus button by the message box that gathers everything you can add to a message: files, tools and connected apps. Tools keep the menu open when switched, so several can be turned on in one go.",
      "useFor": [
        "Attaching a file or a photo to a message.",
        "Turning tools such as web search or deep research on and off.",
        "Picking a connected app as a source."
      ],
      "notFor": [
        {
          "text": "Choosing how the assistant answers",
          "use": "model-picker"
        },
        {
          "text": "Settings that belong on a settings page"
        },
        {
          "text": "Moving around the site"
        }
      ],
      "whenToUse": [
        "Keep the menu open when a tool is toggled so several can be switched on.",
        "Use short status words for connected apps, such as Connected.",
        "Show every tool that is on as a ToolChip by the message box, so people can see and remove it without opening the menu."
      ],
      "avoid": [
        "Do not put settings that belong in a settings page into the menu.",
        "Do not use it as a site menu.",
        "Do not leave out the check mark. A tool without one looks like a button that does something."
      ],
      "accessibility": [
        "It is a real menu. Tool rows are check items that tell screen readers whether they are on.",
        "On open, the cursor moves to the first available item. Arrow keys wrap around, Home and End jump, and items that are off are skipped.",
        "Escape closes the menu and returns to the plus button. Tab closes it and moves on through the page.",
        "Each section is a group named by its heading, and the heading is not read twice.",
        "The plus button is named by the label, and the menu opens on the other side if it would run off the screen."
      ],
      "accessibilityYouDo": [
        "Pass checked for every tool from your own state, because screen readers read it out."
      ],
      "onPhone": [
        "On a phone the menu opens as a bottom sheet, because it is a menu; the sheet scrolls if the list is long.",
        "Every row is at least 44px tall on touch screens.",
        "Labels and descriptions are cut off with an ellipsis, so keep them short.",
        "Choosing an attach or connector row closes the sheet, while turning a tool on or off keeps it open."
      ],
      "importPath": "import { ToolMenu } from \"./index\"",
      "sourcePath": "src/organisms/ToolMenu.tsx",
      "props": [
        {
          "name": "attach",
          "type": "ToolMenuAction[]",
          "required": false,
          "default": "[]",
          "description": "Attach items with id, label, icon, description and disabled."
        },
        {
          "name": "tools",
          "type": "ToolMenuTool[]",
          "required": false,
          "default": "[]",
          "description": "Tool items. Each has a checked boolean owned by the parent."
        },
        {
          "name": "connectors",
          "type": "ToolMenuConnector[]",
          "required": false,
          "default": "[]",
          "description": "Connector items with an optional status text."
        },
        {
          "name": "onAttach",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when an attach item is chosen."
        },
        {
          "name": "onToggle",
          "type": "(id: string, checked: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called with the new checked value when a tool is chosen."
        },
        {
          "name": "onConnector",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when a connector item is chosen."
        },
        {
          "name": "labels",
          "type": "{ attach?: string; tools?: string; connectors?: string }",
          "required": false,
          "default": "Attach, Tools, Connectors",
          "description": "Section headings."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Add to message\"",
          "description": "Name of the trigger button and the menu."
        },
        {
          "name": "align",
          "type": "\"start\" | \"end\"",
          "required": false,
          "default": "\"start\"",
          "description": "Horizontal alignment of the panel."
        },
        {
          "name": "side",
          "type": "\"bottom\" | \"top\"",
          "required": false,
          "default": "\"top\"",
          "description": "Side of the button the panel opens on. Top suits a composer at the bottom of the page."
        }
      ],
      "variantsHint": {
        "align": [
          "start",
          "end"
        ],
        "side": [
          "bottom",
          "top"
        ]
      },
      "tokens": [
        "--surface-raised",
        "--border",
        "--surface-hover",
        "--fg-muted",
        "--shadow-lg"
      ],
      "related": [
        "tool-chip",
        "popover",
        "composer"
      ],
      "composedOf": [
        "popover",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Menu with tags",
          "description": "Switching a tool on or off updates its check and adds or removes its tag next to the button.",
          "code": "<ToolMenu\n  side=\"bottom\"\n  attach={[{ id: \"file\", label: \"Upload a file\", icon: <FileIcon /> }, { id: \"image\", label: \"Add a photo\", icon: <ImageIcon /> }]}\n  tools={tools.map((t) => ({ ...t, checked: !!on[t.id] }))}\n  connectors={[{ id: \"drive\", label: \"Google Drive\", status: \"Connected\" }]}\n  onToggle={(id, checked) => setOn((s) => ({ ...s, [id]: checked }))}\n/>"
        },
        {
          "title": "Tools only",
          "description": "Sections with nothing in them are left out.",
          "code": "<ToolMenu side=\"bottom\" tools={[{ id: \"search\", label: \"Web search\", icon: <GlobeIcon />, checked: true }]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-tool-menu/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/tool-menu.md"
    },
    {
      "id": "attachment-chip",
      "name": "AttachmentChip",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "attachment",
        "file chip",
        "uploaded file"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        }
      ],
      "summary": "A tag for an attached file or image, with name, size, upload progress, an error with Retry, and Remove.",
      "description": "AttachmentChip shows one attached file by the message box. It has a thumbnail or icon, the file name and size. While uploading it shows a progress bar and a percentage. If the upload fails, it shows the problem in red with a Retry button. Remove is always there and is named after the file.",
      "purpose": "Shows one file attached to a message, with its upload state. Progress, failure and the way out are all visible, so people know whether the file will be sent.",
      "useFor": [
        "A file or image attached in the message box.",
        "An upload in progress, with a bar and a percentage.",
        "An upload that failed, with the reason and a Retry button."
      ],
      "notFor": [
        {
          "text": "A tool that is switched on",
          "use": "tool-chip"
        },
        {
          "text": "Showing what the chat is about",
          "use": "context-chip"
        }
      ],
      "whenToUse": [
        "Say what went wrong and what the person can do.",
        "Keep Remove available while uploading so a person can cancel.",
        "Use a real thumbnail for images when you have one."
      ],
      "avoid": [
        "Do not use amber for a failed upload. Use red with a Retry button.",
        "Do not show progress without a visible percentage.",
        "Do not use it for tools. Use ToolChip."
      ],
      "accessibility": [
        "While uploading, the bar is a meter named \"Uploading\" plus the file name, and screen readers read the percentage.",
        "The percentage and the error are written in words, so color is not the only clue.",
        "Retry and Remove are buttons named after the file, such as \"Remove q3-planning.pdf\".",
        "The default file and image icons are hidden from screen readers.",
        "When an upload finishes or fails, screen readers hear it: the file name and \"uploaded\", or the file name and the error. Nothing is read when the chip first appears.",
        "After Remove, keyboard focus moves to the next attachment, else the previous one, else the message box. Pass focusAfterRemove to choose another place."
      ],
      "accessibilityYouDo": [
        "Keep any thumbnail you pass decorative, with an empty text alternative, because the file name is already shown.",
        "Set readyMessage and errorMessage when you translate."
      ],
      "onPhone": [
        "The chip is 16rem wide but shrinks to fit a narrower container.",
        "A long file name is cut off with an ellipsis, and the full name is only in a title tip that does not show on touch, so show the full name elsewhere if it matters.",
        "The Retry and remove buttons keep their size and have a 44px tap area on touch screens."
      ],
      "importPath": "import { AttachmentChip } from \"./index\"",
      "sourcePath": "src/molecules/AttachmentChip.tsx",
      "props": [
        {
          "name": "name",
          "type": "string",
          "required": true,
          "default": null,
          "description": "File name. Truncated with the full name in the title."
        },
        {
          "name": "size",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Display size such as 2.4 MB."
        },
        {
          "name": "kind",
          "type": "\"file\" | \"image\"",
          "required": false,
          "default": "\"file\"",
          "description": "Chooses the default icon."
        },
        {
          "name": "thumbnail",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Replaces the default icon."
        },
        {
          "name": "status",
          "type": "\"ready\" | \"uploading\" | \"error\"",
          "required": false,
          "default": "\"ready\"",
          "description": "Which state to show."
        },
        {
          "name": "progress",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Upload progress from 0 to 100."
        },
        {
          "name": "errorMessage",
          "type": "string",
          "required": false,
          "default": "\"Upload failed\"",
          "description": "Text shown in the error state."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Retry in the error state."
        },
        {
          "name": "onRemove",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows the remove button."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the chip."
        }
      ],
      "variantsHint": {
        "kind": [
          "file",
          "image"
        ],
        "status": [
          "ready",
          "uploading",
          "error"
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--danger",
        "--danger-fg",
        "--fg-muted",
        "--radius-card"
      ],
      "related": [
        "tool-chip",
        "meter",
        "composer"
      ],
      "composedOf": [
        "meter",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Ready, uploading and failed",
          "description": "All three states together. Retry on the failed one makes it ready.",
          "code": "<AttachmentChip name=\"q3-planning.pdf\" size=\"2.4 MB\" onRemove={() => {}} />\n<AttachmentChip name=\"board-deck.pptx\" size=\"18 MB\" status=\"uploading\" progress={45} onRemove={() => {}} />\n<AttachmentChip name=\"site-photo.png\" kind=\"image\" status=\"error\"\n  errorMessage=\"Upload failed. Check your connection.\" onRetry={retry} onRemove={remove} />"
        },
        {
          "title": "Your own thumbnail",
          "description": "Use any picture, such as a preview of the image.",
          "code": "<AttachmentChip name=\"mockup.png\" kind=\"image\" size=\"1.1 MB\"\n  thumbnail={<div className=\"size-full bg-lime-soft\" />} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-attachment-chip/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/attachment-chip.md"
    },
    {
      "id": "voice-panel",
      "name": "VoicePanel",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "voice mode",
        "voice chat",
        "call screen",
        "voice session"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        }
      ],
      "summary": "A voice conversation (VoiceSession) on the shared AI disc, with every mic, turn and connection state in words, captions, and separate Mute, Hold and End.",
      "description": "VoicePanel, also exported as VoiceSession, runs a spoken conversation with the assistant. It is built on AIPresence, so voice uses the same AI disc as the rest of the product. It has fifteen states, from connecting, listening and speaking to on hold, muted, mic blocked, reconnecting and ended, and each one is written in words. Mute, Hold and End are separate controls, with End set apart in a danger outline. Push-to-talk turns the main control into Hold to talk, with slide off to cancel. Mic blocked and no mic are amber, because the person must act. Every older VoicePanel prop still works.",
      "purpose": "Shows a live voice conversation so that nothing depends on hearing the audio or seeing the animation: the state is in words, the mic state is always visible, and captions carry what was said.",
      "useFor": [
        "A spoken conversation with the assistant, hands-free or push-to-talk.",
        "A support call where the assistant checks something and says so while it works.",
        "Telling people the mic is blocked, the call is on hold, or the connection is coming back.",
        "A voice session that may share the camera or the screen."
      ],
      "notFor": [
        {
          "text": "Typing a message",
          "use": "composer"
        },
        {
          "text": "Showing the assistant is getting ready to reply in text",
          "use": "typing-indicator"
        },
        {
          "text": "Reviewing a finished call as an operator",
          "use": "call-record"
        }
      ],
      "whenToUse": [
        "Keep captions on by default, and keep the transcript after the call.",
        "Say what the assistant is doing when it works, such as Checking your order, and show it on screen too.",
        "Keep Mute, Hold and End as three different things.",
        "Use the footer to say why it is reconnecting and what people can do."
      ],
      "avoid": [
        "Do not rely on the disc alone to show the state, and do not give it a face.",
        "Do not fill silence with fake breaths or filler words. Say One moment, checking.",
        "Do not use amber for Live or Reconnecting. Amber is only for mic blocked or no mic.",
        "Do not make End a primary button next to Mute, and do not remove Mute or End to save space."
      ],
      "accessibility": [
        "There is one polite status region (the disc's). It announces each state change once, never caption words.",
        "The disc is hidden from screen readers. The words and the captions carry the information.",
        "Simple captions are a role=\"log\" that can be reached by keyboard and scrolled. It does not read new captions out.",
        "Mute, Camera, Share screen and Hold to talk report whether they are on with aria-pressed, and their names stay the same.",
        "Keys work while focus is inside the panel and never while typing in a field: Space held to talk, M to mute, Esc to interrupt. End has no key.",
        "The person's own mic level is three neutral bars, never lime. Lime is the AI disc only.",
        "When people turn off motion in their system settings, the disc is drawn still for each state and the state text stays."
      ],
      "accessibilityYouDo": [
        "Keep the captions filled from your speech service, so people who cannot hear can still follow.",
        "Pass muted, and the camera and screen state, from your own state, so each button reports whether it is on.",
        "Stop the microphone and the call in onEnd. Stop the assistant's audio within a moment in onInterrupt.",
        "In micBlocked and noMic, give onTypeInstead a real text route. Every voice flow needs one.",
        "Never restart the camera or screen sharing yourself after a lock. Wait for the person to press Turn camera on.",
        "When a screen reader is running, offer push-to-talk so the assistant does not hear the reader."
      ],
      "onPhone": [
        "The panel fills the width up to 32rem and its padding shrinks a step below 640px. Place it in a full-height Sheet for the phone layout.",
        "The main control is 56px tall. Every other button has a 44px tap area on touch screens.",
        "End sits on the far right, away from Mute, in a danger outline, so it is not pressed by mistake.",
        "Camera and Share screen show only their icons below 640px; their names stay for screen readers.",
        "The disc shrinks from large to medium once captions appear, so captions get the middle of the screen.",
        "Hold to talk ignores scrolling and the long-press menu, and sliding a finger off it cancels.",
        "Key hints hide on touch screens."
      ],
      "importPath": "import { VoicePanel } from \"./index\"",
      "sourcePath": "src/organisms/VoicePanel.tsx",
      "props": [
        {
          "name": "state",
          "type": "\"connecting\" | \"ready\" | \"listening\" | \"userSpeaking\" | \"thinking\" | \"working\" | \"speaking\" | \"interrupted\" | \"muted\" | \"held\" | \"reconnecting\" | \"micBlocked\" | \"noMic\" | \"ended\" | \"error\"",
          "required": true,
          "default": null,
          "description": "Current state. Drives the disc, the words and which controls show."
        },
        {
          "name": "stateLabels",
          "type": "Partial<Record<VoiceState, string>>",
          "required": false,
          "default": null,
          "description": "Overrides the visible text for any state."
        },
        {
          "name": "inputMode",
          "type": "\"handsFree\" | \"pushToTalk\"",
          "required": false,
          "default": "\"handsFree\"",
          "description": "Push-to-talk turns the main control into Hold to talk."
        },
        {
          "name": "onInputModeChange",
          "type": "(mode: \"handsFree\" | \"pushToTalk\") => void",
          "required": false,
          "default": null,
          "description": "Shows a Hands-free / Push to talk switch when given."
        },
        {
          "name": "onTalkStart",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Push-to-talk: the person started holding."
        },
        {
          "name": "onTalkEnd",
          "type": "(cancelled: boolean) => void",
          "required": false,
          "default": null,
          "description": "Push-to-talk: the person let go. cancelled is true when they slid off first."
        },
        {
          "name": "captions",
          "type": "VoiceCaption[] | ReactNode",
          "required": false,
          "default": "[]",
          "description": "Simple captions (id, speaker, text), or your own node such as LiveCaptions."
        },
        {
          "name": "speakerLabels",
          "type": "{ you: string; assistant: string }",
          "required": false,
          "default": "You, Assistant",
          "description": "Names shown before each simple caption."
        },
        {
          "name": "emptyCaptions",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Text shown when there are no captions."
        },
        {
          "name": "muted",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Pressed state of Mute. Defaults to true when state is muted."
        },
        {
          "name": "onMutedChange",
          "type": "(muted: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called by Mute and by the M key."
        },
        {
          "name": "onHold",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Hold. Nothing is heard or said until Resume."
        },
        {
          "name": "onResume",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called by Resume in the held state."
        },
        {
          "name": "onEnd",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called by End. End has no key."
        },
        {
          "name": "onInterrupt",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Interrupt while speaking, and makes Esc stop the assistant."
        },
        {
          "name": "inputLevel",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Mic level 0 to 1. Moves the disc while listening and fills the neutral level bars on Mute."
        },
        {
          "name": "outputLevel",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Assistant level 0 to 1. Moves the disc while speaking. Ignored under reduced motion."
        },
        {
          "name": "workingLabel",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Text for the working state, such as Checking your calendar."
        },
        {
          "name": "onMicRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Allow microphone (blocked) or Check again (no mic)."
        },
        {
          "name": "onTypeInstead",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Type instead, in the blocked and no mic states."
        },
        {
          "name": "media",
          "type": "{ camera?: \"on\" | \"off\" | \"stoppedBySystem\"; screen?: \"on\" | \"off\" | \"stoppedBySystem\" }",
          "required": false,
          "default": null,
          "description": "Camera and screen state. stoppedBySystem shows a notice with a button to turn it on again."
        },
        {
          "name": "cameraOn",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Pressed state of Camera, when you do not pass media."
        },
        {
          "name": "onCameraChange",
          "type": "(on: boolean) => void",
          "required": false,
          "default": null,
          "description": "Shows the Camera toggle when provided."
        },
        {
          "name": "screenSharing",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Pressed state of Share screen, when you do not pass media."
        },
        {
          "name": "onScreenChange",
          "type": "(on: boolean) => void",
          "required": false,
          "default": null,
          "description": "Shows the Share screen toggle when provided. onScreenShareChange is the older name."
        },
        {
          "name": "elapsed",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Time since the start, such as 02:14. Shown, never announced."
        },
        {
          "name": "usage",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Minutes or credits used, such as a compact UsageMeter."
        },
        {
          "name": "disclosure",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Who is talking and whether it is recorded."
        },
        {
          "name": "footer",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Content under the state, such as a connection hint."
        },
        {
          "name": "showKeyHints",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Shows Esc, M and Space hints on screens with a fine pointer."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the panel."
        }
      ],
      "variantsHint": {
        "state": [
          "connecting",
          "ready",
          "listening",
          "userSpeaking",
          "thinking",
          "working",
          "speaking",
          "interrupted",
          "muted",
          "held",
          "reconnecting",
          "micBlocked",
          "noMic",
          "ended",
          "error"
        ],
        "inputMode": [
          "handsFree",
          "pushToTalk"
        ]
      },
      "tokens": [
        "--lime",
        "--surface",
        "--surface-sunken",
        "--border",
        "--border-strong",
        "--warning-soft",
        "--warning-fg",
        "--danger",
        "--danger-fg",
        "--danger-soft",
        "--radius-card",
        "--dur-fast",
        "--dur-base",
        "--dur-slow"
      ],
      "related": [
        "ai-presence",
        "composer",
        "typing-indicator",
        "message-queue",
        "call-record"
      ],
      "composedOf": [
        "ai-presence",
        "button",
        "kbd",
        "segmented-control"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Every state",
          "description": "Pick a state, or use the buttons: Mute, Hold then Resume, Interrupt while speaking (or Esc), and End. Keys work while focus is inside the panel.",
          "code": "<VoiceSession\n  state={muted ? \"muted\" : state}\n  muted={muted}\n  onMutedChange={setMuted}\n  onHold={() => setState(\"held\")}\n  onResume={() => setState(\"listening\")}\n  onInterrupt={() => setState(\"interrupted\")}\n  onEnd={() => setState(\"ended\")}\n  onMicRetry={askForMic}\n  onTypeInstead={openChat}\n  cameraOn={camera}\n  onCameraChange={setCamera}\n  workingLabel=\"Checking your order\"\n  inputLevel={micLevel}\n  outputLevel={voiceLevel}\n  elapsed=\"02:14\"\n  disclosure={<span>Shop assistant · AI · Recorded</span>}\n  captions={captions}\n/>"
        },
        {
          "title": "Push to talk",
          "description": "Hold the button, or hold Space, while you talk. Slide off the button before letting go to cancel. Switch to Hands-free to listen all the time.",
          "code": "<VoiceSession\n  state={state}\n  inputMode={mode}\n  onInputModeChange={setMode}\n  onTalkStart={() => setState(\"userSpeaking\")}\n  onTalkEnd={(cancelled) => cancelled ? setState(\"ready\") : send()}\n  muted={muted}\n  onMutedChange={setMuted}\n  onEnd={end}\n  captions={[]}\n/>"
        },
        {
          "title": "Mic blocked, on hold, camera stopped",
          "description": "Mic blocked is amber with Allow microphone and Type instead, because the person must act. On hold dims the disc. The camera notice is calm and waits for a press.",
          "code": "<VoiceSession state=\"micBlocked\" onMicRetry={askForMic} onTypeInstead={openChat} onEnd={end} captions={[]} />\n<VoiceSession state=\"held\" onResume={resume} onEnd={end} media={{ camera: \"stoppedBySystem\" }} onCameraChange={setCamera} elapsed=\"11:40\" captions={[]} />"
        },
        {
          "title": "Older VoicePanel props",
          "description": "Code written for the first VoicePanel keeps working: state, captions, muted, onMutedChange and onEnd.",
          "code": "<VoicePanel\n  state={muted ? \"muted\" : state}\n  muted={muted}\n  onMutedChange={setMuted}\n  onEnd={() => {}}\n  captions={[\n    { id: \"1\", speaker: \"you\", text: \"What should I pack for two days in Lisbon?\" },\n    { id: \"2\", speaker: \"assistant\", text: \"Pack layers and comfortable shoes. The hills are steep.\" },\n  ]}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-voice-panel/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/voice-panel.md"
    },
    {
      "id": "message-queue",
      "name": "MessageQueue",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "queued messages",
        "pending messages"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "With no items, the list is replaced by the emptyText message.",
          "source": "documented"
        },
        {
          "name": "first or last",
          "note": "The move up button is disabled on the first message and move down on the last.",
          "source": "documented"
        },
        {
          "name": "shortcut hint",
          "note": "Pass sendNowShortcut to show a key hint on the first message's Send now button.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Every button in a row shows a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "Messages sent while the assistant is busy, with Send now, reorder and delete.",
      "description": "MessageQueue lists messages a person sent while the assistant was busy. Each one has a dashed outline and muted text until it runs. A header explains the difference between waiting in line and steering, and you can change that text. Each message has Send now, move up, move down and delete. Your app keeps the order. An empty message shows when the list is empty.",
      "purpose": "Holds messages a person sent while the assistant was still busy. They look pending, with a dashed outline and muted text, so nobody mistakes them for messages already sent.",
      "useFor": [
        "Messages typed while the assistant is working on an earlier one.",
        "Letting people send a waiting message now, move it, or delete it before it runs."
      ],
      "notFor": [
        {
          "text": "Messages that have been sent",
          "use": "message-list"
        },
        {
          "text": "The steps the assistant plans to take",
          "use": "plan-card"
        }
      ],
      "whenToUse": [
        "Show it only while the assistant is working or when messages are waiting.",
        "Change the header text to match how your product handles waiting and steering.",
        "Reorder items in your own state when onMove is called. The list does not reorder itself.",
        "Show the shortcut on Send now only if that shortcut really works in your product."
      ],
      "avoid": [
        "Do not show waiting messages in the chat as if they were sent.",
        "Do not expect it to reorder itself. Your app keeps the order.",
        "Do not hide Delete. People should be able to remove a message before it runs."
      ],
      "accessibility": [
        "It is a named section, and the waiting messages are a numbered list.",
        "Every icon button is named with the message's place, such as \"Move message 2 up\". Send now is named with the message text.",
        "Move up is turned off on the first message, and move down on the last.",
        "After a move, screen readers hear the new place, such as \"Message moved to position 1 of 3\".",
        "Waiting messages differ by a dashed outline and muted text, as well as the header and count."
      ],
      "accessibilityYouDo": [],
      "onPhone": [
        "Each queued message shows its text on a line that gets cut off, and the buttons wrap below it when the row is too narrow.",
        "Send now and the move and delete buttons keep their size and have a 44px tap area on touch screens.",
        "Messages are reordered with the up and down buttons; there is no drag."
      ],
      "importPath": "import { MessageQueue } from \"./index\"",
      "sourcePath": "src/organisms/MessageQueue.tsx",
      "props": [
        {
          "name": "items",
          "type": "QueuedMessage[]",
          "required": true,
          "default": null,
          "description": "Queued messages in send order, each with id and text."
        },
        {
          "name": "onSendNow",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when Send now is pressed on an item."
        },
        {
          "name": "onMove",
          "type": "(id: string, direction: \"up\" | \"down\") => void",
          "required": false,
          "default": null,
          "description": "Called to move an item one place. The parent reorders items."
        },
        {
          "name": "onDelete",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when delete is pressed."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Queued\"",
          "description": "Heading and region name."
        },
        {
          "name": "description",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Header copy explaining queue and steering. Has a default sentence that you can replace."
        },
        {
          "name": "emptyText",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Text shown when items is empty."
        },
        {
          "name": "sendNowShortcut",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shortcut hint shown on the first item's Send now. Omit to hide."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the region."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--border-strong",
        "--fg-muted",
        "--fg-subtle",
        "--radius-card"
      ],
      "related": [
        "composer",
        "voice-panel",
        "kbd"
      ],
      "composedOf": [
        "button",
        "badge",
        "kbd"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A line you can reorder",
          "description": "Move, send and delete change the list. Send now removes the message as if it had started.",
          "code": "<MessageQueue\n  items={items}\n  sendNowShortcut=\"Ctrl Enter\"\n  onSendNow={(id) => setItems((l) => l.filter((m) => m.id !== id))}\n  onMove={move}\n  onDelete={(id) => setItems((l) => l.filter((m) => m.id !== id))}\n/>"
        },
        {
          "title": "Nothing waiting",
          "description": "With no messages, a short note shows instead.",
          "code": "<MessageQueue items={[]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-message-queue/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/message-queue.md"
    },
    {
      "id": "version-pager",
      "name": "VersionPager",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "branch picker",
        "response versions",
        "regenerate pager",
        "pagination"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the current prop.",
          "source": "prop"
        }
      ],
      "summary": "Previous and next buttons with a count like 1/3, for the versions of one message.",
      "description": "VersionPager steps through the versions made when a message is edited or written again. Buttons turn off at either end. Screen readers hear the new position. It can show an Edited label and a button to branch off. It starts on the latest version.",
      "purpose": "Steps through the versions of one message, made by editing it or asking again. The count is small so the message itself stays the focus.",
      "useFor": [
        "A message that was edited, so the earlier wording is still there to look at.",
        "A reply that was written again, so people can compare the attempts.",
        "Starting a new chat from one version, with the branch button."
      ],
      "notFor": [
        {
          "text": "Going back to an earlier point in a long piece of work",
          "use": "checkpoint-list"
        },
        {
          "text": "Switching between an AI suggestion and the person's own edit",
          "use": "revert-toggle"
        },
        {
          "text": "A list of saved versions of a document or design",
          "use": "version-history"
        }
      ],
      "whenToUse": [
        "Keep the pager next to the message it belongs to.",
        "Change the message text with the version so the count and text match.",
        "Offer a branch button only if your product can start a separate chat."
      ],
      "avoid": [
        "Do not use it for unrelated messages. It is for versions of one message.",
        "Do not hide the count. The position is the point of the control.",
        "Do not move the cursor when the version changes. People should be able to keep clicking."
      ],
      "accessibility": [
        "Previous and next are named buttons that turn off at the first and last version.",
        "After each step, screen readers hear the new position, such as \"Version 2 of 3\".",
        "The visible count is hidden from screen readers so it is not read twice.",
        "The whole control is a group named \"Message versions\"."
      ],
      "accessibilityYouDo": [
        "Change the message text when the version changes, so what people hear matches the count.",
        "Place the pager right after the message it belongs to, so screen reader users know which message it changes."
      ],
      "onPhone": [
        "The pager wraps onto a second line if the edited label and branch button do not fit.",
        "The previous and next buttons keep their size and have a 44px tap area on a touch screen.",
        "Tapping steps one version at a time; there is no swipe, so keep the pager next to the message it changes."
      ],
      "importPath": "import { VersionPager } from \"./index\"",
      "sourcePath": "src/molecules/VersionPager.tsx",
      "props": [
        {
          "name": "total",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Number of sibling versions."
        },
        {
          "name": "current",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Controlled current version, 1-based."
        },
        {
          "name": "defaultCurrent",
          "type": "number",
          "required": false,
          "default": "total",
          "description": "Starting version in uncontrolled mode."
        },
        {
          "name": "onChange",
          "type": "(version: number) => void",
          "required": false,
          "default": null,
          "description": "Called with the new 1-based version."
        },
        {
          "name": "edited",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Shows the edited label."
        },
        {
          "name": "editedLabel",
          "type": "string",
          "required": false,
          "default": "\"Edited\"",
          "description": "Text of the edited label."
        },
        {
          "name": "branchAction",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Slot for an action such as Branch from here."
        },
        {
          "name": "announce",
          "type": "(current: number, total: number) => string",
          "required": false,
          "default": "Version N of M",
          "description": "Builds the polite announcement."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Message versions\"",
          "description": "Accessible name for the group."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg-muted",
        "--surface-sunken",
        "--border",
        "--radius-control"
      ],
      "related": [
        "message-actions",
        "feedback-bar",
        "checkpoint-list"
      ],
      "composedOf": [
        "icon-button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Default",
          "description": "Starts on the latest version.",
          "code": "<VersionPager total={3} />"
        },
        {
          "title": "Edited, with a branch button",
          "description": "Use it when your product can start a new chat from this version.",
          "code": "<VersionPager total={2} edited branchAction={<Button size=\"sm\" variant=\"ghost\">Branch from here</Button>} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-version-pager/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/version-pager.md"
    },
    {
      "id": "memory-notice",
      "name": "MemoryNotice",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "memory updated",
        "saved to memory"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "actions",
          "note": "Pass onManage or onUndo to show Manage and Undo buttons under the text.",
          "source": "documented"
        },
        {
          "name": "dismissible",
          "note": "Pass onDismiss to show a close button on the right.",
          "source": "documented"
        }
      ],
      "summary": "A short notice that says what was saved to memory, with Manage, Undo and close.",
      "description": "MemoryNotice appears when something is saved and says exactly what. Manage opens the memory list and Undo removes the item. Each button shows only if you provide what it does. It looks neutral because nothing needs fixing.",
      "purpose": "Says exactly what was just saved to memory, at the moment it is saved, so people can check it and take it back. It looks neutral because nothing has gone wrong.",
      "useFor": [
        "Right after the assistant saves something about the person.",
        "Giving a quick way to undo a save or open the full memory list.",
        "Confirming a save to one project's memory, with your own title."
      ],
      "notFor": [
        {
          "text": "Seeing or editing everything that is remembered",
          "use": "memory-list"
        },
        {
          "text": "Telling people that a chat is not being saved",
          "use": "private-chat-banner"
        },
        {
          "text": "A warning or error the person must fix",
          "use": "callout"
        }
      ],
      "whenToUse": [
        "Show it right when something is saved, and quote what was saved.",
        "Make Undo actually remove the item.",
        "Send Manage to the full memory list."
      ],
      "avoid": [
        "Do not say only \"Memory updated\". Name what changed.",
        "Do not use amber. Saving is not something the person must fix.",
        "Do not keep it on screen after the person closes it."
      ],
      "accessibility": [
        "It is a polite status message, so screen readers can read it without cutting in.",
        "The close button has only an icon, so it carries the name \"Dismiss memory notice\".",
        "Manage, Undo and close are real buttons that work with the keyboard.",
        "The icon is hidden from screen readers."
      ],
      "accessibilityYouDo": [
        "Write the saved text in the person's own terms, so it makes sense when heard on its own.",
        "Move focus to a sensible place after Undo or close removes the notice."
      ],
      "onPhone": [
        "The notice fills the width, and the text wraps beside the icon.",
        "Manage and Undo sit under the text and wrap if they do not fit. Each has a 44px tap area on a touch screen.",
        "The dismiss button stays at the right edge, keeps its size and has a 44px tap area."
      ],
      "importPath": "import { MemoryNotice } from \"./index\"",
      "sourcePath": "src/molecules/MemoryNotice.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Memory updated\"",
          "description": "Heading."
        },
        {
          "name": "saved",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "What was saved, in the person's terms."
        },
        {
          "name": "onManage",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Opens the memory manager. The button is hidden when omitted."
        },
        {
          "name": "onUndo",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Removes the saved item. The button is hidden when omitted."
        },
        {
          "name": "onDismiss",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Dismisses the notice. The close button is hidden when omitted."
        },
        {
          "name": "manageLabel",
          "type": "string",
          "required": false,
          "default": "\"Manage\"",
          "description": "Label of the manage button."
        },
        {
          "name": "undoLabel",
          "type": "string",
          "required": false,
          "default": "\"Undo\"",
          "description": "Label of the undo button."
        },
        {
          "name": "dismissLabel",
          "type": "string",
          "required": false,
          "default": "\"Dismiss memory notice\"",
          "description": "Accessible name of the close button."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken",
        "--border",
        "--fg-muted",
        "--radius-card"
      ],
      "related": [
        "memory-list",
        "private-chat-banner",
        "callout"
      ],
      "composedOf": [
        "button",
        "icon-button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With actions",
          "description": "Undo and close both hide the notice here.",
          "code": "<MemoryNotice saved=\"You prefer metric units.\" onManage={() => {}} onUndo={() => setShown(false)} onDismiss={() => setShown(false)} />"
        },
        {
          "title": "Information only",
          "description": "With no buttons, the notice only says what was saved.",
          "code": "<MemoryNotice title=\"Saved to this project\" saved=\"The beta ships on the first Monday of November.\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-memory-notice/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/memory-notice.md"
    },
    {
      "id": "memory-list",
      "name": "MemoryList",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "memories",
        "saved memories",
        "memory settings"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "paused",
          "note": "Turn on the Pause memory switch (or pass paused) to stop saving new memories while keeping existing ones.",
          "source": "documented"
        },
        {
          "name": "past chats off",
          "note": "Turn off the Search past chats switch (or pass searchChats false) to stop replies drawing on earlier conversations.",
          "source": "documented"
        },
        {
          "name": "editing",
          "note": "Pressing the edit button turns that entry into a text box with Save and Cancel; Save is disabled when the text is empty.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "With no entries, a dashed box shows emptyState or a default message.",
          "source": "documented"
        }
      ],
      "summary": "A list of what the assistant remembers. Each item can be edited or deleted. Two switches sit on top.",
      "description": "MemoryList shows a Pause memory switch and a Search past chats switch above one row for each remembered topic. Each row says where it applies and has Edit and Delete. Your app keeps the data. The empty message can be replaced.",
      "purpose": "The place where people see, fix and remove what the assistant remembers. The two switches sit on top because they change how memory works for everything below them.",
      "useFor": [
        "A memory page in settings.",
        "Letting people pause memory, or stop replies drawing on past chats.",
        "Showing where each memory applies, such as all chats or one project."
      ],
      "notFor": [
        {
          "text": "Telling people that one thing was just saved",
          "use": "memory-notice"
        },
        {
          "text": "A chat that saves nothing at all",
          "use": "private-chat-banner"
        }
      ],
      "whenToUse": [
        "Show where each memory applies.",
        "Explain what pausing does to memories already saved.",
        "Add an undo or a confirm step for delete. The list calls onDelete as soon as the button is pressed."
      ],
      "avoid": [
        "Do not hide entries behind a search. Show them as a list people can scan.",
        "Do not rename the switches without saying what they control.",
        "Do not store anything here. Your app keeps the data."
      ],
      "accessibility": [
        "Both switches have names and are read as on or off.",
        "Edit and delete buttons name the memory they act on, such as \"Edit memory: Work\".",
        "The edit box has a visible label and gets focus when it opens.",
        "Save is turned off while the edit box is empty.",
        "After Save or Cancel, keyboard focus returns to that memory's Edit button. After a delete it moves to the next memory (or the previous one, or the empty message), and screen readers hear \"Memory deleted.\""
      ],
      "accessibilityYouDo": [
        "Give each memory a topic. Without one, the edit and delete buttons repeat the whole memory text in their names.",
        "Set deletedMessage when you translate."
      ],
      "onPhone": [
        "The two switches at the top have a 44px tap area, and their descriptions wrap beside them.",
        "Each row keeps its edit and delete buttons at the right, each with a 44px tap area on a touch screen, and the memory text wraps in the space left.",
        "Editing opens a text box that takes focus at once, so the on-screen keyboard appears; the text is 16px so the page does not zoom."
      ],
      "importPath": "import { MemoryList } from \"./index\"",
      "sourcePath": "src/organisms/MemoryList.tsx",
      "props": [
        {
          "name": "entries",
          "type": "MemoryEntry[]",
          "required": true,
          "default": null,
          "description": "Items with id, optional topic, text and scope."
        },
        {
          "name": "onEdit",
          "type": "(id: string, text: string) => void",
          "required": false,
          "default": null,
          "description": "Called when an edit is saved."
        },
        {
          "name": "onDelete",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when delete is chosen."
        },
        {
          "name": "paused",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Controlled state of Pause memory."
        },
        {
          "name": "defaultPaused",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Starting state when uncontrolled."
        },
        {
          "name": "onPausedChange",
          "type": "(paused: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when the pause switch changes."
        },
        {
          "name": "searchChats",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Controlled state of Search past chats."
        },
        {
          "name": "defaultSearchChats",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Starting state when uncontrolled."
        },
        {
          "name": "onSearchChatsChange",
          "type": "(on: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when the search switch changes."
        },
        {
          "name": "emptyState",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Replaces the empty message."
        },
        {
          "name": "pauseLabel",
          "type": "string",
          "required": false,
          "default": "\"Pause memory\"",
          "description": "Label of the pause switch."
        },
        {
          "name": "pauseDescription",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Helper text under the pause label."
        },
        {
          "name": "searchLabel",
          "type": "string",
          "required": false,
          "default": "\"Search past chats\"",
          "description": "Label of the search switch."
        },
        {
          "name": "searchDescription",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Helper text under the search label."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--border-strong",
        "--fg-muted",
        "--radius-field",
        "--radius-card"
      ],
      "related": [
        "memory-notice",
        "private-chat-banner"
      ],
      "composedOf": [
        "badge",
        "button",
        "icon-button",
        "switch"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Editable list",
          "description": "Edits and deletes work in this demo.",
          "code": "<MemoryList\n  entries={items}\n  onEdit={(id, text) => setItems(items.map((m) => (m.id === id ? { ...m, text } : m)))}\n  onDelete={(id) => setItems(items.filter((m) => m.id !== id))}\n/>"
        },
        {
          "title": "Nothing remembered",
          "description": "Change the message shown when the list is empty.",
          "code": "<MemoryList entries={[]} defaultPaused emptyState=\"Memory is paused, so nothing is being saved.\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-memory-list/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/memory-list.md"
    },
    {
      "id": "private-chat-banner",
      "name": "PrivateChatBanner",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "incognito",
        "temporary chat",
        "private mode"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "compact",
          "note": "Set variant to compact for a small pill instead of the full banner.",
          "source": "documented"
        },
        {
          "name": "exit",
          "note": "Pass onExit to show an Exit button (a close icon in the compact form).",
          "source": "documented"
        }
      ],
      "summary": "A notice that stays visible during a private chat, says how long a copy is kept, and has Exit.",
      "description": "PrivateChatBanner tells people the chat is private for as long as it lasts. The full version says how long a copy is kept and has an Exit button. The small version is a pill for the top bar. Both look neutral. You write the wording, so it states your real time.",
      "purpose": "Keeps it clear, for the whole chat, that this chat is private and how long a copy is kept. It looks neutral because a private chat is a choice, not a problem.",
      "useFor": [
        "The top of a private or temporary chat, for as long as it lasts.",
        "A small pill in the top bar when space is tight.",
        "Stating your product's real retention time in your own words."
      ],
      "notFor": [
        {
          "text": "Confirming that something was saved to memory",
          "use": "memory-notice"
        },
        {
          "text": "A warning the person must act on",
          "use": "callout"
        }
      ],
      "whenToUse": [
        "Keep it visible for the whole private chat.",
        "Use the small pill when space is tight.",
        "Change the description if your retention time is not 30 days. The default text says a copy may be kept for 30 days."
      ],
      "avoid": [
        "Do not imply that nothing is stored if a copy is kept.",
        "Do not use amber. Private mode is a choice, not a problem.",
        "Do not offer to turn it into a regular chat unless your product can."
      ],
      "accessibility": [
        "The full banner is a region named by its label, so screen readers can jump to it.",
        "The small pill's close button has only an icon, and its name includes the mode, such as \"Exit private chat\".",
        "The state is said in words, not by color or a border alone.",
        "The ghost icon is hidden from screen readers."
      ],
      "accessibilityYouDo": [
        "Place the banner before the chat, so screen reader users meet it first. It is not read out when it appears.",
        "Move focus to a sensible place when Exit ends the private chat."
      ],
      "onPhone": [
        "The banner fills the width and wraps: the text has a 224px base width, so the Exit button drops to its own line when the row is tight.",
        "The compact pill stays one line and keeps its desktop height. Its exit button keeps its size and has a 44px tap area on a touch screen."
      ],
      "importPath": "import { PrivateChatBanner } from \"./index\"",
      "sourcePath": "src/molecules/PrivateChatBanner.tsx",
      "props": [
        {
          "name": "variant",
          "type": "\"banner\" | \"compact\"",
          "required": false,
          "default": "\"banner\"",
          "description": "Full-width strip or header pill."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Private chat\"",
          "description": "Name of the mode."
        },
        {
          "name": "description",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Retention copy. The default says a copy may be kept for 30 days, so change it if your period differs."
        },
        {
          "name": "onExit",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Leaves the private chat. The action is hidden when omitted."
        },
        {
          "name": "exitLabel",
          "type": "string",
          "required": false,
          "default": "\"Exit\"",
          "description": "Label of the banner exit button."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {
        "variant": [
          "banner",
          "compact"
        ]
      },
      "tokens": [
        "--surface-sunken",
        "--border-strong",
        "--fg-muted",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "memory-notice",
        "memory-list",
        "callout"
      ],
      "composedOf": [
        "button",
        "icon-button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Full banner",
          "description": "Full width. Says how long a copy is kept.",
          "code": "<PrivateChatBanner onExit={() => {}} />"
        },
        {
          "title": "Small pill",
          "description": "Fits in the top bar next to the title.",
          "code": "<PrivateChatBanner variant=\"compact\" onExit={() => {}} />"
        },
        {
          "title": "Your own wording",
          "description": "Change the text to match what your product does.",
          "code": "<PrivateChatBanner label=\"Temporary chat\" description=\"Not saved to history. Deleted from our systems within 30 days.\" onExit={() => {}} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-private-chat-banner/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/private-chat-banner.md"
    },
    {
      "id": "checkpoint-list",
      "name": "CheckpointList",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "checkpoints",
        "restore points",
        "snapshots"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "current",
          "note": "The checkpoint whose id matches currentId shows a Current badge in place of the Rewind button.",
          "source": "documented"
        },
        {
          "name": "rewind options",
          "note": "Pressing Rewind opens the restore choices under that checkpoint.",
          "source": "documented"
        },
        {
          "name": "confirm",
          "note": "Choosing a restore mode shows a plain warning with a Confirm and a Cancel button before anything happens.",
          "source": "documented"
        },
        {
          "name": "no files changed",
          "note": "Restore work only is disabled for a checkpoint where files is 0.",
          "source": "documented"
        }
      ],
      "summary": "A list of earlier steps you can go back to, with restore and summarize buttons and a confirm step.",
      "description": "CheckpointList shows each earlier request with how long ago it was and how many files changed. Rewind on a row opens choices: Restore everything, Restore conversation only, Restore work only and Summarize from here. Restores ask for confirmation first. The current step is marked and has no choices. A note area can say what is not covered.",
      "purpose": "Lets people go back to an earlier step of their work or chat. Restores ask for confirmation inside the row, so people see what they are about to undo.",
      "useFor": [
        "An agent or coding session where each request changes files.",
        "Undoing the conversation, the work, or both, back to one request.",
        "Shortening a long chat from one point with Summarize from here."
      ],
      "notFor": [
        {
          "text": "Stepping between versions of one message",
          "use": "version-pager"
        },
        {
          "text": "Named saves of a document or design",
          "use": "version-history"
        },
        {
          "text": "Undoing one AI edit to a short piece of text",
          "use": "revert-toggle"
        }
      ],
      "whenToUse": [
        "Keep the confirm step in the row so the person sees what they are acting on.",
        "Show the file count so people see what a restore will change.",
        "Fill in the limitations note when some changes are not tracked, so people know before they restore."
      ],
      "avoid": [
        "Do not skip the confirm step for the restores.",
        "Do not use a pop-up window for the confirmation.",
        "Do not offer choices on the current step. There is nothing to restore."
      ],
      "accessibility": [
        "The rows are a numbered list, and the current row is marked as the current step for screen readers.",
        "Rewind tells screen readers whether its choices are open.",
        "The confirm step is a named group that says what the restore will remove.",
        "Restore work only is turned off for a step with no changed files.",
        "Keyboard focus follows the choices: picking a restore moves it to the confirm step, so the warning is read; Cancel returns it to that choice; after a restore or a summary it returns to the row's Rewind button, or to the row itself if it became the current one."
      ],
      "accessibilityYouDo": [
        "Pass currentId, so screen readers can tell which step is the current one."
      ],
      "onPhone": [
        "Each row shows the prompt on one line and cuts it off with an ellipsis. The full text is in a title attribute, which a touch screen does not show, so on a phone it is not available.",
        "Rewind opens the restore choices under the row; they wrap, and every button has a 44px tap area on a touch screen.",
        "The confirm text and its warning wrap, so the destructive step stays readable on a narrow screen."
      ],
      "importPath": "import { CheckpointList } from \"./index\"",
      "sourcePath": "src/organisms/CheckpointList.tsx",
      "props": [
        {
          "name": "checkpoints",
          "type": "Checkpoint[]",
          "required": true,
          "default": null,
          "description": "Rows with id, prompt, time and files. Rendered in the order given."
        },
        {
          "name": "currentId",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id of the current checkpoint. It is marked and has no actions."
        },
        {
          "name": "onRestore",
          "type": "(id: string, mode: RestoreMode) => void",
          "required": false,
          "default": null,
          "description": "Called after the person confirms. Mode is everything, conversation or work."
        },
        {
          "name": "onSummarize",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when Summarize from here is chosen. It has no confirm step."
        },
        {
          "name": "limitations",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Slot under the list for what checkpoints do not cover."
        },
        {
          "name": "labels",
          "type": "Partial<Record<RestoreMode | \"summarize\" | \"confirm\" | \"cancel\" | \"current\" | \"rewind\", string>>",
          "required": false,
          "default": null,
          "description": "Overrides for visible labels."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--danger",
        "--border",
        "--radius-card"
      ],
      "related": [
        "version-pager",
        "context-meter",
        "checklist"
      ],
      "composedOf": [
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Rewind list",
          "description": "In this demo, restoring moves the marker to the chosen step. A step with no changed files cannot use Restore work only.",
          "code": "<CheckpointList\n  currentId={current}\n  checkpoints={[\n    { id: \"c3\", prompt: \"Add a retry to the upload step\", time: \"2 min ago\", files: 2 },\n    { id: \"c2\", prompt: \"Split the settings form into sections\", time: \"14 min ago\", files: 5 },\n    { id: \"c1\", prompt: \"Set up the project\", time: \"1 hr ago\", files: 0 },\n  ]}\n  onRestore={(id) => setCurrent(id)}\n  limitations=\"Edits made by shell commands are not tracked.\"\n/>"
        },
        {
          "title": "Your own wording",
          "description": "Change any visible label.",
          "code": "<CheckpointList\n  checkpoints={[{ id: \"a\", prompt: \"Draft the intro\", time: \"5 min ago\", files: 1 }]}\n  labels={{ rewind: \"Go back\", everything: \"Undo all\" }}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-checkpoint-list/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/checkpoint-list.md"
    },
    {
      "id": "revert-toggle",
      "name": "RevertToggle",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "undo",
        "restore previous",
        "before after toggle"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "no edit",
          "note": "Without userValue only the AI value is shown and the switch and revert button are hidden.",
          "source": "documented"
        },
        {
          "name": "reverted",
          "note": "Pressing the revert button switches back to the AI version and announces it to screen readers.",
          "source": "documented"
        }
      ],
      "summary": "Lets people flip between the AI's suggestion and their own edit, with a way back to the original.",
      "description": "RevertToggle shows one version at a time and lets people switch between the AI suggestion and their edit. Revert to original goes back to the AI version and tells your app to drop the edit. With no edit, it shows the AI version alone. Screen readers hear which version is showing.",
      "purpose": "Shows one version of a short text at a time, so people can flip between the AI suggestion and their own edit. The original is kept so they can always go back.",
      "useFor": [
        "A short AI suggestion the person has edited, such as a reply draft or a title.",
        "Comparing the AI's wording with the person's own before they keep one.",
        "Dropping an edit and going back to the original suggestion."
      ],
      "notFor": [
        {
          "text": "Long text where people need to see exactly what changed",
          "use": "diff-view"
        },
        {
          "text": "Several versions of one chat message",
          "use": "version-pager"
        }
      ],
      "whenToUse": [
        "Keep the AI version until the person reverts or discards it.",
        "Use it for short text where a side-by-side view would be too much.",
        "Clear the edit in onRevert. The part shows the AI version but does not remove your stored edit."
      ],
      "avoid": [
        "Do not overwrite the AI version when the person edits.",
        "Do not use it for long documents. A view that highlights changes fits better.",
        "Do not show the switch before an edit exists."
      ],
      "accessibility": [
        "The version switch is a set of choices: arrow keys move between them, and Home and End jump to the ends.",
        "After each change, screen readers hear \"Showing\" and the version name.",
        "Revert to original is a normal button.",
        "With no edit, the switch and button are not shown at all."
      ],
      "accessibilityYouDo": [
        "Move focus to the text after a revert, because the switch and button go away once the edit is cleared."
      ],
      "onPhone": [
        "The switch and the Revert button sit on one line and wrap when they do not fit.",
        "The two switch options keep their size and have a 44px tap area on a touch screen, and their labels do not wrap.",
        "The value card fills the width, so long text wraps inside it."
      ],
      "importPath": "import { RevertToggle } from \"./index\"",
      "sourcePath": "src/molecules/RevertToggle.tsx",
      "props": [
        {
          "name": "aiValue",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The original AI suggestion."
        },
        {
          "name": "userValue",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The person's edit. Without it only the AI value shows."
        },
        {
          "name": "value",
          "type": "\"ai\" | \"user\"",
          "required": false,
          "default": null,
          "description": "Controlled active version."
        },
        {
          "name": "defaultValue",
          "type": "\"ai\" | \"user\"",
          "required": false,
          "default": "\"user\" when an edit exists",
          "description": "Starting version when uncontrolled."
        },
        {
          "name": "onChange",
          "type": "(value: \"ai\" | \"user\") => void",
          "required": false,
          "default": null,
          "description": "Called when the version changes."
        },
        {
          "name": "onRevert",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called when Revert to original is chosen."
        },
        {
          "name": "aiLabel",
          "type": "string",
          "required": false,
          "default": "\"AI suggestion\"",
          "description": "Label of the AI option."
        },
        {
          "name": "userLabel",
          "type": "string",
          "required": false,
          "default": "\"Your edit\"",
          "description": "Label of the edit option."
        },
        {
          "name": "revertLabel",
          "type": "string",
          "required": false,
          "default": "\"Revert to original\"",
          "description": "Label of the revert button."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Version\"",
          "description": "Accessible name of the switch."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {
        "value": [
          "ai",
          "user"
        ],
        "defaultValue": [
          "ai",
          "user"
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--radius-card"
      ],
      "related": [
        "version-pager",
        "feedback-bar"
      ],
      "composedOf": [
        "segmented-control",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "After an edit",
          "description": "Reverting drops the edit here, and the switch goes away.",
          "code": "<RevertToggle\n  aiValue=\"Thank you for waiting. We expect to ship the fix on Friday.\"\n  userValue={edit}\n  onRevert={() => setEdit(undefined)}\n/>"
        },
        {
          "title": "Starting on the AI version",
          "description": "Opens on the AI version even when an edit exists.",
          "code": "<RevertToggle aiValue=\"Weekly sync, Tuesdays at 10:00.\" userValue=\"Weekly sync, Tuesdays at 09:30.\" defaultValue=\"ai\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-revert-toggle/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/revert-toggle.md"
    },
    {
      "id": "context-meter",
      "name": "ContextMeter",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "context usage",
        "token meter",
        "context window meter"
      ],
      "category": "numbers",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "near limit",
          "note": "At or above summarizeAt a warning line appears, and a Summarize button if onSummarize is passed.",
          "source": "documented"
        },
        {
          "name": "summarizing",
          "note": "Pass summarizing to disable the button and change its text to Summarizing.",
          "source": "documented"
        },
        {
          "name": "compacted",
          "note": "Pass compactionNote to add a check line below the bar saying what was condensed.",
          "source": "documented"
        }
      ],
      "summary": "Shows how full the chat is, with a Summarize button when it is nearly full.",
      "description": "ContextMeter shows how full the chat is, as a bar and short text. The assistant can only keep so much of a chat in mind. The bar stays neutral until it nears the limit. Then it turns amber, a line says why, and a Summarize button appears. A marker can show that earlier messages were summarized.",
      "purpose": "Shows how much of the chat the assistant can still keep in mind. It stays quiet until people can do something, then warns in amber and offers to summarize.",
      "useFor": [
        "A long chat that may reach the limit of what the assistant can hold.",
        "Offering Summarize when the chat is nearly full.",
        "Leaving a note after earlier messages have been summarized."
      ],
      "notFor": [
        {
          "text": "Credits or a plan allowance",
          "use": "usage-meter"
        },
        {
          "text": "A limit that has already been reached",
          "use": "rate-limit-notice"
        },
        {
          "text": "A plain bar with no warning text or actions",
          "use": "meter"
        }
      ],
      "whenToUse": [
        "Keep the meter quiet until the person can do something about it.",
        "Show the reason and the button together with amber.",
        "Leave a marker after summarizing so the chat history stays honest.",
        "Pass onSummarize, or the warning appears with nothing to act on."
      ],
      "avoid": [
        "Do not warn when there is plenty of room. Amber means act now.",
        "Do not show Summarize unless it works.",
        "Do not rely on the bar color alone. The text carries the state."
      ],
      "accessibility": [
        "The bar is a named meter, so screen readers hear its value, such as \"68% of context\".",
        "The warning is written beside the bar, so it does not rely on color.",
        "Summarize turns off and reads \"Summarizing\" while a summary runs.",
        "The bar's grow-in motion stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Tell people when summarizing ends. The button label changes, but nothing is read out."
      ],
      "onPhone": [
        "The meter fills the width, and the amount and the warning share one row.",
        "The Summarize button appears under the bar when the limit is near, and has a 44px tap area on a touch screen.",
        "The bar is only 6px tall and carries no tap action, so the amount in text is the part to read."
      ],
      "importPath": "import { ContextMeter } from \"./index\"",
      "sourcePath": "src/molecules/ContextMeter.tsx",
      "props": [
        {
          "name": "percent",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Share of the context window in use, 0 to 100."
        },
        {
          "name": "valueText",
          "type": "string",
          "required": false,
          "default": "\"N% of context\"",
          "description": "Text beside the bar and the meter's value text."
        },
        {
          "name": "summarizeAt",
          "type": "number",
          "required": false,
          "default": "85",
          "description": "Percent at which the bar warns and Summarize appears."
        },
        {
          "name": "onSummarize",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called on Summarize. The action stays hidden when omitted."
        },
        {
          "name": "summarizing",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Disables the action and changes its label while a summary runs."
        },
        {
          "name": "compactionNote",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shows a marker row with this text."
        },
        {
          "name": "warnText",
          "type": "string",
          "required": false,
          "default": "\"Close to the limit.\"",
          "description": "Reason shown beside the amber state."
        },
        {
          "name": "summarizeLabel",
          "type": "string",
          "required": false,
          "default": "\"Summarize\"",
          "description": "Label of the action."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Context used\"",
          "description": "Accessible name of the meter."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--attention",
        "--attention-fg",
        "--fg",
        "--border"
      ],
      "related": [
        "usage-meter",
        "checkpoint-list"
      ],
      "composedOf": [
        "meter",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Normal",
          "description": "Far from the limit, there is no warning and no button.",
          "code": "<ContextMeter percent={68} onSummarize={() => {}} />"
        },
        {
          "title": "Near the limit",
          "description": "Near the limit, the bar warns and Summarize appears.",
          "code": "<ContextMeter percent={91} onSummarize={() => {}} />"
        },
        {
          "title": "After summarizing",
          "description": "Leave a marker once the conversation has been shortened.",
          "code": "<ContextMeter percent={34} compactionNote=\"Earlier messages were summarized.\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-context-meter/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/context-meter.md"
    },
    {
      "id": "usage-meter",
      "name": "UsageMeter",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "quota meter",
        "usage bar",
        "progress bar"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "running low",
          "note": "When the used share reaches warnAt, the bar warns and a running-low message shows with the optional upgrade slot.",
          "source": "documented"
        },
        {
          "name": "not enough",
          "note": "When estimate is more than remaining, the bar turns attention-coloured and says there is not enough left.",
          "source": "documented"
        },
        {
          "name": "estimate",
          "note": "Pass estimate to show how much this action will use.",
          "source": "documented"
        }
      ],
      "summary": "How many credits are left, with an optional cost estimate, reset date and upgrade button.",
      "description": "UsageMeter shows how many credits are left and can say what the next action will cost. It stays neutral until most credits are used or the cost is more than what is left. Then it says why in amber and shows the upgrade button you provide.",
      "purpose": "Shows how many credits are left and what the next action will cost, before it runs. It warns only when the person may need to act.",
      "useFor": [
        "Before a paid action, to show about how much it will use.",
        "A plan's remaining allowance, with when it resets.",
        "Offering an upgrade once credits run low."
      ],
      "notFor": [
        {
          "text": "How full the current chat is",
          "use": "context-meter"
        },
        {
          "text": "A limit that has been reached and blocks the next request",
          "use": "rate-limit-notice"
        }
      ],
      "whenToUse": [
        "Show the estimate before the action runs.",
        "Show when the allowance resets.",
        "Offer the upgrade only when credits run low."
      ],
      "avoid": [
        "Do not use amber while there is plenty left.",
        "Do not hide retries that use up credits.",
        "Do not give an exact estimate you cannot back up. Say about."
      ],
      "accessibility": [
        "The bar is a named meter that screen readers read as \"N of M credits left\".",
        "The estimate and the warning are written out, so color is not the only clue.",
        "The warning says why, such as running low or not enough left for this.",
        "The bar's grow-in motion stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Put a real button or link with a clear name in the upgrade slot.",
        "Place the meter before the action it estimates, so the cost is heard before the action."
      ],
      "onPhone": [
        "The meter fills the width, with the amount on the left and the reset time on the right.",
        "The warning and your upgrade action share a row that wraps. A button gets a 44px tap area by itself. If your upgrade action is a small link, give it the tap class."
      ],
      "importPath": "import { UsageMeter } from \"./index\"",
      "sourcePath": "src/molecules/UsageMeter.tsx",
      "props": [
        {
          "name": "remaining",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Credits or quota left."
        },
        {
          "name": "total",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Full allowance for the period."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "default": "\"credits\"",
          "description": "Plural unit name used in the text."
        },
        {
          "name": "estimate",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Cost of the pending action. Shows This will use about N credits."
        },
        {
          "name": "resetText",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Reset time, for example Resets Nov 1."
        },
        {
          "name": "warnAt",
          "type": "number",
          "required": false,
          "default": "0.8",
          "description": "Fraction used, 0 to 1, at which the meter warns."
        },
        {
          "name": "upgrade",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Slot for an upgrade action, shown only in the warn state."
        },
        {
          "name": "warnText",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Overrides the reason in the warn state."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Usage\"",
          "description": "Accessible name of the meter."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--attention",
        "--attention-fg",
        "--fg",
        "--fg-subtle"
      ],
      "related": [
        "context-meter",
        "rate-limit-notice"
      ],
      "composedOf": [
        "meter"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With an estimate",
          "description": "Shows the cost before the person confirms an action.",
          "code": "<UsageMeter remaining={340} total={500} estimate={12} resetText=\"Resets Nov 1\" />"
        },
        {
          "title": "Running low",
          "description": "When credits run low, the meter explains why and shows the upgrade button.",
          "code": "<UsageMeter remaining={40} total={500} resetText=\"Resets Nov 1\" upgrade={<Button size=\"sm\" variant=\"lime\">Upgrade</Button>} />"
        },
        {
          "title": "Cost is more than what is left",
          "description": "The warning says so even when usage is moderate.",
          "code": "<UsageMeter remaining={30} total={100} estimate={60} unit=\"credits\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-usage-meter/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/usage-meter.md"
    },
    {
      "id": "agent-status-icon",
      "name": "AgentStatusIcon",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "status icon",
        "run status"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        }
      ],
      "summary": "A small icon that shows what an assistant is doing right now, with a different shape for each state.",
      "description": "AgentStatusIcon shows whether an assistant is working, needs you, is waiting, is done, has failed or has stopped. Each state has its own shape, so you do not need to see color. The working icon spins, and stays still if you turn motion off. Amber is only for \"needs you\", because only then must a person act.",
      "purpose": "A tiny picture of an assistant's state that still reads in a crowded row. Each state has its own shape so it works without color, and only \"needs you\" is amber, because only then must a person act.",
      "useFor": [
        "Beside a task or run name, to show its state at a glance.",
        "In a compact header or row where there is no room for words.",
        "With showLabel on, when there is room to write the state as well."
      ],
      "notFor": [
        {
          "text": "A run that people press to open its details",
          "use": "background-run-chip"
        },
        {
          "text": "A general label, such as a category or a count",
          "use": "badge"
        },
        {
          "text": "Showing how far along one long job is",
          "use": "generation-progress"
        }
      ],
      "whenToUse": [
        "Put the icon next to the task name so they are read together.",
        "Show the text where there is room, such as a page header.",
        "Use \"needs you\" only when a person must act."
      ],
      "avoid": [
        "Don't use it for anything except what an assistant is doing.",
        "Don't change its color. The color is part of the meaning.",
        "Don't put the words only in a hover tip."
      ],
      "accessibility": [
        "Without visible text, screen readers hear the state as the icon's name, such as \"Needs input\".",
        "With showLabel on, the icon is hidden from screen readers and the words are read once.",
        "Each state has its own shape, so color is never the only clue.",
        "The working icon spins, and stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Pass label in the person's language when your app is not in English.",
        "Turn on showLabel instead of writing the state beside the icon yourself, so it is not read twice."
      ],
      "onPhone": [
        "It is a small icon that keeps its size on a phone, 16px by default.",
        "With showLabel the words sit beside the icon in 12px text, so the status is not shown by the icon alone.",
        "It has no tap action, so wrap it in a button if it must open something."
      ],
      "importPath": "import { AgentStatusIcon } from \"./index\"",
      "sourcePath": "src/atoms/AgentStatusIcon.tsx",
      "props": [
        {
          "name": "status",
          "type": "\"working\" | \"needs-input\" | \"idle\" | \"completed\" | \"failed\" | \"stopped\"",
          "required": true,
          "default": null,
          "description": "Which state to show. Selects the shape, the tone and the default label."
        },
        {
          "name": "size",
          "type": "number",
          "required": false,
          "default": "16",
          "description": "Glyph width and height in px."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Replaces the default accessible text, for example to translate it."
        },
        {
          "name": "showLabel",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Render the label as visible text beside the glyph."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes. With showLabel they go on the wrapper, otherwise on the svg."
        }
      ],
      "variantsHint": {
        "status": [
          "working",
          "needs-input",
          "idle",
          "completed",
          "failed",
          "stopped"
        ]
      },
      "tokens": [
        "--attention",
        "--success",
        "--danger",
        "--fg-muted",
        "--fg-subtle"
      ],
      "related": [
        "agent-session-list",
        "task-tree",
        "spinner",
        "attention-dot"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "All six states",
          "description": "Each state has its own shape. The icon has a name for screen readers, so it works alone in a crowded row.",
          "code": "<div className=\"flex gap-4\">\n  <AgentStatusIcon status=\"working\" />\n  <AgentStatusIcon status=\"needs-input\" />\n  <AgentStatusIcon status=\"idle\" />\n  <AgentStatusIcon status=\"completed\" />\n  <AgentStatusIcon status=\"failed\" />\n  <AgentStatusIcon status=\"stopped\" />\n</div>"
        },
        {
          "title": "With words beside it",
          "description": "Show the text when there is room. A screen reader reads the text once and skips the icon.",
          "code": "<div className=\"flex flex-wrap gap-4\">\n  <AgentStatusIcon status=\"needs-input\" showLabel />\n  <AgentStatusIcon status=\"completed\" showLabel />\n  <AgentStatusIcon status=\"failed\" showLabel label=\"Failed to start\" />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-agent-status-icon/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/agent-status-icon.md"
    },
    {
      "id": "permission-mode-switcher",
      "name": "PermissionModeSwitcher",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "permission mode",
        "auto approve setting"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open",
          "note": "Pressing the trigger opens a list of modes, each with a risk label.",
          "source": "documented"
        },
        {
          "name": "locked",
          "note": "Set lockedReason on a mode to dim it, explain why it is locked and make it unselectable.",
          "source": "documented"
        },
        {
          "name": "high risk",
          "note": "A mode with risk high shows its risk label in the danger colour, plus its warning text if set.",
          "source": "documented"
        },
        {
          "name": "keyboard",
          "note": "Up and down arrows move between modes and the chosen one is announced to screen readers.",
          "source": "documented"
        },
        {
          "name": "shortcut hint",
          "note": "Pass shortcut to show a key hint at the bottom of the list.",
          "source": "documented"
        }
      ],
      "summary": "A button that always shows how much freedom the assistant has, and lets you change it.",
      "description": "PermissionModeSwitcher always shows the current mode, and its level of risk, on the button. Opening it lists each mode with a plain description and a risk label. A mode can have a warning, or be locked with a reason, such as a rule set by your admin. Put it below the box where people type.",
      "purpose": "Keeps the assistant's level of freedom in view at all times, on a button below the box where people type. Opening it lists every mode with what it allows and how risky it is, so nobody changes it by guesswork.",
      "useFor": [
        "Below a chat or task input, to show and change how much the assistant may do without asking.",
        "Products where an admin may lock some modes and people need to see why.",
        "Offering a risky mode with a warning written next to it."
      ],
      "notFor": [
        {
          "text": "Asking about one specific action",
          "use": "permission-prompt"
        },
        {
          "text": "Choosing which model answers",
          "use": "model-picker"
        },
        {
          "text": "Switching between two or three equal views",
          "use": "segmented-control"
        }
      ],
      "whenToUse": [
        "Keep the button on screen the whole time, so the mode is never hidden.",
        "Describe each mode as what the assistant may do without asking.",
        "Add a warning to the riskiest mode.",
        "Tell people why a mode is locked and what they can do.",
        "Pass shortcut only if your app really binds that key."
      ],
      "avoid": [
        "Don't show risk with only an icon or color. Keep the words.",
        "Don't hide locked modes. Showing them with a reason explains the limit.",
        "Don't show a shortcut the app does not have."
      ],
      "accessibility": [
        "The button says whether the list is open, and names the current mode and its risk.",
        "The modes work as one set of options: Tab lands on the chosen one and the up and down arrow keys move between them.",
        "Locked modes are marked unavailable and show the reason in words.",
        "Screen readers hear the new mode after a change.",
        "Escape closes the list and returns you to the button."
      ],
      "accessibilityYouDo": [
        "Write each lockedReason so it says who locked the mode and why."
      ],
      "onPhone": [
        "The trigger is a pill that keeps its size and has a 44px tap area on a touch screen.",
        "On a phone the panel opens as a bottom sheet with a backdrop. On wider screens it floats above the trigger and is at most the screen width minus 32px.",
        "Each mode row is a button with its description visible, so nothing needs hover.",
        "The keyboard shortcut line is shown on a phone too, so leave the shortcut prop out of phone-only layouts."
      ],
      "importPath": "import { PermissionModeSwitcher } from \"./index\"",
      "sourcePath": "src/molecules/PermissionModeSwitcher.tsx",
      "props": [
        {
          "name": "modes",
          "type": "PermissionMode[]",
          "required": false,
          "default": "defaultPermissionModes",
          "description": "Modes in display order. Each has id, label, description, risk (low, medium or high), and optional warning and lockedReason."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Selected mode id, for controlled use."
        },
        {
          "name": "defaultValue",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Initial mode id when uncontrolled. Defaults to the first mode."
        },
        {
          "name": "onChange",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when the person picks an unlocked mode."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Permission mode\"",
          "description": "Name for the trigger text, the popover and the radio group."
        },
        {
          "name": "shortcut",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shortcut shown at the foot of the panel. Pass it only if the app binds it."
        },
        {
          "name": "shortcutLabel",
          "type": "string",
          "required": false,
          "default": "\"to cycle modes\"",
          "description": "Text beside the shortcut."
        },
        {
          "name": "announcePrefix",
          "type": "string",
          "required": false,
          "default": "\"Mode changed to\"",
          "description": "Start of the polite announcement made after a change."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-raised",
        "--border",
        "--danger-fg",
        "--fg-muted",
        "--radius-control"
      ],
      "related": [
        "popover",
        "segmented-control",
        "permission-prompt"
      ],
      "composedOf": [
        "popover",
        "kbd"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "The five modes",
          "description": "The five default modes. The riskiest one shows a warning.",
          "code": "const [mode, setMode] = useState(\"manual\");\n\n<PermissionModeSwitcher value={mode} onChange={setMode} shortcut=\"Shift+Tab\" />"
        },
        {
          "title": "Locked by your admin",
          "description": "A locked mode is switched off and says why.",
          "code": "<PermissionModeSwitcher\n  defaultValue=\"manual\"\n  modes={[\n    { id: \"manual\", label: \"Manual\", description: \"Asks before every change.\", risk: \"low\" },\n    { id: \"auto\", label: \"Auto\", description: \"A second check reviews each step.\", risk: \"medium\", lockedReason: \"Disabled by your admin\" },\n    { id: \"bypass\", label: \"Bypass\", description: \"Never asks first.\", risk: \"high\", warning: \"Use only in a safe, separate space.\", lockedReason: \"Disabled by your admin\" },\n  ]}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-permission-mode-switcher/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/permission-mode-switcher.md"
    },
    {
      "id": "diff-file-row",
      "name": "DiffFileRow",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "changed file",
        "file diff row"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "selected",
          "note": "Set with the selected prop.",
          "source": "prop"
        }
      ],
      "summary": "One changed file in a review, with how much was added and removed, and buttons to keep or reject it.",
      "description": "DiffFileRow shows a file name, how many lines were added and removed, and buttons to keep or reject the file. After you choose, it shows a label with an icon and words. Undo puts it back. If you kept some changes in the file and not others, it says Partly kept. DiffView uses it for its file list. You can also use it alone in a short summary.",
      "purpose": "One line per changed file, so people can see the size of each change and decide on the whole file in one step. After a choice the buttons become a labelled result, so the list shows what is settled.",
      "useFor": [
        "A short list of changed files in a summary, with Keep and Reject for each.",
        "A file list beside your own display of the changed lines.",
        "Showing which files are kept, rejected or partly kept after a review."
      ],
      "notFor": [
        {
          "text": "Reviewing the changed lines themselves",
          "use": "diff-view"
        },
        {
          "text": "A file the person attached to a message",
          "use": "attachment-chip"
        }
      ],
      "whenToUse": [
        "Show both counts, even when one is zero.",
        "Keep the full file path available, since many files share a name.",
        "Pass onReset if people should be able to change their mind. Without it, decided rows have no Undo."
      ],
      "avoid": [
        "Don't show the counts with only color. Keep the plus and minus signs.",
        "Don't hide Reject behind Keep. Show both until a choice is made.",
        "Don't drop the file name to save space. Shorten the folder part instead."
      ],
      "accessibility": [
        "Keep, Reject and Undo include the file path in their names, so screen readers know which file they act on.",
        "The selected file is marked as the current one for screen readers.",
        "A decided file shows an icon and words, not only a color. A partly kept file shows a half-filled icon and the words Partly kept.",
        "The counts use plus and minus signs as well as color, and screen readers hear them as a sentence, such as \"12 lines added, 3 removed\".",
        "After Keep, Reject or Undo, keyboard focus moves to the file name, which now says the new state, such as \"Kept\"."
      ],
      "accessibilityYouDo": [],
      "onPhone": [
        "The file name takes the first line and is cut with an ellipsis, with the Keep and Reject buttons wrapping under it when the row is narrow.",
        "Tapping the name selects the file, and every button has a 44px tap area on a touch screen.",
        "The row highlight on hover is only decoration, so selection is shown without hover."
      ],
      "importPath": "import { DiffFileRow } from \"./index\"",
      "sourcePath": "src/molecules/DiffFileRow.tsx",
      "props": [
        {
          "name": "path",
          "type": "string",
          "required": true,
          "default": null,
          "description": "File path. The directory part is muted and the file name is emphasised."
        },
        {
          "name": "added",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Count of added lines."
        },
        {
          "name": "removed",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Count of removed lines."
        },
        {
          "name": "state",
          "type": "\"pending\" | \"kept\" | \"rejected\" | \"partial\"",
          "required": false,
          "default": "\"pending\"",
          "description": "Review state. Pending shows the buttons, kept and rejected show a badge, and partial shows a Partly kept badge beside the buttons. DiffView derives partial from the hunks."
        },
        {
          "name": "selected",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Marks the row as the file currently shown. Sets aria-current on the name button."
        },
        {
          "name": "onSelect",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called when the name is chosen."
        },
        {
          "name": "onKeep",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called by the Keep button. In DiffView this decides every hunk in the file."
        },
        {
          "name": "onReject",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called by the Reject button."
        },
        {
          "name": "onReset",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Adds an Undo button to decided and partly kept rows."
        },
        {
          "name": "labels",
          "type": "{ keep, reject, kept, rejected, partial, undo, lines }",
          "required": false,
          "default": null,
          "description": "Replace the button and badge text, and lines(added, removed), the sentence screen readers hear for the counts."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the row."
        }
      ],
      "variantsHint": {
        "state": [
          "pending",
          "kept",
          "rejected",
          "partial"
        ]
      },
      "tokens": [
        "--success-fg",
        "--danger-fg",
        "--surface-sunken",
        "--surface-hover",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "diff-view",
        "checklist-row",
        "tray-row"
      ],
      "composedOf": [
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Waiting, kept and rejected",
          "description": "Choose Keep or Reject to see the label. Undo puts the row back.",
          "code": "const [s, setS] = useState(\"pending\");\n\n<DiffFileRow path=\"src/lib/format.ts\" added={12} removed={3} state={s} selected\n  onKeep={() => setS(\"kept\")} onReject={() => setS(\"rejected\")} onReset={() => setS(\"pending\")} />"
        },
        {
          "title": "Partly kept",
          "description": "Some changes in the file are kept and others are not. Keep and Reject still decide the whole file.",
          "code": "<div className=\"max-w-xl\">\n  <DiffFileRow path=\"src/lib/format.ts\" added={12} removed={3} state=\"partial\" onKeep={() => {}} onReject={() => {}} onReset={() => {}} />\n</div>"
        },
        {
          "title": "A new file",
          "description": "A file with nothing removed still shows both counts.",
          "code": "<div className=\"max-w-xl\">\n  <DiffFileRow path=\"notes/packing-list.txt\" added={6} removed={0} onKeep={() => {}} onReject={() => {}} />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-diff-file-row/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/diff-file-row.md"
    },
    {
      "id": "diff-view",
      "name": "DiffView",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "diff viewer",
        "code diff",
        "compare changes"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "pending",
          "note": "A file or change block with no decision yet shows Keep and Reject buttons.",
          "source": "documented"
        },
        {
          "name": "kept",
          "note": "After Keep, the block shows a green Kept badge and an Undo button.",
          "source": "documented"
        },
        {
          "name": "rejected",
          "note": "After Reject, the block shows a red Rejected badge, an Undo button and dimmed lines.",
          "source": "documented"
        },
        {
          "name": "partly kept",
          "note": "A file with some blocks kept and others undecided shows as partly kept in the file list.",
          "source": "documented"
        },
        {
          "name": "all reviewed",
          "note": "When nothing is left to review, the footer says so and Keep all, Reject all and Next file are disabled.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "With no files, only the line No changes to review is shown.",
          "source": "documented"
        },
        {
          "name": "wide lines",
          "note": "Long lines scroll sideways inside each block, which can be focused with the keyboard.",
          "source": "documented"
        }
      ],
      "summary": "Review the changes an assistant made to your files, and keep or reject each one.",
      "description": "DiffView shows a list of changed files and the changes in the chosen file. Each group of changes has its own Keep and Reject. Each file also has Keep and Reject, which decide all its changes at once. A file is kept when every change is kept, rejected when every change is rejected, and partly kept when it is mixed. Added and removed lines have a plus or minus sign, so you do not need color to tell them apart. A bar at the bottom offers Keep all, Reject all and Next file, and counts the files still to review. Use it when an assistant has edited files and you decide what stays.",
      "purpose": "The place to decide what stays after an assistant edits files. Files are listed on one side and the chosen file's changes on the other, so people can decide one group of lines, a whole file, or everything at once.",
      "useFor": [
        "Reviewing edits an assistant made before they are saved or shared.",
        "Letting people keep some changes in a file and reject others.",
        "Working through many changed files, with a count of how many are left."
      ],
      "notFor": [
        {
          "text": "A short summary of changed files without the lines",
          "use": "diff-file-row"
        },
        {
          "text": "Showing code that is not a change",
          "use": "code-block"
        },
        {
          "text": "Going back to an earlier saved version of the whole work",
          "use": "version-history"
        }
      ],
      "whenToUse": [
        "Show how many files are left, so people know how much remains.",
        "Give Keep all and Reject all the same visual weight.",
        "Save people's choices in your app if the review must survive a page reload.",
        "Show real line numbers so people can find each change."
      ],
      "avoid": [
        "Don't mark lines with only color. Keep the plus and minus signs.",
        "Don't offer Keep all without showing how many files it affects. Changes already decided stay as they are.",
        "Don't use it to compare long paragraphs of writing. It shows lines in a fixed-width font."
      ],
      "accessibility": [
        "Added and removed lines show a plus or minus sign, and screen readers hear \"Added:\" or \"Removed:\" before the text.",
        "Line numbers are hidden from screen readers so they do not interrupt the text.",
        "Every Keep, Reject and Undo button names the change and the file, for example \"Keep hunk 2 in notes/trip-plan.txt\".",
        "Screen readers hear each choice, and how many files are left to review.",
        "Each group of lines can be reached with Tab and scrolled sideways, so long lines can be read."
      ],
      "accessibilityYouDo": [
        "If you translate the labels, also pass hunk, remaining, announceFile, announceHunk and announceAll, because they are spoken in button names and updates."
      ],
      "onPhone": [
        "Below 768px wide the file list sits above the changes instead of beside them.",
        "Each block of lines scrolls sideways on its own, because the lines do not wrap, and the two line-number columns use 96px of a phone screen.",
        "Keep, Reject and Undo buttons keep their size and have a 44px tap area on a touch screen, and the footer buttons wrap onto more rows."
      ],
      "importPath": "import { DiffView } from \"./index\"",
      "sourcePath": "src/organisms/DiffView.tsx",
      "props": [
        {
          "name": "files",
          "type": "DiffFile[]",
          "required": true,
          "default": null,
          "description": "Files to review. Each has id, path and hunks. Counts are computed from the hunk lines."
        },
        {
          "name": "statuses",
          "type": "Record<string, DiffReviewState>",
          "required": false,
          "default": null,
          "description": "Review state per file id, for controlled use. Missing ids are pending."
        },
        {
          "name": "defaultStatuses",
          "type": "Record<string, DiffReviewState>",
          "required": false,
          "default": null,
          "description": "Initial states when uncontrolled."
        },
        {
          "name": "onStatusesChange",
          "type": "(next: Record<string, DiffReviewState>) => void",
          "required": false,
          "default": null,
          "description": "Called after any decision at file or hunk level. File states are derived from the hunks, so a mixed file reports \"partial\"."
        },
        {
          "name": "hunkStatuses",
          "type": "Record<string, DiffHunkState[]>",
          "required": false,
          "default": null,
          "description": "Decision per hunk, by file id then hunk index, for controlled use. Missing entries follow the file's kept or rejected decision, or pending."
        },
        {
          "name": "defaultHunkStatuses",
          "type": "Record<string, DiffHunkState[]>",
          "required": false,
          "default": null,
          "description": "Initial hunk decisions when uncontrolled."
        },
        {
          "name": "onHunkStatusesChange",
          "type": "(next: Record<string, DiffHunkState[]>) => void",
          "required": false,
          "default": null,
          "description": "Called after any decision, with the decisions for every hunk."
        },
        {
          "name": "onHunkChange",
          "type": "(fileId: string, hunkIndex: number, state: DiffHunkState) => void",
          "required": false,
          "default": null,
          "description": "Called when one hunk's Keep, Reject or Undo is chosen. The index starts at 0."
        },
        {
          "name": "activeId",
          "type": "string",
          "required": false,
          "default": null,
          "description": "File whose hunks are shown, for controlled use."
        },
        {
          "name": "defaultActiveId",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Initial file when uncontrolled. Defaults to the first file."
        },
        {
          "name": "onActiveChange",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when the shown file changes."
        },
        {
          "name": "labels",
          "type": "DiffViewLabels",
          "required": false,
          "default": null,
          "description": "Replace the visible text, including the remaining-count function, the hunk title function, the keep, reject, kept, rejected, partial and undo words, and announceFile, announceHunk and announceAll, the sentences screen readers hear after each decision."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the outer section."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--success-soft",
        "--success-fg",
        "--danger-soft",
        "--danger-fg",
        "--surface-sunken",
        "--border",
        "--radius-card"
      ],
      "related": [
        "diff-file-row",
        "checklist",
        "approval-prompt"
      ],
      "composedOf": [
        "diff-file-row",
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Review two files",
          "description": "Decide per file, per change, or use the bottom bar. Next file jumps to a file you have not finished.",
          "code": "<DiffView files={files} />"
        },
        {
          "title": "Review one change at a time",
          "description": "The first file has two changes. Keep one and reject the other, and the file shows Partly kept.",
          "code": "// The first file in files has two changes.\n<DiffView files={files} />"
        },
        {
          "title": "Partly reviewed",
          "description": "Start with one file already decided. The bottom bar shows one file left.",
          "code": "<DiffView files={files} defaultStatuses={{ a: \"kept\" }} defaultActiveId=\"b\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-diff-view/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/diff-view.md"
    },
    {
      "id": "plan-card",
      "name": "PlanCard",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "plan",
        "agent plan",
        "proposed plan"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        }
      ],
      "summary": "Shows what the assistant plans to do before it starts, so you can approve or ask for changes.",
      "description": "PlanCard lays out what the assistant intends to do: a title, numbered steps with a short note each, and an optional time or cost estimate. A label says nothing has changed yet. You approve the plan or ask for a revision. While the plan is open, steps can be edited or removed. Use it when someone should agree to the approach before anything changes.",
      "purpose": "Shows the steps the assistant intends to take before anything changes, so a person can agree to the approach or ask for another. It does not take your place on the page when it appears, because a plan can wait.",
      "useFor": [
        "Before a task that edits, sends or deletes things, so the person agrees first.",
        "Letting people edit or remove steps before they approve.",
        "Showing a time or cost estimate next to the steps."
      ],
      "notFor": [
        {
          "text": "A yes or no on one single action",
          "use": "approval-prompt"
        },
        {
          "text": "Showing progress through steps that are already running",
          "use": "step-timeline"
        },
        {
          "text": "A to-do list people tick off themselves",
          "use": "checklist"
        }
      ],
      "whenToUse": [
        "Show the plan before anything changes, and say so.",
        "Keep each step's note to one short sentence.",
        "Give an estimate when you can, in time or cost.",
        "Let people edit or remove a step while the plan can still change."
      ],
      "avoid": [
        "Don't show a plan that has already run as if it were still waiting.",
        "Don't use amber on the card. A plan is not urgent.",
        "Don't pack a long list of tasks into one step."
      ],
      "accessibility": [
        "The card is named by its title, and the steps are a numbered list.",
        "Edit and Remove buttons include the step number and title.",
        "Screen readers hear when the plan is approved or a revision is requested.",
        "It does not move your place on the page when it appears.",
        "The state is written in words, and Approved adds a check mark."
      ],
      "accessibilityYouDo": [
        "Set headingLevel so the title fits your page outline. It is 3 unless you change it."
      ],
      "onPhone": [
        "The card has 20px padding, and each step keeps its Edit button and remove icon on the same row as its text, so step text gets narrower when edit tools are on.",
        "The remove icon and buttons keep their size and have a 44px tap area on a touch screen.",
        "Approve and Revise wrap under each other if they do not fit on one row."
      ],
      "importPath": "import { PlanCard } from \"./index\"",
      "sourcePath": "src/organisms/PlanCard.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Plan title, shown as the heading."
        },
        {
          "name": "steps",
          "type": "PlanStep[]",
          "required": true,
          "default": null,
          "description": "Ordered steps. Each has id, title and an optional description."
        },
        {
          "name": "estimate",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Time or cost estimate shown under the title."
        },
        {
          "name": "state",
          "type": "\"draft\" | \"awaiting\" | \"approved\" | \"revised\"",
          "required": false,
          "default": null,
          "description": "State, for controlled use."
        },
        {
          "name": "defaultState",
          "type": "PlanState",
          "required": false,
          "default": "\"awaiting\"",
          "description": "Initial state when uncontrolled."
        },
        {
          "name": "onStateChange",
          "type": "(state: PlanState) => void",
          "required": false,
          "default": null,
          "description": "Called when Approve or Revise changes the state."
        },
        {
          "name": "onApprove",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called by Approve."
        },
        {
          "name": "onRevise",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called by Revise."
        },
        {
          "name": "onEditStep",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Adds an Edit button to each step while the plan is draft or awaiting."
        },
        {
          "name": "onRemoveStep",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Adds a Remove button to each step while the plan is draft or awaiting."
        },
        {
          "name": "readOnlyLabel",
          "type": "string | null",
          "required": false,
          "default": "\"Nothing has changed yet\"",
          "description": "Read-only badge text. Null hides it. It is hidden once approved."
        },
        {
          "name": "labels",
          "type": "PlanCardLabels",
          "required": false,
          "default": null,
          "description": "Replace button text and the state notes."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the part's own heading, so it fits the page outline."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the section."
        }
      ],
      "variantsHint": {
        "state": [
          "draft",
          "awaiting",
          "approved",
          "revised"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--surface-sunken",
        "--radius-card"
      ],
      "related": [
        "approval-prompt",
        "step-timeline",
        "checklist",
        "permission-prompt"
      ],
      "composedOf": [
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Awaiting approval",
          "description": "The starting state. Approve and Revise work, and the \"nothing has changed\" label shows.",
          "code": "<PlanCard\n  title=\"Plan the party seating\"\n  estimate=\"About 6 minutes\"\n  steps={[\n    { id: \"1\", title: \"Look at the guest list\", description: \"See who has replied so far.\" },\n    { id: \"2\", title: \"Add a seat for each guest\", description: \"Group friends and family together.\" },\n    { id: \"3\", title: \"Check the table plan\", description: \"Make sure nobody is left out.\" },\n  ]}\n/>"
        },
        {
          "title": "Editable steps",
          "description": "Each step gets Edit and Remove buttons. They go away once the plan is approved or revised.",
          "code": "<PlanCard\n  title=\"Plan the party seating\"\n  steps={steps}\n  onEditStep={() => {}}\n  onRemoveStep={() => {}}\n/>"
        },
        {
          "title": "Draft and approved",
          "description": "A draft keeps the buttons off while the plan is being written. An approved plan hides the \"nothing has changed\" label.",
          "code": "<PlanCard title=\"Plan the party seating\" steps={steps} state=\"draft\" />\n<PlanCard title=\"Plan the party seating\" steps={steps} state=\"approved\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-plan-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/plan-card.md"
    },
    {
      "id": "permission-prompt",
      "name": "PermissionPrompt",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "permission request",
        "allow deny",
        "access request"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        }
      ],
      "summary": "Asks you to allow one thing the assistant wants to do, with a choice to always allow it and an optional note.",
      "description": "PermissionPrompt asks you to allow one thing the assistant wants to do. It shows what the step is, a plain description, the exact thing it will act on, and why it is asking. You can Allow once, Allow always for a clearly named group of things, or Deny. A saved rule lists exactly what it covers. While you are inside the prompt, Esc denies and N opens the note box. ApprovalPrompt is for one clear yes or no. This prompt adds saved choices and a note.",
      "purpose": "Asks before the assistant does one thing that needs a person's yes. It shows the exact command or target, so people approve what will really happen, and can save the choice for a clearly named group of actions.",
      "useFor": [
        "Before a command, file change or web request that needs approval.",
        "Letting people allow an action once, for this session, or always for a named group.",
        "Letting people send a note with their decision to steer the assistant."
      ],
      "notFor": [
        {
          "text": "A plain yes or no with no saved choices",
          "use": "approval-prompt"
        },
        {
          "text": "Changing how much the assistant may do in general",
          "use": "permission-mode-switcher"
        },
        {
          "text": "A question with several possible answers",
          "use": "agent-question-card"
        }
      ],
      "whenToUse": [
        "Show the exact thing it will act on, not a loose summary.",
        "List everything a saved choice would allow.",
        "Offer only Allow once when you cannot show everything a saved choice would allow.",
        "Name the helper when a background task is asking.",
        "Tell people the note box is where they can adjust their choice."
      ],
      "avoid": [
        "Don't pick Allow always for people. Allow once is selected first.",
        "Don't jump the cursor into the prompt unless the assistant is stuck waiting.",
        "Don't use it for a risky action that needs one clear yes or no. Use ApprovalPrompt.",
        "Don't rely on the keys alone. Deny, Add a note and Confirm also work with a mouse, a tap, or Tab.",
        "Don't change what Tab does. It keeps moving to the next control."
      ],
      "accessibility": [
        "The prompt is named \"Permission needed\" plus the step, and the choices work with the arrow keys.",
        "Escape anywhere inside the prompt denies, and N opens the note box unless you are typing. Screen readers are told about both keys.",
        "The reason is read with the choices, and the note box has a label and a hint.",
        "The Add a note button says whether the note box is open.",
        "After a choice, the result is shown with an icon and words, not only a color, and keyboard focus moves to it.",
        "A decision made elsewhere, such as from another screen, is announced to screen readers."
      ],
      "accessibilityYouDo": [
        "Set autoFocus only when the assistant cannot continue without an answer. Otherwise leave people where they are.",
        "Write a reason that says why this step needs a yes."
      ],
      "onPhone": [
        "The Escape to deny and N for a note shortcuts need a keyboard, so on a phone people use the choices and the confirm button.",
        "Long commands and file paths scroll sideways inside their own boxes instead of wrapping.",
        "The note field is 44px tall with 16px text on a touch screen.",
        "The choice rows grow to at least 44px tall on touch screens."
      ],
      "importPath": "import { PermissionPrompt } from \"./index\"",
      "sourcePath": "src/organisms/PermissionPrompt.tsx",
      "props": [
        {
          "name": "tool",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Kind of action, such as \"Bash command\". Used as the title and the section name."
        },
        {
          "name": "description",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Plain description of what the action does."
        },
        {
          "name": "target",
          "type": "string | string[]",
          "required": true,
          "default": null,
          "description": "The literal command or target. An array lists each subcommand of a compound command."
        },
        {
          "name": "reason",
          "type": "string",
          "required": false,
          "default": "\"This command requires approval\"",
          "description": "Why the person is being asked."
        },
        {
          "name": "requestedBy",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Name of the subagent that asked, shown as a badge."
        },
        {
          "name": "sessionScope",
          "type": "string",
          "required": false,
          "default": null,
          "description": "What a session-only approval covers. Adds the middle choice \"Allow for this session\" (choice \"session\", state \"allowed-session\", result \"Allowed for this session\"). Omit to hide it."
        },
        {
          "name": "ruleScope",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Scope of the saved rule, such as `npm test *`. Omit to offer only Allow once."
        },
        {
          "name": "ruleCovers",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Exactly what a saved rule covers, one entry per rule. Falls back to ruleScope."
        },
        {
          "name": "ruleLifetime",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Where and how long a saved rule lasts."
        },
        {
          "name": "onceOnlyReason",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Explains why there is no saved-rule option. Shown when ruleScope is omitted."
        },
        {
          "name": "state",
          "type": "\"pending\" | \"allowed-once\" | \"allowed-session\" | \"allowed-rule\" | \"denied\"",
          "required": false,
          "default": null,
          "description": "State, for controlled use. Uncontrolled it starts pending and follows the choice."
        },
        {
          "name": "onStateChange",
          "type": "(state: PermissionState) => void",
          "required": false,
          "default": null,
          "description": "Called after Confirm or Esc with the new state."
        },
        {
          "name": "onDecide",
          "type": "(decision: { choice: \"once\" | \"session\" | \"always\" | \"deny\"; note: string }) => void",
          "required": false,
          "default": null,
          "description": "Called after Confirm, or after Esc with choice \"deny\". Carries the choice and the trimmed note."
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Move focus to the first choice on mount. Use only when the agent cannot continue until the person answers."
        },
        {
          "name": "labels",
          "type": "PermissionPromptLabels",
          "required": false,
          "default": null,
          "description": "Replace choice text, the note label, hint and placeholder, and the scope heading. The Esc and N key hints are fixed."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the section."
        }
      ],
      "variantsHint": {
        "state": [
          "pending",
          "allowed-once",
          "allowed-session",
          "allowed-rule",
          "denied"
        ]
      },
      "tokens": [
        "--code-bg",
        "--code-fg",
        "--surface-sunken",
        "--border",
        "--success",
        "--danger",
        "--radius-control",
        "--radius-field",
        "--radius-card"
      ],
      "related": [
        "approval-prompt",
        "permission-mode-switcher",
        "tool-call-card"
      ],
      "composedOf": [
        "badge",
        "button",
        "kbd"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Saving a choice for next time",
          "description": "Choose Allow always to see what the saved choice covers before you confirm.",
          "code": "<PermissionPrompt\n  tool=\"Send an email\"\n  description=\"Send the weekly update to your team\"\n  target=\"To: team@example.com\"\n  ruleScope=\"Emails to team@example.com\"\n  ruleCovers={[\"Emails to team@example.com\"]}\n  ruleLifetime=\"Saved for this project\"\n/>"
        },
        {
          "title": "A request with several parts",
          "description": "When a request has several parts, each part is listed, and a saved choice covers each one.",
          "code": "<PermissionPrompt\n  tool=\"Book a trip\"\n  description=\"Book the flight and the hotel\"\n  target={[\"Flight to Lisbon\", \"Hotel in Lisbon\"]}\n  requestedBy=\"travel-helper\"\n  ruleScope=\"Bookings in Lisbon\"\n  ruleCovers={[\"Flights to Lisbon\", \"Hotels in Lisbon\"]}\n  ruleLifetime=\"Saved for this project\"\n/>"
        },
        {
          "title": "Allow once only",
          "description": "When a saved choice cannot show everything it would allow, offer only Allow once and say why.",
          "code": "<PermissionPrompt\n  tool=\"Change a setting\"\n  description=\"Change your payment details\"\n  target=\"Account > Payment\"\n  reason=\"This change needs your approval\"\n  onceOnlyReason=\"You cannot save this choice for next time.\"\n/>"
        },
        {
          "title": "Allow for this session",
          "description": "Add sessionScope to offer a middle choice. It lasts until the session ends and saves nothing for later.",
          "code": "<PermissionPrompt\n  tool=\"Edit files\"\n  description=\"Update the budget spreadsheet\"\n  target=\"budget-2026.xlsx\"\n  sessionScope=\"Edits to budget-2026.xlsx\"\n  ruleScope=\"Edits to spreadsheets\"\n/>"
        },
        {
          "title": "Keyboard shortcuts",
          "description": "Click inside the prompt, then press N to open the note box or Esc to deny. The line below shows what you chose.",
          "code": "<PermissionPrompt\n  tool=\"Delete files\"\n  description=\"Clear out old downloads\"\n  target=\"Downloads/old-photos\"\n  onDecide={(d) => console.log(d.choice, d.note)}\n/>"
        },
        {
          "title": "After you choose",
          "description": "The prompt shrinks to a short summary. These are the three results.",
          "code": "<PermissionPrompt tool=\"Send an email\" description=\"Send the weekly update\" target=\"To: team@example.com\" ruleScope=\"Emails to team@example.com\" state=\"allowed-once\" />\n<PermissionPrompt tool=\"Send an email\" description=\"Send the weekly update\" target=\"To: team@example.com\" ruleScope=\"Emails to team@example.com\" state=\"allowed-rule\" />\n<PermissionPrompt tool=\"Send an email\" description=\"Send the weekly update\" target=\"To: team@example.com\" ruleScope=\"Emails to team@example.com\" state=\"denied\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-permission-prompt/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/permission-prompt.md"
    },
    {
      "id": "agent-session-list",
      "name": "AgentSessionList",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "agent runs",
        "sessions",
        "task list"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "grouped",
          "note": "Sessions are grouped by status into Needs input, Working, Ready for review and Completed, and a group with no sessions is left out.",
          "source": "documented"
        },
        {
          "name": "peek open",
          "note": "Pressing Peek on a session that has peek data opens its latest output or question and a reply box.",
          "source": "documented"
        },
        {
          "name": "reply sent",
          "note": "Sending a reply calls onReply, clears the box and announces it to screen readers.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "With no sessions, nothing is shown.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Peek, the reply box and the send button show a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "A list of assistant tasks grouped by what they need, with a Peek panel to read the latest and reply.",
      "description": "AgentSessionList groups tasks under Needs input, Working, Ready for review and Completed, each with a count. A row shows a status icon, the task name, a one-line summary marked as written by AI, how old it is and an optional link label. Peek opens the latest update or question, how long it has waited, and a box to reply. Use it as the home screen for assistants working in the background.",
      "purpose": "A home screen for assistants working in the background, sorted by what each one needs from you. Peek lets people read the latest and reply without leaving the list.",
      "useFor": [
        "Showing several assistant tasks at once, grouped into needs input, working, ready for review and completed.",
        "Answering a waiting assistant straight from the list.",
        "Showing a one-line summary, written by the assistant, for each task."
      ],
      "notFor": [
        {
          "text": "Only the items that are waiting on the person, across all runs",
          "use": "approval-inbox"
        },
        {
          "text": "A list of past chats",
          "use": "conversation-list"
        },
        {
          "text": "One task broken into smaller tasks",
          "use": "task-tree"
        }
      ],
      "whenToUse": [
        "Keep the AI-written label on summaries written by AI.",
        "Put Needs input first so waiting tasks are easy to find.",
        "Show how long a task has waited in the Peek panel."
      ],
      "avoid": [
        "Don't remove the AI-written label from summaries written by AI.",
        "Don't rely on heading color. The group name and count are words.",
        "Don't use amber on rows that are not waiting for a person."
      ],
      "accessibility": [
        "Each group is named by its heading, and the count is read with the word sessions.",
        "Peek is a button that says whether it is open, and its name includes the task.",
        "The reply box and its send button are named with the task.",
        "Screen readers hear when a reply is sent.",
        "Each status icon has its own shape and a name for screen readers."
      ],
      "accessibilityYouDo": [
        "Give every session a unique id: it links each Peek button to its panel.",
        "Set headingLevel so the group names fit your page outline. It is 3 unless you change it."
      ],
      "onPhone": [
        "Each row wraps, and the age and Peek button move under the name when the row is narrow.",
        "Opening a peek shows a reply field that is 44px tall with 16px text on a touch screen, so the page does not zoom.",
        "The summary and peek panel are indented 28px to line up with the name, which narrows them a little."
      ],
      "importPath": "import { AgentSessionList } from \"./index\"",
      "sourcePath": "src/organisms/AgentSessionList.tsx",
      "props": [
        {
          "name": "sessions",
          "type": "AgentSession[]",
          "required": true,
          "default": null,
          "description": "Sessions to list. Each has id, name, status, summary, age, and optional pr, peek and group."
        },
        {
          "name": "onReply",
          "type": "(id: string, text: string) => void",
          "required": false,
          "default": null,
          "description": "Called when a reply is sent from Peek. The field clears afterwards."
        },
        {
          "name": "labels",
          "type": "AgentSessionListLabels",
          "required": false,
          "default": null,
          "description": "Replace group names, button text and the AI badge text."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the part's own heading, so it fits the page outline."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--surface-sunken",
        "--fg-muted",
        "--fg-subtle",
        "--radius-field",
        "--radius-card"
      ],
      "related": [
        "agent-status-icon",
        "task-tree",
        "conversation-list",
        "tray"
      ],
      "composedOf": [
        "agent-status-icon",
        "ai-badge",
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Tasks grouped by need",
          "description": "Groups with no tasks are left out. Peek works on any row that has something to show.",
          "code": "<AgentSessionList sessions={sessions} />"
        },
        {
          "title": "Reply from Peek",
          "description": "Open Peek on a task that is waiting, type a reply and send it.",
          "code": "<AgentSessionList sessions={waiting} onReply={(id, text) => send(id, text)} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-agent-session-list/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/agent-session-list.md"
    },
    {
      "id": "task-tree",
      "name": "TaskTree",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "tree view",
        "subtasks",
        "task hierarchy"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "running",
          "note": "A task with status running shows the working icon.",
          "source": "documented"
        },
        {
          "name": "done",
          "note": "A task with status done shows the completed icon, and is removed after dismissAfterMs if that is set and its children are also done.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "A task with status failed shows the failed icon and a dismiss button that hides it.",
          "source": "documented"
        },
        {
          "name": "stopped",
          "note": "A task with status stopped shows the stopped icon.",
          "source": "documented"
        },
        {
          "name": "partial",
          "note": "A task with status partial shows a half-filled circle.",
          "source": "documented"
        },
        {
          "name": "collapsed",
          "note": "A task with children can be collapsed to hide them; pass their ids in defaultCollapsed to start closed.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "A row gets a soft background under the pointer.",
          "source": "documented"
        }
      ],
      "summary": "A list of tasks and the smaller tasks inside them, each with a status and an Open button.",
      "description": "TaskTree lists tasks, with smaller tasks indented beneath them. Each row shows a name, a status in words beside an icon, a (+N) count of tasks inside it, and an Open button. Bigger tasks open and close. A failed row stays until you dismiss it. A finished row can remove itself after a short wait. Use it beside the box where people type, to show what helpers are doing.",
      "purpose": "Shows the helpers an assistant has started, with smaller tasks indented under the task that started them. Failed rows wait for you, and finished rows can tidy themselves away.",
      "useFor": [
        "Beside the box where people type, to show what helpers are doing.",
        "A task that splits into smaller tasks, each with its own status.",
        "Letting people open one helper to see what it did."
      ],
      "notFor": [
        {
          "text": "The steps of one task in the order they happen",
          "use": "step-timeline"
        },
        {
          "text": "Separate tasks grouped by what they need from the person",
          "use": "agent-session-list"
        },
        {
          "text": "A to-do list the person ticks off",
          "use": "checklist"
        }
      ],
      "whenToUse": [
        "Keep failed rows visible until the person dismisses them.",
        "Remove the row from your own data when it is dismissed, so a later update does not bring it back.",
        "Let finished rows leave on their own when they would otherwise pile up.",
        "Use Open to take people to that task's own page."
      ],
      "avoid": [
        "Don't show status with only color. Each row has an icon shape and words.",
        "Don't make the wait so short that a result vanishes before it can be read.",
        "Don't nest deeper than people can follow. Each level moves further right.",
        "Don't use it for a simple list of steps in order. Use StepTimeline."
      ],
      "accessibility": [
        "Rows are list items, and bigger tasks have a button that says Expand or Collapse with the task name.",
        "Screen readers hear the (+N) count as the number of tasks inside.",
        "Status is an icon shape plus words. Partial uses a half-filled circle.",
        "Open and Dismiss buttons include the task name, and each removal, timed ones too, is announced.",
        "The running icon spins, and stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Leave dismissAfterMs off if people need time to read finished rows."
      ],
      "onPhone": [
        "Each level of nesting indents by 20px, so deep trees leave less width for the task name, which is cut with an ellipsis.",
        "The status word and Open button wrap under the name when the row is narrow.",
        "The expand, Open and dismiss controls keep their size and have a 44px tap area on a touch screen."
      ],
      "importPath": "import { TaskTree } from \"./index\"",
      "sourcePath": "src/organisms/TaskTree.tsx",
      "props": [
        {
          "name": "tasks",
          "type": "TaskNode[]",
          "required": true,
          "default": null,
          "description": "Root tasks. Each has id, name, status (running, done, failed, stopped or partial) and optional children."
        },
        {
          "name": "onOpen",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called by a row's Open button."
        },
        {
          "name": "onDismiss",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when a row is dismissed, either by the Dismiss button on a failed row or by dismissAfterMs on a done row. The tree hides the row at once, and your app removes it from its data here."
        },
        {
          "name": "dismissAfterMs",
          "type": "number",
          "required": false,
          "default": "undefined",
          "description": "Milliseconds after which a done row is dismissed and onDismiss(id) is called. Off when undefined. Failed rows never auto-dismiss. A done parent waits until its nested rows are done. The timer is not motion, so reduced motion does not change it."
        },
        {
          "name": "defaultCollapsed",
          "type": "string[]",
          "required": false,
          "default": "[]",
          "description": "Ids of parents that start collapsed."
        },
        {
          "name": "labels",
          "type": "TaskTreeLabels",
          "required": false,
          "default": null,
          "description": "Replace status names and button text."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--surface-hover",
        "--danger",
        "--success",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "agent-status-icon",
        "step-timeline",
        "agent-session-list",
        "checklist"
      ],
      "composedOf": [
        "agent-status-icon",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Tasks inside tasks",
          "description": "The (+N) count includes every task inside. Close a parent task to hide what is inside.",
          "code": "<TaskTree tasks={tasks} onOpen={(id) => open(id)} />"
        },
        {
          "title": "Dismissing a failed task",
          "description": "Failed rows have a Dismiss button and stay until you use it.",
          "code": "<TaskTree tasks={[{ id: \"a\", name: \"Email the travel agent\", status: \"failed\" }, { id: \"b\", name: \"Lint\", status: \"done\" }]} />"
        },
        {
          "title": "Finished tasks leave on their own",
          "description": "The finished row disappears after 4 seconds. The failed row stays.",
          "code": "<TaskTree\n  tasks={rows}\n  dismissAfterMs={4000}\n  onDismiss={(id) => setRows((r) => r.filter((x) => x.id !== id))}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-task-tree/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/task-tree.md"
    },
    {
      "id": "provenance-popover",
      "name": "ProvenancePopover",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "content credentials",
        "origin info",
        "c2pa"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "The trigger pill gets a stronger tint under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The trigger shows a focus ring on keyboard focus.",
          "source": "documented"
        },
        {
          "name": "edited by a person",
          "note": "Pass editedByPerson to add a row saying whether a person edited it.",
          "source": "documented"
        }
      ],
      "summary": "A label that says content was made with AI, and opens a short note on how.",
      "description": "ProvenancePopover shows an AI label in plain words, with the details one click away. The panel can show which tool made it, the date, what the AI did, whether a person edited it, why you are seeing it, and a link to more. Anything you do not know is left out.",
      "purpose": "A small AI label in plain words that opens a short note on how the content was made. The label is always visible and the details are one click away, so it informs without getting in the way.",
      "useFor": [
        "Marking text, images or files that AI made or changed.",
        "Explaining which tool made something, when, and whether a person edited it.",
        "Telling people why they are seeing AI content."
      ],
      "notFor": [
        {
          "text": "A plain AI label with nothing more to explain",
          "use": "ai-badge"
        },
        {
          "text": "Showing where a single fact came from",
          "use": "citation-hover-card"
        },
        {
          "text": "Showing how sure the AI is about an answer",
          "use": "confidence-indicator"
        }
      ],
      "whenToUse": [
        "Show the label first and keep the details one click away.",
        "Fill in only what you are sure of. Missing details are left out.",
        "Say whether a person edited it, if you keep track.",
        "Explain why in one sentence."
      ],
      "avoid": [
        "Do not label content AI did not touch. Too many labels weaken trust.",
        "Do not rely on the green color to show it is AI. The words do that.",
        "Do not put long policy text in the panel. Link to it.",
        "Do not make up details your system does not record."
      ],
      "accessibility": [
        "The label is a button with words on it, so it is never only an icon.",
        "It tells screen readers whether the panel is open, and opens with Enter, Space, a click or a tap.",
        "Escape closes the panel and returns to the label. Clicking outside also closes it.",
        "The panel is named by its title, and the details are laid out as lists."
      ],
      "accessibilityYouDo": [
        "Write a label that says what happened in plain words, such as AI-generated or Edited with AI.",
        "Translate the row labels with rowLabels if your product is not in English."
      ],
      "onPhone": [
        "The label is a button. On touch screens it keeps its small badge size and has a 44px tap area.",
        "On a phone the panel opens as a bottom sheet with a backdrop, within thumb reach. On wider screens it opens under the label, flips above if there is no room below, and stays inside the screen edges.",
        "Tap outside the panel to close it. The View full details link is small text with no 44px target."
      ],
      "importPath": "import { ProvenancePopover } from \"./index\"",
      "sourcePath": "src/molecules/ProvenancePopover.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"AI-generated\"",
          "description": "Visible text of the label and the accessible name of the trigger."
        },
        {
          "name": "madeWith",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Tool or model that produced the content."
        },
        {
          "name": "date",
          "type": "string",
          "required": false,
          "default": null,
          "description": "When it was made, as display text."
        },
        {
          "name": "actions",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "What the AI did, one short phrase each."
        },
        {
          "name": "editedByPerson",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Whether a person changed it afterwards. Omit to hide the row."
        },
        {
          "name": "why",
          "type": "string",
          "required": false,
          "default": null,
          "description": "One line on why the person is seeing this content."
        },
        {
          "name": "detailsHref",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Link to a fuller record. The link is hidden when omitted."
        },
        {
          "name": "detailsLabel",
          "type": "string",
          "required": false,
          "default": "\"View full details\"",
          "description": "Text of the details link."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"About this content\"",
          "description": "Panel heading and accessible name of the panel."
        },
        {
          "name": "rowLabels",
          "type": "{ madeWith; date; actions; edited; yes; no }",
          "required": false,
          "default": null,
          "description": "Replaces the row labels for localisation."
        },
        {
          "name": "open / onOpenChange",
          "type": "boolean / (open: boolean) => void",
          "required": false,
          "default": null,
          "description": "Controlled mode. Omit both for self-managed state."
        },
        {
          "name": "align / side",
          "type": "\"start\" | \"end\" / \"bottom\" | \"top\"",
          "required": false,
          "default": "\"start\" / \"bottom\"",
          "description": "Which edge of the label the panel hangs from."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--lime",
        "--on-lime",
        "--surface-raised",
        "--surface-sunken",
        "--border",
        "--shadow-lg",
        "--radius-control"
      ],
      "related": [
        "ai-badge",
        "confidence-indicator",
        "source-card"
      ],
      "composedOf": [
        "popover"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Every detail filled in",
          "description": "Every detail filled in. Click, tap, or press Enter or Space on the label to open it.",
          "code": "<ProvenancePopover\n  madeWith=\"Tattva Writer, model 4\"\n  date=\"3 Oct 2026\"\n  actions={[\"Drafted the summary\", \"Shortened the intro\"]}\n  editedByPerson\n  why=\"You asked for a summary of the meeting notes.\"\n  detailsHref=\"#details\"\n/>"
        },
        {
          "title": "Minimal",
          "description": "Only a tool and a reason. Use this when that is all you know.",
          "code": "<ProvenancePopover label=\"Made with AI\" madeWith=\"Image generator\" why=\"Generated from your prompt.\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-provenance-popover/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/provenance-popover.md"
    },
    {
      "id": "citation-hover-card",
      "name": "CitationHoverCard",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "hover card",
        "citation preview",
        "link preview"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "Moving the pointer onto the number opens a source card above it, and leaving closes it.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Focusing the number with the keyboard opens the card too.",
          "source": "documented"
        },
        {
          "name": "pinned",
          "note": "Clicking the number keeps the card open until you click it again, click outside or press Escape.",
          "source": "documented"
        }
      ],
      "summary": "A small number in a sentence that shows where a fact came from.",
      "description": "CitationHoverCard is a small number placed inside AI-written text. It opens a card with the source's title, website, a short quote, a trust label, and a link to the source. Point at it or tab to it to open the card. Click or tap to keep it open. Escape closes it.",
      "purpose": "A small number placed right after a fact that shows where it came from. The source card opens on pointing, tabbing or tapping, so checking a claim never needs a mouse.",
      "useFor": [
        "A fact in an AI answer that comes from a web page or document.",
        "Showing a source's title, website, a short quote and a trust label without leaving the text."
      ],
      "notFor": [
        {
          "text": "The full list of sources for an answer",
          "use": "source-list"
        },
        {
          "text": "A plain numbered link to a source, with no card",
          "use": "citation-marker"
        },
        {
          "text": "A source shown as a card of its own",
          "use": "source-card"
        }
      ],
      "whenToUse": [
        "Put the number right after the fact it supports.",
        "Show a trust label only when you have a real reason.",
        "Keep quotes short and taken straight from the source.",
        "Add a Sources list too, so every source is in one place."
      ],
      "avoid": [
        "Do not make pointing at it the only way to reach the source.",
        "Do not use a trust label as a score without saying how it is decided.",
        "Do not use it where the card would cover the text being read."
      ],
      "accessibility": [
        "The number is a button named like \"Source 1: energy.example.gov\", and it tells screen readers whether the card is open.",
        "The card opens on pointing, on tabbing to it and on click. A click keeps it open, which is how touch screens use it.",
        "Escape closes the card and returns to the number. Moving focus away also closes it.",
        "The card's link can be reached with Tab, and the trust label is written in words."
      ],
      "accessibilityYouDo": [
        "Also list every source in a Sources list, so people can reach them all without opening each card.",
        "Place the number right after the fact it supports, so the link between them is clear when read aloud."
      ],
      "onPhone": [
        "A tap opens the card and pins it. Tap the number again or tap outside to close it.",
        "The number is a small inline button. On touch screens it keeps its size and has a 44px tap area, so the line it sits in does not get taller.",
        "On a phone the card is fixed along the bottom of the screen, 1rem in from each side and above the bottom safe area, so it cannot run off the edge. The Open source link is small text with no 44px target."
      ],
      "importPath": "import { CitationHoverCard } from \"./index\"",
      "sourcePath": "src/molecules/CitationHoverCard.tsx",
      "props": [
        {
          "name": "n",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Citation number shown in the marker."
        },
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Source title."
        },
        {
          "name": "domain",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Source domain. Part of the marker's accessible name."
        },
        {
          "name": "url",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Target of the open link."
        },
        {
          "name": "snippet",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Short excerpt, clamped to three lines."
        },
        {
          "name": "trust",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Trust label as text, such as Government or Academic."
        },
        {
          "name": "favicon",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Small mark for the source. Defaults to the first letter of the domain."
        },
        {
          "name": "openLabel",
          "type": "string",
          "required": false,
          "default": "\"Open source\"",
          "description": "Text of the link."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--accent-soft",
        "--accent-fg",
        "--surface-raised",
        "--border",
        "--shadow-lg",
        "--fg-muted",
        "--radius-overlay"
      ],
      "related": [
        "citation-marker",
        "source-card",
        "source-list",
        "summary-card"
      ],
      "composedOf": [
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Inline in a sentence",
          "description": "Point at the number, tab to it, or tap it. Press Escape to close.",
          "code": "<p className=\"text-body leading-5\">\n  Renewables supplied about a quarter of generation last year.\n  <CitationHoverCard n={1} title=\"Annual energy review 2025\" domain=\"energy.example.gov\"\n    url=\"https://example.com\" snippet=\"Renewables supplied 24 percent of national generation, up from 21 percent the year before.\"\n    trust=\"Government\" />\n</p>"
        },
        {
          "title": "Without a quote or trust label",
          "description": "Only what you have. The site icon falls back to the first letter of the website.",
          "code": "<p className=\"text-body leading-5\">\n  The report was updated in March.\n  <CitationHoverCard n={2} title=\"Changelog\" domain=\"docs.example.com\" url=\"https://example.com\" />\n</p>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-citation-hover-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/citation-hover-card.md"
    },
    {
      "id": "suggestion-bar",
      "name": "SuggestionBar",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "inline suggestion",
        "accept reject bar"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        }
      ],
      "summary": "A bar under an AI draft. Nothing changes in your document until you choose.",
      "description": "SuggestionBar sits under a draft the AI has written but not yet added. You can Keep it (or Replace), Insert below, Regenerate, or Discard. A \"Fine-tune this\" box lets you ask for a change. Regenerate asks for a fresh version of the same request. Fine-tune changes the current draft. A note beside Regenerate says what another try costs.",
      "purpose": "Sits under an AI draft that has not been added yet. Nothing changes in the document until the person chooses, and Regenerate and Fine-tune are kept clearly apart.",
      "useFor": [
        "A draft paragraph the AI wrote for a document.",
        "Replacing selected text with a rewrite, with Replace as the main button.",
        "Showing what another try costs when tries count against a limit."
      ],
      "notFor": [
        {
          "text": "One small edit with a before and after",
          "use": "suggestion-card"
        },
        {
          "text": "An action the assistant wants to take that needs a yes",
          "use": "approval-prompt"
        },
        {
          "text": "A suggested ending to the line being typed",
          "use": "ghost-text"
        }
      ],
      "whenToUse": [
        "Keep the draft out of the document until the person chooses Keep, Replace or Insert below.",
        "Show what another try costs when tries count against a limit.",
        "Call the box Fine-tune this and the button Regenerate, so they are not confused.",
        "Return the cursor to the document after Keep or Discard."
      ],
      "avoid": [
        "Do not add the draft just because time passed or the person clicked away.",
        "Do not call a request for a change Regenerate.",
        "Do not hide Discard. Closing the bar should never be the only way out."
      ],
      "accessibility": [
        "Status changes, such as \"Added to the document\", are announced to screen readers separately from the draft.",
        "While the draft is being written, the spinner is named \"Writing a draft\" for screen readers. The draft text itself is not read out as it changes.",
        "The Fine-tune box has a name, and its send button is turned off while the box is empty.",
        "Every action is a real button, in reading order.",
        "Keyboard focus follows the buttons as they change: to Discard while a new draft is written, to the main action when it is ready, and back to the document after Keep, Replace, Insert below or Discard (the nearest text box or editor, else the bar itself). Pass focusAfter to choose the place."
      ],
      "accessibilityYouDo": [
        "Set status to generating while a new draft is written and back to ready when it is done, so screen readers hear the right update."
      ],
      "onPhone": [
        "The buttons wrap onto new rows, and Discard moves to the right edge of its row.",
        "The Fine-tune field is 16px on touch screens, so the page does not zoom when it takes focus, The field grows to 44px tall, and the Send button keeps its size and has a 44px tap area.",
        "The long placeholder is cut off in a narrow field. The bar does not move above the on-screen keyboard, so place it where the keyboard will not cover it."
      ],
      "importPath": "import { SuggestionBar } from \"./index\"",
      "sourcePath": "src/organisms/SuggestionBar.tsx",
      "props": [
        {
          "name": "status",
          "type": "\"generating\" | \"ready\" | \"committed\" | \"discarded\"",
          "required": false,
          "default": "\"ready\"",
          "description": "Where the staged output is. The consumer owns this state."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Optional preview of the staged output."
        },
        {
          "name": "primaryLabel",
          "type": "string",
          "required": false,
          "default": "\"Keep\"",
          "description": "Label of the primary action. Use Replace over a selection."
        },
        {
          "name": "onKeep / onInsertBelow / onDiscard",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Commit, commit as a new block below, or drop the draft."
        },
        {
          "name": "onRegenerate",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Ask for a fresh sample of the same request."
        },
        {
          "name": "onRefine",
          "type": "(instruction: string) => void",
          "required": false,
          "default": null,
          "description": "Send a follow-up instruction typed in the Fine-tune field."
        },
        {
          "name": "retryCost",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Cost of a retry, shown next to Regenerate."
        },
        {
          "name": "labels",
          "type": "Partial<Record<string, string>>",
          "required": false,
          "default": null,
          "description": "Overrides for every piece of copy, including the status messages."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the container."
        }
      ],
      "variantsHint": {
        "status": [
          "generating",
          "ready",
          "committed",
          "discarded"
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--lime",
        "--on-lime",
        "--fg-subtle",
        "--radius-field",
        "--radius-card"
      ],
      "related": [
        "selection-toolbar",
        "approval-prompt",
        "rate-limit-notice"
      ],
      "composedOf": [
        "button",
        "spinner"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Try each button",
          "description": "Try each button. Regenerate and Fine-tune show a short wait. Keep and Discard finish it.",
          "code": "const [status, setStatus] = useState<SuggestionBarStatus>(\"ready\");\n\n<SuggestionBar status={status} retryCost=\"Uses 1 AI response\"\n  onKeep={() => setStatus(\"committed\")} onInsertBelow={() => setStatus(\"committed\")}\n  onDiscard={() => setStatus(\"discarded\")}\n  onRegenerate={() => setStatus(\"generating\")}\n  onRefine={(text) => setStatus(\"generating\")}>\n  Our quarterly numbers beat the forecast, driven by steady renewals and two large new accounts.\n</SuggestionBar>"
        },
        {
          "title": "Replace a selection",
          "description": "Use Replace as the main button when the draft takes the place of selected text.",
          "code": "<SuggestionBar primaryLabel=\"Replace\">A shorter version of the paragraph.</SuggestionBar>"
        },
        {
          "title": "While the draft is written",
          "description": "The buttons are hidden while the draft is written. Discard stays.",
          "code": "<SuggestionBar status=\"generating\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-suggestion-bar/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/suggestion-bar.md"
    },
    {
      "id": "ghost-text",
      "name": "GhostText",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "inline completion",
        "autocomplete text",
        "copilot suggestion"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "accept button",
          "note": "Pass onAccept to turn the key hint into a button that accepts the suggestion.",
          "source": "documented"
        },
        {
          "name": "dismiss button",
          "note": "Pass onDismiss to show a small close button beside the suggestion.",
          "source": "documented"
        },
        {
          "name": "partial hint",
          "note": "Pass partialHint to show a short note about accepting only part of the suggestion.",
          "source": "documented"
        }
      ],
      "summary": "Faded suggested text that appears after what you typed, with a Tab key hint.",
      "description": "GhostText shows a suggested ending in faded type with a dotted underline, so it looks different from real text. A key hint shows which key accepts it. The component only reports when someone accepts or dismisses. Your editor decides which keys do that and what happens to the text.",
      "purpose": "A suggested ending shown faded after what someone typed, with a hint for the key that accepts it. It looks clearly different from real text and only becomes text when accepted.",
      "useFor": [
        "Completing the sentence or line someone is typing.",
        "Offering a long suggestion that can be accepted a word at a time, with a hint that says how."
      ],
      "notFor": [
        {
          "text": "A whole paragraph or a rewrite",
          "use": "suggestion-bar"
        },
        {
          "text": "A correction to text already written",
          "use": "suggestion-card"
        },
        {
          "text": "Ideas for what to ask next",
          "use": "suggestion-chips"
        }
      ],
      "whenToUse": [
        "Make Tab, or your accept key, work from the keyboard as well as from the hint.",
        "Dismiss on Escape, and when the person keeps typing.",
        "For long suggestions, let people accept one word at a time and say how.",
        "Keep the suggestion looking different from real text."
      ],
      "avoid": [
        "Do not put the suggestion into the real text before it is accepted.",
        "Do not make screen readers announce the suggestion. It changes with every key press.",
        "Do not use it for whole paragraphs. Use SuggestionBar."
      ],
      "accessibility": [
        "Screen readers skip the faded text. A hidden description carries it instead, with the keys that accept or dismiss it.",
        "It announces nothing on its own, because the suggestion changes with every key press.",
        "When you give it actions, the key hint and the dismiss control are real buttons with names, such as \"Accept suggestion, Tab\"."
      ],
      "accessibilityYouDo": [
        "Pass an id and point the text box's aria-describedby at it, so screen readers read the hidden description.",
        "Make the accept key, usually Tab, and Escape work in your editor. The component only shows the hint."
      ],
      "onPhone": [
        "Phones have no Tab or Esc key, so pass onAccept and onDismiss. The key hint then becomes a tap target and a dismiss button appears.",
        "On touch screens both buttons keep their size and have a 44px tap area, so the line they sit in does not get taller.",
        "The hint shows the accept key as text, so set acceptKey to something a phone user understands or hide the hint."
      ],
      "importPath": "import { GhostText } from \"./index\"",
      "sourcePath": "src/atoms/GhostText.tsx",
      "props": [
        {
          "name": "children",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The suggested continuation."
        },
        {
          "name": "typed",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Text already typed, rendered in normal ink before the suggestion."
        },
        {
          "name": "id",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id of the hidden description. Point the field's aria-describedby at it."
        },
        {
          "name": "acceptKey",
          "type": "string",
          "required": false,
          "default": "\"Tab\"",
          "description": "Key shown in the hint and named in the description."
        },
        {
          "name": "dismissKey",
          "type": "string",
          "required": false,
          "default": "\"Esc\"",
          "description": "Key named in the description."
        },
        {
          "name": "partialHint",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Text for a partial accept path, such as one word or one line."
        },
        {
          "name": "description",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Replaces the default hidden description."
        },
        {
          "name": "onAccept",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called when the hint button is activated. Makes the hint a button."
        },
        {
          "name": "onDismiss",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called when the dismiss button is activated. Shows the button."
        },
        {
          "name": "acceptLabel / dismissLabel",
          "type": "string",
          "required": false,
          "default": "\"Accept suggestion\" / \"Dismiss suggestion\"",
          "description": "Accessible names of the two controls."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken",
        "--fg-subtle",
        "--border-strong",
        "--border",
        "--radius-control"
      ],
      "related": [
        "kbd",
        "suggestion-bar",
        "suggestion-card"
      ],
      "composedOf": [
        "kbd"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Accept or dismiss",
          "description": "Click the Tab hint to accept, or the close button to dismiss. In a real editor, Tab accepts and Escape dismisses.",
          "code": "<GhostText typed=\"Thanks for the update.\" partialHint=\"Ctrl + Right accepts one word\"\n  onAccept={() => accept()} onDismiss={() => dismiss()}>\n  {\" I will review the draft tomorrow morning.\"}\n</GhostText>"
        },
        {
          "title": "Display only",
          "description": "Without buttons the hint is plain text. Use it when your editor handles every key.",
          "code": "<GhostText typed=\"Dear team,\">{\" thank you for your patience.\"}</GhostText>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-ghost-text/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/ghost-text.md"
    },
    {
      "id": "selection-toolbar",
      "name": "SelectionToolbar",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "floating toolbar",
        "bubble menu",
        "text selection menu"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "more open",
          "note": "Pressing the more button, or Arrow Down on it, opens a menu of extra edits.",
          "source": "documented"
        },
        {
          "name": "empty instruction",
          "note": "The send button is disabled until something is typed in the ask box.",
          "source": "documented"
        },
        {
          "name": "keyboard",
          "note": "Left and right arrows move between controls and Escape calls onClose.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "Each preset button gets a soft background under the pointer.",
          "source": "documented"
        }
      ],
      "summary": "A small bar that appears over highlighted text, with quick edits, a menu of more, and a box to ask for your own.",
      "description": "SelectionToolbar offers quick AI edits for highlighted text. It shows ready-made buttons, a More options menu and a box to ask AI for your own edit. It does not position itself, so you place it near the highlighted text. Real apps should offer the same actions in a right-click menu and a keyboard shortcut, because a floating bar is hard to reach.",
      "purpose": "A small bar of quick AI edits for highlighted text, with a menu of more and a box to ask for your own edit. It does not position itself, so it fits into any editor.",
      "useFor": [
        "Rephrasing, shortening or changing the tone of highlighted text.",
        "Asking for a custom edit to a selection in your own words."
      ],
      "notFor": [
        {
          "text": "Showing the result of an edit before it is applied",
          "use": "suggestion-bar"
        },
        {
          "text": "Ideas for what to ask next in a chat",
          "use": "suggestion-chips"
        }
      ],
      "whenToUse": [
        "Offer every action in a right-click menu and with a keyboard shortcut too.",
        "Show the result in a SuggestionBar, so nothing changes until it is chosen.",
        "Return the cursor to the text when the bar closes.",
        "Place the bar next to the highlighted text, because it does not position itself."
      ],
      "avoid": [
        "Do not show it only when pointing.",
        "Do not apply an edit straight from a button without a preview.",
        "Do not add more than four or five quick edits. Put the rest in the menu."
      ],
      "accessibility": [
        "Screen readers know it as a named toolbar laid out side to side.",
        "Tab stops once on the bar. Left and Right arrows, Home and End move between controls. In the text box, the arrows move the cursor first and leave the box only at its ends.",
        "The More options menu opens with Down or Up arrow and lands on the first item. Escape or Tab closes it and returns to its button.",
        "Escape closes the menu first. Pressing it again asks the bar to close.",
        "The More options button, the text box and the send button all have names for screen readers."
      ],
      "accessibilityYouDo": [
        "Hide the bar and return focus to the text when onClose is called.",
        "Offer the same actions in a right-click menu and with a keyboard shortcut, because a floating bar is hard to reach."
      ],
      "onPhone": [
        "The bar wraps onto new rows. The Ask AI field is at least 192px wide and takes its own row when the chips fill the first.",
        "The More menu opens as a bottom sheet with a backdrop on phones, and its items are at least 44px tall.",
        "The ask field is 16px on touch screens, so the page does not zoom. The bar does no measuring, so place it clear of the system selection menu and the keyboard, and note that onClose only runs on Escape, which a phone does not have."
      ],
      "importPath": "import { SelectionToolbar } from \"./index\"",
      "sourcePath": "src/molecules/SelectionToolbar.tsx",
      "props": [
        {
          "name": "presets",
          "type": "SelectionAction[]",
          "required": false,
          "default": "Rephrase, Shorten, Change tone",
          "description": "Chips shown in the bar. Each has id, label and optional icon."
        },
        {
          "name": "moreActions",
          "type": "SelectionAction[]",
          "required": false,
          "default": "Elaborate, More formal, More casual, Bulletize, Summarize",
          "description": "Overflow menu items. An empty array hides the menu."
        },
        {
          "name": "onAction",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called for a preset or menu item."
        },
        {
          "name": "onAsk",
          "type": "(instruction: string) => void",
          "required": false,
          "default": null,
          "description": "Called with the typed instruction. The field clears after sending."
        },
        {
          "name": "onClose",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called on Escape when the menu is closed."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Edit selected text\"",
          "description": "Accessible name of the toolbar."
        },
        {
          "name": "moreLabel / askLabel / askPlaceholder / sendLabel",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Copy and accessible names for the menu button, the field and the send button."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the bar."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-raised",
        "--surface",
        "--surface-sunken",
        "--border",
        "--shadow-md",
        "--radius-control",
        "--radius-field",
        "--radius-overlay"
      ],
      "related": [
        "suggestion-bar",
        "suggestion-chips",
        "popover"
      ],
      "composedOf": [
        "popover",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "The standard bar",
          "description": "Tab once to enter the bar. Arrow keys move between controls. Escape closes it.",
          "code": "<SelectionToolbar onAction={(id) => run(id)} onAsk={(text) => ask(text)} onClose={() => hide()} />"
        },
        {
          "title": "Your own quick edits, no menu",
          "description": "Use your own quick edits, and drop the menu of more.",
          "code": "<SelectionToolbar presets={[{ id: \"fix\", label: \"Fix grammar\" }, { id: \"short\", label: \"Make shorter\" }]} moreActions={[]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-selection-toolbar/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/selection-toolbar.md"
    },
    {
      "id": "suggestion-card",
      "name": "SuggestionCard",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "writing suggestion",
        "edit suggestion"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "category",
          "note": "Set category to correctness, clarity, engagement or tone to change the badge colour and icon.",
          "source": "documented"
        },
        {
          "name": "removal",
          "note": "When suggestion is empty, the card shows (remove) in place of the new text.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Accept and Dismiss show a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "One writing suggestion with its type, the before and after, a reason, and Accept and Dismiss buttons.",
      "description": "SuggestionCard shows one suggestion to improve your writing. A label with an icon and a word names the kind of change. Your original text is crossed out next to the suggested text, and a short reason says why. Accept or Dismiss ends it. The types are Correctness, Clarity, Engagement and Tone, and you can swap the label and icon.",
      "purpose": "Shows one writing suggestion with its type, the original crossed out next to the new text, and a short reason. One card per suggestion keeps each decision small.",
      "useFor": [
        "A spelling, grammar, clarity or tone fix for a phrase.",
        "A suggestion opened from an underlined phrase in the text."
      ],
      "notFor": [
        {
          "text": "A whole AI draft",
          "use": "suggestion-bar"
        },
        {
          "text": "A suggested ending to the line being typed",
          "use": "ghost-text"
        }
      ],
      "whenToUse": [
        "Show one suggestion per card.",
        "Keep the explanation to a sentence.",
        "When you add a type, give it both a label and an icon.",
        "After Accept or Dismiss, move the cursor to the next suggestion or back to the text."
      ],
      "avoid": [
        "Do not tell types apart by color alone.",
        "Do not accept just because someone pointed at it or tabbed to it.",
        "Do not use it for a whole draft. Use SuggestionBar."
      ],
      "accessibility": [
        "The card is named by its type, such as \"Clarity suggestion\".",
        "The type is a word plus an icon. Color is only extra.",
        "Hidden words say \"Original\" and \"Suggested\" before each text, because screen readers may not mention crossed-out text.",
        "Accept and Dismiss are real buttons.",
        "After Accept or Dismiss, keyboard focus moves to the next suggestion, else the previous one, else the text. Pass focusAfter to choose another place."
      ],
      "accessibilityYouDo": [
        "When you add your own type, give it both a categoryLabel and a categoryIcon, so it never relies on color."
      ],
      "onPhone": [
        "The card fills the width up to 384px, so it fills a phone and does not grow on a tablet.",
        "Accept and Dismiss keep their size and have a 44px tap area on touch screens.",
        "The original and the suggestion wrap as one paragraph."
      ],
      "importPath": "import { SuggestionCard } from \"./index\"",
      "sourcePath": "src/molecules/SuggestionCard.tsx",
      "props": [
        {
          "name": "category",
          "type": "\"correctness\" | \"clarity\" | \"engagement\" | \"tone\"",
          "required": false,
          "default": "\"correctness\"",
          "description": "Picks the default label, icon and badge tone."
        },
        {
          "name": "categoryLabel",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Replaces the category text, for document-specific categories."
        },
        {
          "name": "categoryIcon",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Replaces the category icon."
        },
        {
          "name": "original",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Text as written, shown struck through."
        },
        {
          "name": "suggestion",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Proposed replacement. An empty string shows (remove)."
        },
        {
          "name": "explanation",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Short reason for the change."
        },
        {
          "name": "onAccept / onDismiss",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Resolve the suggestion."
        },
        {
          "name": "acceptLabel / dismissLabel",
          "type": "string",
          "required": false,
          "default": "\"Accept\" / \"Dismiss\"",
          "description": "Button text."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the card."
        }
      ],
      "variantsHint": {
        "category": [
          "correctness",
          "clarity",
          "engagement",
          "tone"
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--lime",
        "--danger",
        "--fg-muted",
        "--radius-card"
      ],
      "related": [
        "suggestion-bar",
        "ghost-text",
        "confidence-indicator"
      ],
      "composedOf": [
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Try it",
          "description": "Accept or dismiss to see the result. The card shows one suggestion, like when you click an underlined phrase.",
          "code": "<SuggestionCard category=\"clarity\" original=\"in order to be able to\" suggestion=\"to\"\n  explanation=\"Shorter phrasing says the same thing.\"\n  onAccept={() => accept()} onDismiss={() => dismiss()} />"
        },
        {
          "title": "The types",
          "description": "Each type has its own icon and word, so none depends on color.",
          "code": "<div className=\"grid gap-3 sm:grid-cols-2\">\n  <SuggestionCard category=\"correctness\" original=\"their going\" suggestion=\"they're going\" explanation=\"Use the contraction of they are.\" />\n  <SuggestionCard category=\"tone\" original=\"Gimme the file\" suggestion=\"Could you send the file?\" explanation=\"This reads as more polite.\" />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-suggestion-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/suggestion-card.md"
    },
    {
      "id": "summary-card",
      "name": "SummaryCard",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "ai summary",
        "overview card",
        "tldr"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "streaming",
          "note": "Set with the generating prop.",
          "source": "prop"
        }
      ],
      "summary": "An AI-written overview with its sources, a warning to double-check, and a way to give feedback.",
      "description": "SummaryCard holds an AI-written overview. The top has an AI label, a title and a status line. Below are the summary, optional extra detail, the list of sources, a note to double-check, and a way to give feedback. Put citation numbers inside the summary so each fact links to its source.",
      "purpose": "Holds an AI-written overview together with what people need to judge it: an AI label, what it is based on, its sources, a note to double-check and a way to give feedback.",
      "useFor": [
        "A summary of a long thread, document or set of search results.",
        "An overview at the top of results, with sources and citations.",
        "A summary still being written, with a status line and a spinner."
      ],
      "notFor": [
        {
          "text": "The closing point of a single answer",
          "use": "key-takeaway"
        },
        {
          "text": "A normal assistant reply in a chat",
          "use": "message"
        },
        {
          "text": "A list of sources on its own",
          "use": "source-list"
        }
      ],
      "whenToUse": [
        "Say in the status line what the summary is based on.",
        "Put the note to double-check under the content, and make it specific for sensitive topics.",
        "Put citations in the text, so each fact can be checked.",
        "Keep the first view short. Put the depth behind Show more."
      ],
      "avoid": [
        "Do not make screen readers read the summary as it appears. Update the status line instead.",
        "Do not remove the AI label.",
        "Do not leave out sources when the summary states facts."
      ],
      "accessibility": [
        "The card is a section named by its title, and the AI label is written in words.",
        "The status line is announced when it changes. The summary text is not.",
        "Show more is a button that tells screen readers whether the detail is open. The detail is hidden when closed.",
        "Sources are a named, numbered list of links."
      ],
      "accessibilityYouDo": [
        "Report progress by changing the status line, not the summary. Do not make screen readers read the summary as it arrives.",
        "Put citation numbers inside the summary, so each fact can be checked by keyboard."
      ],
      "onPhone": [
        "The card fills the width with 20px padding. The title row wraps, and the status moves to its own line if it does not fit.",
        "The Show more button, the feedback buttons and the reasons keep their size and have a 44px tap area on touch screens.",
        "Sources stack in one column below 640px wide. Citation markers inside the text follow the Citation hover card rules."
      ],
      "importPath": "import { SummaryCard } from \"./index\"",
      "sourcePath": "src/organisms/SummaryCard.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Overview\"",
          "description": "Heading and accessible name of the section."
        },
        {
          "name": "aiLabel",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Replaces the text of the AI label."
        },
        {
          "name": "status",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Status line in the header. It is a polite status region."
        },
        {
          "name": "generating",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Shows a spinner beside the status."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The summary."
        },
        {
          "name": "detail",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Expandable detail. Omit to hide the control."
        },
        {
          "name": "expandLabel / collapseLabel",
          "type": "string",
          "required": false,
          "default": "\"Show more\" / \"Show less\"",
          "description": "Text of the expand control."
        },
        {
          "name": "defaultExpanded / expanded / onExpandedChange",
          "type": "boolean / boolean / (v: boolean) => void",
          "required": false,
          "default": "false",
          "description": "Uncontrolled or controlled expansion."
        },
        {
          "name": "sources",
          "type": "Source[]",
          "required": false,
          "default": null,
          "description": "Source cards shown in a numbered list."
        },
        {
          "name": "disclaimer",
          "type": "ReactNode",
          "required": false,
          "default": "\"AI responses may include mistakes. Check important information.\"",
          "description": "Disclaimer line. Pass null to remove it."
        },
        {
          "name": "onFeedback",
          "type": "(rating, reason?) => void",
          "required": false,
          "default": null,
          "description": "Shows the feedback row when provided."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the section."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--lime",
        "--fg-muted",
        "--fg-subtle",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "citation-hover-card",
        "source-list",
        "feedback-bar",
        "provenance-popover"
      ],
      "composedOf": [
        "ai-badge",
        "spinner",
        "feedback-bar",
        "source-list",
        "citation-hover-card"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With citations and detail",
          "description": "The numbers open source previews. Show more reveals the detail.",
          "code": "<SummaryCard title=\"Overview\" status=\"Based on 2 sources\"\n  detail=\"Wind power grew faster than solar, and battery storage doubled.\"\n  sources={[\n    { id: 1, title: \"Annual energy review 2025\", url: \"https://example.com\", domain: \"energy.example.gov\", snippet: \"Renewables supplied 24 percent of generation.\" },\n    { id: 2, title: \"Grid storage outlook\", url: \"https://example.com\", domain: \"journal.example.edu\" },\n  ]}\n  onFeedback={() => {}}>\n  Renewables supplied about a quarter of national generation last year.\n  <CitationHoverCard n={1} title=\"Annual energy review 2025\" domain=\"energy.example.gov\" url=\"https://example.com\" trust=\"Government\" />\n</SummaryCard>"
        },
        {
          "title": "While the draft is written",
          "description": "A spinner beside the status line shows work in progress. Screen readers announce the status words.",
          "code": "<SummaryCard status=\"Reading the sources\" generating disclaimer={null}>Writing a summary of the conversation.</SummaryCard>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-summary-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/summary-card.md"
    },
    {
      "id": "parameter-panel",
      "name": "ParameterPanel",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "settings panel",
        "generation settings",
        "controls panel"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "loading",
          "note": "Set with the busy prop.",
          "source": "prop"
        }
      ],
      "summary": "A set of choices for making something with AI, with a live cost and a Generate button.",
      "description": "ParameterPanel shows one control for each setting: a choice between a few options, shape buttons, style buttons, a number, or a repeat code with a Fixed switch. When you give it a cost, the cost under the controls updates as settings change, and screen readers hear it. Put it next to where people type their request, so the cost is clear before anything is made.",
      "purpose": "Gathers the settings for one thing being made, with the cost shown above Generate. People can change settings while they can still see what it will cost.",
      "useFor": [
        "Settings for one image, video or other result, next to where the request is typed.",
        "Showing a cost that updates as the settings change.",
        "Letting people fix a repeat code so they can get similar results again."
      ],
      "notFor": [
        {
          "text": "Settings that apply to the whole app"
        },
        {
          "text": "Actions on a result that already exists",
          "use": "variation-actions"
        },
        {
          "text": "Choosing which AI model to use",
          "use": "model-picker"
        }
      ],
      "whenToUse": [
        "Show the cost before making anything, so people can change settings first.",
        "Keep the list short. Put the settings people change most at the top.",
        "Add a hint on the repeat code that says what it does and does not promise.",
        "Work out the cost from the same settings that Generate uses.",
        "Set busy while the result is being made, so Generate turns off and shows it is working."
      ],
      "avoid": [
        "Do not use amber for the cost. Cost is just information until it blocks something.",
        "Do not bury the Generate button below a long list of settings.",
        "Do not keep the settings inside the panel. Your app owns them.",
        "Do not use it for settings that apply to the whole app. It is for one creation."
      ],
      "accessibility": [
        "The panel is a form named by its title, and Generate submits it, so Enter in a number box generates.",
        "Each group of options is named by its label. Arrow keys move and choose, and Tab stops only on the chosen option.",
        "Shape options say the ratio in words. The small drawing beside them is hidden from screen readers.",
        "The Fixed switch and the number boxes are named from their labels.",
        "Screen readers hear the new cost when it changes, without losing their place."
      ],
      "accessibilityYouDo": [
        "Pass estimateCost when there is a cost, so screen readers hear it change.",
        "Write labels that make sense on their own. Each control is named from its label, and the hint is not read with it."
      ],
      "onPhone": [
        "The panel fills the width, and the option chips wrap onto new lines.",
        "Switches and number boxes grow to 44px tall on a touch screen, and the number boxes are 16px so the page does not zoom. Chips keep their size and have a 44px tap area.",
        "Option descriptions are set as a title only, which a finger cannot show, so put anything people must read in the label or hint.",
        "The cost line and Generate button stay on one row at the bottom."
      ],
      "importPath": "import { ParameterPanel } from \"./index\"",
      "sourcePath": "src/organisms/ParameterPanel.tsx",
      "props": [
        {
          "name": "parameters",
          "type": "ParameterSpec[]",
          "required": true,
          "default": null,
          "description": "Schema in display order. Each entry has an id, a label, an optional hint and a kind of segmented, aspect, presets, number or seed."
        },
        {
          "name": "values",
          "type": "Record<string, string | number | boolean | null>",
          "required": true,
          "default": null,
          "description": "Current value for each parameter id. A seed is a number when Fixed is on and null when it is off."
        },
        {
          "name": "onChange",
          "type": "(values: ParameterValues) => void",
          "required": true,
          "default": null,
          "description": "Called with a new values object whenever one control changes."
        },
        {
          "name": "onGenerate",
          "type": "(values: ParameterValues) => void",
          "required": true,
          "default": null,
          "description": "Called with the current values when the form is submitted. Not called while busy."
        },
        {
          "name": "estimateCost",
          "type": "(values: ParameterValues) => number",
          "required": false,
          "default": null,
          "description": "Returns the cost for the current values. Without it the readout is empty."
        },
        {
          "name": "formatCost",
          "type": "(cost: number) => string",
          "required": false,
          "default": "`About ${cost} credits`",
          "description": "Formats the readout text."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Settings\"",
          "description": "Heading, also the accessible name of the form."
        },
        {
          "name": "generateLabel",
          "type": "string",
          "required": false,
          "default": "\"Generate\"",
          "description": "Text on the submit button."
        },
        {
          "name": "busyLabel",
          "type": "string",
          "required": false,
          "default": "\"Generating…\"",
          "description": "Button text while busy."
        },
        {
          "name": "busy",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Disables Generate and shows a spinner."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the form, for width."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--lime",
        "--on-lime",
        "--fg-muted",
        "--shadow-sm",
        "--radius-control",
        "--radius-field",
        "--radius-card"
      ],
      "related": [
        "prompt-box",
        "segmented-control",
        "variation-actions"
      ],
      "composedOf": [
        "segmented-control",
        "switch",
        "button",
        "spinner"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Video settings with cost",
          "description": "Length, shape and a repeat code. The cost changes when the length changes.",
          "code": "const params: ParameterSpec[] = [\n  { kind: \"segmented\", id: \"duration\", label: \"Duration\", options: [{ value: \"5\", label: \"5 s\" }, { value: \"10\", label: \"10 s\" }] },\n  { kind: \"aspect\", id: \"ratio\", label: \"Shape\", options: [\"16:9\", \"9:16\", \"1:1\", \"4:3\"].map((r) => ({ value: r, label: r })) },\n  { kind: \"seed\", id: \"seed\", label: \"Repeat code\", defaultSeed: 4821 },\n];\nconst [values, setValues] = useState({ duration: \"5\", ratio: \"16:9\", seed: null });\n\n<ParameterPanel parameters={params} values={values} onChange={setValues}\n  onGenerate={() => {}} estimateCost={(v) => Number(v.duration) * 12} />"
        },
        {
          "title": "Style buttons, a number and a busy state",
          "description": "Custom cost text and button text. While busy, the button is off and shows a spinner.",
          "code": "const params: ParameterSpec[] = [\n  { kind: \"presets\", id: \"style\", label: \"Style\", options: [{ value: \"photo\", label: \"Photo\" }, { value: \"ink\", label: \"Ink\" }, { value: \"paper\", label: \"Paper cut\" }] },\n  { kind: \"number\", id: \"count\", label: \"Images\", min: 1, max: 4, step: 1, unit: \"images\" },\n];\nconst [values, setValues] = useState({ style: \"photo\", count: 2 });\n\n<ParameterPanel title=\"Image settings\" parameters={params} values={values} onChange={setValues}\n  onGenerate={() => {}} estimateCost={(v) => Number(v.count) * 4}\n  formatCost={(c) => `${c} credits for this run`} generateLabel=\"Make images\" busy />"
        },
        {
          "title": "One setting, no cost",
          "description": "With no cost given, that space stays empty. The repeat code starts fixed here, so the number shows.",
          "code": "const [values, setValues] = useState({ seed: 1234 });\n\n<ParameterPanel title=\"Repeat results\"\n  parameters={[{ kind: \"seed\", id: \"seed\", label: \"Repeat code\", hint: \"Similar, not identical\" }]}\n  values={values} onChange={setValues} onGenerate={() => {}} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-parameter-panel/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/parameter-panel.md"
    },
    {
      "id": "variation-actions",
      "name": "VariationActions",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "more like this",
        "upscale",
        "variation buttons"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "disabled",
          "note": "Set with the disabled prop.",
          "source": "prop"
        }
      ],
      "summary": "A row of things you can do with one AI result, each with a Subtle or Strong choice, and a line saying where it came from.",
      "description": "VariationActions lists things to do with one result, such as Vary, Upscale, Remix and Extend. Each has its own strength choice. A line says which result this one came from, and each action can show what it costs. You can change the list, and drop the strength choice where it is not needed.",
      "purpose": "A row of things to do with one result, such as Vary or Upscale. Each action keeps its own strength choice and shows its cost beside it, so nothing is charged by surprise.",
      "useFor": [
        "Under a generated image or clip, to make a new take from it.",
        "Showing what each action costs before it runs.",
        "Saying which result this one came from, with the lineage line."
      ],
      "notFor": [
        {
          "text": "Settings for something not made yet",
          "use": "parameter-panel"
        },
        {
          "text": "Copy, retry and feedback on a chat reply",
          "use": "message-actions"
        },
        {
          "text": "Choosing between several results side by side",
          "use": "variant-grid"
        }
      ],
      "whenToUse": [
        "Say which result this one came from.",
        "Show what each action costs, if it costs anything.",
        "Keep the strength choice next to the action it changes.",
        "Hide the strength choice for actions that do not have one."
      ],
      "avoid": [
        "Do not run an action when someone changes strength. Only the action button does that.",
        "Do not put more than a handful of actions in one row.",
        "Do not hide costs and charge afterward.",
        "Do not use the \"came from\" line as decoration. Leave it out when the result has no source."
      ],
      "accessibility": [
        "Each strength choice is named after its action, such as \"Vary strength\". Arrow keys move and choose.",
        "Actions are real buttons with visible words.",
        "Each action keeps its own strength, so changing one never changes another.",
        "Changing a strength never runs an action. Only the action button does."
      ],
      "accessibilityYouDo": [
        "Write each cost in words, such as \"About 4 credits\", so it reads clearly aloud.",
        "Tell people when an action starts and when it is done. The row does not announce anything."
      ],
      "onPhone": [
        "Each action is its own row: the button, its strength switch and its cost wrap onto new lines when the row is full.",
        "Buttons and switch options keep their size and have a 44px tap area on a touch screen."
      ],
      "importPath": "import { VariationActions } from \"./index\"",
      "sourcePath": "src/molecules/VariationActions.tsx",
      "props": [
        {
          "name": "actions",
          "type": "VariationAction[]",
          "required": false,
          "default": "Vary, Upscale, Remix, Extend",
          "description": "Each action has an id, a label, an optional cost node and an optional strength flag. Set strength to false to hide the choice."
        },
        {
          "name": "onAction",
          "type": "(actionId: string, strength: string) => void",
          "required": true,
          "default": null,
          "description": "Called when an action button is pressed. Strength is an empty string for actions with no strength choice."
        },
        {
          "name": "strengths",
          "type": "SegmentOption<string>[]",
          "required": false,
          "default": "Subtle, Strong",
          "description": "Strength choices shared by all actions that have one."
        },
        {
          "name": "defaultStrength",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Strength each action starts on. Defaults to the first strength."
        },
        {
          "name": "lineage",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Line above the actions, for example Made from Variant B."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Disables the action buttons."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg-muted",
        "--fg-subtle",
        "--border",
        "--surface-sunken"
      ],
      "related": [
        "segmented-control",
        "variant-grid",
        "parameter-panel"
      ],
      "composedOf": [
        "button",
        "segmented-control"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Standard actions, with where it came from",
          "description": "The standard list is Vary, Upscale, Remix and Extend. Extend has no strength choice.",
          "code": "<VariationActions lineage=\"Made from Variant B\" onAction={(id, strength) => {}} />"
        },
        {
          "title": "Showing costs",
          "description": "Each action can show a cost. Use it to say what the action will spend.",
          "code": "<VariationActions\n  lineage=\"Made from Variant B\"\n  onAction={() => {}}\n  actions={[\n    { id: \"vary\", label: \"Vary\", cost: \"About 4 credits\" },\n    { id: \"upscale\", label: \"Upscale\", cost: \"About 8 credits\" },\n    { id: \"remix\", label: \"Remix\", cost: \"About 4 credits\" },\n  ]}\n/>"
        },
        {
          "title": "Your own strengths, switched off",
          "description": "Your own strength choices, starting on the second one. Disabled turns the buttons off.",
          "code": "<VariationActions\n  onAction={() => {}}\n  strengths={[{ value: \"low\", label: \"Low\" }, { value: \"mid\", label: \"Medium\" }, { value: \"high\", label: \"High\" }]}\n  defaultStrength=\"mid\"\n  actions={[{ id: \"vary\", label: \"Vary\" }]}\n  disabled\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-variation-actions/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/variation-actions.md"
    },
    {
      "id": "version-history",
      "name": "VersionHistory",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "history",
        "revisions",
        "versions"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "current",
          "note": "The version matching currentId (or the first one) shows a Current badge and has no Preview or Restore buttons.",
          "source": "documented"
        },
        {
          "name": "previewing",
          "note": "Pressing Preview marks that row, and shows a bar above the list with Exit preview and Restore.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "A row that is not being previewed gets a soft background under the pointer.",
          "source": "documented"
        }
      ],
      "summary": "A list of earlier versions of your work, with who made each, when, and Preview and Restore buttons.",
      "description": "VersionHistory lists versions with the newest first. The oldest is Version 1. Each row shows who made it (AI or a person), when, and an optional short note. Preview shows an older version with a banner and an Exit preview button. Preview does not create a new version, and the list says so. Restore brings that version back.",
      "purpose": "Lists earlier versions of a piece of work, newest first, with who made each one. Preview and Restore are separate, so people can look without changing anything.",
      "useFor": [
        "A document, design or page that both the AI and people edit.",
        "Looking at an older version before deciding to bring it back.",
        "Telling AI edits apart from people's edits."
      ],
      "notFor": [
        {
          "text": "Versions of one chat message",
          "use": "version-pager"
        },
        {
          "text": "Going back to an earlier step of an agent's work",
          "use": "checkpoint-list"
        },
        {
          "text": "Undoing one AI edit to a short text",
          "use": "revert-toggle"
        }
      ],
      "whenToUse": [
        "Mark the current version in words, with no buttons on it.",
        "Say that Preview does not create a version, so people feel safe trying it.",
        "Show who made each version, so AI edits and human edits are easy to tell apart.",
        "Show the previewed version in your own screen. The list only tells you which one through onPreviewChange."
      ],
      "avoid": [
        "Do not restore when someone only previews. They are different actions.",
        "Do not put the oldest version first. The numbering expects newest first.",
        "Do not use amber on the preview banner. Nothing is waiting on the person.",
        "Do not hide the banner. People need a clear way out of a preview."
      ],
      "accessibility": [
        "Versions are a numbered list, and the current one is marked for screen readers.",
        "Preview and Restore buttons include the version name, such as \"Preview Version 3\".",
        "Each Preview button tells screen readers whether it is on.",
        "Starting a preview is announced as \"Previewing\" and the version name.",
        "Who made each version and which one is current are said in words, not color.",
        "After a restore, keyboard focus moves to the restored version and screen readers hear \"Restored\" and its name. Leaving a preview from the bar returns focus to that version's Preview button."
      ],
      "accessibilityYouDo": [
        "Keep version names and summaries short, so the button names stay easy to hear.",
        "Set restoredMessage when you translate."
      ],
      "onPhone": [
        "Each version is a row whose text has a 192px minimum width, so Preview and Restore wrap below the text on a narrow screen.",
        "Preview and Restore keep their size and have a 44px tap area on a touch screen.",
        "The preview bar at the top wraps, with its buttons under the text when needed."
      ],
      "importPath": "import { VersionHistory } from \"./index\"",
      "sourcePath": "src/organisms/VersionHistory.tsx",
      "props": [
        {
          "name": "versions",
          "type": "VersionEntry[]",
          "required": true,
          "default": null,
          "description": "Newest first. Each entry has an id, time, author (ai or person) and optional name, authorName and summary. A missing name becomes Version N."
        },
        {
          "name": "currentId",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id of the current version. Defaults to the first entry."
        },
        {
          "name": "previewId",
          "type": "string | null",
          "required": false,
          "default": null,
          "description": "Controlled preview. Omit to let the component manage it."
        },
        {
          "name": "onPreviewChange",
          "type": "(id: string | null) => void",
          "required": false,
          "default": null,
          "description": "Called when a preview starts or exits."
        },
        {
          "name": "onRestore",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Called when Restore is pressed on a row or in the banner."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Version history\"",
          "description": "Heading and accessible name of the section."
        },
        {
          "name": "previewNote",
          "type": "string",
          "required": false,
          "default": "\"Preview does not create a new version.\"",
          "description": "Note under the list and in the preview banner."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the section."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--surface-hover",
        "--border",
        "--lime (via Badge)",
        "--radius-card"
      ],
      "related": [
        "canvas-panel",
        "badge",
        "variation-actions"
      ],
      "composedOf": [
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "List with preview",
          "description": "Press Preview on any older version. The banner offers Exit preview and Restore. Restore makes that version the current one.",
          "code": "const [previewId, setPreviewId] = useState<string | null>(null);\nconst [currentId, setCurrentId] = useState(versions[0].id);\n\n<VersionHistory versions={versions} currentId={currentId} previewId={previewId}\n  onPreviewChange={setPreviewId} onRestore={(id) => { setCurrentId(id); setPreviewId(null); }} />"
        },
        {
          "title": "Already previewing",
          "description": "The same list already showing a preview, as when someone arrives from a link.",
          "code": "const [previewId, setPreviewId] = useState<string | null>(\"v2\");\nconst [currentId, setCurrentId] = useState(versions[0].id);\n\n<VersionHistory versions={versions} currentId={currentId} previewId={previewId}\n  onPreviewChange={setPreviewId} onRestore={(id) => { setCurrentId(id); setPreviewId(null); }} />"
        },
        {
          "title": "Your own names",
          "description": "The list handles previews itself. A name you give replaces the automatic one.",
          "code": "<div className=\"max-w-md\">\n  <VersionHistory\n    title=\"Edits\"\n    versions={[\n      { id: \"b\", name: \"Before the rewrite\", time: \"1 h ago\", author: \"person\", authorName: \"Maya\" },\n      { id: \"a\", name: \"Original\", time: \"Yesterday\", author: \"ai\" },\n    ]}\n    onRestore={() => {}}\n  />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-version-history/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/version-history.md"
    },
    {
      "id": "generation-progress",
      "name": "GenerationProgress",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "progress",
        "generating",
        "render progress",
        "progress bar"
      ],
      "category": "creating",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "error",
          "note": "Set with the error prop.",
          "source": "prop"
        }
      ],
      "summary": "Shows how a slow AI task is going: waiting, making it, ready to listen early, finishing, done, failed or cancelled.",
      "description": "GenerationProgress shows where a slow task stands. While it works, it shows a bar with a percent, or a moving bar if the percent is not known. A gray placeholder holds the space for the result. It shows the time left and a Cancel button while work is under way, and swaps in the result when done. For audio, the placeholder is a flat waveform with a label, a streaming status lets people listen before the end is made, and a note can say what cancelling does to their credits. Screen readers hear the status only when it changes, never every percent.",
      "purpose": "Shows where a slow task stands, from waiting to done or failed. A placeholder the same shape as the result holds its place, so the page does not jump when it arrives.",
      "useFor": [
        "Making an image, video or file that takes more than a few seconds.",
        "Waiting in line before the work starts: show the queue position.",
        "A task that can fail and be tried again.",
        "A song, voice-over or sound effect: use previewShape=\"audio\", and streaming when the start can be played early."
      ],
      "notFor": [
        {
          "text": "A short wait for a chat reply",
          "use": "typing-indicator"
        },
        {
          "text": "A list of steps an agent is working through",
          "use": "checklist"
        },
        {
          "text": "An amount used out of a limit",
          "use": "meter"
        }
      ],
      "whenToUse": [
        "Make the result area the same shape as the result, so the page does not jump.",
        "Offer Cancel for anything that takes more than a few seconds.",
        "Say why it failed.",
        "Pass percent only when you know the real progress. Without it, a moving bar shows instead.",
        "For audio, use previewShape=\"audio\" and say what is coming in previewLabel, such as \"Song, about 2 minutes\".",
        "Say what cancelling does to credits with refundNote, when you know it."
      ],
      "avoid": [
        "Do not fake a percent. Use the moving bar instead.",
        "Do not announce every percent change. This component already avoids that.",
        "Do not use amber for waiting in line. Waiting is not something the person must act on.",
        "Do not remove Cancel once the task has started.",
        "Do not autoplay streaming audio. Offer Listen now and let the person press it.",
        "Do not draw a fake waveform in the placeholder. The flat bars say the sound is not there yet."
      ],
      "accessibility": [
        "Screen readers hear the status only when it changes, such as \"Generating\" or \"Done\", never each percent.",
        "The visible status line is hidden from screen readers, so nothing is said twice.",
        "The percent bar is a meter named \"Generation progress\".",
        "A failure shows an icon and the words \"Generation failed\", not color alone.",
        "The placeholder is hidden from screen readers, and its shimmer stops when people turn off motion in their system settings.",
        "The audio placeholder's label is plain text; its flat bars are hidden from screen readers.",
        "Cancelled shows an icon and the word Cancelled, and the announcement includes the credits note.",
        "Listen now, Cancel, Retry and Start again are 44px tall on a touch screen."
      ],
      "accessibilityYouDo": [
        "Pass error with a reason, so screen readers hear why it failed.",
        "Give the finished result its own text alternative.",
        "Pass refundNote only when your product knows whether cancelling returns credits. Never guess.",
        "Listen now must not start playback by itself somewhere off screen. Move focus to the player you show."
      ],
      "onPhone": [
        "The preview area and the status line fill the width; the status text, time left and action wrap onto new lines.",
        "The preview area takes its height from the class you pass, so size it for a narrow screen.",
        "Action buttons are 44px tall on a touch screen and wrap onto a new line when space is short.",
        "The audio placeholder fills the width at a fixed short height, so it does not take a 16:9 block on a phone."
      ],
      "importPath": "import { GenerationProgress } from \"./index\"",
      "sourcePath": "src/molecules/GenerationProgress.tsx",
      "props": [
        {
          "name": "status",
          "type": "\"queued\" | \"generating\" | \"streaming\" | \"finalising\" | \"done\" | \"failed\" | \"cancelled\"",
          "required": true,
          "default": null,
          "description": "Current state. streaming: the start can already be played while the rest is made. cancelled: the person stopped it."
        },
        {
          "name": "percent",
          "type": "number",
          "required": false,
          "default": null,
          "description": "0 to 100. A bar shows while generating or streaming. Omit for an indeterminate bar."
        },
        {
          "name": "queuePosition",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Shown beside Queued."
        },
        {
          "name": "eta",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Time estimate, shown while queued, generating, streaming or finalising."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Message shown, and announced, when failed."
        },
        {
          "name": "onCancel",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Cancel while queued, generating, streaming or finalising."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Retry when failed, and Start again when cancelled."
        },
        {
          "name": "onListen",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Listen now while streaming. Start playback only from this press."
        },
        {
          "name": "listenLabel",
          "type": "string",
          "required": false,
          "default": "\"Listen now\"",
          "description": "Text for the Listen now button."
        },
        {
          "name": "preview",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Result shown when done, and while streaming if you pass a player that can play early. A placeholder fills the area before then."
        },
        {
          "name": "previewShape",
          "type": "\"media\" | \"audio\"",
          "required": false,
          "default": "\"media\"",
          "description": "audio swaps the 16:9 block for a flat waveform with a label."
        },
        {
          "name": "previewLabel",
          "type": "string",
          "required": false,
          "default": "\"The audio will appear here\"",
          "description": "Words in the audio placeholder, such as \"Song, about 2 minutes\"."
        },
        {
          "name": "previewClassName",
          "type": "string",
          "required": false,
          "default": "\"aspect-video\" (media), none (audio)",
          "description": "Classes for the preview area, to match the result shape."
        },
        {
          "name": "refundNote",
          "type": "string",
          "required": false,
          "default": null,
          "description": "What cancelling does to credits. Shown next to Cancel while work runs and after cancelling, and read out with Cancelled."
        },
        {
          "name": "labels",
          "type": "Partial<Record<GenerationStatus, string>>",
          "required": false,
          "default": null,
          "description": "Replaces the text for a status. Also used for the announcement."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the wrapper."
        }
      ],
      "variantsHint": {
        "status": [
          "queued",
          "generating",
          "streaming",
          "finalising",
          "done",
          "failed",
          "cancelled"
        ],
        "previewShape": [
          "media",
          "audio"
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--fg-subtle",
        "--danger-fg",
        "--radius-card"
      ],
      "related": [
        "meter",
        "skeleton",
        "variant-grid"
      ],
      "composedOf": [
        "meter",
        "skeleton",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Try each state",
          "description": "Switch the state to see each one. The percent bar shows only while making it, with a percent.",
          "code": "<GenerationProgress status=\"generating\" percent={42} eta=\"About 30 seconds left\"\n  onCancel={() => {}} />"
        },
        {
          "title": "Progress not known",
          "description": "Leave out the percent when progress is not known.",
          "code": "<GenerationProgress status=\"generating\" eta=\"This can take a few minutes\" onCancel={() => {}} />"
        },
        {
          "title": "Waiting in line",
          "description": "Shows your place in line and no bar.",
          "code": "<GenerationProgress status=\"queued\" queuePosition={3} onCancel={() => {}} />"
        },
        {
          "title": "Failed",
          "description": "Failed shows the message in words and offers Retry.",
          "code": "<GenerationProgress status=\"failed\" error=\"Something went wrong. Please try again.\" onRetry={() => {}} />"
        },
        {
          "title": "Audio: listen before it is finished",
          "description": "Streaming means the start can be played while the rest is still being made. Listen now never plays on its own; the person presses it. The credits note sits next to Cancel.",
          "code": "<GenerationProgress status=\"streaming\" percent={64} eta=\"About 40 seconds left\" previewShape=\"audio\"\n  previewLabel=\"Song, about 2 minutes\" onListen={() => {}} onCancel={() => {}}\n  refundNote=\"Credits for started work are not returned.\" />"
        },
        {
          "title": "Audio: queued and cancelled",
          "description": "previewShape audio swaps the 16:9 block for a flat waveform with a label. After Cancel the note says what happened to the credits, and Start again is offered.",
          "code": "<GenerationProgress status=\"queued\" queuePosition={2} eta=\"A couple of minutes\" previewShape=\"audio\"\n  previewLabel=\"Voice-over, about 45 seconds\" onCancel={() => {}} refundNote=\"Cancelling now returns your 40 credits.\" />\n<GenerationProgress status=\"cancelled\" previewShape=\"audio\" onRetry={() => {}}\n  refundNote=\"Your 40 credits were returned.\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-generation-progress/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/generation-progress.md"
    },
    {
      "id": "starter-gallery",
      "name": "StarterGallery",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "prompt gallery",
        "templates",
        "examples gallery"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "no templates",
          "note": "Without templates, the templates section and its heading are left out.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "A template card gets a stronger border and a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Intent buttons and template cards show a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "A first screen that says what the assistant can do, and offers ideas to start with.",
      "description": "StarterGallery is for when nothing has been started yet. It opens with one sentence on what the assistant can and cannot do. Then it offers a few ideas as buttons, then template cards with a title, a short note and an optional category. A Shuffle button can sit beside the templates heading. Every control is a real button you can reach with Tab.",
      "purpose": "The first screen, before anything exists. It says plainly what the assistant can and cannot do, then offers ideas and templates so people do not face an empty box.",
      "useFor": [
        "A first visit, or any time nothing has been made yet.",
        "Offering three to six ideas next to the box where people type.",
        "A set of templates people can start from."
      ],
      "notFor": [
        {
          "text": "Ideas shown under a reply, partway through a chat",
          "use": "suggestion-chips"
        },
        {
          "text": "A list or search that has nothing to show",
          "use": "empty-state"
        }
      ],
      "whenToUse": [
        "Say what the assistant cannot do as well as what it can.",
        "Offer three to six starting ideas next to the box where people type.",
        "Write template notes that say what the person gets.",
        "Show it on the first screen and when nothing exists yet, not everywhere."
      ],
      "avoid": [
        "Do not list more than six ideas. Move the rest into templates.",
        "Do not use the opening sentence for advertising.",
        "Do not use amber on cards. Nothing here needs action.",
        "Do not hide the box where people type behind the gallery."
      ],
      "accessibility": [
        "The section is named by its heading.",
        "Ideas and templates are lists, so screen readers say how many there are.",
        "Ideas and template cards are real buttons, reached with Tab and used with Enter or Space.",
        "A template card is read as its category, then its title, then its note.",
        "When the templates change, for example after Shuffle, screen readers hear it.",
        "If the gallery goes away after an idea or template is chosen, keyboard focus moves to the nearest message box. Pass focusAfterChoose to choose another place."
      ],
      "accessibilityYouDo": [
        "Give the Shuffle button a clear name, such as \"Show other templates\".",
        "Write the statement as one plain sentence that is clear when heard on its own."
      ],
      "onPhone": [
        "The templates show in one column below 640px, then two columns, then three from 1024px.",
        "Each template card is a full-width button, and the intent buttons wrap across the width.",
        "The intent buttons keep their size and have a 44px tap area on a touch screen."
      ],
      "importPath": "import { StarterGallery } from \"./index\"",
      "sourcePath": "src/organisms/StarterGallery.tsx",
      "props": [
        {
          "name": "heading",
          "type": "string",
          "required": false,
          "default": "\"What would you like to make?\"",
          "description": "Serif heading and accessible name of the section."
        },
        {
          "name": "statement",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "One sentence on what the assistant can and cannot do."
        },
        {
          "name": "intents",
          "type": "StarterIntent[]",
          "required": true,
          "default": null,
          "description": "Starter intents with an id and a label. Three to six works best."
        },
        {
          "name": "onIntent",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Called when an intent is pressed."
        },
        {
          "name": "templates",
          "type": "StarterTemplate[]",
          "required": false,
          "default": "[]",
          "description": "Cards with an id, title, description and optional category. The section is hidden when empty."
        },
        {
          "name": "onTemplate",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when a template card is pressed."
        },
        {
          "name": "intentsLabel",
          "type": "string",
          "required": false,
          "default": "\"Start with\"",
          "description": "Label above the intents."
        },
        {
          "name": "templatesLabel",
          "type": "string",
          "required": false,
          "default": "\"Templates\"",
          "description": "Label above the templates."
        },
        {
          "name": "randomize",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Slot beside the templates label, for a Randomize button."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the section."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--surface-hover",
        "--border",
        "--border-strong",
        "--fg-muted",
        "--radius-card"
      ],
      "related": [
        "prompt-box",
        "suggestion-chips",
        "tray"
      ],
      "composedOf": [
        "button",
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Ideas and templates",
          "description": "A sentence on what it can do, three ideas and three templates with categories.",
          "code": "<StarterGallery\n  statement=\"I can write, shorten and reword text you give me. I cannot open links or see your files.\"\n  intents={[{ id: \"draft\", label: \"Draft an email\" }, { id: \"sum\", label: \"Summarize a document\" }, { id: \"plan\", label: \"Plan my week\" }]}\n  onIntent={() => {}}\n  templates={[\n    { id: \"a\", title: \"Weekly update\", description: \"Share progress with your team.\", category: \"Writing\" },\n    { id: \"b\", title: \"Meeting notes\", description: \"Turn rough notes into clear actions.\", category: \"Work\" },\n    { id: \"c\", title: \"Product description\", description: \"Describe an item in a few lines.\", category: \"Marketing\" },\n  ]}\n  onTemplate={() => {}}\n/>"
        },
        {
          "title": "With a Shuffle button",
          "description": "Add a button for shuffling. Here it swaps the templates.",
          "code": "<StarterGallery\n  statement=\"I can write, shorten and reword text you give me. I cannot open links or see your files.\"\n  intents={[{ id: \"draft\", label: \"Draft an email\" }, { id: \"sum\", label: \"Summarize a document\" }, { id: \"plan\", label: \"Plan my week\" }]}\n  onIntent={() => {}}\n  templates={picked.map((t, i) => ({ id: `${n}-${i}`, title: t, description: \"A starting point you can change.\" }))}\n  onTemplate={() => {}}\n  randomize={<Button size=\"sm\" variant=\"ghost\" onClick={() => setN(n + 1)}>Shuffle</Button>}\n/>"
        },
        {
          "title": "Ideas only",
          "description": "Leave out the templates for a shorter first screen.",
          "code": "<StarterGallery heading=\"How can I help?\" statement=\"I answer questions about your orders. I cannot change an order.\"\n  intents={[{ id: \"track\", label: \"Track an order\" }, { id: \"return\", label: \"Start a return\" }, { id: \"invoice\", label: \"Find an invoice\" }]}\n  onIntent={() => {}} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-starter-gallery/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/starter-gallery.md"
    },
    {
      "id": "popover",
      "name": "Popover",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "popup",
        "flyout",
        "dropdown panel"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open or closed",
          "note": "Set with the open prop.",
          "source": "prop"
        },
        {
          "name": "closing",
          "note": "After open turns false the panel stays for --dur-fast while it fades out, and it cannot be pressed. Under reduced motion it goes at once.",
          "source": "documented"
        }
      ],
      "summary": "A small panel that opens beside a button, for menus, pickers and short explanations.",
      "description": "Popover shows a button and, when open, a panel just below or above it. If the panel would run off the screen, it opens on the other side and stays a little away from the edges. When it opens, your cursor moves into the panel. When it closes, the cursor returns to the button and the panel fades out quickly. Menus and lists add arrow keys, Home and End, and Tab leaves the menu. It can manage itself, or your app can open and close it.",
      "purpose": "A small panel that opens beside a button, for menus, pickers and short explanations. Focus moves in when it opens and back when it closes, so keyboard users keep their place.",
      "useFor": [
        "A menu of actions behind a button.",
        "A list of options to pick from, such as a style or a size.",
        "A short explanation or a few settings that open from a button."
      ],
      "notFor": [
        {
          "text": "Something people must answer before going on. Use a dialog"
        },
        {
          "text": "Actions on text the person has selected",
          "use": "selection-toolbar"
        }
      ],
      "whenToUse": [
        "Give the button its screen reader details, so it says whether the panel is open.",
        "Give the panel a name that says what it holds.",
        "Use a menu for a list of actions and a list box for a list of options, so arrow keys work.",
        "Keep panels narrower than the screen. A panel wider than the screen still runs off the right edge.",
        "Open upward near the bottom of a screen. Automatic flipping covers cases you did not plan for."
      ],
      "avoid": [
        "Do not use it for something people must answer. Use a dialog.",
        "Do not put one popover inside another.",
        "Do not use an icon-only button without a name.",
        "Do not rely on pointing to open it.",
        "Do not use a menu or list box for content that is not a list of items. Use the default panel."
      ],
      "accessibility": [
        "When the panel opens, focus moves into it: to the chosen or first item in a menu or list, or to the first control in other panels.",
        "Escape closes the panel and returns focus to the button.",
        "In menus and lists, the up and down arrows move between items and wrap around. Home and End jump to the ends, and unavailable items are skipped.",
        "In a menu or list, Tab closes it and moves on from the button.",
        "The panel has a name, and it flips to the other side when it would run off the screen.",
        "Closing is a quick plain fade in --dur-fast, faster than opening. The panel cannot be pressed while it fades. When people turn off motion in their system settings, it goes at once."
      ],
      "accessibilityYouDo": [
        "Spread triggerProps onto your button, so screen readers hear whether the panel is open.",
        "Give the button a name, even when it shows only an icon.",
        "Set role to \"menu\" for actions or \"listbox\" for options, and give each item the matching item role, so the arrow keys work."
      ],
      "onPhone": [
        "Menus open as a bottom sheet with a backdrop on a narrow touch screen, kept within thumb reach and padded for the bottom safe area.",
        "Other roles, such as a dialog or listbox, stay anchored to the trigger unless you set phoneSheet; they are at least 224px wide and move to stay inside the screen edges.",
        "It closes when a finger touches outside it, and the sheet scrolls inside itself at up to 80% of the screen height.",
        "Give an anchored panel inside a scrolling or clipped parent the portal prop so it is not cut off."
      ],
      "importPath": "import { Popover } from \"./index\"",
      "sourcePath": "src/atoms/Popover.tsx",
      "props": [
        {
          "name": "trigger",
          "type": "(api: PopoverTriggerApi) => ReactNode",
          "required": true,
          "default": null,
          "description": "Render function. Receives open, toggle and triggerProps with aria-expanded, aria-haspopup and aria-controls."
        },
        {
          "name": "children",
          "type": "ReactNode | ((api: { close: () => void }) => ReactNode)",
          "required": true,
          "default": null,
          "description": "Panel content. A function receives close."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name for the panel."
        },
        {
          "name": "align",
          "type": "\"start\" | \"end\"",
          "required": false,
          "default": "\"start\"",
          "description": "Which edge of the trigger the panel lines up with."
        },
        {
          "name": "side",
          "type": "\"bottom\" | \"top\"",
          "required": false,
          "default": "\"bottom\"",
          "description": "Whether the panel opens below or above the trigger."
        },
        {
          "name": "role",
          "type": "\"dialog\" | \"menu\" | \"listbox\"",
          "required": false,
          "default": "\"dialog\"",
          "description": "Role on the panel, also used for aria-haspopup on the trigger."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Controlled mode. Omit to let the popover manage itself."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when the panel opens or closes."
        },
        {
          "name": "autoFocus",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Moves focus into the panel when it opens. Menu and listbox panels focus the checked radio or selected option, or else the first enabled item. Dialog panels focus the first focusable element, or the panel itself if there is none. Set false when the opener manages focus."
        },
        {
          "name": "flip",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "After the panel mounts it is measured. If the preferred side overflows the viewport and the other side has more room, the panel opens there. It is also kept 8px inside the left and right viewport edges."
        },
        {
          "name": "portal",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Draws the panel on the page itself (fixed position) so a scrolling or clipped parent can never cut it off. Use it for menus inside lists and side bars. The panel keeps its place as the page scrolls or resizes."
        },
        {
          "name": "phoneSheet",
          "type": "boolean",
          "required": false,
          "default": "true for menus",
          "description": "On a phone (a narrow touch screen) the panel opens as a bottom sheet with a backdrop, within thumb reach. On by default when role is menu. Set false to keep it floating."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the wrapper."
        },
        {
          "name": "panelClassName",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the panel, for example to set a width."
        }
      ],
      "variantsHint": {
        "align": [
          "start",
          "end"
        ],
        "side": [
          "bottom",
          "top"
        ],
        "role": [
          "dialog",
          "menu",
          "listbox"
        ]
      },
      "tokens": [
        "--surface-raised",
        "--border",
        "--shadow-lg",
        "--dur-fast",
        "--radius-overlay"
      ],
      "related": [
        "segmented-control",
        "kbd"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Basic panel",
          "description": "Give the button its screen reader details and open the panel on click.",
          "code": "<Popover label=\"About credits\"\n  trigger={({ toggle, triggerProps }) => <Button variant=\"secondary\" size=\"sm\" onClick={toggle} {...triggerProps}>About credits</Button>}>\n  <p className=\"p-2 text-body leading-5\">Cost depends on duration.</p>\n</Popover>"
        },
        {
          "title": "A menu that closes when you pick",
          "description": "The panel can close itself when you pick something. Use a menu for a list of actions.",
          "code": "<Popover label=\"Options\" role=\"menu\" align=\"end\"\n  trigger={({ toggle, triggerProps }) => <Button variant=\"secondary\" size=\"sm\" onClick={toggle} {...triggerProps}>Options</Button>}>\n  {({ close }) => (\n    <div className=\"flex flex-col\">\n      <button role=\"menuitem\" className=\"rounded-xl px-3 py-1.5 text-left text-body leading-5 hover:bg-hover\" onClick={close}>Rename</button>\n      <button role=\"menuitem\" className=\"rounded-xl px-3 py-1.5 text-left text-body leading-5 hover:bg-hover\" onClick={close}>Duplicate</button>\n    </div>\n  )}\n</Popover>"
        },
        {
          "title": "Opens upward",
          "description": "Open upward when the button is near the bottom of the screen. If the panel still does not fit, it opens downward.",
          "code": "<Popover label=\"Tips\" side=\"top\"\n  trigger={({ toggle, triggerProps }) => <Button variant=\"ghost\" size=\"sm\" onClick={toggle} {...triggerProps}>Tips</Button>}>\n  <p className=\"p-2 text-body leading-5\">Press Esc to close.</p>\n</Popover>"
        },
        {
          "title": "Near the edge of the screen",
          "description": "A wide panel on a button at the right edge. It would run past the edge, so it shifts left to stay on screen.",
          "code": "<div className=\"flex justify-end\">\n  <Popover label=\"Share options\" panelClassName=\"w-72\"\n    trigger={({ toggle, triggerProps }) => <Button variant=\"secondary\" size=\"sm\" onClick={toggle} {...triggerProps}>Share</Button>}>\n    <p className=\"p-2 text-body leading-5\">Anyone with the link can view this. The panel shifts left to stay on screen.</p>\n  </Popover>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-popover/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/popover.md"
    },
    {
      "id": "segmented-control",
      "name": "SegmentedControl",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "segmented button",
        "toggle group",
        "button group",
        "radio buttons"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "The option matching value gets a raised surface and is the one tab stop. When the choice changes, the raised surface slides to the new option in --dur-base. In forced colours the chosen option gets a border instead.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "An unselected option's text darkens under the pointer.",
          "source": "documented"
        },
        {
          "name": "keyboard",
          "note": "Arrow keys, Home and End move to another option and select it.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears on the focused option.",
          "source": "documented"
        }
      ],
      "summary": "A row of joined buttons where you pick one option, using clicks or arrow keys.",
      "description": "SegmentedControl is a set of options drawn as joined buttons. You can pick only one. Arrow keys move and choose, Home and End jump to the first and last, and Tab stops only on the chosen one. The raised background slides to the new choice. Options can have a short hint that shows on hover.",
      "purpose": "A row of joined buttons for picking one of a few short options. All the options stay in view, so people see their choice next to the others.",
      "useFor": [
        "Two to five short options where only one can be chosen, such as a length or a view.",
        "A setting inside a crowded row: use the small size.",
        "Switching between two versions of the same content."
      ],
      "notFor": [
        {
          "text": "Turning one thing on or off",
          "use": "switch"
        },
        {
          "text": "Running an action",
          "use": "button"
        },
        {
          "text": "Many options that will not fit on one line"
        }
      ],
      "whenToUse": [
        "Use it for two to five short options where only one can be chosen.",
        "Give the group a name that says what is being chosen.",
        "Keep words short so the group stays on one line.",
        "Use the small size inside crowded rows."
      ],
      "avoid": [
        "Do not use it for actions. Use buttons.",
        "Do not use it for many options. The group does not wrap to a new line.",
        "Do not choose a value that is not in the list. Nothing would be reachable with Tab.",
        "Do not use it for on and off. Use Switch."
      ],
      "accessibility": [
        "Screen readers hear a named group of options, and which one is chosen.",
        "Arrow keys move and choose, and wrap around at the ends. Home and End jump to the first and last.",
        "Tab stops once on the chosen option, so the group takes one Tab to enter and one to leave.",
        "The chosen option is shown by a raised background and shadow, not by text color alone.",
        "The raised background slides to the new choice in --dur-base on --ease-arrive. It does not slide when it is first placed, and it moves at once when people turn off motion in their system settings.",
        "In forced-colors mode the sliding background is hidden and the chosen option gets a border in the system highlight color."
      ],
      "accessibilityYouDo": [
        "Give the group a label that says what is being chosen.",
        "Keep option words short and clear on their own. The hint shows only on hover."
      ],
      "onPhone": [
        "The options sit in one row that scrolls sideways when it is wider than the screen, so keep to two or three short labels to avoid hidden options.",
        "Each option keeps its size and has a 44px tap area on a touch screen.",
        "An option description is set as a title only, which a finger cannot show."
      ],
      "importPath": "import { SegmentedControl } from \"./index\"",
      "sourcePath": "src/atoms/SegmentedControl.tsx",
      "props": [
        {
          "name": "options",
          "type": "SegmentOption<T>[]",
          "required": true,
          "default": null,
          "description": "Each option has a value, a label and an optional description used as a title."
        },
        {
          "name": "value",
          "type": "T",
          "required": true,
          "default": null,
          "description": "Selected value."
        },
        {
          "name": "onChange",
          "type": "(value: T) => void",
          "required": true,
          "default": null,
          "description": "Called on click and on arrow, Home and End keys."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name for the group."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "default": "\"md\"",
          "description": "Pill size."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the group."
        }
      ],
      "variantsHint": {
        "size": [
          "sm",
          "md"
        ]
      },
      "tokens": [
        "--surface-sunken",
        "--surface",
        "--border",
        "--fg-muted",
        "--shadow-sm",
        "--dur-fast",
        "--dur-base",
        "--ease-arrive",
        "--radius-control"
      ],
      "related": [
        "switch",
        "parameter-panel",
        "variation-actions"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Default size",
          "description": "A group of three options.",
          "code": "const [v, setV] = useState(\"week\");\n\n<SegmentedControl label=\"Range\" value={v} onChange={setV}\n  options={[{ value: \"day\", label: \"Day\" }, { value: \"week\", label: \"Week\" }, { value: \"month\", label: \"Month\" }]} />"
        },
        {
          "title": "Small size",
          "description": "The small size fits inside rows and panels.",
          "code": "<SegmentedControl size=\"sm\" label=\"Strength\" value={v} onChange={setV}\n  options={[{ value: \"week\", label: \"Subtle\" }, { value: \"day\", label: \"Strong\" }]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-segmented-control/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/segmented-control.md"
    },
    {
      "id": "meter",
      "name": "Meter",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "meter bar",
        "progress bar",
        "gauge",
        "capacity bar"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "warning",
          "note": "When warnAt is set and value divided by max reaches it, the fill changes to the attention colour.",
          "source": "documented"
        }
      ],
      "summary": "A thin bar that shows how much of something is used, and turns amber past a set point.",
      "description": "Meter is a thin bar that shows an amount out of a limit. It is dark by default and turns amber once it passes a set point. Use it for something with a limit, such as conversation space used. Show amber only when the person should act, and add words beside the bar that say why.",
      "purpose": "A thin bar for an amount out of a limit, such as space used in a chat. It stays dark and only turns amber past the point where the person should act.",
      "useFor": [
        "Space used in a conversation, or credits used in a plan.",
        "Any amount with a known limit that people may need to act on.",
        "A building block inside a larger meter that adds words around it."
      ],
      "notFor": [
        {
          "text": "A wait with no known end",
          "use": "spinner"
        },
        {
          "text": "Progress of a slow AI task",
          "use": "generation-progress"
        },
        {
          "text": "How full the chat is, with words and a Summarize button",
          "use": "context-meter"
        }
      ],
      "whenToUse": [
        "Add words for the amount, so it makes sense when read aloud.",
        "Set a warning point only when the person can do something about it.",
        "Put words beside the bar that explain the warning.",
        "Set a limit for amounts that are not percents."
      ],
      "avoid": [
        "Do not use it for a task with no known total. Use a moving indicator.",
        "Do not rely on amber alone for the warning.",
        "Do not use it for something with no limit.",
        "Do not announce every small change to screen readers."
      ],
      "accessibility": [
        "Screen readers hear it as a meter with a name, a range and the current amount.",
        "When valueText is set, screen readers read it in place of the bare number.",
        "The grow-in motion stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Give it a label that says what is measured.",
        "Pass valueText with words for the amount, such as \"68% of context\".",
        "Put words beside the bar that say why it turned amber. The color change is not announced."
      ],
      "onPhone": [
        "The bar fills the width of its container and is 6px tall, which is a display, not a tap target.",
        "Show the value as text beside it, since a thumb covers a bar this thin."
      ],
      "importPath": "import { Meter } from \"./index\"",
      "sourcePath": "src/atoms/Meter.tsx",
      "props": [
        {
          "name": "value",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Current amount. The fill width is clamped between 0 and 100 percent of max."
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "default": "100",
          "description": "Amount that fills the bar."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name, such as Context used."
        },
        {
          "name": "warnAt",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Fraction of max, 0 to 1. At or above it the fill uses the attention color."
        },
        {
          "name": "valueText",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Text read out with the value, such as 68% of context."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the track."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--border",
        "--fg",
        "--attention",
        "--dur-base"
      ],
      "related": [
        "generation-progress",
        "skeleton"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Plain",
          "description": "A dark fill, as wide as the amount used out of the limit.",
          "code": "<Meter label=\"Space used\" value={40} valueText=\"40% of the space\" />"
        },
        {
          "title": "Past the warning point",
          "description": "At 85 with a warning point of 80 percent, the fill turns amber. The words beside it say why.",
          "code": "<div className=\"w-64 space-y-1\">\n  <Meter label=\"Space used\" value={85} warnAt={0.8} valueText=\"85% of the space\" />\n  <p className=\"text-small leading-4 text-attention-fg\">85% used. Start a new chat soon.</p>\n</div>"
        },
        {
          "title": "A limit other than 100",
          "description": "Set a limit when the amount is not a percent.",
          "code": "<Meter label=\"Credits used\" value={30} max={120} valueText=\"30 of 120 credits\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-meter/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/meter.md"
    },
    {
      "id": "kbd",
      "name": "Kbd",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "key hint",
        "keyboard key",
        "keyboard shortcut",
        "shortcut hint",
        "hotkey"
      ],
      "category": "controls",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A small key label, such as Tab or Esc, that shows which key to press.",
      "description": "Kbd shows a key name in a small outlined box. Put it next to the action the key does. The action must still be reachable without the key.",
      "purpose": "A small key label, such as Esc or Tab, set beside the action it triggers. It is only a hint: the action must also work without the key.",
      "useFor": [
        "Beside a menu item or button that also has a keyboard shortcut.",
        "In help text that explains how to move around with the keyboard.",
        "Each key of a combination, one label per key."
      ],
      "notFor": [
        {
          "text": "Something people click",
          "use": "button"
        },
        {
          "text": "Labels that are not keyboard keys",
          "use": "badge"
        }
      ],
      "whenToUse": [
        "Put it beside words that name the action.",
        "Use one label per key in a combination.",
        "Make every shortcut also work with a mouse or tap.",
        "Use short names such as Esc and Tab."
      ],
      "avoid": [
        "Do not use it as a button. It does nothing when clicked.",
        "Do not show it on touch-only screens.",
        "Do not use it alone. Say what the key does.",
        "Do not use it for hints that are not about the keyboard."
      ],
      "accessibility": [
        "It is marked up as a keyboard key, and screen readers read the key name as text.",
        "It cannot be reached with Tab and does nothing when clicked."
      ],
      "accessibilityYouDo": [
        "Put words beside it that name the action. Screen readers read only the key name.",
        "Make the action work with a mouse or tap too.",
        "Hide it on touch-only screens, where there is no keyboard."
      ],
      "onPhone": [
        "It is a small label for a keyboard key, and keyboard shortcuts do not apply on a phone, so hide it there or show it only where a keyboard is likely.",
        "Its text is 12px on a touch screen."
      ],
      "importPath": "import { Kbd } from \"./index\"",
      "sourcePath": "src/atoms/Kbd.tsx",
      "props": [
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Key label, such as Tab or Esc."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes merged onto the kbd element."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border-strong",
        "--fg-muted"
      ],
      "related": [
        "popover",
        "segmented-control"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Single keys",
          "description": "Put the label next to the action it triggers.",
          "code": "<p className=\"flex items-center gap-2 text-body leading-5\">Press <Kbd>Esc</Kbd> to close</p>"
        },
        {
          "title": "Combination",
          "description": "Use one label per key.",
          "code": "<p className=\"flex items-center gap-1 text-body leading-5\"><Kbd>Ctrl</Kbd> + <Kbd>Enter</Kbd><span className=\"ml-2 text-fg-muted\">Send</span></p>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-kbd/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/kbd.md"
    },
    {
      "id": "illustration",
      "name": "Illustration",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "spot illustration",
        "artwork",
        "image"
      ],
      "category": "states",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A small drawing for empty, error, permission and success screens, in dark lines with one green touch.",
      "description": "Illustration draws one idea for each name: a chat, a search, an error, a lock, a plug, an hourglass, a check, a tray or a flag. It uses dark lines on light fills and at most one green fill. Its colors follow light and dark mode. It is only decoration unless you give it a title.",
      "purpose": "A small drawing that sets the tone of an empty, error or success screen. It shows one idea in dark lines with one green touch, and follows light and dark mode.",
      "useFor": [
        "An empty, first-use or cleared screen.",
        "An error, offline, permission or usage limit screen.",
        "Inside EmptyState, which picks a drawing for you."
      ],
      "notFor": [
        {
          "text": "Small areas, headings or lists of features",
          "use": "pictogram"
        },
        {
          "text": "A photo or generated image",
          "use": "media-frame"
        },
        {
          "text": "An icon inside a button",
          "use": "icon-button"
        }
      ],
      "whenToUse": [
        "Use it to support the words on a screen, never to replace them.",
        "Pick the drawing that matches the message, so the picture and words agree.",
        "Leave it as decoration when the words beside it already say everything.",
        "Show one drawing per area."
      ],
      "avoid": [
        "Do not use it as decoration on busy screens.",
        "Do not add robots, brains, glowing orbs or sparkles to new drawings.",
        "Do not give it fixed colors. Use the theme colors so dark mode works.",
        "Do not use amber in the drawing. Amber is for words that ask a person to act."
      ],
      "accessibility": [
        "Without a title, screen readers skip the drawing.",
        "With a title, screen readers read it as a picture and say the title.",
        "The keyboard never stops on it.",
        "The drawing does not move, so there is no motion to turn off."
      ],
      "accessibilityYouDo": [
        "Leave out the title when the nearby words already say what happened, which should be almost always.",
        "Give a title only when the drawing adds meaning that the words do not."
      ],
      "onPhone": [
        "Illustration is a fixed 64, 96 or 160px and does not scale with the screen, so choose 64 or 96 on a phone.",
        "It is not tappable and has no text, so nothing changes on touch screens."
      ],
      "importPath": "import { Illustration } from \"./index\"",
      "sourcePath": "src/atoms/Illustration.tsx",
      "props": [
        {
          "name": "name",
          "type": "IllustrationName",
          "required": true,
          "default": null,
          "description": "One of empty-chat, no-results, error, permission, offline, rate-limit, success, upload, first-run."
        },
        {
          "name": "size",
          "type": "64 | 96 | 160",
          "required": false,
          "default": "96",
          "description": "Rendered size in px. Sets the stroke weight too."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Accessible name. Without it the art is aria-hidden."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the svg element."
        }
      ],
      "variantsHint": {
        "size": [
          64,
          96,
          160
        ]
      },
      "tokens": [
        "--fg",
        "--surface",
        "--surface-sunken",
        "--lime"
      ],
      "related": [
        "pictogram",
        "empty-state"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Three sizes",
          "description": "Line thickness grows with size, so the drawing looks balanced at each size.",
          "code": "<Illustration name=\"no-results\" size={64} />\n<Illustration name=\"no-results\" size={96} />\n<Illustration name=\"no-results\" size={160} />"
        },
        {
          "title": "All names",
          "description": "Each name has one job. Pick the one that matches what is happening, not the one you like best.",
          "code": "{spotIllustrations.map((s) => (\n  <Illustration key={s.name} name={s.name} size={64} />\n))}"
        },
        {
          "title": "When the picture says something",
          "description": "A title lets screen readers describe the picture. Use it only when the picture says something the nearby words do not.",
          "code": "<Illustration name=\"offline\" size={96} title=\"Plug disconnected from its socket\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-illustration/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/illustration.md"
    },
    {
      "id": "pictogram",
      "name": "Pictogram",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "icon illustration",
        "symbol"
      ],
      "category": "states",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A one-color line icon, larger than a button icon, for small areas and lists of features.",
      "description": "Pictogram is a bigger, more detailed cousin of an icon. It is made of lines only and takes its color from the text around it. Use it where a full drawing is too much, such as small empty areas or a row of things the assistant can do.",
      "purpose": "A line drawing larger than an icon, for spots where a full illustration is too much. It takes the color of the text around it.",
      "useFor": [
        "A row or grid of things the assistant can do.",
        "A small empty area where a full drawing would not fit.",
        "Beside a heading, to mark its topic."
      ],
      "notFor": [
        {
          "text": "A full empty, error or success screen",
          "use": "illustration"
        },
        {
          "text": "An icon inside a button",
          "use": "icon-button"
        }
      ],
      "whenToUse": [
        "Put a visible label beside it.",
        "Use it in small empty areas where a full drawing would crowd the words.",
        "Use one size within a group."
      ],
      "avoid": [
        "Do not use it as a button icon. Use a small icon.",
        "Do not fill its shapes. Pictograms stay as lines.",
        "Do not add any color except the text color."
      ],
      "accessibility": [
        "Without a title, screen readers skip it.",
        "With a title, screen readers read it as a picture and say the title.",
        "The keyboard never stops on it.",
        "It uses the surrounding text color, so it follows the theme."
      ],
      "accessibilityYouDo": [
        "Check that the text color it takes stands out against the background.",
        "Give a title only when it means something the nearby words do not say."
      ],
      "onPhone": [
        "Pictogram is a fixed 32, 48 or 64px and looks the same on a phone.",
        "It is not tappable. If it starts something, wrap it in a button, which gets a 44px target on touch screens."
      ],
      "importPath": "import { Pictogram } from \"./index\"",
      "sourcePath": "src/atoms/Pictogram.tsx",
      "props": [
        {
          "name": "name",
          "type": "PictogramName",
          "required": true,
          "default": null,
          "description": "One of chat, agent, document, image, code, data, search, shield, memory, voice, plan, tools."
        },
        {
          "name": "size",
          "type": "32 | 48 | 64",
          "required": false,
          "default": "32",
          "description": "Rendered size in px."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Accessible name. Without it the pictogram is aria-hidden."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the svg element."
        }
      ],
      "variantsHint": {
        "size": [
          32,
          48,
          64
        ]
      },
      "tokens": [
        "--fg",
        "--fg-muted"
      ],
      "related": [
        "illustration",
        "empty-state"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Sizes",
          "description": "The lines get thicker as the picture grows, so it looks balanced at each size.",
          "code": "<Pictogram name=\"document\" size={32} />\n<Pictogram name=\"document\" size={48} />\n<Pictogram name=\"document\" size={64} />"
        },
        {
          "title": "All names",
          "description": "",
          "code": "{pictogramNames.map((n) => <Pictogram key={n} name={n} size={48} />)}"
        },
        {
          "title": "Takes the text color",
          "description": "Set a text color on the area around it and the pictogram follows.",
          "code": "<div className=\"text-fg-muted\"><Pictogram name=\"shield\" size={48} title=\"Protected\" /></div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-pictogram/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/pictogram.md"
    },
    {
      "id": "empty-state",
      "name": "EmptyState",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "blank slate",
        "zero state",
        "no results",
        "no data",
        "first use"
      ],
      "category": "states",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "first use",
          "note": "The default variant shows an empty-chat drawing, a title and a short body for a first visit.",
          "source": "documented"
        },
        {
          "name": "no results",
          "note": "Set variant to no-results for a search drawing; it is announced to screen readers as a status.",
          "source": "documented"
        },
        {
          "name": "cleared",
          "note": "Set variant to cleared for a success drawing when everything is done.",
          "source": "documented"
        },
        {
          "name": "error",
          "note": "Set variant to error for an error drawing and a message that the work is safe.",
          "source": "documented"
        },
        {
          "name": "permission",
          "note": "Set variant to permission for a lock drawing and an Action needed tag.",
          "source": "documented"
        },
        {
          "name": "offline",
          "note": "Set variant to offline for a plug drawing; it is announced as a status.",
          "source": "documented"
        },
        {
          "name": "rate limit",
          "note": "Set variant to rate-limit for an hourglass drawing and an Action needed tag.",
          "source": "documented"
        },
        {
          "name": "compact",
          "note": "Pass compact to drop the drawing and tighten the padding.",
          "source": "documented"
        }
      ],
      "summary": "A message for an area with nothing in it: a drawing, a title that says why, one line, and one button.",
      "description": "EmptyState has up to five parts: a drawing, a title, one line of text, one main button and an optional second link. Seven ready-made versions cover first use, no results, cleared, error, permission, offline and usage limit. Each says why the area is empty first, then what to do next. You can change every word.",
      "purpose": "Fills an area that has nothing to show with a reason and one next step. Saying the cause first stops people wondering whether something broke.",
      "useFor": [
        "A first visit, before anything has been made.",
        "A search or filter with no results.",
        "An area where everything is done, or that cannot load, is private or is offline."
      ],
      "notFor": [
        {
          "text": "A short error inside a page or reply",
          "use": "error-state"
        },
        {
          "text": "A usage limit inside a chat",
          "use": "rate-limit-notice"
        },
        {
          "text": "Content that is still loading",
          "use": "skeleton"
        }
      ],
      "whenToUse": [
        "Say why in the title and what to do next on the button.",
        "Treat a filtered list differently from one that is truly empty.",
        "Offer one main button and at most one second link.",
        "Keep words short and plain, and never point to another part of the app as the only way out."
      ],
      "avoid": [
        "Do not leave a dead end. Every screen needs a way forward, or a reason there is none.",
        "Do not use amber unless a person must act.",
        "Do not let the drawing be the only explanation.",
        "Do not use several drawings on one screen."
      ],
      "accessibility": [
        "The title is a real heading.",
        "Screen readers announce it only for no results and offline, which follow something the person did or a change in the connection. The others are not announced every time a page loads.",
        "The built-in drawing is hidden from screen readers. A drawing you pass keeps its own name.",
        "The amber \"Action needed\" tag has an icon and words, so the need to act is not shown by color alone."
      ],
      "accessibilityYouDo": [
        "Set headingLevel so the title fits the headings around it.",
        "Give the button or link you add words that name the next step.",
        "Turn on live when your own empty state appears after something the person did."
      ],
      "onPhone": [
        "The panel fills the width, with the art, text and buttons stacked in one column.",
        "Text is capped at 44 characters wide and the title breaks anywhere, so long titles do not overflow.",
        "The action and secondary buttons wrap onto separate lines when they do not fit, and each button has a 44px tap area on touch screens."
      ],
      "importPath": "import { EmptyState } from \"./index\"",
      "sourcePath": "src/molecules/EmptyState.tsx",
      "props": [
        {
          "name": "variant",
          "type": "\"first-use\" | \"no-results\" | \"cleared\" | \"error\" | \"permission\" | \"offline\" | \"rate-limit\"",
          "required": false,
          "default": "\"first-use\"",
          "description": "Sets default art, copy, tone and live behavior."
        },
        {
          "name": "art",
          "type": "ReactNode | null",
          "required": false,
          "default": null,
          "description": "Replaces the variant's illustration. Pass an Illustration or a Pictogram, or null for none."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Names the cause or invites the first step. Defaults per variant."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "One-line body with the next step. Defaults per variant."
        },
        {
          "name": "action",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The one primary action, usually a Button."
        },
        {
          "name": "secondary",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Optional secondary link or ghost button."
        },
        {
          "name": "layout",
          "type": "\"center\" | \"start\"",
          "required": false,
          "default": "\"center\"",
          "description": "Center for full panels. Start left-aligns text in small areas."
        },
        {
          "name": "compact",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Omits the art and tightens spacing."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4",
          "required": false,
          "default": "3",
          "description": "Heading level of the title."
        },
        {
          "name": "live",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Overrides role=\"status\". By default it is on for no-results and offline only."
        },
        {
          "name": "attentionLabel",
          "type": "string",
          "required": false,
          "default": "\"Action needed\"",
          "description": "Text of the amber chip in the permission and rate-limit variants."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the container."
        }
      ],
      "variantsHint": {
        "variant": [
          "first-use",
          "no-results",
          "cleared",
          "error",
          "permission",
          "offline",
          "rate-limit"
        ],
        "layout": [
          "center",
          "start"
        ],
        "headingLevel": [
          2,
          3,
          4
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--fg-muted",
        "--attention-soft",
        "--attention-fg",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "illustration",
        "pictogram",
        "error-state",
        "rate-limit-notice",
        "callout"
      ],
      "composedOf": [
        "illustration"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "First use",
          "description": "A truly empty area invites the first step. The title is positive and the button does what it says.",
          "code": "<EmptyState\n  variant=\"first-use\"\n  title=\"Start by adding a document\"\n  action={<Button>Add a document</Button>}\n  secondary={<Button variant=\"ghost\">Learn how documents work</Button>}\n>\n  Documents give the assistant something to read.\n</EmptyState>"
        },
        {
          "title": "No results, left-aligned",
          "description": "A filtered list is not the same as an empty one. Say the filters caused it and offer to clear them. Left-align the text in small areas.",
          "code": "<EmptyState variant=\"no-results\" layout=\"start\" action={<Button variant=\"secondary\">Clear filters</Button>} />"
        },
        {
          "title": "Permission",
          "description": "A person has to act here, so this version adds an amber tag with an icon and words, next to a line that says why.",
          "code": "<EmptyState variant=\"permission\" action={<Button>Request access</Button>} />"
        },
        {
          "title": "Compact, no art",
          "description": "Crowded places such as a table or a menu skip the drawing.",
          "code": "<EmptyState variant=\"cleared\" compact layout=\"start\" title=\"No open tasks\" headingLevel={3}>\n  New tasks will show up as they arrive.\n</EmptyState>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-empty-state/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/empty-state.md"
    },
    {
      "id": "media-frame",
      "name": "MediaFrame",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "image",
        "aspect ratio",
        "image frame",
        "thumbnail"
      ],
      "category": "creating",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        }
      ],
      "summary": "An image in a fixed shape, with a placeholder while it loads, a message if it fails, and an optional caption and AI label.",
      "description": "MediaFrame saves a box in one of four shapes before the image arrives, so the page does not jump. While loading, it shows the image's main color if you give one, or a shimmer if you do not. If the image fails, it shows an icon and a Retry button. A description of the image is required, unless the image is only decoration.",
      "purpose": "Shows an image without making the page jump. It saves the space first, fills it with a placeholder, and offers Retry if the image fails.",
      "useFor": [
        "A photo or generated image in a reply or a card.",
        "An image that may load slowly or fail.",
        "An AI-made image that needs a label saying where it came from."
      ],
      "notFor": [
        {
          "text": "Several images in a gallery",
          "use": "media-grid"
        },
        {
          "text": "A drawing for an empty or error screen",
          "use": "illustration"
        },
        {
          "text": "An AI-made option the person picks from",
          "use": "variant-tile"
        }
      ],
      "whenToUse": [
        "Describe what the image shows.",
        "Give the main color when you have it, so the placeholder matches the image.",
        "Label AI-made images with the visible AI label.",
        "Use one shape for a group of images."
      ],
      "avoid": [
        "Do not use the AI request as the image description. Describe what is in the picture.",
        "Do not leave out the description unless the image is only decoration.",
        "Do not hide the AI label to make an image look cleaner.",
        "Do not let images pick their own size. Without a set shape, the page jumps."
      ],
      "accessibility": [
        "The description is required. With decorative, the image gets an empty one and screen readers skip it.",
        "With a caption, the image and caption form one figure, so screen readers read them together.",
        "If the image fails, the message is real text and Retry is a real button.",
        "The AI label is written in words. With provenance, it is a button: Enter, Space or a tap opens it, and Escape closes it.",
        "The placeholder shimmer and the fade-in stop when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Write alt text that says what the image shows, not how it was made.",
        "Set decorative only when the image adds nothing the text does not.",
        "Announce a failed load yourself if people must know. The error message appears without being announced."
      ],
      "onPhone": [
        "The frame fills the width of its container and keeps its ratio, so the height follows the width.",
        "The AI label sits at the bottom left of the image, and with provenance it opens a popover from a button that has a 44px tap area on touch screens.",
        "The Retry button keeps its size on touch screens and has a 44px tap area. Keep errorLabel short so the error tile fits a small frame."
      ],
      "importPath": "import { MediaFrame } from \"./index\"",
      "sourcePath": "src/molecules/MediaFrame.tsx",
      "props": [
        {
          "name": "src",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Image URL."
        },
        {
          "name": "alt",
          "type": "string",
          "required": true,
          "default": null,
          "description": "What the image shows. Ignored when decorative is set."
        },
        {
          "name": "decorative",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Renders alt=\"\"."
        },
        {
          "name": "ratio",
          "type": "\"1:1\" | \"4:3\" | \"16:9\" | \"3:2\"",
          "required": false,
          "default": "\"4:3\"",
          "description": "Fixed aspect ratio that is reserved before load."
        },
        {
          "name": "dominantColor",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Average color as a CSS color. Used as the placeholder. Without it a shimmer shows while loading."
        },
        {
          "name": "caption",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Wraps the frame in figure and adds a figcaption."
        },
        {
          "name": "aiGenerated",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Shows the AI label on the image."
        },
        {
          "name": "aiLabel",
          "type": "string",
          "required": false,
          "default": "\"AI-generated\"",
          "description": "Text of the label."
        },
        {
          "name": "provenance",
          "type": "Omit<ProvenancePopoverProps, \"label\">",
          "required": false,
          "default": null,
          "description": "When set, the label opens a ProvenancePopover with these details."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called when Retry is pressed. The image is requested again either way."
        },
        {
          "name": "status",
          "type": "\"loading\" | \"error\"",
          "required": false,
          "default": null,
          "description": "Forces a state, for previews or controlled loading."
        },
        {
          "name": "errorLabel",
          "type": "string",
          "required": false,
          "default": "\"Image could not load\"",
          "description": "Text in the error tile."
        },
        {
          "name": "retryLabel",
          "type": "string",
          "required": false,
          "default": "\"Retry\"",
          "description": "Text of the retry button."
        },
        {
          "name": "flush",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Edge to edge inside a card: no corners or border of its own, so the card's corners clip the picture."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the frame wrapper."
        }
      ],
      "variantsHint": {
        "ratio": [
          "1:1",
          "4:3",
          "16:9",
          "3:2"
        ],
        "status": [
          "loading",
          "error"
        ]
      },
      "tokens": [
        "--surface-sunken",
        "--border",
        "--dur-base",
        "--lime-soft",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "media-grid",
        "ai-badge",
        "provenance-popover",
        "skeleton"
      ],
      "composedOf": [
        "ai-badge",
        "provenance-popover",
        "button",
        "skeleton"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Ratios",
          "description": "Pick the shape that fits the content. The frame crops the image to fill it.",
          "code": "<div className=\"grid w-full grid-cols-2 gap-3 sm:grid-cols-4\">\n  <MediaFrame src={sampleImage(30)} alt=\"Harbor at low tide\" ratio=\"1:1\" caption=\"1:1\" />\n  <MediaFrame src={sampleImage(90)} alt=\"Terraced hillside\" ratio=\"4:3\" caption=\"4:3\" />\n  <MediaFrame src={sampleImage(160)} alt=\"Quiet street\" ratio=\"16:9\" caption=\"16:9\" />\n  <MediaFrame src={sampleImage(210)} alt=\"Lake under cloud\" ratio=\"3:2\" caption=\"3:2\" />\n</div>"
        },
        {
          "title": "Loading and failed",
          "description": "With a main color, the placeholder uses it. Without one, it shimmers. The error message offers Retry.",
          "code": "<div className=\"grid w-full grid-cols-1 gap-3 sm:grid-cols-3\">\n  <MediaFrame src=\"\" alt=\"Loading with a known color\" status=\"loading\" dominantColor=\"var(--lime-soft)\" caption=\"Known color\" />\n  <MediaFrame src=\"\" alt=\"Loading with an unknown color\" status=\"loading\" caption=\"Unknown color\" />\n  <MediaFrame src=\"\" alt=\"Failed image\" status=\"error\" onRetry={() => {}} caption=\"Error\" />\n</div>"
        },
        {
          "title": "Made by AI, with details",
          "description": "The label is written on the image. With details added, it opens a panel with more.",
          "code": "<div className=\"w-full max-w-sm\">\n  <MediaFrame\n    src={sampleImage(270)}\n    alt=\"Greenhouse interior with rows of seedlings\"\n    ratio=\"3:2\"\n    aiGenerated\n    provenance={{ madeWith: \"Image maker\", date: \"12 May\", editedByPerson: false }}\n    caption=\"Generated for the spring newsletter\"\n  />\n</div>"
        },
        {
          "title": "Decorative",
          "description": "Decorative images have an empty description, so screen readers skip them.",
          "code": "<div className=\"w-full max-w-xs\"><MediaFrame src={sampleImage(330)} alt=\"\" decorative ratio=\"16:9\" /></div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-media-frame/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/media-frame.md"
    },
    {
      "id": "media-grid",
      "name": "MediaGrid",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "gallery",
        "image gallery",
        "masonry",
        "photo grid"
      ],
      "category": "creating",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the selected prop.",
          "source": "prop"
        },
        {
          "name": "empty",
          "note": "Set with the empty prop.",
          "source": "prop"
        }
      ],
      "summary": "A gallery of images in even rows that adjusts to the screen, with optional selecting and an empty message.",
      "description": "MediaGrid lays out images with the same spacing and the same shape. It uses 2 columns on small screens, 3 on medium and 4 on large, up to the limit you set. Selecting is optional, with a checkbox button on each image. With no images, it shows an empty message.",
      "purpose": "Lays out many images in even rows with one gap and one shape, so they are easy to scan. It can let people pick several, and shows an empty message when there is nothing.",
      "useFor": [
        "A gallery of uploads or generated images.",
        "Picking several images to download, delete or reuse: turn on selectable."
      ],
      "notFor": [
        {
          "text": "Picking one of several AI-made options",
          "use": "variant-grid"
        },
        {
          "text": "A single image",
          "use": "media-frame"
        }
      ],
      "whenToUse": [
        "Give the grid a name that says what the images are.",
        "Use one shape for the whole grid.",
        "Replace the empty message when you know the reason, such as a filter."
      ],
      "avoid": [
        "Do not mix shapes in one grid.",
        "Do not show what is chosen with color alone. The check mark does that.",
        "Do not use it for a single image. Use MediaFrame."
      ],
      "accessibility": [
        "The grid is a named list, so screen readers say how many images there are.",
        "Each select button is a checkbox named \"Select\" plus the image description. It works with Tab, Enter and Space.",
        "A chosen image shows a check mark and a ring, not just color.",
        "The count line is announced and updates when the choice changes."
      ],
      "accessibilityYouDo": [
        "Give the grid a label that says what the images are, such as \"Your uploads\".",
        "Write a description for every image. A decorative image's select button is named only by its id."
      ],
      "onPhone": [
        "The grid is two columns on a phone, and three or four only from the md and xl breakpoints.",
        "In select mode, each check button sits in the top right corner of the image. On touch screens it keeps its 28px size and has a 44px by 44px tap area.",
        "The count line is 12px text above the grid, and it is announced as a status when the count changes."
      ],
      "importPath": "import { MediaGrid } from \"./index\"",
      "sourcePath": "src/organisms/MediaGrid.tsx",
      "props": [
        {
          "name": "items",
          "type": "MediaGridItem[]",
          "required": true,
          "default": null,
          "description": "Each item is the MediaFrame props except ratio and className, plus an id."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name of the list."
        },
        {
          "name": "columns",
          "type": "2 | 3 | 4",
          "required": false,
          "default": "4",
          "description": "Maximum columns. Fewer show on narrower screens."
        },
        {
          "name": "ratio",
          "type": "MediaRatio",
          "required": false,
          "default": "\"4:3\"",
          "description": "Ratio shared by every tile."
        },
        {
          "name": "selectable",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Adds a checkbox button to each tile."
        },
        {
          "name": "selected",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Controlled selection as item ids."
        },
        {
          "name": "onSelectedChange",
          "type": "(ids: string[]) => void",
          "required": false,
          "default": null,
          "description": "Called with the new selection."
        },
        {
          "name": "empty",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Replaces the default EmptyState."
        },
        {
          "name": "countLabel",
          "type": "(count: number, selected: number) => string",
          "required": false,
          "default": null,
          "description": "Builds the count line. Defaults to a count of images and selected."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the wrapper."
        }
      ],
      "variantsHint": {
        "columns": [
          2,
          3,
          4
        ]
      },
      "tokens": [
        "--lime",
        "--border-strong",
        "--fg",
        "--bg",
        "--radius-card"
      ],
      "related": [
        "media-frame",
        "empty-state",
        "variant-grid"
      ],
      "composedOf": [
        "media-frame",
        "empty-state"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Gallery",
          "description": "Every image has the same shape, so rows stay even.",
          "code": "<MediaGrid label=\"Trip photos\" columns={3} items={items} />"
        },
        {
          "title": "Selectable",
          "description": "Each image has a checkbox button. A check mark shows what is chosen, not only a ring. The count line announces changes.",
          "code": "<MediaGrid label=\"Choose photos\" columns={3} ratio=\"1:1\" selectable items={items} />"
        },
        {
          "title": "Empty",
          "description": "With no images, the grid shows an empty message. You can replace it.",
          "code": "<MediaGrid label=\"Uploads\" items={[]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-media-grid/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/media-grid.md"
    },
    {
      "id": "scene-illustration",
      "name": "SceneIllustration",
      "level": "molecule",
      "since": "0.26.0",
      "aka": [
        "animation",
        "lottie",
        "animated illustration"
      ],
      "category": "states",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "An animated scene for empty, waiting, trust and success moments, told with objects and one lime shape.",
      "description": "SceneIllustration plays one of 35 Tattva scenes from a Lottie file. Each scene tells a small story with objects, and its lime shape shows part of that story: a phone screen, a highlighter, a door, a moon. It plays a short entrance, then a calm loop that carries the meaning, and pauses while off screen. Its colours follow light and dark mode, and it holds still when a person asks for less motion.",
      "purpose": "A larger, moving picture for the moments that matter in an AI product: the first message, waiting, asking first, showing sources, a polite no, done. It helps people read the moment at a glance.",
      "useFor": [
        "The art slot of an EmptyState on a full page or a large panel.",
        "Onboarding, first-use and success screens.",
        "Explaining an AI moment, such as asking before acting or showing sources, on a page or in a guide."
      ],
      "notFor": [
        {
          "text": "Small areas, dense panels or lists",
          "use": "illustration"
        },
        {
          "text": "A heading or a list of features",
          "use": "pictogram"
        },
        {
          "text": "A spinner while something loads for a second or two",
          "use": "spinner"
        }
      ],
      "whenToUse": [
        "Use a scene to support the words on a screen, never to replace them.",
        "Pick the scene whose story matches the message, so the picture and the words agree.",
        "Show at most one moving scene on a screen.",
        "Use the still picture on busy pages and in long lists."
      ],
      "avoid": [
        "Do not use a scene where a spinner or a short status line is enough.",
        "Do not recolour scenes. They take their colours from the theme so dark mode works.",
        "Do not put two moving scenes next to each other.",
        "Do not add text inside a scene. Put the words beside it."
      ],
      "accessibility": [
        "Without a label, screen readers skip the scene.",
        "With a label, screen readers read it as a picture and say the label.",
        "The keyboard never stops on it.",
        "When a person asks for less motion, the scene shows its finished picture and never moves.",
        "It pauses while off screen and never flashes."
      ],
      "accessibilityYouDo": [
        "Leave out the label when the words beside the scene already say what it shows, which should be almost always.",
        "Give a label only when the scene adds meaning the words do not.",
        "Show one moving scene per screen at most, so motion never competes for attention."
      ],
      "onPhone": [
        "Choose size sm or fill on a phone; the height follows at four by three.",
        "The scene is not tappable and has no text, so nothing changes on touch screens.",
        "It pauses while scrolled out of view, which saves battery."
      ],
      "importPath": "import { SceneIllustration } from \"./index\"",
      "sourcePath": "src/molecules/SceneIllustration.tsx",
      "props": [
        {
          "name": "data",
          "type": "object",
          "required": false,
          "default": null,
          "description": "The scene's Lottie data, for example imported from @tattva/ui/scenes/<name>.json."
        },
        {
          "name": "src",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Address of a scene's Lottie file. Used when data is not given."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Accessible name. Without it the scene is hidden from assistive tech."
        },
        {
          "name": "motion",
          "type": "\"loop\" | \"once\" | \"still\"",
          "required": false,
          "default": "\"loop\"",
          "description": "Entrance then a calm loop, entrance only, or the finished picture. Reduced motion always shows the still picture."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\" | \"fill\"",
          "required": false,
          "default": "\"md\"",
          "description": "Width 12rem, 18rem, 30rem or the container's width. Height follows at 4:3."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the scene's box."
        }
      ],
      "variantsHint": {
        "motion": [
          "loop",
          "once",
          "still"
        ],
        "size": [
          "sm",
          "md",
          "lg",
          "fill"
        ]
      },
      "tokens": [
        "--fg",
        "--surface",
        "--lime"
      ],
      "related": [
        "illustration",
        "empty-state",
        "pictogram"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "In an empty screen",
          "description": "Put the scene in EmptyState's art slot. The words say what happened; the scene sets the tone.",
          "code": "import firstUse from \"@tattva/ui/scenes/first-use.json\";\n\n<EmptyState\n  variant=\"first-use\"\n  art={<SceneIllustration data={firstUse} size=\"sm\" />}\n  action={<Button>Ask a question</Button>}\n/>"
        },
        {
          "title": "Loop, once or still",
          "description": "Loop suits a waiting screen. Once suits a success that should settle. Still suits pages with a lot going on. Reduced motion always shows the still picture.",
          "code": "<SceneIllustration data={working} motion=\"loop\" />\n<SceneIllustration data={success} motion=\"once\" />\n<SceneIllustration data={approval} motion=\"still\" />"
        },
        {
          "title": "Loading the file by address",
          "description": "The scene files are also served on the Tattva site and ship in the npm package, so you can load one by address instead of importing it.",
          "code": "<SceneIllustration src=\"/scenes/confidence.json\" label=\"A dial with the needle high in the lime\" />"
        },
        {
          "title": "All scenes",
          "description": "Each scene has one job. Pick the one that matches the moment, not the one you like best.",
          "code": "{sceneNames.map((name) => (\n  <SceneIllustration key={name} src={`/scenes/${name}.json`} size=\"sm\" motion=\"still\" />\n))}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-scene-illustration/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/scene-illustration.md"
    },
    {
      "id": "ai-mark",
      "name": "AIMark",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "ai icon",
        "sparkle",
        "ai logo"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A small four-point shape that tells people something was made by AI.",
      "description": "AIMark is a small shape that matches the color of the text beside it. By default it is just decoration. Give it a title and screen readers will read it out. Many people know the sparkle means AI, but not what the AI does. So add words whenever the action matters.",
      "purpose": "The one shape this system uses to mean AI. It takes the color of the text beside it, and its arms are slightly uneven, so it reads as our own mark rather than a generic sparkle.",
      "useFor": [
        "Beside the words on a button that starts an AI action, like Summarize.",
        "On its own, with a title, inside a small badge or circle.",
        "Inside other AI labels that need a symbol."
      ],
      "notFor": [
        {
          "text": "Labelling a piece of content as made by AI",
          "use": "ai-badge"
        },
        {
          "text": "Explaining how AI content was made",
          "use": "ai-label"
        },
        {
          "text": "Showing who wrote a message",
          "use": "avatar"
        }
      ],
      "whenToUse": [
        "Add words when the action is specific, like Summarize or Rewrite.",
        "Use one mark for AI everywhere so people learn it.",
        "Match its size to the text beside it.",
        "Give it a title when it stands alone."
      ],
      "avoid": [
        "Put it alone on every button. Too many marks lose their meaning.",
        "Use a second AI symbol in the same product.",
        "Make it lime on a plain page background. Lime is a fill that holds dark text.",
        "Use it as decoration where nothing is made by AI."
      ],
      "accessibility": [
        "Without a title, screen readers skip it and read only the words beside it.",
        "With a title, screen readers read it as an image with that name.",
        "It never takes keyboard focus."
      ],
      "accessibilityYouDo": [
        "Give it a title when it stands alone, so screen readers can say what it means.",
        "Check that it stands out from its background when alone. It copies the text color.",
        "Keep a button around it at least 24 pixels wide."
      ],
      "onPhone": [
        "The mark is a fixed pixel size and looks the same on a phone.",
        "It is not tappable. Put it inside a button if it starts something, and that button gets a 44px target on touch screens."
      ],
      "importPath": "import { AIMark } from \"./index\"",
      "sourcePath": "src/atoms/AIMark.tsx",
      "props": [
        {
          "name": "size",
          "type": "16 | 18 | 22 | 24 | 32",
          "required": false,
          "default": "18",
          "description": "Pixel size of the square mark."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Accessible name. When set the mark has role img and this label. When omitted it is hidden from assistive technology."
        },
        {
          "name": "...svg props",
          "type": "SVGProps<SVGSVGElement>",
          "required": false,
          "default": null,
          "description": "Other props go to the svg element, except width, height and title."
        }
      ],
      "variantsHint": {
        "size": [
          16,
          18,
          22,
          24,
          32
        ]
      },
      "tokens": [
        "currentColor",
        "--lime",
        "--on-lime"
      ],
      "related": [
        "ai-label",
        "ai-badge",
        "avatar"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Sizes",
          "description": "Five sizes. The first three match small, medium and large text. The last two are for use on their own.",
          "code": "<div className=\"flex items-end gap-4 text-fg\">\n  <AIMark size={16} /><AIMark size={18} /><AIMark size={22} /><AIMark size={24} /><AIMark size={32} />\n</div>"
        },
        {
          "title": "Paired with text",
          "description": "The mark sits beside words that say what will happen. A screen reader reads only the words.",
          "code": "<Button variant=\"secondary\"><AIMark size={16} />Summarize this page</Button>"
        },
        {
          "title": "Named image",
          "description": "When the mark stands alone, give it a title so screen readers can say what it means.",
          "code": "<span className=\"inline-flex size-8 items-center justify-center rounded-full bg-lime text-on-lime\">\n  <AIMark size={18} title=\"AI-generated\" />\n</span>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-ai-mark/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/ai-mark.md"
    },
    {
      "id": "ai-presence",
      "name": "AIPresence",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "voice orb",
        "ai avatar",
        "assistant indicator",
        "orb"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        }
      ],
      "summary": "A moving lime disc that shows whether the assistant is waiting, listening, thinking, speaking, or has hit a problem.",
      "description": "AIPresence is a flat lime disc with the AI mark in its centre, on a soft lime halo. It uses only theme colours, so it changes with the theme like every other part. Each state moves differently: Ready breathes slowly, Listening sends rings outward, Thinking turns an arc around the mark, Speaking pulses with the voice, and an error turns the disc red. When the state changes, it glides smoothly to the new look. The state is always written out in words too. For people who turn off motion, it stays still and switches instantly. It is not a button, so place a labelled Stop button next to it.",
      "purpose": "A drawn disc that shows what a voice assistant is doing: waiting, listening, thinking, speaking or stuck. It glides between states instead of restarting, and the state is always given in words too.",
      "useFor": [
        "A voice conversation, to show whether the assistant is listening or speaking.",
        "The pause while the assistant thinks between voice turns.",
        "Reacting to how loud the speaker is: pass level."
      ],
      "notFor": [
        {
          "text": "Showing who wrote a message",
          "use": "avatar"
        },
        {
          "text": "A whole voice screen with its controls",
          "use": "voice-panel"
        },
        {
          "text": "The wait before a text reply",
          "use": "typing-indicator"
        }
      ],
      "whenToUse": [
        "Place a labelled Stop button beside the disc whenever the person can interrupt.",
        "Ask for microphone permission when the person presses a button, then start the disc.",
        "Keep the label showing unless the state is written out nearby.",
        "Pass the raw volume and let the disc smooth it."
      ],
      "avoid": [
        "Make the disc the only button or the only sign of what is happening.",
        "Change the state many times a second. Screen readers announce only real changes.",
        "Use amber for the problem state. Amber is only for when a person has to act.",
        "Add a glow outside the disc, or use rainbow or purple gradients."
      ],
      "accessibility": [
        "Screen readers hear the state in words, politely, each time it changes.",
        "The drawing is hidden from screen readers.",
        "For people who turn off motion, the disc stays still, ignores volume and changes state instantly.",
        "The problem state shows the words \"Something went wrong\", not only a red center."
      ],
      "accessibilityYouDo": [
        "Put a labelled Stop button next to it whenever people can interrupt. The disc is not a button.",
        "Change state only when the assistant really changes what it is doing. Screen readers hear each change.",
        "Keep the visible label on, unless the state is written out nearby."
      ],
      "onPhone": [
        "The disc is a fixed size (32, 64 or 112px) and does not scale with the screen, so pick the size that fits a narrow screen.",
        "The disc and its label sit on one row, so a long label can squeeze a small screen. Keep the label short.",
        "It is not a control. Place a Stop button beside it, which has a 44px tap area on touch screens.",
        "Under reduced motion it draws a still frame instead of the animation."
      ],
      "importPath": "import { AIPresence } from \"./index\"",
      "sourcePath": "src/atoms/AIPresence.tsx",
      "props": [
        {
          "name": "state",
          "type": "\"idle\" | \"listening\" | \"thinking\" | \"speaking\" | \"error\"",
          "required": false,
          "default": "\"idle\"",
          "description": "Current state. Each state is a row of animation targets."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "default": "\"md\"",
          "description": "32, 64 or 112 pixels."
        },
        {
          "name": "level",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Audio amplitude from 0 to 1. Smoothed inside the component. Ignored under reduced motion."
        },
        {
          "name": "showLabel",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Show the state as visible text. The status region is present either way."
        },
        {
          "name": "labels",
          "type": "Partial<Record<AIPresenceState, string>>",
          "required": false,
          "default": null,
          "description": "Replace the text for any state."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the wrapper."
        }
      ],
      "variantsHint": {
        "state": [
          "idle",
          "listening",
          "thinking",
          "speaking",
          "error"
        ],
        "size": [
          "sm",
          "md",
          "lg"
        ]
      },
      "tokens": [
        "--code-bg",
        "--fg-subtle",
        "--lime",
        "--danger",
        "--fg-muted",
        "--danger-fg"
      ],
      "related": [
        "voice-panel",
        "avatar",
        "ai-mark"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Switch between states",
          "description": "The disc glides between states. The label and the hidden message for screen readers change only when the state does.",
          "code": "const [state, setState] = useState<AIPresenceState>(\"listening\");\n\n<AIPresence state={state} size=\"lg\" />\n{([\"idle\", \"listening\", \"thinking\", \"speaking\", \"error\"] as const).map((s) => (\n  <Button key={s} size=\"sm\" onClick={() => setState(s)}>{s}</Button>\n))}\n<Button size=\"sm\" variant=\"ghost\" onClick={() => setState(\"idle\")}>Stop</Button>"
        },
        {
          "title": "Audio level",
          "description": "Pass the sound volume from 0 to 1. The disc smooths it, rising fast and falling slowly.",
          "code": "<AIPresence state=\"speaking\" size=\"lg\" level={level} />"
        },
        {
          "title": "Sizes",
          "description": "",
          "code": "<AIPresence size=\"sm\" state=\"thinking\" />\n<AIPresence size=\"md\" state=\"thinking\" />\n<AIPresence size=\"lg\" state=\"thinking\" showLabel={false} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-ai-presence/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/ai-presence.md"
    },
    {
      "id": "ai-label",
      "name": "AILabel",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "ai generated label",
        "made with ai"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "The lime pill darkens slightly under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears on the label button on keyboard focus.",
          "source": "documented"
        },
        {
          "name": "open",
          "note": "Pressing the label opens a popover with the overview, details, resources and actions you pass, or a default line if none are passed.",
          "source": "documented"
        },
        {
          "name": "edited",
          "note": "Pass edited to replace the pill with a small revert button that calls onRevert.",
          "source": "documented"
        },
        {
          "name": "placed",
          "note": "Pass placed to pin the label to a corner or edge of its parent, depending on level.",
          "source": "documented"
        }
      ],
      "summary": "A small lime tag that says content was made by AI and explains how. Once a person edits the content, it becomes an undo button.",
      "description": "AILabel marks content as made by AI. Pressing it opens a small note with up to four parts: overview, details, links and buttons. It only labels and explains. It never starts AI work. The level setting picks where it sits: on a feature, a message, a field or an image. After an edit, the lime color goes away and an icon button brings back the AI version.",
      "purpose": "A small lime tag that marks content as made by AI and opens a note explaining how. Once a person edits the content it turns into an undo button, because the content is no longer purely AI.",
      "useFor": [
        "A box, message, field or image whose content the AI made.",
        "Explaining what the AI did, with details, links or buttons.",
        "Letting people go back to the AI version after they edit it."
      ],
      "notFor": [
        {
          "text": "A button that starts AI work",
          "use": "button"
        },
        {
          "text": "A simple mark with no explanation",
          "use": "ai-badge"
        },
        {
          "text": "A full record of where content came from",
          "use": "provenance-popover"
        }
      ],
      "whenToUse": [
        "Put one label on the whole box, not one on every line.",
        "Keep the label away from the edge of the box.",
        "Use the buttons part for things like Learn more or Turn off.",
        "Set edited to true as soon as a person changes the content, so the undo button appears.",
        "In onRevert, restore the AI version and set edited back to false."
      ],
      "avoid": [
        "Use the label as a button that starts AI work.",
        "Put it on content a person wrote.",
        "Make the label amber. Amber is only for when a person has to act.",
        "Push the label right up against the edge of a box."
      ],
      "accessibility": [
        "The tag is a real button. Screen readers hear its words, then \"About this AI content\", and whether the note is open.",
        "Opening the note moves the keyboard into it. Escape closes it and returns the keyboard to the tag; tabbing away or clicking outside also closes it.",
        "Each part of the note is named, such as Overview and Details.",
        "The undo button shows only an icon, but screen readers hear \"Revert to AI version\".",
        "The pressable area is at least 24 pixels tall, and 44 on touch screens."
      ],
      "accessibilityYouDo": [
        "Give buttons and links you put in the note their own clear words."
      ],
      "onPhone": [
        "On touch screens the pill keeps its size and has a 44px tap area. The revert button grows to 44px square.",
        "The explainer opens on tap and closes by tapping outside, and its panel is at most the screen width minus 2rem.",
        "Buttons in the Actions section wrap onto more lines on a narrow screen."
      ],
      "importPath": "import { AILabel } from \"./index\"",
      "sourcePath": "src/molecules/AILabel.tsx",
      "props": [
        {
          "name": "level",
          "type": "\"feature\" | \"message\" | \"field\" | \"image\"",
          "required": false,
          "default": "\"feature\"",
          "description": "Where the label sits. Feature is the upper right of a container. Field is the right side, vertically centred. Message and image are our own placements."
        },
        {
          "name": "size",
          "type": "16 | 18 | 22",
          "required": false,
          "default": "18",
          "description": "Pill and mark size. Match the adjacent text size."
        },
        {
          "name": "text",
          "type": "string",
          "required": false,
          "default": "\"AI\"",
          "description": "Word in the pill."
        },
        {
          "name": "placed",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Apply absolute placement for the level. The parent must be positioned."
        },
        {
          "name": "overview",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Toggletip section: what the AI did."
        },
        {
          "name": "details",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Toggletip section: specifics for this instance."
        },
        {
          "name": "resources",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Toggletip section: links."
        },
        {
          "name": "actions",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Toggletip section: buttons. The label itself never triggers AI work."
        },
        {
          "name": "edited",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Drops the tint and shows the icon-only revert button."
        },
        {
          "name": "onRevert",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called when revert is pressed. The app restores the AI version."
        },
        {
          "name": "revertLabel",
          "type": "string",
          "required": false,
          "default": "\"Revert to AI version\"",
          "description": "Accessible name of the revert button."
        },
        {
          "name": "popoverLabel",
          "type": "string",
          "required": false,
          "default": "\"About this AI content\"",
          "description": "Accessible name of the toggletip panel."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the wrapper."
        }
      ],
      "variantsHint": {
        "level": [
          "feature",
          "message",
          "field",
          "image"
        ],
        "size": [
          16,
          18,
          22
        ]
      },
      "tokens": [
        "--lime",
        "--lime-hover",
        "--on-lime",
        "--border",
        "--surface-raised",
        "--radius-control"
      ],
      "related": [
        "ai-mark",
        "ai-badge",
        "provenance-popover",
        "revert-toggle"
      ],
      "composedOf": [
        "ai-mark",
        "popover"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Feature container",
          "description": "Sits at the upper right of a box, a little away from the edge.",
          "code": "<div className=\"relative w-full max-w-sm rounded-card border border-line bg-surface p-5 pr-16 text-body leading-5 text-fg\">\n  Draft reply to the supplier.\n  <AILabel level=\"feature\" placed overview=\"This draft was written from the thread above.\" details=\"Nobody has checked it yet.\" />\n</div>"
        },
        {
          "title": "Toggletip sections",
          "description": "All four parts filled in. Buttons are plain buttons and links are links. Escape or a click outside closes the note.",
          "code": "<AILabel\n  overview=\"Summarized from 3 documents.\"\n  details=\"Names and dates were not checked.\"\n  resources={<a href=\"#sources\">How summaries are made</a>}\n  actions={<Button size=\"sm\" variant=\"secondary\">Learn more</Button>}\n/>"
        },
        {
          "title": "Edited, with revert",
          "description": "After an edit, the tag turns into an icon button named Revert to AI version. Pressing it brings the tag back.",
          "code": "<AILabel edited={edited} onRevert={() => setEdited(false)} overview=\"Written from the three documents you attached.\" />"
        },
        {
          "title": "Sizes",
          "description": "Match the size to the text around it.",
          "code": "<AILabel size={16} />\n<AILabel size={18} />\n<AILabel size={22} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-ai-label/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/ai-label.md"
    },
    {
      "id": "chart-frame",
      "name": "ChartFrame",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "chart container",
        "chart card",
        "chart wrapper"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "empty",
          "note": "Set with the empty prop.",
          "source": "prop"
        }
      ],
      "summary": "The box every chart sits in. It adds a title, a short written summary, a table view and clear messages when data is loading or missing.",
      "description": "ChartFrame gives a chart a title, a line saying what is measured and over what time, and a short summary in words. A View as table button swaps the chart for the same numbers in a table. It also shows messages while data loads, when there is none, or when something fails. In a very narrow space it shows a tiny trend line instead.",
      "purpose": "The box every chart sits in. It puts the title, what is measured and a summary in words above the chart, so the point is clear before anyone reads the lines.",
      "useFor": [
        "Any chart on a page, so it gets a title, a summary and a table view.",
        "Showing clear messages while data loads, when there is none, or when loading fails.",
        "A chart that may land in a narrow space: pass a small trend line as the fallback."
      ],
      "notFor": [
        {
          "text": "One headline number",
          "use": "stat-tile"
        },
        {
          "text": "A table of numbers with no chart",
          "use": "data-table"
        }
      ],
      "whenToUse": [
        "Write the summary as a sentence that says what the data shows, with the key number.",
        "Say what is measured and over what time in the subtitle.",
        "Give every chart a table, so the table button is always there.",
        "When there is nothing to show, say why and what to do next.",
        "Pass onRetry, so the error message offers a Try again button."
      ],
      "avoid": [
        "Don't skip the summary because the chart looks obvious. Some people need the words.",
        "Don't hide numbers in a pop-up. Put them in the table.",
        "Don't put filters inside the box. Keep them in one row above the charts they control.",
        "Don't make the box shorter than the chart and its labels."
      ],
      "accessibility": [
        "The title and summary sit in the chart's caption, so screen readers hear them with the chart.",
        "View as table swaps in a real table with the same numbers, and the button label changes to match the view.",
        "While data loads, screen readers hear \"Loading chart\".",
        "A failure is announced right away."
      ],
      "accessibilityYouDo": [
        "Write a summary that says what the data shows, with the key number. Some people only get the words.",
        "Pass table data for every chart, or the View as table button does not appear."
      ],
      "onPhone": [
        "The chart is drawn at the width of the frame, and below minWidth (280px unless you change it) it is replaced by your fallback, usually a Sparkline, with a note to use the table view.",
        "Cards use 16px padding and the title wraps instead of being cut off.",
        "The View as table button swaps in a DataTable that scrolls sideways inside the frame, so give the table a narrow column set for phones."
      ],
      "importPath": "import { ChartFrame } from \"./index\"",
      "sourcePath": "src/molecules/ChartFrame.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Chart title, set in the display style."
        },
        {
          "name": "subtitle",
          "type": "string",
          "required": false,
          "default": null,
          "description": "States the unit and the time window."
        },
        {
          "name": "summary",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Plain-language reading of the chart, shown above it in body text."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The chart. It receives the full width of the frame."
        },
        {
          "name": "table",
          "type": "DataTableProps",
          "required": false,
          "default": null,
          "description": "Data for the table view. Without it the toggle is hidden. Each chart exports a helper that builds it."
        },
        {
          "name": "source",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Source line shown under the chart."
        },
        {
          "name": "note",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Caveat shown after the source, for example that the latest point is still streaming."
        },
        {
          "name": "legend",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Rendered above the chart. Charts draw their own legend, so use this only for a shared legend."
        },
        {
          "name": "state",
          "type": "\"ready\" | \"loading\" | \"empty\" | \"error\"",
          "required": false,
          "default": "\"ready\"",
          "description": "Anything other than ready replaces the chart."
        },
        {
          "name": "empty",
          "type": "ChartEmptyState",
          "required": false,
          "default": null,
          "description": "What is missing, why, and an optional action. Shown in the empty state."
        },
        {
          "name": "errorMessage",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Message for the error state. A default says nothing was lost."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called by the retry button in the error state."
        },
        {
          "name": "fallback",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Shown instead of the chart when the frame is narrower than minWidth."
        },
        {
          "name": "minWidth",
          "type": "number",
          "required": false,
          "default": "280",
          "description": "Narrowest width in px at which the chart is drawn."
        },
        {
          "name": "defaultView",
          "type": "\"chart\" | \"table\"",
          "required": false,
          "default": "\"chart\"",
          "description": "Which view is shown first."
        },
        {
          "name": "onDownload",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Adds a Download data button and calls this when pressed."
        },
        {
          "name": "stateHeight",
          "type": "number",
          "required": false,
          "default": "200",
          "description": "Height in px of the loading, empty and error states, so the layout does not jump."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the figure."
        }
      ],
      "variantsHint": {
        "state": [
          "ready",
          "loading",
          "empty",
          "error"
        ],
        "defaultView": [
          "chart",
          "table"
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--chart-axis",
        "--font-serif",
        "--radius-card"
      ],
      "related": [
        "data-table",
        "line-chart",
        "bar-chart",
        "sparkline"
      ],
      "composedOf": [
        "button",
        "skeleton",
        "error-state",
        "data-table"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A chart with a summary and a table",
          "description": "The summary says what the chart shows. View as table swaps the chart for the same numbers in a table.",
          "code": "<ChartFrame\n  title=\"How long orders take\"\n  subtitle=\"Minutes per order, last 7 days\"\n  summary=\"The slowest orders took 82 minutes on Friday. A typical order stayed between 26 and 30 minutes all week.\"\n  source=\"Source: order records.\"\n  table={lineChartTable(DAYS, LATENCY, { xHeader: \"Day\", caption: \"How long orders take, last 7 days\", unit: \"min\" })}\n>\n  <LineChart series={LATENCY} x={DAYS} unit=\"minutes\" ariaLabel=\"How long orders take\" />\n</ChartFrame>"
        },
        {
          "title": "Loading, nothing to show, and a problem",
          "description": "Loading shows a grey placeholder. When there is nothing to show, it says what is missing, why and what to do. A problem offers a retry.",
          "code": "<ChartFrame title=\"How long orders take\" subtitle=\"Minutes per order, last 7 days\" state=\"loading\" />\n<ChartFrame\n  title=\"How long orders take\"\n  subtitle=\"Minutes per order, last 7 days\"\n  state=\"empty\"\n  empty={{\n    what: \"No orders in this time range.\",\n    why: \"Your filters hide every shop.\",\n    action: <Button size=\"sm\" variant=\"secondary\">Clear filters</Button>,\n  }}\n/>\n<ChartFrame title=\"How long orders take\" subtitle=\"Minutes per order, last 7 days\" state=\"error\" onRetry={() => {}} />"
        },
        {
          "title": "A tiny version in narrow spaces",
          "description": "This box is too narrow for a full chart, so it shows a tiny trend line. The table view still has every value.",
          "code": "<div className=\"w-55\">\n  <ChartFrame\n    title=\"Slowest orders\"\n    subtitle=\"Minutes, last 7 days\"\n    table={lineChartTable(DAYS, [LATENCY[1]], { xHeader: \"Day\", caption: \"Slowest orders, last 7 days\", unit: \"min\" })}\n    fallback={<Sparkline data={LATENCY[1].data as number[]} label=\"Slowest orders\" unit=\" min\" />}\n  >\n    <LineChart series={[LATENCY[1]]} x={DAYS} />\n  </ChartFrame>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-chart-frame/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/chart-frame.md"
    },
    {
      "id": "data-table",
      "name": "DataTable",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "table",
        "data grid",
        "grid table"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "focus",
          "note": "The scrollable table region can be focused with the keyboard and shows a focus ring.",
          "source": "documented"
        },
        {
          "name": "sticky header",
          "note": "Pass stickyHeader (with maxHeight) to keep the column headings in view while the body scrolls.",
          "source": "documented"
        },
        {
          "name": "scrolling",
          "note": "A table wider or taller than its box scrolls inside it instead of stretching the page.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "With no rows, a single line (emptyText, \"No rows.\" by default) is shown under the headings.",
          "source": "documented"
        },
        {
          "name": "sorted",
          "note": "A sortable heading (sortable on the column) cycles none, ascending, descending when pressed; the table shows arrows and says how it is sorted.",
          "source": "documented"
        },
        {
          "name": "dense",
          "note": "Pass density=\"dense\" for tighter rows.",
          "source": "documented"
        },
        {
          "name": "up or down",
          "note": "A column's direction function adds an arrow and the up or down colour to a cell.",
          "source": "documented"
        },
        {
          "name": "current row",
          "note": "The row matching activeRowKey is marked with aria-current and a sunken background.",
          "source": "documented"
        },
        {
          "name": "row hover",
          "note": "When onRowClick is set, rows highlight on hover and the row name is a button.",
          "source": "documented"
        }
      ],
      "summary": "The numbers behind a chart, in a plain table with digits lined up.",
      "description": "DataTable shows numbers as a table. The first column names each row. Numbers line up on the right so they are easy to compare. Headings can sort, a cell can carry an up or down marker, and rows can be dense for screens people read all day. On a narrow screen the table scrolls sideways. ChartFrame uses it for the View as table view.",
      "purpose": "The numbers behind a chart, in a plain table. Numbers line up on the right with equal-width digits, so a column can be compared at a glance.",
      "useFor": [
        "The table view of a chart, inside ChartFrame.",
        "A small set of numbers that people need to read and compare exactly.",
        "Giving every value that a chart shows only in a pop-up a place to be read.",
        "A watchlist, a positions list or any list of numbers people sort and scan: turn on sortable headings, dense rows and the up or down marker."
      ],
      "notFor": [
        {
          "text": "Laying out a page or a form"
        },
        {
          "text": "A side-by-side comparison of products or plans",
          "use": "comparison-table"
        },
        {
          "text": "One headline number",
          "use": "stat-tile"
        }
      ],
      "whenToUse": [
        "Put the unit in each number column heading.",
        "Use commas in big numbers and the same decimals down a column.",
        "Include every number the chart shows, even ones that only appear in a pop-up.",
        "Mark missing values with words such as \"No data\"."
      ],
      "avoid": [
        "Don't line text up on the right.",
        "Don't merge cells or stack headings. Keep one row of headings.",
        "Don't use it to lay out a page. It is for data."
      ],
      "accessibility": [
        "It is a real table: the first column names each row, and screen readers pair each number with its headings.",
        "The caption is read by screen readers and also names the scrolling area.",
        "The scrolling area can be reached with the Tab key, so keyboard users can scroll it sideways.",
        "Number columns line up on the right with equal-width digits.",
        "A sortable heading is a real button inside the heading cell. The heading carries aria-sort, and a polite message says how the table is now sorted.",
        "An up or down marker has an arrow shape and a hidden word, so direction is not colour alone.",
        "When rows can be chosen, the row name is a real button and the chosen row has aria-current."
      ],
      "accessibilityYouDo": [
        "Write a caption that says what the table holds. It is hidden on screen, and screen readers use it as the table's name.",
        "Put the unit in each number column heading, so each number is heard with its unit.",
        "Write missing values as words, such as \"No data\", not as an empty cell.",
        "The up or down marker adds an arrow and a hidden word, but your text must still carry its own sign (+ or −). Do not leave the number unsigned and rely on the colour.",
        "If you keep the sort in your own code, pass sort and onSortChange together, or the headings will look broken."
      ],
      "onPhone": [
        "By default the table keeps its columns and scrolls sideways inside its own box, so the page itself never scrolls sideways.",
        "Set responsive=\"stack\" and a narrow container turns each row into a card with the column name before each value, so nothing scrolls.",
        "Sort buttons in the header and the first-column row button are 44px tall on touch screens.",
        "Tapping anywhere on a row calls onRowClick, so there is no hover state to rely on."
      ],
      "importPath": "import { DataTable } from \"./index\"",
      "sourcePath": "src/molecules/DataTable.tsx",
      "props": [
        {
          "name": "columns",
          "type": "DataTableColumn[]",
          "required": true,
          "default": null,
          "description": "Column definitions: key, header, numeric, sortable, sortValue and direction. The first column is the row header. sortValue gives the raw number or text to sort by when the cell shows formatted text. direction returns \"up\", \"down\" or \"flat\" for a cell, which adds an arrow and the up or down colour."
        },
        {
          "name": "rows",
          "type": "Record<string, ReactNode>[]",
          "required": true,
          "default": null,
          "description": "One object per row, keyed by column key. Values are already formatted."
        },
        {
          "name": "caption",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Names the table and states what it holds. It is also the accessible name of the scroll region."
        },
        {
          "name": "stickyHeader",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Keeps the header row visible while the body scrolls. Pair it with maxHeight."
        },
        {
          "name": "maxHeight",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Maximum height in px before the table scrolls vertically."
        },
        {
          "name": "density",
          "type": "\"comfortable\" | \"dense\"",
          "required": false,
          "default": "\"comfortable\"",
          "description": "Row spacing. Dense fits more rows."
        },
        {
          "name": "sort",
          "type": "SortState | null",
          "required": false,
          "default": null,
          "description": "The current sort, when your code keeps it. SortState is { key, direction: \"asc\" | \"desc\" }. Use with onSortChange."
        },
        {
          "name": "defaultSort",
          "type": "SortState | null",
          "required": false,
          "default": "null",
          "description": "The starting sort when the table keeps it itself."
        },
        {
          "name": "onSortChange",
          "type": "(sort: SortState | null) => void",
          "required": false,
          "default": null,
          "description": "Called when a sortable heading is pressed. Each press goes none, ascending, descending, none."
        },
        {
          "name": "onRowClick",
          "type": "(row: DataRow, index: number) => void",
          "required": false,
          "default": null,
          "description": "Called when a row is chosen. The row name becomes a button, so the keyboard works. Clicking elsewhere on the row also calls it."
        },
        {
          "name": "activeRowKey",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Key of the row to mark as the current one (aria-current). Matches rowKey."
        },
        {
          "name": "rowKey",
          "type": "(row: DataRow, index: number) => string",
          "required": false,
          "default": null,
          "description": "A stable key per row. Defaults to the row's id value, then its position. Needed for activeRowKey when rows reorder."
        },
        {
          "name": "responsive",
          "type": "\"scroll\" | \"stack\"",
          "required": false,
          "default": "\"scroll\"",
          "description": "What a narrow container does with the table. scroll keeps the columns and scrolls sideways. stack turns each row into a card, with the column name before each value, so nothing scrolls. It follows the width of the table's own container, not the screen."
        },
        {
          "name": "emptyText",
          "type": "string",
          "required": false,
          "default": "\"No rows.\"",
          "description": "Shown when there are no rows."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the scroll region."
        }
      ],
      "variantsHint": {
        "density": [
          "comfortable",
          "dense"
        ],
        "responsive": [
          "scroll",
          "stack"
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--fg",
        "--fg-muted",
        "--up-fg",
        "--down-fg",
        "--surface-hover",
        "--radius-card"
      ],
      "related": [
        "chart-frame",
        "bar-chart",
        "line-chart",
        "stat-tile",
        "sparkline"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Wait times by shop",
          "description": "Number columns line up on the right. Values are already formatted.",
          "code": "<div className=\"w-full max-w-xl\">\n  <DataTable\n    caption=\"Wait times by shop, last 7 days\"\n    columns={[\n      { key: \"model\", header: \"Shop\" },\n      { key: \"p50\", header: \"Typical (min)\", numeric: true },\n      { key: \"p95\", header: \"Slowest (min)\", numeric: true },\n      { key: \"calls\", header: \"Orders\", numeric: true },\n    ]}\n    rows={[\n      { model: \"North shop\", p50: \"28\", p95: \"77\", calls: \"48,200\" },\n      { model: \"East shop\", p50: \"24\", p95: \"66\", calls: \"31,450\" },\n      { model: \"West shop\", p50: \"18\", p95: \"41\", calls: \"12,030\" },\n    ]}\n  />\n</div>"
        },
        {
          "title": "A long table with fixed headings",
          "description": "For long tables, headings stay in view while the rows scroll.",
          "code": "<div className=\"w-full max-w-md\">\n  <DataTable\n    stickyHeader\n    maxHeight={160}\n    caption=\"Daily sales, last 7 days\"\n    columns={[{ key: \"day\", header: \"Day\" }, { key: \"cost\", header: \"Sales (USD)\", numeric: true }]}\n    rows={DAYS.map((d, i) => ({ day: d, cost: \"$\" + [310, 325, 298, 340, 352, 365, 380][i] }))}\n  />\n</div>"
        },
        {
          "title": "Cards on a phone",
          "description": "Set responsive to stack and each row becomes a card when the table is narrow, with the column name before each value. Nothing scrolls sideways. This frame is narrow on purpose, so you see the phone layout here.",
          "code": "<div className=\"w-72\">\n  <DataTable\n    responsive=\"stack\"\n    caption=\"Wait times by shop, last 7 days\"\n    columns={[\n      { key: \"shop\", header: \"Shop\" },\n      { key: \"p50\", header: \"Typical (min)\", numeric: true },\n      { key: \"p95\", header: \"Slowest (min)\", numeric: true },\n    ]}\n    rows={[\n      { shop: \"North shop\", p50: \"28\", p95: \"77\" },\n      { shop: \"East shop\", p50: \"24\", p95: \"66\" },\n    ]}\n  />\n</div>"
        },
        {
          "title": "Sortable, dense, with up and down",
          "description": "A watchlist. Headings sort (none, then ascending, then descending). The change column gets an arrow and a colour. Pressing a row name chooses that row.",
          "code": "function Example() {\n  const [active, setActive] = useState(\"TCS\");\n  return (\n    <DataTable\n      caption=\"Watchlist, sorted by any column\"\n      density=\"dense\"\n      defaultSort={{ key: \"chg\", direction: \"desc\" }}\n      activeRowKey={active}\n      onRowClick={(r) => setActive(String(r.id))}\n      columns={[\n        { key: \"name\", header: \"Stock\", sortable: true },\n        { key: \"price\", header: \"Price (₹)\", numeric: true, sortable: true,\n          sortValue: (r) => Number(String(r.price).replace(/,/g, \"\")) },\n        { key: \"chg\", header: \"Change\", numeric: true, sortable: true,\n          sortValue: (r) => Number(String(r.chg).replace(\"−\", \"-\").replace(\"%\", \"\")),\n          direction: (r) => String(r.chg).startsWith(\"+\") ? \"up\" : String(r.chg).startsWith(\"−\") ? \"down\" : \"flat\" },\n      ]}\n      rows={[\n        { id: \"RELIANCE\", name: \"Reliance\", price: \"2,874.50\", chg: \"+1.42%\" },\n        { id: \"TCS\", name: \"TCS\", price: \"3,912.10\", chg: \"−0.63%\" },\n        { id: \"INFY\", name: \"Infosys\", price: \"1,688.25\", chg: \"0.00%\" },\n        { id: \"HDFCBANK\", name: \"HDFC Bank\", price: \"1,642.80\", chg: \"−1.18%\" },\n      ]}\n    />\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-data-table/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/data-table.md"
    },
    {
      "id": "stat-tile",
      "name": "StatTile",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "statistic",
        "stat",
        "kpi",
        "metric",
        "metric card",
        "kpi card",
        "number card"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "better",
          "note": "When the delta direction matches goodDirection, the change is shown in green with the word better.",
          "source": "documented"
        },
        {
          "name": "worse",
          "note": "When the delta direction is the opposite of goodDirection, the change is shown in red with the word worse.",
          "source": "documented"
        },
        {
          "name": "flat or neutral",
          "note": "A flat change, or goodDirection set to neutral, is shown in the muted colour with no verdict.",
          "source": "documented"
        },
        {
          "name": "needs action",
          "note": "Pass needsAction to add an attention tag and turn the threshold bar to the warning colour.",
          "source": "documented"
        },
        {
          "name": "threshold",
          "note": "Pass threshold to show a meter bar with a marker at the limit and its label below.",
          "source": "documented"
        }
      ],
      "summary": "One big number with how it changed, an optional tiny trend line and an optional limit.",
      "description": "StatTile shows a name, a big number and how much it changed. The change has an arrow, a plus or minus, and the word \"better\" or \"worse\", so color is never the only clue. You say which direction is good, so a drop in wait time reads as good. Amber shows only when a person has to act.",
      "purpose": "One headline number with how it changed. The change says \"better\" or \"worse\" in words, and you choose which direction is good, so a drop in wait time reads as good news.",
      "useFor": [
        "A row of the few numbers that matter most, above the charts.",
        "A number with its change since the last period.",
        "A number measured against a limit or target, shown on a small scale."
      ],
      "notFor": [
        {
          "text": "A change over time that people need to read point by point",
          "use": "line-chart"
        },
        {
          "text": "Comparing several amounts",
          "use": "bar-chart"
        },
        {
          "text": "How much of an allowance is used or left",
          "use": "usage-meter"
        }
      ],
      "whenToUse": [
        "Say which direction is good on every tile, so lower-is-better numbers read correctly.",
        "Say what the change is compared to, such as the previous 7 days.",
        "For rates from small groups, show the range or group size in the detail line.",
        "Put one row of tiles above the charts for the numbers that matter most."
      ],
      "avoid": [
        "Don't use amber for a number that just got worse. Amber means a person must act.",
        "Don't rely on color for the change. The arrow, the plus or minus and the word \"better\" or \"worse\" carry the meaning.",
        "Don't draw a chart with one bar when a tile says it.",
        "Don't add labels to the trend line. The tile's number and change say it."
      ],
      "accessibility": [
        "Each tile is a group named by its label.",
        "Screen readers hear the change as a direction word, such as \"up\", then the amount and \"better\" or \"worse\".",
        "The arrow is hidden from screen readers. Color only repeats what the words say.",
        "The limit scale is a meter, read with the value and the limit's label."
      ],
      "accessibilityYouDo": [
        "Set goodDirection on every tile where lower is better, or a drop is called \"worse\".",
        "Say what the change is compared to in versus, such as \"vs previous 7 days\".",
        "Pass a trend line with decorative set, so screen readers do not hear the numbers twice."
      ],
      "onPhone": [
        "The tile fills the width of its container and the value breaks onto a new line instead of overflowing.",
        "The sparkline stays at its own size on the right and the text on the left shrinks first.",
        "Nothing in the tile needs hover or a tap."
      ],
      "importPath": "import { StatTile } from \"./index\"",
      "sourcePath": "src/molecules/StatTile.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Metric name in sentence case, without a trailing colon."
        },
        {
          "name": "value",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Formatted value, for example 1.24 s or $4.2K."
        },
        {
          "name": "serif",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Sets the value in the display style."
        },
        {
          "name": "delta",
          "type": "StatDelta",
          "required": false,
          "default": null,
          "description": "text (signed, with unit), direction (up, down or flat) and an optional versus period."
        },
        {
          "name": "goodDirection",
          "type": "\"up\" | \"down\" | \"neutral\"",
          "required": false,
          "default": "\"up\"",
          "description": "Which direction of change is an improvement. Use down for latency, cost and error rate. Neutral shows no better or worse word."
        },
        {
          "name": "detail",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Visible line under the value, such as an interval and sample size."
        },
        {
          "name": "spark",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Slot for a Sparkline. Pass it with decorative set."
        },
        {
          "name": "threshold",
          "type": "StatThreshold",
          "required": false,
          "default": null,
          "description": "value, max, at and label. Draws a thin scale with a tick at the threshold."
        },
        {
          "name": "needsAction",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Shows the amber chip and turns the threshold fill amber. Use only when a person has to act."
        },
        {
          "name": "actionText",
          "type": "string",
          "required": false,
          "default": "\"Needs review\"",
          "description": "Text of the amber chip."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the tile."
        }
      ],
      "variantsHint": {
        "goodDirection": [
          "up",
          "down",
          "neutral"
        ]
      },
      "tokens": [
        "--success-fg",
        "--danger-fg",
        "--attention-soft",
        "--attention-fg",
        "--fg",
        "--fg-muted",
        "--border",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "sparkline",
        "meter",
        "line-chart"
      ],
      "composedOf": [
        "meter"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A row of shop numbers",
          "description": "Seven tiles for a shop dashboard. For wait time, sales cost and refunds, lower is better. The happy-customer tile shows its range and sample size.",
          "code": "<div className=\"grid w-full gap-3 sm:grid-cols-2 lg:grid-cols-3\">\n  <StatTile label=\"Typical wait\" value=\"28 min\" goodDirection=\"down\" delta={{ text: \"-4.1%\", direction: \"down\", versus: \"vs previous 7 days\" }} spark={<Sparkline decorative label=\"Typical wait\" data={[830, 812, 845, 820, 790, 805, 812]} />} />\n  <StatTile label=\"Longest waits\" value=\"77 min\" goodDirection=\"down\" delta={{ text: \"+6.0%\", direction: \"up\", versus: \"vs previous 7 days\" }} spark={<Sparkline decorative label=\"Longest waits\" data={[2050, 2100, 2200, 2180, 2300, 2250, 2310]} />} />\n  <StatTile label=\"Time to answer the phone\" value=\"41 s\" goodDirection=\"down\" delta={{ text: \"-2.8%\", direction: \"down\", versus: \"vs previous 7 days\" }} spark={<Sparkline decorative label=\"Time to answer the phone\" data={[440, 430, 420, 425, 415, 412, 410]} />} />\n  <StatTile label=\"Orders\" value=\"18.4K\" goodDirection=\"neutral\" delta={{ text: \"+9.2%\", direction: \"up\", versus: \"vs previous 7 days\" }} spark={<Sparkline decorative label=\"Orders\" data={[15.2, 16.1, 15.8, 17.0, 17.6, 18.0, 18.4]} />} />\n  <StatTile label=\"Refunds paid\" value=\"$1,240\" goodDirection=\"down\" delta={{ text: \"+5.1%\", direction: \"up\", versus: \"vs previous 7 days\" }} spark={<Sparkline decorative label=\"Refunds paid\" data={[980, 1020, 1100, 1130, 1180, 1210, 1240]} />} />\n  <StatTile label=\"Refund rate\" value=\"0.8%\" goodDirection=\"down\" delta={{ text: \"-0.2 pts\", direction: \"down\", versus: \"vs previous 7 days\" }} spark={<Sparkline decorative label=\"Refund rate\" data={[0.6, 0.7, 0.6, 0.9, 1.1, 1.0, 0.8]} />} />\n  <StatTile label=\"Happy customers\" value=\"92.5%\" goodDirection=\"up\" detail=\"Likely between 88.1% and 95.4%, from 200 customers\" delta={{ text: \"+1.5 pts\", direction: \"up\", versus: \"vs last survey\" }} />\n</div>"
        },
        {
          "title": "A limit and a call to act",
          "description": "The tick marks the limit on a thin scale. The amber chip and amber fill are for when a person has to review.",
          "code": "<div className=\"grid w-full gap-3 sm:grid-cols-2\">\n  <StatTile label=\"Longest waits\" value=\"77 min\" goodDirection=\"down\" delta={{ text: \"+6.0%\", direction: \"up\", versus: \"vs previous 7 days\" }} threshold={{ value: 77, max: 100, at: 90, label: \"Limit 90 min\" }} />\n  <StatTile label=\"Refund rate\" value=\"2.4%\" goodDirection=\"down\" needsAction actionText=\"Needs review\" delta={{ text: \"+1.6 pts\", direction: \"up\", versus: \"vs previous 7 days\" }} threshold={{ value: 2.4, max: 4, at: 2, label: \"Limit 2%\" }} />\n</div>"
        },
        {
          "title": "A headline number",
          "description": "Use the larger style for the one tile that leads a page.",
          "code": "<div className=\"w-full max-w-xs\">\n  <StatTile serif label=\"Happy customers\" value=\"92.5%\" detail=\"Likely between 88.1% and 95.4%, from 200 customers\" />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-stat-tile/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/stat-tile.md"
    },
    {
      "id": "sparkline",
      "name": "Sparkline",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "mini chart",
        "trend line"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "With an empty data array, nothing is drawn.",
          "source": "documented"
        },
        {
          "name": "single point",
          "note": "With one value, only the end dot is drawn and no line.",
          "source": "documented"
        },
        {
          "name": "min and max",
          "note": "Pass showMinMax to mark the lowest and highest points with small rings.",
          "source": "documented"
        },
        {
          "name": "decorative",
          "note": "Pass decorative to hide it from screen readers when the number beside it already says the same thing.",
          "source": "documented"
        }
      ],
      "summary": "A tiny trend line that highlights the latest value and can be read aloud.",
      "description": "Sparkline draws a small line and marks the latest value with a bigger dot. Optional dots mark the lowest and highest points. A screen reader hears the first, last, lowest and highest values. Inside a StatTile it is only decoration, because the tile already shows the number.",
      "purpose": "A tiny trend line with the latest value marked, for places with no room for a full chart. It has no axes or labels, so it always sits beside a number.",
      "useFor": [
        "Beside a headline number, inside a StatTile.",
        "A table cell or list row that shows a trend.",
        "The fallback in a ChartFrame that is too narrow for the full chart."
      ],
      "notFor": [
        {
          "text": "A trend people need to read exact values from",
          "use": "line-chart"
        },
        {
          "text": "The only place the numbers appear",
          "use": "data-table"
        }
      ],
      "whenToUse": [
        "Put it beside a number that gives the current value.",
        "Cover the same time as the number beside it.",
        "Use it in place of a chart that is too narrow to draw."
      ],
      "avoid": [
        "Don't add axes, grid lines or labels. Use a LineChart for that.",
        "Don't let a screen reader read it inside a tile. It would repeat the tile.",
        "Don't compare trend lines as if they share a scale when they do not."
      ],
      "accessibility": [
        "On its own, screen readers hear the label with the first, last, lowest and highest values.",
        "With decorative set, it is hidden from screen readers.",
        "The latest point is larger, with a ring that keeps it clear of the line."
      ],
      "accessibilityYouDo": [
        "Set decorative when the number beside it already says the same thing, such as inside a StatTile.",
        "Give it a label and unit that say what it measures. Screen readers use them.",
        "Put the full numbers in a table somewhere on the page."
      ],
      "onPhone": [
        "It is a fixed-size picture, 96 by 28px unless you pass width and height, so it does not resize with its container.",
        "It has no hover or tap behaviour, so put the numbers next to it in text.",
        "Set width yourself if you need it larger on a phone."
      ],
      "importPath": "import { Sparkline } from \"./index\"",
      "sourcePath": "src/organisms/Sparkline.tsx",
      "props": [
        {
          "name": "data",
          "type": "number[]",
          "required": true,
          "default": null,
          "description": "Values in time order."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "What the line measures. Used in the accessible name."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "default": "\"\"",
          "description": "Unit appended to values in the accessible name. Include a leading space if needed."
        },
        {
          "name": "formatValue",
          "type": "(n: number) => string",
          "required": false,
          "default": "formatNumber",
          "description": "Formats values in the accessible name."
        },
        {
          "name": "width",
          "type": "number",
          "required": false,
          "default": "96",
          "description": "Width in px."
        },
        {
          "name": "height",
          "type": "number",
          "required": false,
          "default": "28",
          "description": "Height in px."
        },
        {
          "name": "showMinMax",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Marks the minimum and maximum with hollow dots."
        },
        {
          "name": "decorative",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Hides the sparkline from assistive technology. Use when a visible number carries the same information."
        },
        {
          "name": "color",
          "type": "string",
          "required": false,
          "default": "\"var(--chart-1)\"",
          "description": "Color of the emphasized end point."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the svg."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--chart-axis",
        "--chart-1",
        "--surface"
      ],
      "related": [
        "stat-tile",
        "line-chart",
        "chart-frame"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Plain, and with lowest and highest points",
          "description": "The latest point has the main chart color. The line stays grey so the latest value stands out.",
          "code": "<Sparkline label=\"Slowest orders\" unit=\" min\" data={[2050, 2100, 2200, 2180, 2300, 2250, 2310]} />\n<Sparkline label=\"Slowest orders\" unit=\" min\" showMinMax width={160} height={36} data={[2050, 2100, 2200, 2180, 2300, 2250, 2310]} />"
        },
        {
          "title": "Inside a tile",
          "description": "Here it is decoration only, so screen readers skip it.",
          "code": "<div className=\"w-56\">\n  <StatTile label=\"Refunds paid\" value=\"$1,240\" goodDirection=\"down\" spark={<Sparkline decorative label=\"Refunds paid\" data={[980, 1020, 1100, 1130, 1180, 1210, 1240]} />} />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-sparkline/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/sparkline.md"
    },
    {
      "id": "line-chart",
      "name": "LineChart",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "line graph",
        "time series chart"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "streaming",
          "note": "Set with the streaming prop.",
          "source": "prop"
        }
      ],
      "summary": "A line chart for change over time. Lines are named at their ends and can be explored with the keyboard.",
      "description": "LineChart draws up to five lines. Solid lines are real numbers and dotted lines are guesses about the future. From the third line on, a dash pattern and a marker shape back up the color. With two to four lines, each is named at its end. You can tab to the chart and use the arrow keys to move between points.",
      "purpose": "Shows change over time for up to five lines. Lines are named at their ends where there is room, so people do not have to look back and forth at a key.",
      "useFor": [
        "A measure tracked over days, hours or months.",
        "Comparing a few groups over the same time.",
        "A forecast after real values: set where the projection starts.",
        "Live data where the latest point may still change: turn on streaming."
      ],
      "notFor": [
        {
          "text": "Comparing amounts at one point in time",
          "use": "bar-chart"
        },
        {
          "text": "A trend in a very small space",
          "use": "sparkline"
        },
        {
          "text": "A best guess with a range",
          "use": "dot-plot"
        }
      ],
      "whenToUse": [
        "Use it for change over time. Keep to four lines where you can.",
        "Use solid lines for real numbers and dotted lines only for forecasts.",
        "Put it in a ChartFrame with a summary and a table.",
        "Switch to several small charts when lines bunch up and their names would overlap.",
        "Group extra lines into one \"Other\" line. Lines after the fifth are not drawn."
      ],
      "avoid": [
        "Don't put two different measures on one chart. Use two charts.",
        "Don't add a sixth line. Group the smallest into one grey \"Other\" line.",
        "Don't change the other colors when a filter removes a line. Each line keeps its color.",
        "Don't use amber for a line. It is saved for things that need a person."
      ],
      "accessibility": [
        "You can tab to the chart. Left and Right move between points, Up and Down switch lines, Home and End jump to the ends, and Escape clears.",
        "Each step is read out as line, time, value and unit, with \"projected\" or \"provisional\" added when it applies.",
        "From the third line on, a dash pattern and a marker shape back up the color.",
        "The pop-up is hidden from screen readers, because the same values are read out as you move.",
        "The draw-in animation is skipped when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Put it in a ChartFrame with a summary and a table.",
        "Give it an ariaLabel and a unit, so each point is read with what it measures."
      ],
      "onPhone": [
        "The chart measures its container and redraws at that width, so it fills a phone screen.",
        "Touch or drag on the chart to show the tooltip. Vertical swipes still scroll the page, and the tooltip stays until you tap elsewhere.",
        "The tooltip flips to the left of your finger past the middle of the chart, and axis labels at the edges are pulled inward so they are not cut off.",
        "Give the chart a height of about 200 to 240px on a phone, and it never goes below 160px."
      ],
      "importPath": "import { LineChart } from \"./index\"",
      "sourcePath": "src/organisms/LineChart.tsx",
      "props": [
        {
          "name": "series",
          "type": "LineSeries[]",
          "required": true,
          "default": null,
          "description": "Up to five series. Each has id, label, data (numbers or null) and optional projectionFrom. Color follows position."
        },
        {
          "name": "x",
          "type": "string[]",
          "required": true,
          "default": null,
          "description": "X labels, one per data position, already formatted."
        },
        {
          "name": "formatValue",
          "type": "(n: number) => string",
          "required": false,
          "default": "formatNumber",
          "description": "Formats the axis, labels and tooltip."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "default": "\"\"",
          "description": "Unit word read after each value by screen readers."
        },
        {
          "name": "height",
          "type": "number",
          "required": false,
          "default": "240",
          "description": "Plot height in px including the x axis band. Minimum 160."
        },
        {
          "name": "yDomain",
          "type": "[number, number]",
          "required": false,
          "default": null,
          "description": "Fixed y domain. Overrides zeroBaseline."
        },
        {
          "name": "zeroBaseline",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Start the y axis at zero when all values are non-negative."
        },
        {
          "name": "streaming",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Makes the latest point of each series hollow because it is not final."
        },
        {
          "name": "animate",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Reveals the lines once on mount, from left to right in --dur-slow on --ease-arrive. Skipped when the person prefers reduced motion."
        },
        {
          "name": "ariaLabel",
          "type": "string",
          "required": false,
          "default": "\"Line chart\"",
          "description": "Name for the chart region."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--chart-1",
        "--chart-2",
        "--chart-3",
        "--chart-4",
        "--chart-5",
        "--chart-grid",
        "--chart-axis",
        "--surface",
        "--fg",
        "--fg-muted",
        "--dur-slow",
        "--ease-arrive",
        "--radius-overlay"
      ],
      "related": [
        "chart-frame",
        "chart-legend",
        "sparkline",
        "bar-chart"
      ],
      "composedOf": [
        "chart-legend",
        "chart-frame"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Two lines, named at their ends",
          "description": "A key shows for two or more lines. The names at the line ends repeat it. Click or tap the chart, or tab to it and use the arrow keys.",
          "code": "<div className=\"w-full\">\n  <LineChart series={LATENCY} x={DAYS} unit=\"minutes\" ariaLabel=\"How long orders take\" />\n</div>"
        },
        {
          "title": "Four lines that differ by more than color",
          "description": "The third and fourth lines have a dash pattern and a square or triangle dot, so you can tell them apart without color.",
          "code": "<div className=\"w-full\">\n  <LineChart series={MODEL_RPM} x={DAYS} unit=\"orders per hour\" ariaLabel=\"Orders per hour by shop\" />\n</div>"
        },
        {
          "title": "Real numbers and a forecast",
          "description": "Forecast points are dotted. Here Saturday and Sunday are forecast.",
          "code": "<div className=\"w-full\">\n  <LineChart series={[{ id: \"cost\", label: \"Daily sales\", data: [310, 325, 298, 340, 352, 365, 380], projectionFrom: 5 }]} x={DAYS} formatValue={(n) => \"$\" + n.toLocaleString(\"en-US\")} unit=\"dollars\" ariaLabel=\"Daily sales\" />\n</div>"
        },
        {
          "title": "A number still coming in",
          "description": "While the latest point is still coming in, it is hollow. The pop-up and screen reader both call it not final.",
          "code": "<div className=\"w-full\">\n  <LineChart streaming series={[{ id: \"tps\", label: \"Orders per hour\", data: [410, 425, 398, 440, 452, 447, 463, 301] }]} x={[\"10:00\", \"10:05\", \"10:10\", \"10:15\", \"10:20\", \"10:25\", \"10:30\", \"10:35\"]} unit=\"orders per hour\" ariaLabel=\"Orders per hour\" />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-line-chart/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/line-chart.md"
    },
    {
      "id": "bar-chart",
      "name": "BarChart",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "bar graph",
        "column chart",
        "histogram",
        "stacked bar"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "Moving the pointer over a bar or touching it shades its row and shows a tooltip with the values.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The chart can be focused with the keyboard; arrow keys step through bars, Home and End jump to the ends and Escape clears the tooltip.",
          "source": "documented"
        },
        {
          "name": "highlight",
          "note": "Pass highlight with a category label to colour that bar and grey out the rest.",
          "source": "documented"
        },
        {
          "name": "stacked",
          "note": "Pass series with values per row to stack segments, add a legend and let arrow keys move between segments.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "With no data, only the axis is drawn.",
          "source": "documented"
        },
        {
          "name": "truncated labels",
          "note": "Category labels that do not fit are cut off with an ellipsis, and totals too wide for a vertical bar are left out.",
          "source": "documented"
        }
      ],
      "summary": "Bars that compare amounts. Highlight one bar, or stack up to four parts.",
      "description": "BarChart compares amounts. Bars always start at zero. You can highlight one bar in the main color and leave the rest grey. A stacked version splits each bar into up to four parts, with a key in the same order.",
      "purpose": "Compares amounts with bars that always start at zero. One bar can be highlighted while the rest stay grey, so the bar that tells the story stands out.",
      "useFor": [
        "Comparing amounts across categories, such as shops or months.",
        "Pointing at one category: set highlight.",
        "Showing what an amount is made of, with up to four stacked parts."
      ],
      "notFor": [
        {
          "text": "Change over time across many points",
          "use": "line-chart"
        },
        {
          "text": "A single number",
          "use": "stat-tile"
        },
        {
          "text": "A best guess with a range",
          "use": "dot-plot"
        }
      ],
      "whenToUse": [
        "Start every bar at zero. Never cut the scale.",
        "Highlight a bar when it is the story, and leave the rest grey.",
        "Lay bars sideways for long names or more than a handful of bars.",
        "Keep stacks to four parts, and list the key in the same order as the stack."
      ],
      "avoid": [
        "Don't give every bar its own color. One set of bars takes one color.",
        "Don't draw a chart with one bar. Use a StatTile.",
        "Don't squeeze numbers inside bars that are too small. They sit at the bar end, and the table has them all.",
        "Don't use a pie or donut to show parts of a whole. Use a stacked bar."
      ],
      "accessibility": [
        "You can tab to the chart. Arrow keys move between bars and, in a stacked chart, between parts. Home and End jump to the first and last bar.",
        "Each step is read out as the part or measure, the category, the value and the unit.",
        "Stacked parts have a small gap between them, and the key names each part in stack order.",
        "The pop-up is hidden from screen readers, because the same values are read out as you move."
      ],
      "accessibilityYouDo": [
        "Put it in a ChartFrame with a summary and a table.",
        "Give it an ariaLabel, a measure and a unit, so each bar is read with what it measures."
      ],
      "onPhone": [
        "The chart measures its container and redraws at that width. Long category labels are shortened with an ellipsis to fit.",
        "Touch or drag on a bar to show the tooltip. Vertical swipes still scroll the page, and the tooltip stays until you tap elsewhere.",
        "Horizontal bars suit a phone best, because labels have room to the left and the chart grows downward."
      ],
      "importPath": "import { BarChart } from \"./index\"",
      "sourcePath": "src/organisms/BarChart.tsx",
      "props": [
        {
          "name": "data",
          "type": "BarDatum[]",
          "required": true,
          "default": null,
          "description": "One entry per category: label, and value (single series) or values (stacked). Values must be zero or more."
        },
        {
          "name": "orientation",
          "type": "\"horizontal\" | \"vertical\"",
          "required": false,
          "default": "\"horizontal\"",
          "description": "Horizontal reads best for long labels and many categories."
        },
        {
          "name": "series",
          "type": "BarSeries[]",
          "required": false,
          "default": null,
          "description": "Stack segment definitions, at most four. When set the chart is stacked and each datum uses values."
        },
        {
          "name": "highlight",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Label of the one category to emphasize. Others use the neutral."
        },
        {
          "name": "measure",
          "type": "string",
          "required": false,
          "default": "\"Value\"",
          "description": "Name of the measure for single series charts, used in announcements and the tooltip."
        },
        {
          "name": "formatValue",
          "type": "(n: number) => string",
          "required": false,
          "default": "formatNumber",
          "description": "Formats axis ticks, value labels and the tooltip."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "default": "\"\"",
          "description": "Unit word read after each value by screen readers."
        },
        {
          "name": "height",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Total height in px. Vertical charts default to 240. Horizontal charts size to their rows."
        },
        {
          "name": "ariaLabel",
          "type": "string",
          "required": false,
          "default": "\"Bar chart\"",
          "description": "Name for the chart region."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {
        "orientation": [
          "horizontal",
          "vertical"
        ]
      },
      "tokens": [
        "--chart-1",
        "--chart-2",
        "--chart-3",
        "--chart-4",
        "--chart-neutral",
        "--chart-grid",
        "--chart-axis",
        "--surface",
        "--surface-hover",
        "--radius-overlay"
      ],
      "related": [
        "chart-frame",
        "chart-legend",
        "dot-plot",
        "stat-tile"
      ],
      "composedOf": [
        "chart-legend"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "One bar stands out",
          "description": "One bar is the point, so it gets the main color and the rest are grey. Numbers sit at the ends of the bars.",
          "code": "<div className=\"w-full\">\n  <BarChart\n    highlight=\"North shop\"\n    measure=\"Sales\"\n    data={[{ label: \"North shop\", value: 4200 }, { label: \"East shop\", value: 2900 }, { label: \"West shop\", value: 1700 }, { label: \"Online\", value: 900 }, { label: \"Other\", value: 400 }]}\n    formatValue={(n) => \"$\" + n.toLocaleString(\"en-US\")}\n    unit=\"dollars\"\n    ariaLabel=\"Sales by shop\"\n  />\n</div>"
        },
        {
          "title": "Upright bars",
          "description": "Use upright bars for a short list with short names. Names that do not fit are cut short. The full name is in the pop-up and table.",
          "code": "<div className=\"w-full max-w-lg\">\n  <BarChart\n    orientation=\"vertical\"\n    measure=\"Refunds\"\n    data={[{ label: \"Late\", value: 42 }, { label: \"Wrong item\", value: 118 }, { label: \"Damaged\", value: 23 }, { label: \"Missing\", value: 9 }, { label: \"Other\", value: 14 }]}\n    unit=\"errors\"\n    ariaLabel=\"Errors by status code\"\n  />\n</div>"
        },
        {
          "title": "Stacked bars",
          "description": "Parts follow the key from left to right. The total sits at the end of each bar. Each part's number is in the pop-up and table.",
          "code": "<div className=\"w-full\">\n  <BarChart\n    series={[{ id: \"in\", label: \"Walk-in\" }, { id: \"out\", label: \"Online\" }, { id: \"cache\", label: \"Phone\" }]}\n    data={[\n      { label: \"North shop\", values: [12, 4, 8] },\n      { label: \"East shop\", values: [9, 3, 5] },\n      { label: \"West shop\", values: [6, 2, 1] },\n      { label: \"Online\", values: [3, 1, 0] },\n    ]}\n    unit=\"hundred orders\"\n    ariaLabel=\"Orders by shop and type\"\n  />\n</div>"
        },
        {
          "title": "With a table view",
          "description": "A helper builds the table data for ChartFrame.",
          "code": "<div className=\"w-full\">\n  <ChartFrame\n    title=\"Sales by shop\"\n    subtitle=\"US dollars, last 7 days\"\n    summary=\"The North shop made 4,200 of 10,100 dollars, the biggest share of sales.\"\n    table={barChartTable(SPEND, { labelHeader: \"Shop\", caption: \"Sales by shop\", measure: \"Sales\", formatValue: (n) => \"$\" + n.toLocaleString(\"en-US\") })}\n  >\n    <BarChart highlight=\"North shop\" measure=\"Sales\" data={SPEND} formatValue={(n) => \"$\" + n.toLocaleString(\"en-US\")} unit=\"dollars\" ariaLabel=\"Sales by shop\" />\n  </ChartFrame>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-bar-chart/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/bar-chart.md"
    },
    {
      "id": "dot-plot",
      "name": "DotPlot",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "scatter",
        "strip plot",
        "dot chart"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "Moving the pointer over a row shades it and shows a tooltip with the estimate, interval and sample size.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The chart can be focused with the keyboard; arrow keys step through rows and Escape clears the tooltip.",
          "source": "documented"
        },
        {
          "name": "reference line",
          "note": "Pass reference to draw a labelled vertical line at that value.",
          "source": "documented"
        },
        {
          "name": "narrow",
          "note": "Below about 440px wide, the value text beside each row is hidden, though the tooltip still shows it.",
          "source": "documented"
        },
        {
          "name": "sample size",
          "note": "Pass showN to print each row's sample size when the data has n.",
          "source": "documented"
        }
      ],
      "summary": "Shows a best guess and how sure it is, with a caption that explains the marks.",
      "description": "DotPlot draws one row for each result. A dot shows the best guess and a thin line shows the range it likely falls in. A caption above says what the dot and line mean and how many people or cases it covers. The numbers are printed beside each row when there is room.",
      "purpose": "Shows a best guess and the range it likely falls in, one row per result. A caption says what the dot and line mean, because a bare range line means little to most readers.",
      "useFor": [
        "Survey or test results where how sure you are matters.",
        "Comparing groups of different sizes: turn on showN.",
        "Results measured against a target, with a reference line."
      ],
      "notFor": [
        {
          "text": "Amounts that should be compared by bar length",
          "use": "bar-chart"
        },
        {
          "text": "Change over time",
          "use": "line-chart"
        }
      ],
      "whenToUse": [
        "Write the caption so a reader can say what the line means, how sure it is and how many people are behind it.",
        "Show group sizes when groups are small or differ between rows.",
        "Print the numbers as text, so no one has to read them off the scale.",
        "When the question is how two things differ, show that difference directly."
      ],
      "avoid": [
        "Don't leave the range line unexplained. A bare line means nothing.",
        "Don't treat overlapping lines as proof that two results are the same. They are not.",
        "Don't show scores with no range and no group size. At least show how many were asked.",
        "Don't cut a bar chart to fit a range. This chart marks positions, not bar lengths, so its scale can start above zero."
      ],
      "accessibility": [
        "The caption is shown above the chart and is also part of the chart's name for screen readers.",
        "You can tab to the chart. Arrow keys move between rows, and Home and End jump to the first and last.",
        "Each row is read out as measure, row, value and unit, then the range and group size.",
        "The range line has end caps, and the dot has a ring that keeps it clear."
      ],
      "accessibilityYouDo": [
        "Write a caption that says what the dot and line mean and how many people are behind them. Screen readers hear it as part of the chart's name.",
        "Set measure, intervalLabel and unit, so each row is read in full.",
        "Put it in a ChartFrame with a table. The numbers beside each row are dropped when the chart is narrow."
      ],
      "onPhone": [
        "The chart measures its container and redraws at that width. Long row labels are shortened with an ellipsis to fit.",
        "Touch a row to show the tooltip. Vertical swipes still scroll the page, and the tooltip stays until you tap elsewhere.",
        "The caption above the chart wraps, so write it in plain words."
      ],
      "importPath": "import { DotPlot } from \"./index\"",
      "sourcePath": "src/organisms/DotPlot.tsx",
      "props": [
        {
          "name": "data",
          "type": "DotDatum[]",
          "required": true,
          "default": null,
          "description": "One row per estimate: label, estimate, low, high and an optional n."
        },
        {
          "name": "caption",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Says what the dot and line mean, with the interval level and sample size. Rendered above the chart."
        },
        {
          "name": "showN",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Prints n beside each row. Use it for small samples."
        },
        {
          "name": "measure",
          "type": "string",
          "required": false,
          "default": "\"Estimate\"",
          "description": "Name of the estimate in announcements and the tooltip."
        },
        {
          "name": "intervalLabel",
          "type": "string",
          "required": false,
          "default": "\"interval\"",
          "description": "Names the interval, for example 95% confidence interval."
        },
        {
          "name": "formatValue",
          "type": "(n: number) => string",
          "required": false,
          "default": "formatNumber",
          "description": "Formats the axis, text column and tooltip."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "default": "\"\"",
          "description": "Unit word read after each value by screen readers."
        },
        {
          "name": "domain",
          "type": "[number, number]",
          "required": false,
          "default": null,
          "description": "Fixed value axis. Defaults to the interval extent. The axis does not need to start at zero."
        },
        {
          "name": "reference",
          "type": "{ value: number; label: string }",
          "required": false,
          "default": null,
          "description": "A named reference line, such as the current model."
        },
        {
          "name": "ariaLabel",
          "type": "string",
          "required": false,
          "default": "\"Dot plot\"",
          "description": "Name for the chart region."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--chart-1",
        "--chart-grid",
        "--chart-axis",
        "--surface",
        "--surface-hover",
        "--fg",
        "--fg-muted",
        "--radius-overlay"
      ],
      "related": [
        "bar-chart",
        "chart-frame",
        "stat-tile",
        "data-table"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Happy customers, with a range",
          "description": "The caption says what is measured, how sure we are and how many customers. The text column gives the numbers.",
          "code": "<div className=\"w-full\">\n  <DotPlot\n    caption=\"Dots show the share of happy customers. Lines show the range we are 95% sure it falls in. 200 customers per shop.\"\n    measure=\"Happy customers\"\n    intervalLabel=\"95% range\"\n    data={[\n      { label: \"North shop\", estimate: 0.925, low: 0.881, high: 0.954, n: 200 },\n      { label: \"East shop\", estimate: 0.87, low: 0.82, high: 0.91, n: 200 },\n      { label: \"West shop\", estimate: 0.81, low: 0.75, high: 0.86, n: 200 },\n    ]}\n    formatValue={(n) => Math.round(n * 1000) / 10 + \"%\"}\n    unit=\"percent\"\n    ariaLabel=\"Happy customers by shop\"\n  />\n</div>"
        },
        {
          "title": "Different group sizes and a goal line",
          "description": "Each row prints how many customers were asked, so a wide range from a small group makes sense. The line marks the current shop.",
          "code": "<div className=\"w-full\">\n  <DotPlot\n    showN\n    caption=\"Dots show the share of happy customers. Lines show the range we are 95% sure it falls in. Customers asked are listed per row.\"\n    measure=\"Happy customers\"\n    intervalLabel=\"95% range\"\n    reference={{ value: 0.87, label: \"Current shop\" }}\n    data={[\n      { label: \"North shop\", estimate: 0.925, low: 0.881, high: 0.954, n: 200 },\n      { label: \"East shop\", estimate: 0.87, low: 0.82, high: 0.91, n: 200 },\n      { label: \"West shop\", estimate: 0.81, low: 0.75, high: 0.86, n: 60 },\n      { label: \"Online\", estimate: 0.79, low: 0.7, high: 0.86, n: 40 },\n    ]}\n    formatValue={(n) => Math.round(n * 1000) / 10 + \"%\"}\n    unit=\"percent\"\n    ariaLabel=\"Happy customers by shop\"\n  />\n</div>"
        },
        {
          "title": "With a table view",
          "description": "A helper lists the best guess, both ends of the range and the group size.",
          "code": "<div className=\"w-full\">\n  <ChartFrame\n    title=\"Happy customers\"\n    subtitle=\"Share of 200 customers per shop who were happy, latest survey\"\n    summary=\"92.5% of North shop customers were happy. We are 95% sure the true figure is between 88.1% and 95.4%.\"\n    table={dotPlotTable(EVAL200, { labelHeader: \"Shop\", caption: \"Happy customers by shop\", measure: \"Happy customers\", intervalLabel: \"95% range\", formatValue: (n) => Math.round(n * 1000) / 10 + \"%\" })}\n  >\n    <DotPlot caption=\"Dots show the share of happy customers. Lines show the range we are 95% sure it falls in. 200 customers per shop.\" measure=\"Happy customers\" intervalLabel=\"95% range\" data={EVAL200} formatValue={(n) => Math.round(n * 1000) / 10 + \"%\"} unit=\"percent\" />\n  </ChartFrame>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-dot-plot/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/dot-plot.md"
    },
    {
      "id": "chart-legend",
      "name": "ChartLegend",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "legend",
        "key"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hidden series",
          "note": "An item whose id is in hidden is shown struck through and dimmed.",
          "source": "documented"
        },
        {
          "name": "toggle",
          "note": "Pass onToggle to turn each entry into a button that shows or hides its series.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "A toggle entry gets a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A toggle entry shows a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "A key that explains each color, dash and marker in a chart, in the chart's order. It can also switch lines on and off.",
      "description": "ChartLegend lists each line or bar with the same color, dash and marker the chart uses. Items follow the chart's order. They can become buttons that show or hide a line. Charts draw their own key when they have two or more lines. Use this one for custom layouts or a key shared by several charts.",
      "purpose": "A key that shows the same color, dash and marker as each line or bar, in the chart's order. It can also turn lines on and off.",
      "useFor": [
        "One key shared by several charts.",
        "A custom layout where the key sits apart from the chart.",
        "Letting people show or hide lines: pass onToggle."
      ],
      "notFor": [
        {
          "text": "A chart with only one line. The title names it"
        },
        {
          "text": "A LineChart or stacked BarChart on its own. They draw their own key"
        }
      ],
      "whenToUse": [
        "Show a key for two or more lines, and none for one.",
        "Keep the key in the same order as the lines or stack.",
        "Let a hidden line keep its color. Hiding one line must not recolor the others.",
        "Hide the line in the chart when its item is turned off. The key only tells you through onToggle."
      ],
      "avoid": [
        "Don't color the name text. The sample beside it shows which is which.",
        "Don't make the key the only way to tell lines apart. With four or fewer, name the lines at their ends too.",
        "Don't add a key for one line. The title names it."
      ],
      "accessibility": [
        "It is a list named \"Legend\" by default, and every item has a written name.",
        "With onToggle, each item is a button that screen readers hear as pressed or not, with the word \"shown\" or \"hidden\".",
        "The color samples are hidden from screen readers, because the name says the same thing.",
        "From the third item on, a dash pattern and a marker shape back up the color."
      ],
      "accessibilityYouDo": [
        "List items in the same order as the chart's lines or stack.",
        "Give it a label that says which chart it belongs to when a page has more than one key."
      ],
      "onPhone": [
        "The legend wraps onto more lines when the chart is narrow.",
        "When the items are toggle buttons they are 24px tall. On touch screens they keep that size and have a 44px tap area."
      ],
      "importPath": "import { ChartLegend } from \"./index\"",
      "sourcePath": "src/organisms/ChartLegend.tsx",
      "props": [
        {
          "name": "items",
          "type": "LegendItem[]",
          "required": true,
          "default": null,
          "description": "Items in the chart's series or stack order. Each has id, label and an optional style."
        },
        {
          "name": "kind",
          "type": "\"line\" | \"bar\"",
          "required": false,
          "default": "\"line\"",
          "description": "Line shows the dash and marker. Bar shows a rounded swatch."
        },
        {
          "name": "hidden",
          "type": "string[]",
          "required": false,
          "default": "[]",
          "description": "Ids currently hidden. Only used with onToggle."
        },
        {
          "name": "onToggle",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Makes items toggle buttons. Pressed means shown."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Legend\"",
          "description": "Accessible name for the list."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the list."
        }
      ],
      "variantsHint": {
        "kind": [
          "line",
          "bar"
        ]
      },
      "tokens": [
        "--chart-1",
        "--chart-2",
        "--chart-3",
        "--chart-4",
        "--chart-5",
        "--surface",
        "--fg-muted",
        "--fg-subtle",
        "--radius-control"
      ],
      "related": [
        "line-chart",
        "bar-chart",
        "chart-frame"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A key with dashes and dots",
          "description": "The first two lines are solid with round dots. From the third on, the dash and dot shape change too, not only the color.",
          "code": "<ChartLegend items={[{ id: \"a\", label: \"North shop\" }, { id: \"b\", label: \"East shop\" }, { id: \"c\", label: \"West shop\" }, { id: \"d\", label: \"Online\" }]} />"
        },
        {
          "title": "A key for bars",
          "description": "Use this for bars and stacks. List items in the order the stack is read.",
          "code": "<ChartLegend kind=\"bar\" label=\"Key, in stack order\" items={[{ id: \"in\", label: \"Walk-in\" }, { id: \"out\", label: \"Online\" }, { id: \"cache\", label: \"Phone\" }]} />"
        },
        {
          "title": "Switch lines on and off",
          "description": "Pressed means the line is shown. A hidden line keeps its place and color, and its name is crossed out.",
          "code": "function LegendToggleDemo() {\n  const [hidden, setHidden] = useState<string[]>([]);\n  return (\n    <ChartLegend\n      items={[{ id: \"a\", label: \"North shop\" }, { id: \"b\", label: \"East shop\" }, { id: \"c\", label: \"West shop\" }, { id: \"d\", label: \"Online\" }]}\n      hidden={hidden}\n      onToggle={(id) => setHidden((h) => (h.includes(id) ? h.filter((x) => x !== id) : [...h, id]))}\n    />\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-chart-legend/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/chart-legend.md"
    },
    {
      "id": "stack",
      "name": "Stack",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "vstack",
        "flex column",
        "vertical stack"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "Stacks things top to bottom with an even space between them.",
      "description": "Stack puts items in a column with the same space between each. A tighter area shrinks the space on its own. Use it instead of adding space to each item by hand.",
      "purpose": "Puts things in a column with one even gap between them. The gap belongs to the stack, not to each item, so spacing stays consistent and shrinks in tighter areas.",
      "useFor": [
        "A column of cards, paragraphs or form fields that need the same space between them.",
        "A list of items, rendered as a real list element.",
        "Groups inside groups, with a smaller gap inside each group."
      ],
      "notFor": [
        {
          "text": "Items that sit side by side in a row",
          "use": "cluster"
        },
        {
          "text": "Items laid out in columns",
          "use": "grid"
        },
        {
          "text": "Limiting how wide a page column gets",
          "use": "container"
        }
      ],
      "whenToUse": [
        "Use the space setting between items, not extra margins.",
        "Leave more space between groups than inside them.",
        "Use a real list when the items are a list.",
        "Put stacks inside stacks for groups, with less space inside."
      ],
      "avoid": [
        "Do not add margins to items to adjust spacing.",
        "Do not use spacing outside the set sizes.",
        "Do not space unrelated areas the same as related items.",
        "Do not use Stack for a row. Use Cluster."
      ],
      "accessibility": [
        "It adds no meaning of its own: it renders the element you choose.",
        "As a ul or ol it is still announced as a list, even though the bullets are removed.",
        "Items keep the order they have in your code, so screen readers and the keyboard follow what people see."
      ],
      "accessibilityYouDo": [
        "Set as to ul or ol when the items are a list, and wrap each item in li.",
        "Set as to main, nav or section when the stack is a page area, so screen reader users can jump to it."
      ],
      "onPhone": [
        "Items stack in one column and fill the width of the container.",
        "The gap is the same on a phone, unless the area is set to compact density, which makes it 20% smaller."
      ],
      "importPath": "import { Stack } from \"./index\"",
      "sourcePath": "src/atoms/Stack.tsx",
      "props": [
        {
          "name": "gap",
          "type": "1 | 2 | 3 | 4 | 5 | 6 | 7 | 8",
          "required": false,
          "default": "4",
          "description": "Step on the 4px grid, so 1 is 4px and 8 is 32px. Multiplied by --density."
        },
        {
          "name": "as",
          "type": "\"div\" | \"section\" | \"article\" | \"ul\" | \"ol\" | \"nav\" | \"header\" | \"footer\" | \"main\" | \"aside\"",
          "required": false,
          "default": "\"div\"",
          "description": "Element to render. List elements get their markers and padding reset."
        },
        {
          "name": "className / style",
          "type": "string / CSSProperties",
          "required": false,
          "default": null,
          "description": "Merged with the component's own classes and gap."
        }
      ],
      "variantsHint": {
        "gap": [
          1,
          2,
          3,
          4,
          5,
          6,
          7,
          8
        ],
        "as": [
          "div",
          "section",
          "article",
          "ul",
          "ol",
          "nav",
          "header",
          "footer",
          "main",
          "aside"
        ]
      },
      "tokens": [
        "--density"
      ],
      "related": [
        "cluster",
        "grid",
        "container"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Normal space",
          "description": "The default space between items.",
          "code": "<Stack gap={4}>\n  <p>First</p>\n  <p>Second</p>\n  <p>Third</p>\n</Stack>"
        },
        {
          "title": "Tighter space",
          "description": "The same space shrinks a little in a tight area. This is a proposal.",
          "code": "<div data-density=\"compact\">\n  <Stack gap={4}>\n    <p>First</p>\n    <p>Second</p>\n  </Stack>\n</div>"
        },
        {
          "title": "As a list",
          "description": "Use a list element when the items are a list.",
          "code": "<Stack as=\"ul\" gap={2}>\n  <li>Search</li>\n  <li>Read</li>\n</Stack>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-stack/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/stack.md"
    },
    {
      "id": "cluster",
      "name": "Cluster",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "hstack",
        "inline",
        "wrap",
        "flex row",
        "group"
      ],
      "category": "screens",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A row of items that wraps to the next line when it runs out of room.",
      "description": "Cluster puts items in a row and wraps them to the next line when space runs out. Use it for tags, rows of buttons and small details. It uses the same spacing as Stack.",
      "purpose": "A row of small things that wraps onto the next line instead of running off the edge. Nothing is hidden or squeezed, so tags and buttons stay readable at any width.",
      "useFor": [
        "A set of tags or filters.",
        "A row of buttons, such as Cancel and Send.",
        "Small details in one line, such as a file count and a date.",
        "A label at one end and an action pushed to the other."
      ],
      "notFor": [
        {
          "text": "Items that go top to bottom",
          "use": "stack"
        },
        {
          "text": "Form fields or cards that should line up in columns",
          "use": "grid"
        }
      ],
      "whenToUse": [
        "Use it for groups that can wrap, such as tags and filters.",
        "Keep the space smaller than the space between groups.",
        "Use a real list for a set of related links or tags.",
        "Line up the text bottoms when text sizes differ."
      ],
      "avoid": [
        "Do not use it for form columns. Use Grid.",
        "Do not force one line and hide what does not fit.",
        "Do not count on the order changing when items wrap.",
        "Do not put long paragraphs inside as items."
      ],
      "accessibility": [
        "It adds no meaning of its own: it renders the element you choose.",
        "As a ul or ol it is still announced as a list, even though the bullets are removed.",
        "Items wrap to the next line instead of overflowing, so nothing needs sideways scrolling when text is zoomed.",
        "Wrapping keeps the order of your code, so screen readers and the keyboard follow the same order."
      ],
      "accessibilityYouDo": [
        "Set as to ul or ol when the items are a list, and wrap each item in li.",
        "Set as to nav when the row is a set of links for moving around."
      ],
      "onPhone": [
        "Items wrap onto the next line when they do not fit, so a row of chips or actions never runs off a phone screen.",
        "The gap stays the same, and buttons inside a wrapped row keep their size and have a 44px tap area on a touch screen."
      ],
      "importPath": "import { Cluster } from \"./index\"",
      "sourcePath": "src/atoms/Cluster.tsx",
      "props": [
        {
          "name": "gap",
          "type": "1 | 2 | 3 | 4 | 5 | 6 | 7 | 8",
          "required": false,
          "default": "2",
          "description": "Gap step on the 4px grid, scaled by --density."
        },
        {
          "name": "align",
          "type": "\"start\" | \"center\" | \"end\" | \"baseline\"",
          "required": false,
          "default": "\"center\"",
          "description": "Cross-axis alignment."
        },
        {
          "name": "justify",
          "type": "\"start\" | \"center\" | \"end\" | \"between\"",
          "required": false,
          "default": "\"start\"",
          "description": "Main-axis distribution."
        },
        {
          "name": "as",
          "type": "\"div\" | \"ul\" | \"ol\" | \"nav\" | \"section\" | \"header\" | \"footer\"",
          "required": false,
          "default": "\"div\"",
          "description": "Element to render."
        }
      ],
      "variantsHint": {
        "gap": [
          1,
          2,
          3,
          4,
          5,
          6,
          7,
          8
        ],
        "align": [
          "start",
          "center",
          "end",
          "baseline"
        ],
        "justify": [
          "start",
          "center",
          "end",
          "between"
        ],
        "as": [
          "div",
          "ul",
          "ol",
          "nav",
          "section",
          "header",
          "footer"
        ]
      },
      "tokens": [
        "--density"
      ],
      "related": [
        "stack",
        "grid"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Tags that wrap",
          "description": "",
          "code": "<Cluster gap={2}>\n  <Badge>Draft</Badge>\n  <Badge>Shared</Badge>\n  <Badge>Reviewed</Badge>\n</Cluster>"
        },
        {
          "title": "Push apart",
          "description": "Push the main button to the far end while the rest stay at the start.",
          "code": "<Cluster justify=\"between\" className=\"w-full\">\n  <span>3 files</span>\n  <Button size=\"sm\">Send</Button>\n</Cluster>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-cluster/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/cluster.md"
    },
    {
      "id": "grid",
      "name": "Grid",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "grid layout",
        "columns",
        "simple grid"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A grid that changes its number of columns to fit the screen.",
      "description": "Grid sets how many columns show at each screen size. The defaults are 4 columns on phones, 8 on tablets and 12 on large screens. Or set a minimum item width, and the grid fits as many items as it can. The space between items follows the page spacing.",
      "purpose": "Lays items out in columns that change with the screen size: 4 on phones, 8 on tablets and 12 on large screens. Or give a minimum width, and it fits as many items in a row as there is room for.",
      "useFor": [
        "A page layout with a side rail and a main area.",
        "A set of cards that should fill the row and wrap.",
        "Content that needs one column on phones and more on large screens."
      ],
      "notFor": [
        {
          "text": "A row of tags or buttons that should just wrap",
          "use": "cluster"
        },
        {
          "text": "A single column of items",
          "use": "stack"
        },
        {
          "text": "Comparing options feature by feature",
          "use": "comparison-table"
        }
      ],
      "whenToUse": [
        "Set columns for page layout and a minimum width for sets of cards.",
        "Use one or two columns on phones.",
        "Keep items in reading order.",
        "Use the page spacing so gaps change with the screen size."
      ],
      "avoid": [
        "Do not move items so they look different from reading order.",
        "Do not give items a fixed width.",
        "Do not put grids inside grids more than two deep.",
        "Do not use 12 columns on a phone."
      ],
      "accessibility": [
        "It adds no meaning of its own. The items inside carry it.",
        "Columns drop as the screen narrows, so content stays readable without sideways scrolling.",
        "It never reorders items, so screen readers and the keyboard follow the order of your code."
      ],
      "accessibilityYouDo": [
        "Keep the order on screen the same as the order in your code. Do not use column placement to move items around."
      ],
      "onPhone": [
        "Below 840px it uses 4 columns by default, so on a phone each item must span columns or it gets a quarter of the width.",
        "With the min prop it fits as many columns as it can, and a single column on a phone if min is wider than half the screen.",
        "The gutter is 16px on a phone and grows at 840px."
      ],
      "importPath": "import { Grid } from \"./index\"",
      "sourcePath": "src/atoms/Grid.tsx",
      "props": [
        {
          "name": "cols",
          "type": "{ base?: Cols; md?: Cols; lg?: Cols }",
          "required": false,
          "default": "{ base: 4, md: 8, lg: 12 }",
          "description": "Column counts per window class. Cols is 1, 2, 3, 4, 6, 8 or 12."
        },
        {
          "name": "min",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Minimum item width as a CSS length. Switches to auto-fit and ignores cols."
        },
        {
          "name": "gap",
          "type": "1 | 2 | 3 | 4 | 5 | 6 | 7 | 8",
          "required": false,
          "default": "--gutter",
          "description": "Gap step scaled by --density. Omit to use the gutter token (16px, then 24px from md)."
        }
      ],
      "variantsHint": {
        "gap": [
          1,
          2,
          3,
          4,
          5,
          6,
          7,
          8
        ]
      },
      "tokens": [
        "--gutter",
        "--density",
        "--breakpoint-md",
        "--breakpoint-lg"
      ],
      "related": [
        "stack",
        "container",
        "cluster"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Cards that rearrange",
          "description": "One column on phones, two on tablets, three on large screens.",
          "code": "<Grid cols={{ base: 1, md: 2, lg: 3 }} className=\"w-full\">\n  <Card>One</Card>\n  <Card>Two</Card>\n  <Card>Three</Card>\n</Grid>"
        },
        {
          "title": "Fits as many as it can",
          "description": "Each item has a minimum width. The number per row follows the space available.",
          "code": "<Grid min=\"9rem\" className=\"w-full\">\n  <Card>A</Card><Card>B</Card><Card>C</Card><Card>D</Card>\n</Grid>"
        },
        {
          "title": "Items that span columns",
          "description": "An item can take up more than one column.",
          "code": "<Grid className=\"w-full\">\n  <div className=\"col-span-4 md:col-span-3 lg:col-span-4\">Rail</div>\n  <div className=\"col-span-4 md:col-span-5 lg:col-span-8\">Content</div>\n</Grid>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-grid/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/grid.md"
    },
    {
      "id": "container",
      "name": "Container",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "wrapper",
        "max width",
        "center"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A centered column with a set width and margins on the sides.",
      "description": "Container limits how wide a column can get: chat, home, page or full. It adds margins on the sides that grow on larger screens. The widths are proposals to confirm.",
      "purpose": "A centered column with a maximum width and side margins. Long lines are hard to read, so the chat width keeps a conversation at a comfortable line length.",
      "useFor": [
        "The conversation and the message box, with the chat width so they line up.",
        "Settings and documentation pages, with the page width.",
        "A home screen, with the home width."
      ],
      "notFor": [
        {
          "text": "Laying out columns inside the page",
          "use": "grid"
        },
        {
          "text": "Spacing items in a column",
          "use": "stack"
        }
      ],
      "whenToUse": [
        "Use chat width for the conversation and the message box so they line up.",
        "Use page width for settings and documentation.",
        "Use the main element once per page.",
        "Let side margins grow on larger screens."
      ],
      "avoid": [
        "Do not put containers inside containers.",
        "Do not hard-code widths in components.",
        "Do not use full width for running text.",
        "Do not let chat lines run past about 80 characters."
      ],
      "accessibility": [
        "It adds no meaning of its own: it renders the element you choose.",
        "Side margins keep text off the screen edge at every size, unless you turn them off with flush.",
        "The chat and home widths keep lines of text to a readable length."
      ],
      "accessibilityYouDo": [
        "Set as to main for the main area of the page, and use main only once per page.",
        "Do not use the full width for running text, because very long lines are hard to read."
      ],
      "onPhone": [
        "On a phone it fills the width with a 16px side margin, and the width presets do not apply because they are all wider than a phone.",
        "Use flush when content such as an image must reach the screen edge.",
        "It adds no safe-area padding, so add that yourself where content sits against a notch or the bottom bar."
      ],
      "importPath": "import { Container } from \"./index\"",
      "sourcePath": "src/atoms/Container.tsx",
      "props": [
        {
          "name": "size",
          "type": "\"chat\" | \"home\" | \"page\" | \"full\"",
          "required": false,
          "default": "\"page\"",
          "description": "Max-width preset. chat 768px, home 680px, page 1120px, full uncapped."
        },
        {
          "name": "flush",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Removes the side margins for content that bleeds to the edge."
        },
        {
          "name": "as",
          "type": "\"div\" | \"main\" | \"section\" | \"article\" | \"header\" | \"footer\" | \"nav\"",
          "required": false,
          "default": "\"div\"",
          "description": "Element to render."
        }
      ],
      "variantsHint": {
        "size": [
          "chat",
          "home",
          "page",
          "full"
        ],
        "as": [
          "div",
          "main",
          "section",
          "article",
          "header",
          "footer",
          "nav"
        ]
      },
      "tokens": [
        "--w-chat",
        "--w-home",
        "--w-page",
        "--margin"
      ],
      "related": [
        "grid",
        "stack"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Chat width",
          "description": "This width fits about 70 to 75 characters a line at normal text size.",
          "code": "<Container size=\"chat\">\n  <Stack>…thread…</Stack>\n</Container>"
        },
        {
          "title": "Page and home widths",
          "description": "",
          "code": "<Container size=\"home\">…</Container>\n<Container size=\"page\">…</Container>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-container/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/container.md"
    },
    {
      "id": "reveal",
      "name": "Reveal",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "fade in",
        "entrance animation",
        "transition"
      ],
      "category": "screens",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "entering",
          "note": "On first render it rises or fades in, with a small stagger set by index.",
          "source": "documented"
        },
        {
          "name": "reduced motion",
          "note": "When the user prefers reduced motion, no animation runs and the content is simply shown.",
          "source": "documented"
        }
      ],
      "summary": "Makes new content ease into view, one after another.",
      "description": "Reveal eases content in with a small rise or a quick fade. Items in a group appear one after another, with the delay capped so the whole group settles in under half a second. For people who ask for less motion, it shows content at once. The timings are proposals.",
      "purpose": "Eases new content in with a small rise or a fade, so people notice what just appeared. In a group, each item starts a moment after the one before, and the whole group settles quickly.",
      "useFor": [
        "A new answer or card that appears once.",
        "A short list of steps or results that arrive together.",
        "Small status text such as Saved, using the fade."
      ],
      "notFor": [
        {
          "text": "Text that arrives word by word as it is written",
          "use": "streaming-reveal"
        },
        {
          "text": "Placeholders while content loads",
          "use": "skeleton"
        },
        {
          "text": "Content that changes every second"
        }
      ],
      "whenToUse": [
        "Use it on content that appears once, such as a new answer or card.",
        "Keep groups to a few items so the wait stays short.",
        "Use the fade for small status text.",
        "Pair it with Stack so the layout does not jump."
      ],
      "avoid": [
        "Do not animate things that happen all the time.",
        "Do not wrap content that changes every second.",
        "Do not delay long lists item by item.",
        "Do not animate error messages."
      ],
      "accessibility": [
        "When people turn off motion in their system settings, content shows at once with no animation.",
        "Content is on the page from the start, so screen readers can read it before the animation ends.",
        "It only fades and moves a few pixels up. Nothing flashes or spins."
      ],
      "accessibilityYouDo": [
        "Do not let the animation be the only sign that something changed. If people need to know, say it in words or announce it."
      ],
      "onPhone": [
        "It plays the same short fade or rise on a phone, and nothing about it depends on hover.",
        "With reduced motion turned on in the phone settings, it shows the content with no animation."
      ],
      "importPath": "import { Reveal } from \"./index\"",
      "sourcePath": "src/atoms/Reveal.tsx",
      "props": [
        {
          "name": "variant",
          "type": "\"rise\" | \"fade\"",
          "required": false,
          "default": "\"rise\"",
          "description": "rise is opacity plus 6px of travel. fade is opacity only."
        },
        {
          "name": "index",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Position in a group. Delay is index x 40ms, held at the fifth item."
        },
        {
          "name": "as",
          "type": "\"div\" | \"li\" | \"section\" | \"article\" | \"span\" | \"p\"",
          "required": false,
          "default": "\"div\"",
          "description": "Element to render."
        }
      ],
      "variantsHint": {
        "variant": [
          "rise",
          "fade"
        ],
        "as": [
          "div",
          "li",
          "section",
          "article",
          "span",
          "p"
        ]
      },
      "tokens": [
        "--dur-base",
        "--dur-fast",
        "--ease-out"
      ],
      "related": [
        "streaming-reveal",
        "jump-to-latest",
        "stack"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "One after another",
          "description": "Each item appears just after the one before.",
          "code": "<Stack gap={2}>\n  {items.map((t, i) => (\n    <Reveal key={t} index={i}>{t}</Reveal>\n  ))}\n</Stack>"
        },
        {
          "title": "Fade only",
          "description": "Use the fade when you do not want any movement.",
          "code": "<Reveal variant=\"fade\">Saved</Reveal>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-reveal/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/reveal.md"
    },
    {
      "id": "streaming-reveal",
      "name": "StreamingReveal",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "streaming text",
        "typewriter",
        "stream"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "streaming",
          "note": "Set with the streaming prop.",
          "source": "prop"
        }
      ],
      "summary": "Text that appears as it is written, with each new word fading in once.",
      "description": "StreamingReveal fades in each new word from a faint blur as it arrives, in --dur-base on --ease-arrive. Words already shown never animate again. A half-written word waits until it is finished, so words appear whole. For people who ask for less motion, text appears at once.",
      "purpose": "Shows a reply as it is being written, fading in each new word once. Words already shown never move or fade again, so the text stays calm to read.",
      "useFor": [
        "The text of an assistant reply while it streams in.",
        "Any text that grows word by word from a live source."
      ],
      "notFor": [
        {
          "text": "Content that appears all at once",
          "use": "reveal"
        },
        {
          "text": "A whole reply with its avatar, cursor and actions",
          "use": "message"
        },
        {
          "text": "Showing the assistant is busy before any words arrive",
          "use": "typing-indicator"
        }
      ],
      "whenToUse": [
        "Add words in place and let lines wrap on their own.",
        "Pass text in at a steady pace, about 30 times a second at most.",
        "Mark the reply as finished when it ends so the last word shows.",
        "Let Message handle what screen readers announce."
      ],
      "avoid": [
        "Do not animate height or position.",
        "Do not rebuild old words, which would replay their fade.",
        "Do not add your own blur or slide on top of the built-in fade.",
        "Do not slow the text down to show off the effect."
      ],
      "accessibility": [
        "When people turn off motion in their system settings, the text appears with no fade or blur.",
        "A half-written word is held back until it is complete, so words never appear in pieces.",
        "Words are real text, so selecting, copying and searching the page still work.",
        "It announces nothing by itself, so screen readers are not flooded with every new word."
      ],
      "accessibilityYouDo": [
        "Put it inside Message, or another part that tells screen readers when the reply is complete."
      ],
      "onPhone": [
        "Each new word fades in from a faint blur in --dur-base (220ms) on --ease-arrive, and new words add to the end so the text does not jump as it streams.",
        "With reduced motion turned on in the phone settings, the text just appears."
      ],
      "importPath": "import { StreamingReveal } from \"./index\"",
      "sourcePath": "src/molecules/StreamingReveal.tsx",
      "props": [
        {
          "name": "text",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Text received so far. Pass the buffer, not a delta."
        },
        {
          "name": "streaming",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "While true, a trailing partial word is held back until it is complete."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Class for the wrapping span."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--animate-word-in",
        "--blur-arrive",
        "--dur-base",
        "--ease-arrive"
      ],
      "related": [
        "reveal",
        "jump-to-latest",
        "message"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A reply as it is written",
          "description": "Replay writes the text again. Only the newest words are faint.",
          "code": "const { text, streaming } = useStreamingText(answer);\n<StreamingReveal text={text} streaming={streaming} />"
        },
        {
          "title": "Inside a message",
          "description": "The message keeps its blinking cursor and screen reader behavior. This only changes how words appear.",
          "code": "<Message role=\"assistant\" streaming={streaming}>\n  <p><StreamingReveal text={text} streaming={streaming} /></p>\n</Message>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-streaming-reveal/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/streaming-reveal.md"
    },
    {
      "id": "jump-to-latest",
      "name": "JumpToLatest",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "scroll to bottom",
        "new messages button",
        "jump to bottom"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hidden",
          "note": "When visible is false the button is not rendered.",
          "source": "documented"
        },
        {
          "name": "new messages",
          "note": "When count is above zero the label changes to New messages and a lime count badge is shown.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The button gets a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "A small floating button that takes you back to the newest message.",
      "description": "JumpToLatest floats over the conversation. It says \"Jump to latest\", or \"New messages\" with a count if messages arrived while you were scrolled up. Click it to go to the bottom.",
      "purpose": "A floating button that brings a reader back to the newest message after they scroll up. It only appears when they are away from the bottom, so the conversation never pulls them down against their will.",
      "useFor": [
        "A long conversation where the reader has scrolled up to reread something.",
        "Telling a reader that new messages arrived while they were scrolled up, with a count."
      ],
      "notFor": [
        {
          "text": "Reading out new messages as they arrive",
          "use": "message-list"
        },
        {
          "text": "A general action inside the page",
          "use": "button"
        }
      ],
      "whenToUse": [
        "Place it over the conversation, not inside the scrolling part.",
        "Show it only when the reader is away from the bottom.",
        "Scroll smoothly on click, or at once for people who ask for less motion.",
        "When you send a message, show it near the top and let this button handle the rest."
      ],
      "avoid": [
        "Do not pull a reader down who scrolled up on purpose.",
        "Do not show it at the bottom.",
        "Do not rely on the count alone. Keep the label.",
        "Do not use amber. Nothing here needs a decision."
      ],
      "accessibility": [
        "It is a real button with visible text, so screen readers say what people see.",
        "The count is read after the label, with a short pause between them.",
        "When people turn off motion in their system settings, it appears without the rise.",
        "When hidden, it is removed from the page, so the keyboard never lands on it."
      ],
      "accessibilityYouDo": [
        "Use scrollToBottom from useStickToBottom as the click handler. It jumps straight down, with no smooth scroll, when people turn off motion.",
        "Make the scrolling area reachable by keyboard, so people can scroll the conversation without a mouse."
      ],
      "onPhone": [
        "The button is centered at the bottom of its thread. It keeps its size and has a 44px tap area on a touch screen.",
        "It sits 12px above the thread's bottom edge and ignores safe areas, so place the thread above the composer and the home bar.",
        "A tap scrolls to the newest message, and your code decides when the button shows."
      ],
      "importPath": "import { JumpToLatest } from \"./index\"",
      "sourcePath": "src/molecules/JumpToLatest.tsx",
      "props": [
        {
          "name": "visible",
          "type": "boolean",
          "required": true,
          "default": null,
          "description": "Shows the pill. Usually !atBottom from useStickToBottom."
        },
        {
          "name": "count",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Unseen messages. Above zero the label switches to newLabel and shows the count."
        },
        {
          "name": "onClick",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Scrolls the thread, usually scrollToBottom."
        },
        {
          "name": "label / newLabel",
          "type": "string",
          "required": false,
          "default": "\"Jump to latest\" / \"New messages\"",
          "description": "Visible text for each state."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--animate-rise",
        "--lime",
        "--on-lime",
        "--shadow-md",
        "--surface-raised",
        "--radius-control"
      ],
      "related": [
        "streaming-reveal",
        "reveal",
        "message-list"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "In a long conversation",
          "description": "Scroll up, then add a message. The button appears with a count.",
          "code": "const { ref, atBottom, unseen, markUnseen, scrollToBottom } = useStickToBottom();\n<div className=\"relative\">\n  <div ref={ref} className=\"h-56 overflow-y-auto\">…</div>\n  <JumpToLatest visible={!atBottom} count={unseen} onClick={scrollToBottom} />\n</div>"
        },
        {
          "title": "Both labels",
          "description": "The two labels side by side.",
          "code": "<div className=\"relative h-16\">\n  <JumpToLatest visible onClick={go} />\n</div>\n<div className=\"relative h-16\">\n  <JumpToLatest visible count={3} onClick={go} />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-jump-to-latest/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/jump-to-latest.md"
    },
    {
      "id": "key-takeaway",
      "name": "KeyTakeaway",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "takeaway",
        "highlight",
        "summary point"
      ],
      "category": "trust",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [],
      "summary": "A labeled box for the closing point of an answer.",
      "description": "KeyTakeaway sets the closing point of an answer apart from the rest. An icon and a text label make it stand out, so color is not the only clue. Keep it to one to three sentences.",
      "purpose": "Sets the closing point of a long answer apart, so a reader who skims still finds it. A visible label and an icon mark it, so it does not rely on color.",
      "useFor": [
        "The one to three sentence conclusion at the end of a long answer.",
        "A recommendation that follows from the facts above it."
      ],
      "notFor": [
        {
          "text": "Warnings, limits or errors",
          "use": "callout"
        },
        {
          "text": "A whole overview with sources and feedback",
          "use": "summary-card"
        }
      ],
      "whenToUse": [
        "Use it once, at the end of a long answer.",
        "Write a full sentence that makes sense on its own.",
        "Keep the label visible.",
        "Put it after the facts it sums up."
      ],
      "avoid": [
        "Do not use it for warnings. Use Callout.",
        "Do not repeat the whole answer.",
        "Do not use several in one answer.",
        "Do not hide the label."
      ],
      "accessibility": [
        "Screen readers hear it as a section named by its visible label, so people can jump to it.",
        "The icon is hidden from screen readers. The label carries the meaning."
      ],
      "accessibilityYouDo": [
        "Write a label that makes sense on its own, because screen readers use it as the section's name.",
        "Use only one per answer, so the named sections stay easy to tell apart."
      ],
      "onPhone": [
        "Fills the width of its container, and the text wraps inside the box.",
        "The small label is 12px on a touch screen."
      ],
      "importPath": "import { KeyTakeaway } from \"./index\"",
      "sourcePath": "src/molecules/KeyTakeaway.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Key takeaway\"",
          "description": "Visible label. Also the accessible name of the region."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "One to three sentences."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the section."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken",
        "--lime",
        "--on-lime",
        "--border",
        "--radius-card"
      ],
      "related": [
        "callout",
        "timeline",
        "comparison-table"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Ending an answer",
          "description": "",
          "code": "<KeyTakeaway>Returning customers drove the gain. Look into why people left the smallest plan.</KeyTakeaway>"
        },
        {
          "title": "Your own label",
          "description": "",
          "code": "<KeyTakeaway label=\"In short\">Send the update on Tuesday.</KeyTakeaway>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-key-takeaway/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/key-takeaway.md"
    },
    {
      "id": "timeline",
      "name": "Timeline",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "history timeline",
        "events",
        "chronology"
      ],
      "category": "numbers",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "done",
          "note": "An event with state done shows a lime dot with a check.",
          "source": "documented"
        },
        {
          "name": "current",
          "note": "An event with state current shows a dark filled centre in a ringed dot.",
          "source": "documented"
        },
        {
          "name": "upcoming",
          "note": "An event with state upcoming, or no state, shows an empty ringed dot.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "An event with state failed shows a red dot with a cross.",
          "source": "documented"
        }
      ],
      "summary": "A top-to-bottom list of events with times and an optional status.",
      "description": "Timeline lists events in order. Each has a time, a title, and optionally a description and a status. The status shows as a marker shape and as words, so it is never color alone.",
      "purpose": "Lists events in the order they happened, each with a time, a title and an optional status. The status is shown as a marker shape and in words, so it never relies on color.",
      "useFor": [
        "What an agent did during a task, step by step with times.",
        "The history of an update or a long job, including a step that failed.",
        "A short log of events a person may need to check later."
      ],
      "notFor": [
        {
          "text": "Live progress of steps that are still running",
          "use": "step-timeline"
        },
        {
          "text": "A flow with decisions and branches",
          "use": "process-diagram"
        },
        {
          "text": "Steps that have no times"
        }
      ],
      "whenToUse": [
        "Use it when the order in time matters.",
        "Give each time a standard date value too.",
        "Keep titles to a few words.",
        "Show the status in words as well as the marker."
      ],
      "avoid": [
        "Do not use it for steps with no time. Use a numbered list.",
        "Do not rely on marker color for the status.",
        "Do not put long text in the title.",
        "Do not animate the markers."
      ],
      "accessibility": [
        "It is a numbered list, so screen readers say how many events there are and which one you are on.",
        "Each time is marked up as a time, and carries the exact dateTime you give it.",
        "Each status is written in words. The markers and the connecting line are hidden from screen readers."
      ],
      "accessibilityYouDo": [
        "Give each event a dateTime in a standard format, such as 2026-10-03T09:40, so software can read the time.",
        "Translate the status words with stateLabels if your product is not in English."
      ],
      "onPhone": [
        "Events stay in one column, and the time, title and state wrap onto more lines when they do not fit.",
        "Nothing in it is tappable, so there are no touch targets to size. The small state labels are 12px."
      ],
      "importPath": "import { Timeline } from \"./index\"",
      "sourcePath": "src/molecules/Timeline.tsx",
      "props": [
        {
          "name": "events",
          "type": "TimelineEvent[]",
          "required": true,
          "default": null,
          "description": "Each event has id, time, optional dateTime, title, optional description and optional state (done, current, upcoming, failed)."
        },
        {
          "name": "stateLabels",
          "type": "Record<TimelineState, string>",
          "required": false,
          "default": "English labels",
          "description": "Replaces the state text for localisation."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the list."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--lime",
        "--on-lime",
        "--danger-soft",
        "--danger-fg",
        "--border-strong"
      ],
      "related": [
        "process-diagram",
        "key-takeaway",
        "step-timeline"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "What happened, and when",
          "description": "",
          "code": "<Timeline events={[\n  { id: \"1\", time: \"09:40\", title: \"Request received\", state: \"done\" },\n  { id: \"2\", time: \"09:41\", title: \"Reading 4 files\", state: \"current\" },\n  { id: \"3\", time: \"09:44\", title: \"Report\", state: \"upcoming\" },\n]} />"
        },
        {
          "title": "When something fails",
          "description": "",
          "code": "<Timeline events={[\n  { id: \"1\", time: \"3 Oct\", dateTime: \"2026-10-03\", title: \"Update started\", state: \"done\" },\n  { id: \"2\", time: \"4 Oct\", dateTime: \"2026-10-04\", title: \"Moving your data\", state: \"failed\", description: \"It took too long.\" },\n]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-timeline/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/timeline.md"
    },
    {
      "id": "comparison-table",
      "name": "ComparisonTable",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "compare table",
        "feature comparison",
        "pricing table"
      ],
      "category": "numbers",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "recommended column",
          "note": "Set badge on an item to tint its column and show the badge under its name.",
          "source": "documented"
        },
        {
          "name": "narrow screen",
          "note": "Below the small breakpoint the table becomes one stacked card per item.",
          "source": "documented"
        },
        {
          "name": "scrolling",
          "note": "On wider screens a table that does not fit scrolls sideways and can be focused with the keyboard.",
          "source": "documented"
        },
        {
          "name": "item limit",
          "note": "Only the first five items are shown.",
          "source": "documented"
        }
      ],
      "summary": "Compares 2 to 5 things side by side. On a small screen, it becomes stacked cards.",
      "description": "ComparisonTable lines up items against the same features in a real table. On a small screen it shows one card per item with the same details. Screen readers get only one version at a time. A badge marks the recommended item in words.",
      "purpose": "Lines up two to five options against the same features so people can compare them at a glance. On a small screen it becomes one card per option, because wide tables are hard to read on a phone.",
      "useFor": [
        "Comparing plans, models or products on the same features.",
        "Pointing out a recommended option among a few choices, with a badge."
      ],
      "notFor": [
        {
          "text": "The numbers behind a chart",
          "use": "data-table"
        },
        {
          "text": "Picking one of a few options inside a control",
          "use": "segmented-control"
        },
        {
          "text": "Laying out a page in columns",
          "use": "grid"
        }
      ],
      "whenToUse": [
        "Compare two to five items on the same features.",
        "Keep cell text short.",
        "Mark a recommendation with the badge text.",
        "Write a caption that says what is compared."
      ],
      "avoid": [
        "Do not use a table to lay out a page.",
        "Do not compare more than five items.",
        "Do not show a winner with color alone.",
        "Do not leave cells empty. Write a dash or a reason."
      ],
      "accessibility": [
        "It is a real table with a caption and column and row headings, so screen readers say which option and feature each cell belongs to.",
        "On a small screen the table is hidden and only the cards are shown, so screen readers read one version, not both.",
        "The recommended option is named by the badge text, not only by its tint.",
        "The table can be reached by keyboard and scrolled sideways when it is wider than the space."
      ],
      "accessibilityYouDo": [
        "Write a caption that says what is compared. It names the table for screen readers and heads the cards.",
        "Fill every cell. Write a dash or a short reason instead of leaving one empty."
      ],
      "onPhone": [
        "Below 600px wide it switches from a table to one card per item, with each attribute in a two-column list, so nothing scrolls sideways.",
        "From 600px wide it is a real table that scrolls sideways inside its box if it is wider than the screen.",
        "Only one of the two forms is shown at a time, so screen readers do not read the data twice."
      ],
      "importPath": "import { ComparisonTable } from \"./index\"",
      "sourcePath": "src/organisms/ComparisonTable.tsx",
      "props": [
        {
          "name": "items",
          "type": "ComparisonItem[]",
          "required": true,
          "default": null,
          "description": "Two to five items with id, name, optional subtitle and optional badge. Extras are ignored."
        },
        {
          "name": "rows",
          "type": "ComparisonRow[]",
          "required": true,
          "default": null,
          "description": "Each row has id, label and values in the same order as items."
        },
        {
          "name": "caption",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Names the table. Used as the heading of the stacked cards."
        },
        {
          "name": "attributeLabel",
          "type": "string",
          "required": false,
          "default": "\"Attribute\"",
          "description": "Header of the first column."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken",
        "--lime",
        "--border",
        "--radius-card"
      ],
      "related": [
        "key-takeaway",
        "process-diagram",
        "timeline"
      ],
      "composedOf": [
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Three plans",
          "description": "Make the window narrow to see the stacked cards.",
          "code": "<ComparisonTable\n  caption=\"Plan comparison\"\n  items={[{ id: \"a\", name: \"Free\" }, { id: \"b\", name: \"Team\", badge: \"Recommended\" }, { id: \"c\", name: \"Scale\" }]}\n  rows={[\n    { id: \"seats\", label: \"Seats\", values: [\"1\", \"10\", \"Unlimited\"] },\n    { id: \"history\", label: \"History\", values: [\"7 days\", \"1 year\", \"Unlimited\"] },\n  ]}\n/>"
        },
        {
          "title": "Two options with short notes",
          "description": "",
          "code": "<ComparisonTable caption=\"Which assistant to pick\" items={[{ id: \"f\", name: \"Fast\", subtitle: \"Short answers\" }, { id: \"d\", name: \"Deep\", subtitle: \"Long analysis\" }]}\n  rows={[{ id: \"wait\", label: \"Typical wait\", values: [\"Under 2s\", \"Over 10s\"] }]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-comparison-table/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/comparison-table.md"
    },
    {
      "id": "process-diagram",
      "name": "ProcessDiagram",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "flow diagram",
        "flowchart",
        "process flow"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "node kinds",
          "note": "Set kind on a node to draw it as a start pill, step box, decision hexagon, end box or dashed attention box.",
          "source": "documented"
        },
        {
          "name": "selected node",
          "note": "Focusing or clicking a node outlines it and shows its detail in the line below the diagram.",
          "source": "documented"
        },
        {
          "name": "narrow",
          "note": "When the container is too narrow for one row, the nodes stack top to bottom.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Each node can be reached with the keyboard.",
          "source": "documented"
        }
      ],
      "summary": "A flow chart of steps and arrows, with a written version for people who cannot see it.",
      "description": "ProcessDiagram draws steps left to right, or top to bottom when space is tight. Shape and label carry meaning: a pill starts, a hexagon is a decision, a double outline ends, and a dashed box with an exclamation mark needs a person. It also has a short name, a written description, a step-by-step list and a way to view the source.",
      "purpose": "Draws a short process as steps and arrows, where shape and label carry the meaning. It always comes with a written version, so people who cannot see the drawing get the same information.",
      "useFor": [
        "Explaining how a request moves from start to result.",
        "A process with a decision or a step that needs a person.",
        "A diagram whose text source people may want to copy."
      ],
      "notFor": [
        {
          "text": "Events with times",
          "use": "timeline"
        },
        {
          "text": "Live progress of an agent's steps",
          "use": "step-timeline"
        },
        {
          "text": "A chart or picture that is not a flow of steps",
          "use": "diagram-figure"
        }
      ],
      "whenToUse": [
        "Write the description first and draw the diagram second.",
        "Keep labels to a few words.",
        "Give each step a short sentence of detail.",
        "Keep the source viewable and copyable."
      ],
      "avoid": [
        "Do not show meaning with color alone.",
        "Do not draw arrows between distant steps. Neighboring steps only.",
        "Do not use more than about eight steps.",
        "Do not leave out the written version."
      ],
      "accessibility": [
        "Each step can be reached with Tab, and screen readers hear its number, kind and name, such as \"2. Step: Plan steps\".",
        "Focusing or clicking a step shows its detail in a line below, and screen readers announce it.",
        "A numbered written version lists every step and where it leads, and a key explains each shape in words.",
        "Arrows are hidden from screen readers, because the written version says where each step leads.",
        "The selected step gets a darker outline, not only a color change."
      ],
      "accessibilityYouDo": [
        "Write a label that names the diagram and says a full description follows.",
        "Write a description in plain sentences that says what the diagram shows.",
        "Give each step a one-sentence detail, because it is read out when the step is focused."
      ],
      "onPhone": [
        "When the row of steps is wider than the container, the diagram switches to a single vertical column.",
        "Tap a step to read its detail below the diagram. Steps are 56px tall and 132px wide.",
        "The text alternative and the view-source button stay under the diagram, and the button has a 44px tap area on a touch screen."
      ],
      "importPath": "import { ProcessDiagram } from \"./index\"",
      "sourcePath": "src/organisms/ProcessDiagram.tsx",
      "props": [
        {
          "name": "nodes",
          "type": "ProcessNode[]",
          "required": true,
          "default": null,
          "description": "Each node has id, label, optional kind (start, step, decision, end, attention) and optional detail."
        },
        {
          "name": "edges",
          "type": "ProcessEdge[]",
          "required": false,
          "default": "in order",
          "description": "Arrows with from, to and an optional label. Draw them between neighbouring nodes."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Short alt. Names the diagram and says the description follows."
        },
        {
          "name": "caption",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Visible caption."
        },
        {
          "name": "description",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Long description, visible to everyone."
        },
        {
          "name": "source",
          "type": "string",
          "required": false,
          "default": "generated",
          "description": "Replaces the generated Mermaid-style definition."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border-strong",
        "--lime",
        "--attention",
        "--attention-soft",
        "--fg-subtle"
      ],
      "related": [
        "diagram-figure",
        "timeline",
        "comparison-table"
      ],
      "composedOf": [
        "diagram-figure",
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "How a request is handled",
          "description": "Tab to a step to read its detail. Open View source for the text version.",
          "code": "<ProcessDiagram\n  label=\"How a request is handled. Full description follows.\"\n  caption=\"How a request reaches a result\"\n  description=\"A request is planned. Risky steps wait for a person to approve them before the report.\"\n  nodes={[\n    { id: \"ask\", label: \"Ask\", kind: \"start\", detail: \"The person sends a request.\" },\n    { id: \"plan\", label: \"Plan steps\", detail: \"The assistant lists what it will do.\" },\n    { id: \"risk\", label: \"Is it risky?\", kind: \"decision\", detail: \"Anything that changes your files needs a person.\" },\n    { id: \"approve\", label: \"Approve\", kind: \"attention\", detail: \"The person says yes before anything changes.\" },\n    { id: \"done\", label: \"Report\", kind: \"end\", detail: \"The assistant sums up what happened.\" },\n  ]}\n/>"
        },
        {
          "title": "With labeled branches",
          "description": "Arrows can carry short labels such as yes and no.",
          "code": "<ProcessDiagram label=\"Retry flow. Description follows.\" caption=\"When to try again\"\n  nodes={[{ id: \"a\", label: \"Send request\", kind: \"start\" }, { id: \"b\", label: \"Worked?\", kind: \"decision\" }, { id: \"c\", label: \"Done\", kind: \"end\" }]}\n  edges={[{ from: \"a\", to: \"b\" }, { from: \"b\", to: \"c\", label: \"yes\" }]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-process-diagram/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/process-diagram.md"
    },
    {
      "id": "diagram-figure",
      "name": "DiagramFigure",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "diagram",
        "figure",
        "graphic"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "source open",
          "note": "Pass source to show a View source button that reveals the source in a code block and changes to Hide source.",
          "source": "documented"
        },
        {
          "name": "scrolling",
          "note": "A diagram wider than the figure scrolls sideways and can be focused with the keyboard.",
          "source": "documented"
        }
      ],
      "summary": "Wraps a diagram with a caption, a full description and a way to view its source.",
      "description": "DiagramFigure gives any diagram a short name, a visible caption, a full description and an optional written version. If you give it the source, it adds a View source button that shows text you can copy. ProcessDiagram uses it, and so can any chart or image.",
      "purpose": "Wraps any diagram, chart or complex picture so it has a short name, a visible caption and a full description. A complex picture needs more than one line of text, so the full description sits on the page for everyone.",
      "useFor": [
        "A chart or drawing that needs more explanation than a short name.",
        "A diagram made from text, where people may want to view or copy the source.",
        "A picture that needs a written alternative, such as a numbered list of its steps."
      ],
      "notFor": [
        {
          "text": "A flow of steps and arrows",
          "use": "process-diagram"
        },
        {
          "text": "A decorative picture with nothing to explain"
        }
      ],
      "whenToUse": [
        "Name the diagram in the label and point to the description.",
        "Show the full description to everyone.",
        "Offer the source when the diagram is made from text.",
        "Wrap charts and images that need more than a short name."
      ],
      "avoid": [
        "Do not hide the full description in a pop-up.",
        "Do not repeat the caption in the label.",
        "Do not wrap decorative images.",
        "Do not rely on a hidden description alone."
      ],
      "accessibility": [
        "It is a figure named by your short label, with a visible caption.",
        "The full description and the written version are ordinary text on the page, readable by everyone.",
        "The View source button tells screen readers whether the source is shown or hidden.",
        "A wide diagram scrolls inside its own box, which can be reached by keyboard, so the page never scrolls sideways."
      ],
      "accessibilityYouDo": [
        "Write a label that names the diagram and says the full description follows. Do not repeat the caption.",
        "Write a full description that gives the same information as the picture."
      ],
      "onPhone": [
        "The diagram scrolls sideways inside its own box if it is wider than the screen, and the box is focusable by keyboard.",
        "The padding is 16px on a phone and 20px from 600px wide, and the caption and long description wrap under the diagram.",
        "The View source button has a 44px tap area on a touch screen, and the source code scrolls sideways inside its block."
      ],
      "importPath": "import { DiagramFigure } from \"./index\"",
      "sourcePath": "src/molecules/DiagramFigure.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Short alt. Becomes the figure's aria-label."
        },
        {
          "name": "caption",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Visible caption."
        },
        {
          "name": "description",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Long description shown on the page."
        },
        {
          "name": "alternative",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Text alternative, such as an ordered list."
        },
        {
          "name": "source",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Textual definition. Enables the View source toggle."
        },
        {
          "name": "sourceLanguage",
          "type": "string",
          "required": false,
          "default": "\"text\"",
          "description": "Language label of the source block."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The diagram."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--code-bg",
        "--radius-card"
      ],
      "related": [
        "process-diagram",
        "timeline",
        "key-takeaway"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A simple diagram",
          "description": "",
          "code": "<DiagramFigure label=\"Three stages. Description follows.\" caption=\"Stages\" description=\"Plan, run, review.\" source={\"plan -> run -> review\"}>\n  <svg …/>\n</DiagramFigure>"
        },
        {
          "title": "With a written version",
          "description": "",
          "code": "<DiagramFigure label=\"Result. Description follows.\" caption=\"Result\" alternative={<ol><li>Plan</li><li>Run</li></ol>}>…</DiagramFigure>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-diagram-figure/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/diagram-figure.md"
    },
    {
      "id": "takeover-bar",
      "name": "TakeoverBar",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "take control",
        "pause agent",
        "human takeover"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        }
      ],
      "summary": "A full-width bar that says who is in control: you, or the assistant.",
      "description": "TakeoverBar tells you who is in charge right now. When the assistant is working, it offers Take over. When you have control, the assistant is paused and does nothing until you choose Resume or Hand back. In watch mode it names the one step you are asked to watch. The status sentence is read out once when it changes.",
      "purpose": "Makes control of a shared screen obvious, so the assistant never acts while the person thinks they are in charge. It offers the matching way to change control, so people can step in and hand back without stopping the run.",
      "useFor": [
        "Above a screen or browser the assistant is operating, so people can take over at any time.",
        "Pausing the assistant while the person does a step themselves.",
        "Asking the person to watch one sensitive step, such as a payment."
      ],
      "notFor": [
        {
          "text": "A question the assistant needs answered",
          "use": "agent-question-card"
        },
        {
          "text": "Approving one action",
          "use": "permission-prompt"
        },
        {
          "text": "A run going on in the background",
          "use": "background-run-chip"
        }
      ],
      "whenToUse": [
        "Keep the bar visible for the whole run so it is always clear who is in charge.",
        "Use the watch state only for steps that really need a person, such as payment or sign-in.",
        "Say what the assistant is not doing while you have control.",
        "Keep the buttons plain: Take over, Resume, Hand back."
      ],
      "avoid": [
        "Do not use amber for the working or you-control states.",
        "Do not let the assistant keep acting while the bar says you have control.",
        "Do not hide Take over in a menu.",
        "Do not collect passwords or card numbers through the assistant."
      ],
      "accessibility": [
        "Screen readers say the status sentence when the state changes.",
        "The icon is hidden from screen readers because the sentence says the same thing.",
        "The watch state is shown by an eye icon and words, not by color alone.",
        "All actions are real buttons, and they wrap on narrow screens.",
        "After Take over, Resume or Hand back, keyboard focus moves to the first button of the new state."
      ],
      "accessibilityYouDo": [
        "Keep one bar on the page and change its state, instead of swapping in a new bar, so the change is announced.",
        "Pass step in watch mode, so people hear which step to watch."
      ],
      "onPhone": [
        "The text and the buttons wrap onto two lines on a narrow screen, with the buttons on the right of the second line.",
        "The buttons keep their size and have a 44px tap area on a touch screen.",
        "Nothing depends on hover, and the status text is announced when control changes hands."
      ],
      "importPath": "import { TakeoverBar } from \"./index\"",
      "sourcePath": "src/molecules/TakeoverBar.tsx",
      "props": [
        {
          "name": "state",
          "type": "\"you-control\" | \"agent-working\" | \"supervise\"",
          "required": true,
          "default": null,
          "description": "Who is in control. Decides the text and the actions."
        },
        {
          "name": "step",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The step to watch. Shown in the supervise state."
        },
        {
          "name": "onTakeOver",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Fires from Take over, in the working and supervise states."
        },
        {
          "name": "onResume",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Fires from Resume. The agent continues from where it paused."
        },
        {
          "name": "onHandBack",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Fires from Hand back, in the you-control state."
        },
        {
          "name": "labels",
          "type": "TakeoverBarLabels",
          "required": false,
          "default": null,
          "description": "Overrides visible text: youControl, youControlHint, agentWorking, supervise, superviseHint(step), takeOver, resume, handBack."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "state": [
          "you-control",
          "agent-working",
          "supervise"
        ]
      },
      "tokens": [
        "--surface",
        "--line",
        "--attention-soft",
        "--attention-fg",
        "--radius-card"
      ],
      "related": [
        "agent-question-card",
        "permission-prompt",
        "agent-status-icon"
      ],
      "composedOf": [
        "agent-status-icon",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Take over, then give it back",
          "description": "Press Take over to pause the assistant. Resume or Hand back lets it continue. The second button shows the watch state.",
          "code": "const [state, setState] = useState<TakeoverState>(\"agent-working\");\n<TakeoverBar\n  state={state}\n  step=\"Entering your card number on the checkout page\"\n  onTakeOver={() => setState(\"you-control\")}\n  onResume={() => setState(\"agent-working\")}\n  onHandBack={() => setState(\"agent-working\")}\n/>"
        },
        {
          "title": "You have control",
          "description": "The assistant is paused and not doing anything. This is the state to show after a person takes over.",
          "code": "<TakeoverBar state=\"you-control\" onResume={() => {}} onHandBack={() => {}} />"
        },
        {
          "title": "A step you need to watch",
          "description": "Use this for sensitive pages such as payment. It is the only state in amber, because you have to act.",
          "code": "<TakeoverBar state=\"supervise\" step=\"Entering your card number on the checkout page\" onTakeOver={() => {}} onResume={() => {}} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-takeover-bar/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/takeover-bar.md"
    },
    {
      "id": "agent-question-card",
      "name": "AgentQuestionCard",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "clarifying question",
        "agent question",
        "ask user",
        "human input request",
        "one question"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "answered",
          "note": "Once answered (or when answer is passed), the card collapses to a summary line with a Change button.",
          "source": "documented"
        },
        {
          "name": "sign-in",
          "note": "Set reason to sign-in to show a lock tag and a hint not to type secrets, and hide the free-text box.",
          "source": "documented"
        },
        {
          "name": "agent decides",
          "note": "Let the agent decide and Skip close the question with a matching summary.",
          "source": "documented"
        },
        {
          "name": "text answer",
          "note": "Send is disabled until something is typed in the answer box.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "An option button gets a stronger border under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Options and buttons show a focus ring, and focus moves to the card after an answer or Change.",
          "source": "documented"
        }
      ],
      "summary": "The assistant asks you a question in the middle of a task, with quick answers.",
      "description": "AgentQuestionCard is how the assistant asks for help instead of guessing. A small label says why it is asking: choose an approach, needs information, needs a sign-in, or confirm a risky step. You can tap one of two to four choices, type your own answer, let the assistant decide, or skip. Once answered, the card shrinks to a one-line summary. It uses amber because a person has to act.",
      "purpose": "The assistant stops to ask instead of guessing. A label says why it is asking, and quick choices, a typed answer, Let the agent decide and Skip mean the person is never stuck.",
      "useFor": [
        "Asking the person to choose between two to four approaches.",
        "Asking for a fact the assistant cannot find itself.",
        "Asking someone to sign in, or to confirm a risky step, before the assistant goes on."
      ],
      "notFor": [
        {
          "text": "Allowing or denying one specific action",
          "use": "permission-prompt"
        },
        {
          "text": "Agreeing to a whole plan before work starts",
          "use": "plan-card"
        },
        {
          "text": "Suggested next prompts after a reply",
          "use": "suggestion-chips"
        }
      ],
      "whenToUse": [
        "Ask only when a wrong guess would cost the person time or money.",
        "Write the question so it can be answered without reading the log.",
        "Give two to four choices that really differ, and say how in one line.",
        "Always offer Let the agent decide and Skip."
      ],
      "avoid": [
        "Do not ask for passwords or codes in the text box.",
        "Do not use amber on a card that is already answered.",
        "Do not ask about things the assistant can find out itself.",
        "Do not stack many question cards at once. Use the inbox instead."
      ],
      "accessibility": [
        "The card is a region named by its question.",
        "Choices are real buttons in a list, so Tab moves through them and Enter or Space picks one.",
        "The reason is written in words, so it does not depend on color.",
        "The own-answer box has a visible label.",
        "After an answer, keyboard focus moves to the summary, and after Change it moves back to the question.",
        "An answer given elsewhere is announced to screen readers.",
        "Sign-in questions have no text box by default, and a hint says never to type a password there."
      ],
      "accessibilityYouDo": [
        "Write the question so it makes sense alone, because screen readers use it as the card's name."
      ],
      "onPhone": [
        "The answer options stack in one column on a phone and move to two columns from 640px wide.",
        "The text field is 44px tall with 16px text on a touch screen, and the Send button wraps below it when the row is narrow.",
        "Let the agent decide and Skip are buttons that wrap, so they stay reachable without scrolling sideways."
      ],
      "importPath": "import { AgentQuestionCard } from \"./index\"",
      "sourcePath": "src/organisms/AgentQuestionCard.tsx",
      "props": [
        {
          "name": "question",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The question, in plain words."
        },
        {
          "name": "reason",
          "type": "\"approach\" | \"information\" | \"sign-in\" | \"risky-step\"",
          "required": true,
          "default": null,
          "description": "Why the assistant is asking. Sets the label."
        },
        {
          "name": "options",
          "type": "QuestionOption[]",
          "required": false,
          "default": "[]",
          "description": "Quick answers ({ id, label, description? }). Only the first four are shown."
        },
        {
          "name": "allowText",
          "type": "boolean",
          "required": false,
          "default": "reason !== \"sign-in\"",
          "description": "Show the free-text answer."
        },
        {
          "name": "answer",
          "type": "QuestionAnswer | null",
          "required": false,
          "default": null,
          "description": "Controlled answer ({ kind, value, label }). Omit to let the card keep its own."
        },
        {
          "name": "onAnswer",
          "type": "(answer: QuestionAnswer) => void",
          "required": false,
          "default": null,
          "description": "Fires for an option, typed text, Let the agent decide, or Skip."
        },
        {
          "name": "onReopen",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Fires when the person chooses Change on the summary."
        },
        {
          "name": "runName",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Name of the run that asked."
        },
        {
          "name": "labels",
          "type": "AgentQuestionCardLabels",
          "required": false,
          "default": null,
          "description": "Overrides visible text, including a reasons map."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "reason": [
          "approach",
          "information",
          "sign-in",
          "risky-step"
        ]
      },
      "tokens": [
        "--attention",
        "--attention-soft",
        "--attention-fg",
        "--line",
        "--radius-field",
        "--radius-card"
      ],
      "related": [
        "approval-inbox",
        "permission-prompt",
        "approval-prompt"
      ],
      "composedOf": [
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Choose an approach",
          "description": "Tap a choice. The card collapses to a summary and Change reopens it.",
          "code": "<AgentQuestionCard\n  question=\"Should I plan the trip around museums or the beach?\"\n  reason=\"approach\"\n  runName=\"Trip plan\"\n  options={[\n    { id: \"m\", label: \"Museums\", description: \"Indoor, good for rainy days\" },\n    { id: \"b\", label: \"Beach\", description: \"Outdoor, relaxed pace\" },\n    { id: \"x\", label: \"A mix of both\" },\n  ]}\n  onAnswer={(a) => console.log(a)}\n/>"
        },
        {
          "title": "Missing information",
          "description": "Two choices and a box for any other answer.",
          "code": "<AgentQuestionCard question=\"How many people are travelling?\" reason=\"information\" options={[{ id: \"2\", label: \"2 people\" }, { id: \"4\", label: \"4 people\" }]} />"
        },
        {
          "title": "A sign-in is needed",
          "description": "The card never asks for a password in text. You sign in in the other window, then tell the assistant.",
          "code": "<AgentQuestionCard question=\"The airline site wants you to sign in before I can see your booking.\" reason=\"sign-in\" options={[{ id: \"done\", label: \"I have signed in\" }]} />"
        },
        {
          "title": "Confirm a risky step",
          "description": "Say what will happen and make both answers easy to reach.",
          "code": "<AgentQuestionCard question=\"I am about to delete 12 old invoices. Is that OK?\" reason=\"risky-step\" options={[{ id: \"yes\", label: \"Yes, delete them\" }, { id: \"no\", label: \"No, keep them\" }]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-agent-question-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/agent-question-card.md"
    },
    {
      "id": "approval-inbox",
      "name": "ApprovalInbox",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "needs you inbox",
        "approval inbox",
        "action required",
        "pending approvals",
        "inbox"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "With no items, a compact cleared message replaces the list and the count badge is hidden.",
          "source": "documented"
        },
        {
          "name": "urgent",
          "note": "Items with urgency now get an attention border and a primary action button; later groups use quieter styling.",
          "source": "documented"
        },
        {
          "name": "failure",
          "note": "An item with kind failure shows a red kind badge.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Each action button shows a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "One list of everything across your runs that is waiting for you.",
      "description": "ApprovalInbox gathers every question, approval, failure and finished piece of work that waits on you, from all runs, in one place. Items are grouped by how urgent they are. Each row names the run, says what is needed, shows how long it has waited and has one button. When the list is empty, it says so plainly. It works on a phone.",
      "purpose": "One place for everything, across all runs, that is waiting on a person, sorted by how soon it needs them. Each row has one clear action, so people can clear the list quickly, even on a phone.",
      "useFor": [
        "A home screen or side panel that collects questions, approvals, failures and finished work from many runs.",
        "A phone view where people sort out what waits on them.",
        "Saying plainly that nothing is waiting."
      ],
      "notFor": [
        {
          "text": "Every task, including ones that need nothing from the person",
          "use": "agent-session-list"
        },
        {
          "text": "A single question inside a run",
          "use": "agent-question-card"
        },
        {
          "text": "A catch-up when someone returns to one run",
          "use": "catch-up-summary"
        }
      ],
      "whenToUse": [
        "Sort by how urgent it is, then by how long it has waited.",
        "Give each row one clear action.",
        "Show the run name so people know where the item came from.",
        "Use plain wait times such as 12 min or Yesterday."
      ],
      "avoid": [
        "Do not put finished-and-fine runs here. Only things that need a person.",
        "Do not mark every group amber.",
        "Do not hide the wait time.",
        "Do not add extra actions to rows. Open the details instead."
      ],
      "accessibility": [
        "The inbox is a region named by its title, with real headings for the title and each group.",
        "Each group is a list, so screen readers say how many items it holds.",
        "Each button's name includes the run and what is needed, so the buttons do not all sound the same.",
        "The type of item is written in words, not only shown by an icon or color.",
        "Rows wrap on narrow screens."
      ],
      "accessibilityYouDo": [
        "Set headingLevel so the title fits the headings around it.",
        "Keep actionLabel short, such as Answer or Review. The run and the need are added to the button name for screen readers."
      ],
      "onPhone": [
        "The card has 16px padding on a phone and 20px from 640px wide.",
        "Each row wraps so the action button drops under the text, and long run names are cut with an ellipsis.",
        "Action buttons keep their size and have a 44px tap area on a touch screen."
      ],
      "importPath": "import { ApprovalInbox } from \"./index\"",
      "sourcePath": "src/organisms/ApprovalInbox.tsx",
      "props": [
        {
          "name": "items",
          "type": "InboxItem[]",
          "required": true,
          "default": null,
          "description": "Waiting items: { id, run, kind, urgency, need, waited, actionLabel }. kind is question, approval, failure or review. urgency is now, soon or later."
        },
        {
          "name": "onAction",
          "type": "(item: InboxItem) => void",
          "required": false,
          "default": null,
          "description": "Fires from the row button."
        },
        {
          "name": "labels",
          "type": "ApprovalInboxLabels",
          "required": false,
          "default": null,
          "description": "Overrides title, group names, type names, waiting(time), emptyTitle and emptyBody."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4",
          "required": false,
          "default": "2",
          "description": "Level of the title. Group headings use the next level."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4
        ]
      },
      "tokens": [
        "--attention",
        "--surface",
        "--line",
        "--radius-card"
      ],
      "related": [
        "agent-session-list",
        "agent-question-card",
        "background-run-chip"
      ],
      "composedOf": [
        "agent-status-icon",
        "badge",
        "button",
        "empty-state"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Four things are waiting",
          "description": "Press an action to handle an item. It leaves the list.",
          "code": "<ApprovalInbox\n  items={[\n    { id: \"1\", run: \"Trip plan\", kind: \"question\", urgency: \"now\", need: \"Pick a hotel for Friday night\", waited: \"12 min\", actionLabel: \"Answer\" },\n    { id: \"3\", run: \"Weekly report\", kind: \"failure\", urgency: \"soon\", need: \"Could not open the sales spreadsheet\", waited: \"1 hour\", actionLabel: \"Fix\" },\n    { id: \"4\", run: \"Shop product pages\", kind: \"review\", urgency: \"later\", need: \"18 new descriptions are ready to read\", waited: \"Yesterday\", actionLabel: \"Open\" },\n  ]}\n  onAction={(item) => openItem(item.id)}\n/>"
        },
        {
          "title": "Nothing is waiting",
          "description": "An empty inbox is good news. Say so.",
          "code": "<ApprovalInbox items={[]} />"
        },
        {
          "title": "A single urgent item",
          "description": "Only the urgent group gets the amber outline.",
          "code": "<ApprovalInbox items={[{ id: \"2\", run: \"Invoice cleanup\", kind: \"approval\", urgency: \"now\", need: \"Delete 12 old invoices\", waited: \"3 min\", actionLabel: \"Review\" }]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-approval-inbox/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/approval-inbox.md"
    },
    {
      "id": "background-run-chip",
      "name": "BackgroundRunChip",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "background task",
        "running job"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "open or closed",
          "note": "Set with the expanded prop.",
          "source": "prop"
        }
      ],
      "summary": "A small chip for a run in the background that opens its details.",
      "description": "BackgroundRunChip shows one background run in a single small pill: a state icon, its name, a short status and how long it has run. Pressing it opens the details or a peek. When the run needs you, the chip turns amber so it stands out. Every other state stays calm.",
      "purpose": "A small pill for a run that keeps going while the person does other things. It stays calm while the run works and turns amber only when the run needs the person.",
      "useFor": [
        "A row of background runs in a header or footer.",
        "Opening a run's details or a quick peek.",
        "Drawing attention to a background run that is waiting on the person."
      ],
      "notFor": [
        {
          "text": "A full list of runs with summaries and replies",
          "use": "agent-session-list"
        },
        {
          "text": "Saying who is in control of the screen",
          "use": "takeover-bar"
        },
        {
          "text": "A status that nobody needs to press",
          "use": "agent-status-icon"
        }
      ],
      "whenToUse": [
        "Use it where space is tight, such as a header or a side rail.",
        "Keep the status to two or three words.",
        "Open a peek or panel with the full story on press.",
        "Group chips that need you first."
      ],
      "avoid": [
        "Do not use amber for working, done or failed runs.",
        "Do not put more than a name and a short status in the chip.",
        "Do not use a chip for an action. It only opens details.",
        "Do not let the elapsed time tick every second for screen readers."
      ],
      "accessibility": [
        "It is one real button, and its name reads the state, the run name, the status and the time together.",
        "Amber comes with the words Needs you and a question-mark icon, so it does not rely on color.",
        "It tells screen readers whether its panel is open when you pass expanded.",
        "The spinning icon stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Pass expanded while the panel it opens is showing. Leave it out if the chip goes to another page.",
        "Keep statusText short, because it is read in the button name with the run name and time."
      ],
      "onPhone": [
        "The chip never grows wider than its container, and the name and status text are cut with an ellipsis when they do not fit.",
        "Nothing shows the full text of a cut name on a phone, so keep names and status words short.",
        "On a touch screen the chip keeps its size and has a 44px tap area."
      ],
      "importPath": "import { BackgroundRunChip } from \"./index\"",
      "sourcePath": "src/molecules/BackgroundRunChip.tsx",
      "props": [
        {
          "name": "name",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Name of the run."
        },
        {
          "name": "status",
          "type": "AgentStatus",
          "required": true,
          "default": null,
          "description": "working, needs-input, idle, completed, failed or stopped. needs-input gets the amber style."
        },
        {
          "name": "statusText",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Short status. Defaults to a plain word for the status."
        },
        {
          "name": "elapsed",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Time the run has been going, such as 4 min."
        },
        {
          "name": "expanded",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Set while the details this chip opens are showing. Sets aria-expanded."
        },
        {
          "name": "onClick",
          "type": "MouseEventHandler<HTMLButtonElement>",
          "required": false,
          "default": null,
          "description": "Opens the details. Other button attributes pass through."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the button."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--attention-soft",
        "--attention-fg",
        "--surface",
        "--line",
        "--radius-control"
      ],
      "related": [
        "agent-status-icon",
        "approval-inbox",
        "agent-session-list"
      ],
      "composedOf": [
        "agent-status-icon"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Four runs in a row",
          "description": "Press a chip to open its details. The run that needs you is amber.",
          "code": "<BackgroundRunChip name=\"Invoices\" status=\"needs-input\" statusText=\"Needs you\" elapsed=\"12 min\" onClick={() => openDetails(\"invoices\")} />"
        },
        {
          "title": "Working",
          "description": "The icon spins and stays still if motion is turned off.",
          "code": "<BackgroundRunChip name=\"Trip plan\" status=\"working\" statusText=\"Booking hotels\" elapsed=\"4 min\" />"
        },
        {
          "title": "A long name on a small screen",
          "description": "Long names are cut with an ellipsis and the chip never grows wider than its row.",
          "code": "<BackgroundRunChip name=\"Compare every flight and hotel for the summer trip\" status=\"needs-input\" elapsed=\"1 h\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-background-run-chip/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/background-run-chip.md"
    },
    {
      "id": "catch-up-summary",
      "name": "CatchUpSummary",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "re-entry recap",
        "catch up summary",
        "welcome back",
        "while you were away"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open or closed",
          "note": "Set with the defaultOpen prop.",
          "source": "prop"
        }
      ],
      "summary": "A \"While you were away\" summary shown when you come back to a run.",
      "description": "CatchUpSummary catches you up after a break. It says how long you were away, what finished, what needs you now (with buttons), and what changed as simple counts. A Show full log button leads to the detail. The whole card can fold down to its header.",
      "purpose": "Catches people up after a break: how long they were away, what finished, what needs them now and what changed. What needs action comes before the counts, and the full log is one press away.",
      "useFor": [
        "When someone returns to a run that kept working while they were gone.",
        "Putting the actions that wait on the person at the top of a run.",
        "Summing up changes as counts, with a way into the full log."
      ],
      "notFor": [
        {
          "text": "Waiting items from many runs at once",
          "use": "approval-inbox"
        },
        {
          "text": "The full record of every action",
          "use": "action-log"
        },
        {
          "text": "A section that people open and close, with no recap",
          "use": "collapsible"
        }
      ],
      "whenToUse": [
        "Show it once, when the person returns, not on every visit.",
        "Put what needs the person above what changed.",
        "Use counts for changes and keep the detail in the full log.",
        "Write each line as a short sentence a friend would say."
      ],
      "avoid": [
        "Do not show the whole log here.",
        "Do not use amber for finished work.",
        "Do not hide the actions behind the fold.",
        "Do not show it if nothing happened. Say nothing."
      ],
      "accessibility": [
        "The header is a button that says whether the card is open or closed.",
        "The card is a region named by its header, with a heading for each part, so screen readers can jump between them.",
        "Rows that need you have a dot and words, not color alone.",
        "The fold arrow turns without animation when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Write action labels that make sense alone, such as \"Pick a hotel\", because the buttons are named only by them.",
        "Set headingLevel so the header fits your page outline. It is 3 unless you change it; the inner headings sit one level below."
      ],
      "onPhone": [
        "The whole header is one button across the card that opens and closes the recap. On a touch screen its tap area is at least 44px tall.",
        "Items needing you wrap so the action button drops under the text on a narrow screen.",
        "The change counts wrap onto as many rows as they need."
      ],
      "importPath": "import { CatchUpSummary } from \"./index\"",
      "sourcePath": "src/organisms/CatchUpSummary.tsx",
      "props": [
        {
          "name": "awayFor",
          "type": "string",
          "required": true,
          "default": null,
          "description": "How long the person was away, such as 2 hours."
        },
        {
          "name": "finished",
          "type": "string[]",
          "required": false,
          "default": "[]",
          "description": "What finished while away."
        },
        {
          "name": "needsYou",
          "type": "RecapNeed[]",
          "required": false,
          "default": "[]",
          "description": "Items waiting on the person: { id, text, actionLabel, onAction? }."
        },
        {
          "name": "changes",
          "type": "RecapChange[]",
          "required": false,
          "default": "[]",
          "description": "Counts of what changed: { label, count }."
        },
        {
          "name": "onShowLog",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Opens the full log. The button is hidden when omitted."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Whether the card starts open."
        },
        {
          "name": "labels",
          "type": "CatchUpSummaryLabels",
          "required": false,
          "default": null,
          "description": "Overrides heading, away(time), section titles, empty lines and showLog."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the part's own heading, so it fits the page outline."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--attention",
        "--surface",
        "--line",
        "--sunken",
        "--radius-card"
      ],
      "related": [
        "approval-inbox",
        "summary-card",
        "agent-session-list"
      ],
      "composedOf": [
        "attention-dot",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "After two hours away",
          "description": "Press a Needs you button or Show full log. Press the header to fold the card.",
          "code": "<CatchUpSummary\n  awayFor=\"2 hours\"\n  finished={[\"Found 6 flights under $400\", \"Saved your passport details to the form\"]}\n  needsYou={[{ id: \"n1\", text: \"Choose which hotel to book for Friday\", actionLabel: \"Choose\", onAction: openHotels }]}\n  changes={[{ label: \"files edited\", count: 4 }, { label: \"pages visited\", count: 31 }]}\n  onShowLog={openLog}\n/>"
        },
        {
          "title": "Nothing needs you",
          "description": "A calm recap when everything went well.",
          "code": "<CatchUpSummary awayFor=\"30 minutes\" finished={[\"Backed up 214 photos\"]} changes={[{ label: \"photos copied\", count: 214 }]} />"
        },
        {
          "title": "Folded",
          "description": "Start folded when the person has seen similar recaps before.",
          "code": "<CatchUpSummary awayFor=\"1 day\" defaultOpen={false} finished={[\"Sent the weekly update\"]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-catch-up-summary/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/catch-up-summary.md"
    },
    {
      "id": "approved-by-mark",
      "name": "ApprovedByMark",
      "level": "atom",
      "since": "0.5",
      "aka": [
        "approved by",
        "sign off mark"
      ],
      "category": "agents",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "you",
          "note": "Set by to you for a tick and the text You approved.",
          "source": "documented"
        },
        {
          "name": "check",
          "note": "Set by to check for a shield and the text Automatic check approved.",
          "source": "documented"
        },
        {
          "name": "rule",
          "note": "Set by to rule for a bookmark and the text Allowed by a saved rule.",
          "source": "documented"
        },
        {
          "name": "none",
          "note": "Set by to none for a loop icon and the text Nobody asked (auto mode).",
          "source": "documented"
        },
        {
          "name": "detail",
          "note": "Pass detail to add a tooltip and extra text for screen readers.",
          "source": "documented"
        }
      ],
      "summary": "A small mark that says who said yes to an action.",
      "description": "ApprovedByMark shows who approved an action: you, an automatic check, a saved rule, or nobody. It always uses an icon and words, so the meaning never depends on color. Put it beside any action in a log so people can tell their own approval from a machine's.",
      "purpose": "Says who allowed an action: the person, an automatic check, a saved rule, or nobody. It always pairs an icon with words, so people can tell their own approval from a machine's without relying on color.",
      "useFor": [
        "Beside each action in a log or history.",
        "On a finished step, to show whether a person or a rule let it run.",
        "Making auto mode visible with the words \"Nobody asked\"."
      ],
      "notFor": [
        {
          "text": "A full record of actions with status and Undo",
          "use": "action-log"
        },
        {
          "text": "Showing that content was written by AI",
          "use": "ai-badge"
        },
        {
          "text": "A general status or category label",
          "use": "badge"
        }
      ],
      "whenToUse": [
        "Show it on every action that needed or skipped an approval.",
        "Keep the words visible next to the icon.",
        "Say 'Nobody asked' when auto mode skipped the question.",
        "Add detail naming the saved rule when you can."
      ],
      "avoid": [
        "Don't use color alone to tell the kinds apart.",
        "Don't show 'You approved' for something an automatic check approved.",
        "Don't hide the mark to save space. Let it wrap instead.",
        "Don't use amber for it. It is a record, not a request."
      ],
      "accessibility": [
        "The words are always on screen. The icon is hidden from screen readers.",
        "Extra detail is read by screen readers after the words, and shows as a tooltip on hover.",
        "It is plain text, so it zooms and wraps with the page."
      ],
      "accessibilityYouDo": [
        "Put anything people must see in the visible words, not only in detail: the tooltip appears only on mouse hover.",
        "Pass labels in the person's language when your app is not in English."
      ],
      "onPhone": [
        "It is one short line of small text that wraps inside its container instead of cutting off.",
        "The detail text shows as a tooltip on hover only, so on a phone it is read aloud by screen readers but never shown.",
        "Keep the label words short, because the mark has no tap action."
      ],
      "importPath": "import { ApprovedByMark } from \"./index\"",
      "sourcePath": "src/atoms/ApprovedByMark.tsx",
      "props": [
        {
          "name": "by",
          "type": "\"you\" | \"check\" | \"rule\" | \"none\"",
          "required": true,
          "default": null,
          "description": "Who approved the action. none means nobody was asked (auto mode)."
        },
        {
          "name": "labels",
          "type": "Partial<Record<ApprovedBy, string>>",
          "required": false,
          "default": null,
          "description": "Replace the visible words for one or more kinds."
        },
        {
          "name": "detail",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra plain detail. Set as the tooltip and added as screen reader text."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the span."
        }
      ],
      "variantsHint": {
        "by": [
          "you",
          "check",
          "rule",
          "none"
        ]
      },
      "tokens": [
        "--fg-muted"
      ],
      "related": [
        "badge",
        "ai-badge"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "The four kinds",
          "description": "Each kind has its own icon and words. 'Nobody asked' is shown plainly so auto mode is never hidden.",
          "code": "<ApprovedByMark by=\"you\" />\n<ApprovedByMark by=\"check\" />\n<ApprovedByMark by=\"rule\" />\n<ApprovedByMark by=\"none\" />"
        },
        {
          "title": "Naming the saved rule",
          "description": "Add detail to say which rule allowed it. It shows in the tooltip and is read out by screen readers.",
          "code": "<ApprovedByMark by=\"rule\" detail=\"Rule: emails to team@example.com\" />"
        },
        {
          "title": "Your own words",
          "description": "Replace the words for one kind with labels.",
          "code": "<ApprovedByMark by=\"you\" labels={{ you: \"Approved by Sam\" }} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-approved-by-mark/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/approved-by-mark.md"
    },
    {
      "id": "action-log",
      "name": "ActionLog",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "audit log",
        "activity log",
        "history"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "done",
          "note": "An action with status done shows a green badge, and an Undo button while undoMinutes is above zero.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "An action with status failed shows a red badge.",
          "source": "documented"
        },
        {
          "name": "undone",
          "note": "An action with status undone, or one you just undid, shows a neutral badge.",
          "source": "documented"
        },
        {
          "name": "waiting",
          "note": "An action with status waiting shows an info badge.",
          "source": "documented"
        },
        {
          "name": "cannot undo",
          "note": "A done action with notUndoableReason shows that reason instead of an Undo button.",
          "source": "documented"
        },
        {
          "name": "undo ended",
          "note": "A done action with undoMinutes of 0 shows that the undo time is over.",
          "source": "documented"
        },
        {
          "name": "filtered empty",
          "note": "When the chosen filter matches nothing, a short message replaces the list.",
          "source": "documented"
        }
      ],
      "summary": "A lasting list of what an assistant did, with who approved it and a timed Undo.",
      "description": "ActionLog is a record of what an assistant did. Each row shows what happened, who did it, how it ended, when, and who approved it. While Undo is still allowed, the row shows how many minutes are left. When something cannot be undone, the row says so and gives the reason. A summary line and a filter help people find what they need.",
      "purpose": "A lasting record of what an assistant did, so people can check its work and undo it while there is still time. Each row says who acted, how it ended and who said yes.",
      "useFor": [
        "A history of the actions an assistant took for the person.",
        "Offering Undo for a limited time after an action.",
        "Explaining in words why an action cannot be undone."
      ],
      "notFor": [
        {
          "text": "Saved versions of a document to go back to",
          "use": "version-history"
        },
        {
          "text": "Points to return to inside a single run",
          "use": "checkpoint-list"
        },
        {
          "text": "A short catch-up after time away",
          "use": "catch-up-summary"
        }
      ],
      "whenToUse": [
        "Show the time left on every Undo button.",
        "Say why an action cannot be undone.",
        "Keep the log after the session so people can check later.",
        "Show who approved each action.",
        "Keep undoMinutes up to date. The button shows the number you pass and does not count down by itself."
      ],
      "avoid": [
        "Don't hide failed actions. Trust drops when failures are quiet.",
        "Don't offer Undo after the time is over.",
        "Don't promise Undo for things like sent emails.",
        "Don't remove undone rows. Show them as Undone."
      ],
      "accessibility": [
        "Each status has an icon and a word, never color alone.",
        "The Undo button's name includes the action and the time left.",
        "Screen readers hear the result of each filter change and each undo.",
        "The filter is one group of options that works with the arrow keys.",
        "Rows wrap on narrow screens, and the filter scrolls sideways instead of being cut off."
      ],
      "accessibilityYouDo": [
        "Write each action in plain words, because it is read as part of the Undo button's name.",
        "Give notUndoableReason for anything that can never be undone, so people learn why."
      ],
      "onPhone": [
        "Each row wraps: the action text takes the full width and the status badge and Undo button drop underneath when the row is narrow.",
        "The filter control scrolls sideways inside its own box if its five options do not fit.",
        "Undo and the filter options keep their size and have a 44px tap area on a touch screen."
      ],
      "importPath": "import { ActionLog } from \"./index\"",
      "sourcePath": "src/organisms/ActionLog.tsx",
      "props": [
        {
          "name": "items",
          "type": "ActionLogItem[]",
          "required": true,
          "default": null,
          "description": "Rows: id, action, actor, status (done | failed | undone | waiting), time (text), approvedBy, undoMinutes, notUndoableReason."
        },
        {
          "name": "onUndo",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Called when Undo is pressed. The row then shows Undone."
        },
        {
          "name": "defaultFilter",
          "type": "ActionStatus | \"all\"",
          "required": false,
          "default": "\"all\"",
          "description": "Starting status filter."
        },
        {
          "name": "labels",
          "type": "ActionLogLabels",
          "required": false,
          "default": null,
          "description": "Replace filter names, Undo text (undoFor receives the minutes left), the empty text and the not-undoable text."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the section."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--border",
        "--surface",
        "--success-soft",
        "--danger-soft",
        "--info-soft",
        "--radius-card"
      ],
      "related": [
        "agent-session-list",
        "approved-by-mark",
        "revert-toggle"
      ],
      "composedOf": [
        "approved-by-mark",
        "badge",
        "button",
        "segmented-control"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A morning of chores",
          "description": "Try the filter, then press Undo on the first row. It becomes Undone and the summary updates.",
          "code": "<ActionLog\n  items={[\n    { id: \"1\", action: \"Moved 12 receipts to the Taxes folder\", actor: \"Home helper\", status: \"done\", time: \"10:42\", approvedBy: \"you\", undoMinutes: 8 },\n    { id: \"2\", action: \"Sent the invoice to Dana\", actor: \"Home helper\", status: \"done\", time: \"10:40\", approvedBy: \"rule\", notUndoableReason: \"The email was already sent.\" },\n    { id: \"3\", action: \"Renamed 40 photos from the trip\", actor: \"Photo helper\", status: \"done\", time: \"10:31\", approvedBy: \"check\", undoMinutes: 0 },\n    { id: \"4\", action: \"Booked the hotel in Lisbon\", actor: \"Travel helper\", status: \"failed\", time: \"10:12\", approvedBy: \"you\" },\n    { id: \"5\", action: \"Paid the electricity bill\", actor: \"Home helper\", status: \"waiting\", time: \"10:45\" },\n  ]}\n  onUndo={(id) => console.log(\"undo\", id)}\n/>"
        },
        {
          "title": "Starting on failed actions",
          "description": "Open the log filtered to what went wrong.",
          "code": "<ActionLog items={items} defaultFilter=\"failed\" />"
        },
        {
          "title": "Nothing to show",
          "description": "When the filter matches no rows, the log says so in words.",
          "code": "<ActionLog items={[]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-action-log/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/action-log.md"
    },
    {
      "id": "budget-control",
      "name": "BudgetControl",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "spend limit",
        "cost limit",
        "budget cap"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "near limit",
          "note": "When used reaches warnAt of the cap, the bar changes colour and a Close to the limit note appears.",
          "source": "documented"
        },
        {
          "name": "limit reached",
          "note": "When used reaches the cap, the note reads Limit reached.",
          "source": "documented"
        },
        {
          "name": "editing a cap",
          "note": "Typing a valid number in a limit's box updates it and calls onCapChange.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The cap boxes and the radio options show a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "Shows and sets how much time, money and steps a run may use, and what happens at the limit.",
      "description": "BudgetControl shows three limits for a run: time, spend and number of steps. Each has a progress bar and a number the person can change. A choice sets what happens when a limit is reached. A limit that is close or reached says so in words.",
      "purpose": "Puts the limits of a run where people can see and change them: time, spend and steps. One choice sets what happens at a limit, so a run never quietly goes past it.",
      "useFor": [
        "Before or during a long run, to set how much time, money or steps it may use.",
        "Showing how close a run is to each limit.",
        "Choosing whether a run stops, stops and asks, or asks for more at a limit."
      ],
      "notFor": [
        {
          "text": "Usage of a plan or account over time",
          "use": "usage-meter"
        },
        {
          "text": "How full the conversation's memory is",
          "use": "context-meter"
        },
        {
          "text": "A single bar with no settings",
          "use": "meter"
        }
      ],
      "whenToUse": [
        "Show used and cap in plain numbers next to the bar.",
        "Say what happens at the limit before it happens.",
        "Default to Stop and ask me.",
        "Use the same unit people see on their bill."
      ],
      "avoid": [
        "Don't use jargon like 'iterations'. Say steps.",
        "Don't rely on the bar color alone. A note names the state.",
        "Don't let a cap go below zero.",
        "Don't continue past a limit without asking, unless the person chose that."
      ],
      "accessibility": [
        "Each bar has a name and a spoken value, such as \"12 of 30 minutes used\".",
        "Each number box has a name, such as \"Time limit\", and shows its unit.",
        "Limits that are close or reached are announced, and shown with an icon and words.",
        "The choices for a reached limit are radio buttons that work with the arrow keys."
      ],
      "accessibilityYouDo": [
        "Show your own message if a number is not allowed. The box accepts any number from zero up and shows no error.",
        "Set headingLevel so the heading fits your page outline. It is 3 unless you change it."
      ],
      "onPhone": [
        "The limit field opens the number keypad on a phone, and on a touch screen it is 44px tall with 16px text so the page does not zoom.",
        "Each limit stacks its name above the field when the card is narrow.",
        "The three choices for what happens at a limit are rows that grow to at least 44px tall on touch screens."
      ],
      "importPath": "import { BudgetControl } from \"./index\"",
      "sourcePath": "src/organisms/BudgetControl.tsx",
      "props": [
        {
          "name": "limits",
          "type": "BudgetLimit[]",
          "required": true,
          "default": null,
          "description": "Each limit: id, label, used, cap, unit."
        },
        {
          "name": "onCapChange",
          "type": "(id: string, cap: number) => void",
          "required": false,
          "default": null,
          "description": "Called with a valid, non-negative number when the person edits a cap."
        },
        {
          "name": "limitAction",
          "type": "\"stop-and-ask\" | \"stop\" | \"ask-for-more\"",
          "required": false,
          "default": null,
          "description": "What happens at a limit (controlled)."
        },
        {
          "name": "defaultLimitAction",
          "type": "LimitAction",
          "required": false,
          "default": "\"stop-and-ask\"",
          "description": "Starting choice when uncontrolled."
        },
        {
          "name": "onLimitActionChange",
          "type": "(action: LimitAction) => void",
          "required": false,
          "default": null,
          "description": "Called when the choice changes."
        },
        {
          "name": "warnAt",
          "type": "number",
          "required": false,
          "default": "0.8",
          "description": "Fraction of a cap at which a limit counts as close."
        },
        {
          "name": "labels",
          "type": "BudgetControlLabels",
          "required": false,
          "default": null,
          "description": "Replace the heading, choice names, notes and the number box name."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the part's own heading, so it fits the page outline."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the section."
        }
      ],
      "variantsHint": {
        "limitAction": [
          "stop-and-ask",
          "stop",
          "ask-for-more"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--attention",
        "--border",
        "--surface-sunken",
        "--radius-field",
        "--radius-card"
      ],
      "related": [
        "usage-meter",
        "meter",
        "context-meter"
      ],
      "composedOf": [
        "meter"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Limits for a trip search",
          "description": "Change a number and the bar updates. Spend is close to its limit and steps have reached theirs.",
          "code": "<BudgetControl\n  limits={[\n    { id: \"time\", label: \"Time\", used: 12, cap: 30, unit: \"minutes\" },\n    { id: \"spend\", label: \"Spend\", used: 4.2, cap: 5, unit: \"$\" },\n    { id: \"steps\", label: \"Steps\", used: 50, cap: 50, unit: \"steps\" },\n  ]}\n  onCapChange={(id, cap) => save(id, cap)}\n/>"
        },
        {
          "title": "Plenty of room",
          "description": "Nothing is close to a limit, so no warning shows.",
          "code": "<BudgetControl limits={[{ id: \"time\", label: \"Time\", used: 3, cap: 30, unit: \"minutes\" }, { id: \"steps\", label: \"Steps\", used: 5, cap: 50, unit: \"steps\" }]} />"
        },
        {
          "title": "Spend in credits, stop without asking",
          "description": "Use any unit. Here the choice starts on Stop.",
          "code": "<BudgetControl defaultLimitAction=\"stop\" limits={[{ id: \"spend\", label: \"Spend\", used: 20, cap: 100, unit: \"credits\" }]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-budget-control/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/budget-control.md"
    },
    {
      "id": "autonomy-level",
      "name": "AutonomyLevel",
      "level": "molecule",
      "since": "0.5",
      "aka": [
        "altitude toggle",
        "autonomy level",
        "autonomy slider",
        "detail level"
      ],
      "category": "agents",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "summary",
          "note": "The Summary level shows a short account of the result and anything that needs you.",
          "source": "documented"
        },
        {
          "name": "steps",
          "note": "The Steps level shows each thing the assistant did, one line at a time.",
          "source": "documented"
        },
        {
          "name": "everything",
          "note": "The Everything level shows every detail, including what it read and tried.",
          "source": "documented"
        }
      ],
      "summary": "Switch between a short summary, each step, or every detail of what an assistant did.",
      "description": "AutonomyLevel lets people choose how much detail they see: Summary, Steps or Everything. A line under the control explains the chosen view. It is a SegmentedControl, so the arrow keys move and select. You decide what to show for each view.",
      "purpose": "Lets people choose how much of an assistant's work to read: a short summary, each step, or every detail. The summary comes first, and the detail is one press away.",
      "useFor": [
        "Above a run report or transcript, to switch between a summary and full detail.",
        "Letting people who check work closely see everything the assistant read and tried."
      ],
      "notFor": [
        {
          "text": "Choosing between options that are not levels of detail",
          "use": "segmented-control"
        },
        {
          "text": "One section that people open and close",
          "use": "collapsible"
        },
        {
          "text": "The assistant's reasoning, shown on request",
          "use": "thinking-block"
        }
      ],
      "whenToUse": [
        "Start on Summary for long runs.",
        "Keep the same three names across your product.",
        "Let people change the view without losing their place.",
        "Make Everything truly complete."
      ],
      "avoid": [
        "Don't add more than three levels.",
        "Don't hide errors in Summary.",
        "Don't change the view for people on their own.",
        "Don't use it to pick a topic. It only sets detail."
      ],
      "accessibility": [
        "It is one named group: the arrow keys move and select, and Home and End jump to the ends.",
        "Only the chosen option is a stop for Tab, so the group is quick to pass.",
        "The line under it explains the chosen view in plain text.",
        "On narrow screens the group scrolls sideways instead of being cut off."
      ],
      "accessibilityYouDo": [
        "Put the content it controls straight after it, so keyboard users reach the new view next.",
        "Pass a label that names what is shown when there is more than one toggle on a page."
      ],
      "onPhone": [
        "The three options sit in one row that scrolls sideways inside its own box if the labels are too wide for the screen.",
        "Each option keeps its size and has a 44px tap area on a touch screen.",
        "The hint line under the control is plain text, so it stays visible and does not need a hover."
      ],
      "importPath": "import { AutonomyLevel } from \"./index\"",
      "sourcePath": "src/molecules/AutonomyLevel.tsx",
      "props": [
        {
          "name": "value",
          "type": "Altitude",
          "required": false,
          "default": null,
          "description": "Selected view (controlled): summary | steps | everything."
        },
        {
          "name": "defaultValue",
          "type": "Altitude",
          "required": false,
          "default": "\"summary\"",
          "description": "Starting view when uncontrolled."
        },
        {
          "name": "onChange",
          "type": "(value: Altitude) => void",
          "required": false,
          "default": null,
          "description": "Called when the view changes."
        },
        {
          "name": "labels",
          "type": "Partial<Record<Altitude, string>>",
          "required": false,
          "default": null,
          "description": "Replace the names of the views."
        },
        {
          "name": "hints",
          "type": "Partial<Record<Altitude, string>>",
          "required": false,
          "default": null,
          "description": "Replace the hint line for each view."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"How much detail\"",
          "description": "Accessible name for the group."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--border",
        "--surface-sunken"
      ],
      "related": [
        "segmented-control",
        "timeline",
        "thinking-block"
      ],
      "composedOf": [
        "segmented-control"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Three levels of detail",
          "description": "Pick a view and the list below changes.",
          "code": "const [view, setView] = useState<Altitude>(\"summary\");\n<AutonomyLevel value={view} onChange={setView} />"
        },
        {
          "title": "Start on Steps",
          "description": "Leave it uncontrolled and set where it starts.",
          "code": "<AutonomyLevel defaultValue=\"steps\" />"
        },
        {
          "title": "Your own hints",
          "description": "Replace the hint for any view.",
          "code": "<AutonomyLevel hints={{ summary: \"Just the result of your trip search.\" }} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-autonomy-level/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/autonomy-level.md"
    },
    {
      "id": "gen-ui-renderer",
      "name": "GenUIRenderer",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "generative ui",
        "json renderer",
        "dynamic ui"
      ],
      "category": "generated",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        }
      ],
      "summary": "Draws a screen an assistant wrote, using only approved parts, and sends what the person does back.",
      "description": "An assistant can describe a small screen as data, such as numbers, a chart and a button. GenUIRenderer checks each piece against an approved list and draws it in our own look. A piece that is wrong becomes a quiet text card, and pieces still on their way show as placeholders. When the person presses something, the action goes back to the assistant, and anything hard to undo asks first.",
      "purpose": "Draws a small screen an assistant described as data, using only parts from an approved list and in our own look. Anything wrong becomes a quiet text card instead of breaking, and the person's choices go back to the assistant.",
      "useFor": [
        "An answer that reads better as numbers, a chart or a table than as text.",
        "Asking the person to pick, set options or approve a plan inside the chat.",
        "A screen that streams in piece by piece."
      ],
      "notFor": [
        {
          "text": "A whole page the assistant wrote in its own code",
          "use": "sandboxed-frame"
        },
        {
          "text": "An answer one sentence can give",
          "use": "message"
        },
        {
          "text": "Showing who made the screen, with pin, redo and report",
          "use": "generated-surface"
        }
      ],
      "whenToUse": [
        "Let the host check every part. Never draw a part that is not in the approved list.",
        "Mark anything hard to undo with confirm and a plain sentence of what will happen.",
        "Pass pendingActionId so people know the assistant is working on their choice.",
        "Offer a text answer too, in case the screen cannot be shown."
      ],
      "avoid": [
        "Don't let the assistant set colors, sizes or classes. Our look is not up to it.",
        "Don't skip the confirm step for sending, buying or deleting.",
        "Don't build a screen when one sentence would do.",
        "Don't assume the action worked until the assistant says so."
      ],
      "accessibility": [
        "Every part comes from the approved list and keeps its own labels and keyboard support.",
        "A part missing its words, such as a chart name or a button label, is not drawn. A quiet text card shows instead.",
        "While pieces are still arriving, screen readers hear \"Still loading\" once, not every new piece.",
        "The confirm step moves focus to Confirm, with Cancel next to it.",
        "The waiting note under a part is announced to screen readers."
      ],
      "accessibilityYouDo": [
        "Pass pendingActionId while the assistant works on a choice, so people see and hear that it is waiting.",
        "Mark anything hard to undo with confirm and a plain sentence saying what will happen.",
        "Offer the answer as text too, in case the screen cannot be shown."
      ],
      "onPhone": [
        "Everything renders in one column that fills the width, and the text in the fallback box breaks anywhere so it never pushes the page wide.",
        "A Group set to grid is one column on a phone, two from 640px and three from 1024px.",
        "The Confirm question and its buttons wrap, and the buttons keep their size and have a 44px tap area on a touch screen."
      ],
      "importPath": "import { GenUIRenderer } from \"./index\"",
      "sourcePath": "src/organisms/GenUIRenderer.tsx",
      "props": [
        {
          "name": "state",
          "type": "SpecState",
          "required": true,
          "default": null,
          "description": "The spec so far. Build it with applyChunk(state, chunk) as pieces stream in, or specFrom(root, nodes) for a finished one."
        },
        {
          "name": "onAction",
          "type": "(event: GenUIActionEvent) => void",
          "required": false,
          "default": null,
          "description": "Receives { actionId, nodeId, type: 'press' | 'submit' | 'select' | 'change', value }. Called after any confirm step. Errors thrown here are caught."
        },
        {
          "name": "pendingActionId",
          "type": "string | null",
          "required": false,
          "default": null,
          "description": "Set to the actionId while the assistant handles the action. The part that sent it shows a waiting note and its controls are disabled."
        },
        {
          "name": "labels",
          "type": "GenUIRendererLabels",
          "required": false,
          "default": null,
          "description": "Overrides for visible text: fallbackTitle, loading, confirmQuestion, confirm, cancel, sending, missing."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken",
        "--border",
        "--attention-soft",
        "--attention-fg",
        "--radius-card"
      ],
      "related": [
        "generated-surface",
        "sandboxed-frame",
        "plan-card",
        "parameter-panel"
      ],
      "composedOf": [
        "skeleton",
        "button",
        "callout",
        "stat-tile",
        "data-table",
        "line-chart",
        "bar-chart",
        "source-list",
        "summary-card",
        "plan-card",
        "parameter-panel",
        "variant-grid",
        "checklist",
        "confidence-indicator",
        "badge",
        "empty-state"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A summary that appears piece by piece",
          "description": "Pieces arrive out of order. The page shows placeholders for what is missing and fills them in. The button asks before it sends anything.",
          "code": "const [state, setState] = useState(emptySpec());\n// For each streamed chunk:\nsetState((s) => applyChunk(s, chunk));\n\n<GenUIRenderer\n  state={state}\n  pendingActionId={pending}\n  onAction={(e) => { setPending(e.actionId); sendToAssistant(e); }}\n/>"
        },
        {
          "title": "A broken part shows as a quiet card",
          "description": "An unknown part, a part with styling, a part missing its name and a part that never arrived. Nothing crashes, and the readable text is kept.",
          "code": "const state = specFrom(\"page\", [\n  { id: \"page\", type: \"Group\", children: [\"ok\", \"unknown\", \"styled\", \"nolabel\", \"gone\"] },\n  { id: \"ok\", type: \"Callout\", props: { text: \"You have spent 640 euros of 900.\" } },\n  { id: \"unknown\", type: \"Carousel3D\", props: { title: \"Photos from Lisbon\" } },\n  { id: \"styled\", type: \"Callout\", props: { text: \"Book the hotel before Friday.\", className: \"mt-12 p-8\" } },\n  { id: \"nolabel\", type: \"StatTile\", props: { value: \"120 euros\" } },\n]);\n<GenUIRenderer state={state} />"
        },
        {
          "title": "Pick, set and approve",
          "description": "Each choice goes back to the assistant as an event. Booking the trip is marked as hard to undo, so it asks first and then shows that it is waiting.",
          "code": "<GenUIRenderer\n  state={specFrom(\"page\", nodes)}\n  pendingActionId={pending}\n  onAction={(e) => {\n    // e = { actionId, nodeId, type: \"press\" | \"submit\" | \"select\" | \"change\", value }\n    setPending(e.actionId);\n    sendToAssistant(e);\n  }}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-gen-ui-renderer/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/gen-ui-renderer.md"
    },
    {
      "id": "generated-surface",
      "name": "GeneratedSurface",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "generated content frame",
        "ai generated area"
      ],
      "category": "generated",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        }
      ],
      "summary": "The frame around a screen an assistant made: who made it, its spec, pin, redo and report.",
      "description": "A screen made by an assistant needs to say so. GeneratedSurface shows who made it, lets people read the spec behind it, and offers Pin, Regenerate and Report. While the screen is being made it says so. If making it fails, it shows a text version of the answer and a Try again button.",
      "purpose": "The frame around a screen an assistant made. It always says who made it and lets people read the spec, pin, regenerate or report it, so a generated screen never passes as part of the app.",
      "useFor": [
        "Around every screen GenUIRenderer draws in a chat.",
        "A screen that is still being made: set status to streaming.",
        "A screen that failed: show the answer as text with Try again."
      ],
      "notFor": [
        {
          "text": "A page that runs its own code",
          "use": "sandboxed-frame"
        },
        {
          "text": "A long document or code the person keeps working on",
          "use": "canvas-panel"
        }
      ],
      "whenToUse": [
        "Always show who made the screen, even when it looks like ours.",
        "Keep View spec available so people can check what was sent.",
        "Give a text version for the failed state.",
        "Treat Pin and Regenerate as experiments and watch how people use them."
      ],
      "avoid": [
        "Don't hide the source label to make the screen feel built in.",
        "Don't keep Regenerate on while a screen is still being made.",
        "Don't make Report hard to find.",
        "Don't pin a screen without telling people where it went."
      ],
      "accessibility": [
        "The frame is named \"Made by\" plus the source, so screen reader users can find it.",
        "Pin is a toggle button that says whether it is on, and its words change to Pinned.",
        "While the screen is being made, only the \"Still being made…\" note is announced.",
        "A failure is announced right away, and Try again is a normal button.",
        "View spec opens and closes with the keyboard, and the spec can be scrolled with the keyboard."
      ],
      "accessibilityYouDo": [
        "Pass a plain text fallback, so people still get the answer if the screen fails.",
        "Set source to a name people recognise. Screen readers use it to name the frame."
      ],
      "onPhone": [
        "The surface fills the width, and the header wraps so the Pin and Regenerate buttons move to their own line when needed.",
        "Buttons keep their size and have a 44px tap area on a touch screen.",
        "The spec view scrolls on its own both ways, so a long line does not widen the page."
      ],
      "importPath": "import { GeneratedSurface } from \"./index\"",
      "sourcePath": "src/organisms/GeneratedSurface.tsx",
      "props": [
        {
          "name": "source",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Who made the screen. Shown as 'Made by <source>' in an AI label."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The generated screen, usually a GenUIRenderer."
        },
        {
          "name": "status",
          "type": "\"streaming\" | \"ready\" | \"failed\"",
          "required": false,
          "default": "\"ready\"",
          "description": "streaming shows a status note and turns Regenerate off. failed shows the fallback and Try again."
        },
        {
          "name": "spec",
          "type": "unknown",
          "required": false,
          "default": null,
          "description": "The spec behind the screen. Shown as formatted JSON under View spec. Omit to hide the control."
        },
        {
          "name": "pinned",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Whether the screen is pinned (controlled). When omitted the frame keeps its own state."
        },
        {
          "name": "onPinnedChange",
          "type": "(pinned: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when Pin is toggled."
        },
        {
          "name": "onRegenerate",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Regenerate and calls this when pressed."
        },
        {
          "name": "onReport",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Report and calls this when pressed."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Try again when status is failed."
        },
        {
          "name": "fallback",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Plain text version of the answer, shown when status is failed."
        },
        {
          "name": "labels",
          "type": "GeneratedSurfaceLabels",
          "required": false,
          "default": null,
          "description": "Overrides for visible text: madeBy, viewSpec, hideSpec, pin, pinned, regenerate, report, retry, streaming, failed, overview."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the frame."
        }
      ],
      "variantsHint": {
        "status": [
          "streaming",
          "ready",
          "failed"
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--surface-sunken",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "gen-ui-renderer",
        "sandboxed-frame",
        "ai-label",
        "canvas-panel"
      ],
      "composedOf": [
        "ai-label",
        "button",
        "spinner"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A finished screen",
          "description": "Try Pin and Regenerate. Open View spec to see exactly what the assistant sent.",
          "code": "<GeneratedSurface\n  source=\"Orders helper\"\n  spec={spec}\n  pinned={pinned}\n  onPinnedChange={setPinned}\n  onRegenerate={regenerate}\n  onReport={report}\n>\n  <GenUIRenderer state={state} />\n</GeneratedSurface>"
        },
        {
          "title": "When it could not be made",
          "description": "The person still gets the answer as text and can try again.",
          "code": "<GeneratedSurface\n  source=\"Orders helper\"\n  status=\"failed\"\n  onRetry={retry}\n  fallback={<p>You had 1,240 orders this week, 4.2% more than last week.</p>}\n/>"
        },
        {
          "title": "While it is being made",
          "description": "The status is announced once. Regenerate is off until the screen is ready.",
          "code": "<GeneratedSurface source=\"Orders helper\" status=\"streaming\" onRegenerate={regenerate}>\n  <GenUIRenderer state={state} />\n</GeneratedSurface>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-generated-surface/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/generated-surface.md"
    },
    {
      "id": "sandboxed-frame",
      "name": "SandboxedFrame",
      "level": "organism",
      "since": "0.5",
      "aka": [
        "iframe",
        "sandbox",
        "embed",
        "artifact frame"
      ],
      "category": "generated",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "permission request",
          "note": "When the page asks to open a link or call a tool you allowed, a prompt with Allow and Don't allow appears below the frame.",
          "source": "documented"
        },
        {
          "name": "no permissions",
          "note": "With no permissions, the note says the page can only show things on screen.",
          "source": "documented"
        },
        {
          "name": "resizing",
          "note": "The frame grows or shrinks to the height the page reports, between minHeight and maxHeight.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The frame shows a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "A locked box for a mini app made by someone else, with clear limits and asks before it does anything.",
      "description": "Sometimes an assistant makes a whole small page, not a screen from our parts. SandboxedFrame runs it in a locked box that cannot see your account. The page can ask to open a link or take a step, but only if it declared that up front, and only after the person says yes. It says who made it and what it can do in plain words.",
      "purpose": "A locked box for a small page the assistant wrote itself, outside our approved parts. The page cannot see the account, and anything it asks for needs a yes from the person each time.",
      "useFor": [
        "A mini app, like a budget calculator, that our approved parts cannot build.",
        "A page from another site that should only show things.",
        "A page that may ask to open a link or run a tool, with the person's approval."
      ],
      "notFor": [
        {
          "text": "A screen our approved parts can draw",
          "use": "gen-ui-renderer"
        },
        {
          "text": "Asking for passwords or payment details"
        }
      ],
      "whenToUse": [
        "Use it only when our approved parts cannot do the job.",
        "Declare exactly what the page may ask for, and keep the list short.",
        "Keep the sandbox without allow-same-origin.",
        "Show who made the page and what it can do, next to the frame."
      ],
      "avoid": [
        "Don't build the page's HTML into our own page. Never use dangerouslySetInnerHTML for it.",
        "Don't let the page open links or take steps without asking, even for permissions it declared.",
        "Don't trust a message just because it says it is from the frame. Check who sent it and from where.",
        "Don't ask for passwords or payment details inside a generated page."
      ],
      "accessibility": [
        "The frame has a name, so screen readers say what it holds.",
        "Who made it, what it asked for and what it can do are plain text next to the frame.",
        "A permission question moves focus to Allow, with Don't allow next to it.",
        "A clear outline shows when someone tabs into the frame.",
        "The frame grows to fit the page but stays between a smallest and a largest height."
      ],
      "accessibilityYouDo": [
        "Give it a title that says what the page holds. Screen readers use it to name the frame.",
        "Check the page inside for accessibility yourself. The frame cannot fix it.",
        "Prefer GenUIRenderer when it can do the job, because its parts are already accessible."
      ],
      "onPhone": [
        "The frame fills the width, and the page inside decides its own layout, so build it for a narrow screen.",
        "The frame height follows the page up to the maximum, 640px by default, and scrolls inside after that.",
        "The permission buttons wrap under the question. They keep their size and have a 44px tap area on a touch screen."
      ],
      "importPath": "import { SandboxedFrame } from \"./index\"",
      "sourcePath": "src/organisms/SandboxedFrame.tsx",
      "props": [
        {
          "name": "source",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Who made the page. Shown as 'Generated by <source>'."
        },
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name of the frame."
        },
        {
          "name": "srcDoc",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The page as a string. Its origin is opaque (event.origin is 'null'). Use this or src."
        },
        {
          "name": "src",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Address of a page. Messages are accepted only from that URL's origin. Use this or srcDoc."
        },
        {
          "name": "permissions",
          "type": "(\"open-links\" | \"call-tools\")[]",
          "required": false,
          "default": "[]",
          "description": "What the page may ask for. Shown to the person. Each request still needs a yes."
        },
        {
          "name": "allowedTools",
          "type": "string[]",
          "required": false,
          "default": "[]",
          "description": "Tool names the page may ask for. Other names are ignored."
        },
        {
          "name": "onOpenLink",
          "type": "(url: string) => void",
          "required": false,
          "default": null,
          "description": "Called after the person agrees. Default opens a new tab with noopener and noreferrer. Only http and https links get this far."
        },
        {
          "name": "onCallTool",
          "type": "(name: string, args: Record<string, unknown>) => unknown | Promise<unknown>",
          "required": false,
          "default": null,
          "description": "Called after the person agrees. The page gets a 'tattva:tool-result' message with ok true or false."
        },
        {
          "name": "minHeight",
          "type": "number",
          "required": false,
          "default": "120",
          "description": "Smallest height in px. Resize messages are clamped to this."
        },
        {
          "name": "maxHeight",
          "type": "number",
          "required": false,
          "default": "640",
          "description": "Largest height in px. Resize messages are clamped to this."
        },
        {
          "name": "initialHeight",
          "type": "number",
          "required": false,
          "default": "240",
          "description": "Height before the page sends a resize message."
        },
        {
          "name": "allowForms",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Adds allow-forms to the sandbox."
        },
        {
          "name": "allowSameOrigin",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Adds allow-same-origin to the sandbox. With scripts this lets the page remove its own sandbox. Leave off for pages you did not write."
        },
        {
          "name": "summary",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Replaces the plain-language 'What this can do' sentence."
        },
        {
          "name": "labels",
          "type": "SandboxedFrameLabels",
          "required": false,
          "default": null,
          "description": "Overrides: generatedBy, whatItCanDo, allow, deny."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--attention-soft",
        "--attention-fg",
        "--radius-card"
      ],
      "related": [
        "gen-ui-renderer",
        "generated-surface"
      ],
      "composedOf": [
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A small page that asks to open a link",
          "description": "The page tells us how tall it is, so the frame fits it. Press its button and the frame asks before opening the link.",
          "code": "<SandboxedFrame\n  source=\"Trip helper\"\n  title=\"Trip budget\"\n  srcDoc={html}\n  permissions={[\"open-links\"]}\n/>\n\n// Inside the page:\nparent.postMessage({ type: \"tattva:resize\", height: document.documentElement.scrollHeight }, \"*\");\nparent.postMessage({ type: \"tattva:open-link\", url: \"https://example.com/\" }, \"*\");"
        },
        {
          "title": "A page that can only show things",
          "description": "No permissions were declared, so every request from the page is ignored.",
          "code": "<SandboxedFrame source=\"Trip helper\" title=\"Packing list\" srcDoc={html} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-sandboxed-frame/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/sandboxed-frame.md"
    },
    {
      "id": "field",
      "name": "Field",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "form field",
        "form control",
        "form item",
        "label and help text"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "error",
          "note": "Pass error to show the message with a warning icon under the control, link it with aria-describedby and set aria-invalid on the control.",
          "source": "documented"
        },
        {
          "name": "hint",
          "note": "Pass hint to show help text under the control. It is linked with aria-describedby.",
          "source": "documented"
        },
        {
          "name": "required",
          "note": "Pass required to add an asterisk to the label and required to the control. optional adds the words (optional) instead.",
          "source": "documented"
        }
      ],
      "summary": "Wraps any form control with a visible label, hint text and an error message, and wires them together.",
      "description": "Field puts a label, a control, optional hint text and an optional error message in one stack. It links them for you with ids, so screen readers read the label, the hint and the error when the control is focused. You write the control inside, as a function that receives the props to spread on it. It works with TextField, NumberField, Select or any native control.",
      "purpose": "Gives a control a visible name and the help and error text it needs, linked the way assistive technology expects, so each form field is built the same way.",
      "useFor": [
        "A text, number or select control in a form or settings panel.",
        "A field that needs help text before the user types.",
        "A field that shows a message when the value is not accepted."
      ],
      "notFor": [
        {
          "text": "An on-off setting with its label beside it",
          "use": "switch"
        },
        {
          "text": "A choice between a few options shown side by side",
          "use": "segmented-control"
        },
        {
          "text": "The message box in a chat",
          "use": "composer"
        }
      ],
      "whenToUse": [
        "Spread everything the function gives you onto the control.",
        "Keep the label short and always visible.",
        "Use hint for rules the user needs before typing, such as a length or format.",
        "Show the error after the user has left the field or tried to submit."
      ],
      "avoid": [
        "Do not use a placeholder in place of a label. It disappears when typing starts.",
        "Do not show an error with colour only. Field always adds an icon and text.",
        "Do not wrap a Switch in a Field. Put its label beside it.",
        "Do not write your own id and htmlFor pair. Field makes them for you."
      ],
      "accessibility": [
        "The label is a real <label> joined to the control, so pressing the label focuses the control.",
        "The hint and the error are joined to the control with aria-describedby, so they are read when it is focused.",
        "The error area is always on the page with aria-live set to polite, so a new error is announced without stealing focus.",
        "The error has a warning icon and words, so colour is not the only clue.",
        "The asterisk is hidden from screen readers. The control gets the required attribute instead."
      ],
      "accessibilityYouDo": [
        "Spread the props the function gives you onto the control. If you skip this, the label is not linked.",
        "Write error messages that say what is wrong and how to fix it.",
        "Set error only when the value is wrong, not before the user has had a chance to type.",
        "For NumberField pass the same text as label, because it needs its own accessible name."
      ],
      "onPhone": [
        "Label, control, hint and error stack in one column at the full width of the container.",
        "The control inside it follows its own phone rules: 44px tall and 16px text on touch screens.",
        "The error area is always on the page and is announced without moving focus, so the user is not pulled out of the field."
      ],
      "importPath": "import { Field } from \"./index\"",
      "sourcePath": "src/molecules/Field.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Visible label. It is a real <label> joined to the control with htmlFor."
        },
        {
          "name": "hint",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Help text under the control, linked with aria-describedby."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Error message. Shows with a warning icon, sets aria-invalid on the control and is linked with aria-describedby. The message area is always in the page so screen readers announce changes politely."
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Adds an asterisk to the label and passes required to the control."
        },
        {
          "name": "optional",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Adds the words (optional) after the label. Ignored when required is set."
        },
        {
          "name": "children",
          "type": "(control: { id: string; \"aria-describedby\"?: string; \"aria-invalid\"?: boolean; required?: boolean }) => ReactNode",
          "required": true,
          "default": null,
          "description": "Render function. Spread the object it receives onto the control."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer stack."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg",
        "--fg-muted",
        "--danger-fg"
      ],
      "related": [
        "text-field",
        "number-field",
        "select",
        "switch",
        "segmented-control"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With a hint and an error",
          "description": "Type one or two letters to see the error appear. The message has an icon and words, not only colour.",
          "code": "function Example() {\n  const [name, setName] = useState(\"\");\n  const bad = name.length > 0 && name.trim().length < 3;\n  return (\n    <div className=\"w-72\">\n      <Field label=\"Project name\" hint=\"Shown in the sidebar.\" error={bad ? \"Use at least 3 characters.\" : undefined} required>\n        {(a) => <TextField {...a} value={name} onChange={(e) => setName(e.target.value)} placeholder=\"Q3 planning\" />}\n      </Field>\n    </div>\n  );\n}"
        },
        {
          "title": "Optional select",
          "description": "Use optional to say the field can be left empty. It adds the words (optional) after the label.",
          "code": "function Example() {\n  const [v, setV] = useState(\"\");\n  return (\n    <div className=\"w-72\">\n      <Field label=\"Model\" optional hint=\"You can change this later.\">\n        {(a) => <Select {...a} value={v} onChange={(e) => setV(e.target.value)} options={models} placeholder=\"Choose a model\" />}\n      </Field>\n    </div>\n  );\n}"
        },
        {
          "title": "Around a number field",
          "description": "NumberField takes the same props, plus its own label. The error shows when the quantity is 0.",
          "code": "function Example() {\n  const [n, setN] = useState<number | null>(0);\n  return (\n    <div className=\"w-72\">\n      <Field label=\"Quantity\" hint=\"Between 1 and 20.\" error={n === 0 ? \"Choose at least 1.\" : undefined}>\n        {({ id, ...a }) => <NumberField {...a} id={id} label=\"Quantity\" value={n} onChange={setN} min={0} max={20} />}\n      </Field>\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-field/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/field.md"
    },
    {
      "id": "text-field",
      "name": "TextField",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "input",
        "text input",
        "textbox",
        "text box"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "error",
          "note": "Pass invalid for a danger border and aria-invalid. Explain the problem in text too.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The border gets stronger when the pointer is over the box.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A focus ring is drawn around the whole box (icon and unit included) when you reach it with the keyboard.",
          "source": "documented"
        },
        {
          "name": "disabled",
          "note": "Pass disabled. The box is dimmed, ignores typing and is skipped by the Tab key.",
          "source": "documented"
        },
        {
          "name": "readonly",
          "note": "Pass readOnly. The box gets a sunken background; the text can be selected and copied but not changed.",
          "source": "documented"
        }
      ],
      "summary": "A single-line text box with room for an icon or unit, two sizes and an invalid state.",
      "description": "TextField is a native text input in a bordered box. It can show an icon or a unit before or after the text. The border gets stronger on hover, a focus ring shows for keyboard users, and an invalid field gets a danger border. Pair it with Field to give it a label.",
      "purpose": "A single line of text, email, link, phone number, search or password, with the same look and states as the other form controls.",
      "useFor": [
        "A name, email, link or phone number in a form.",
        "A search box with a search icon.",
        "A value with a unit, such as a price with a currency sign."
      ],
      "notFor": [
        {
          "text": "A number the user steps up and down",
          "use": "number-field"
        },
        {
          "text": "Picking one value from a fixed list",
          "use": "select"
        },
        {
          "text": "Writing a message to the AI",
          "use": "prompt-box"
        },
        {
          "text": "A long, multi-line message with attachments and send",
          "use": "composer"
        }
      ],
      "whenToUse": [
        "Wrap it in Field so it has a visible label.",
        "Use the matching type so phones show the right keyboard.",
        "Use leading for an icon and trailing for a unit.",
        "Keep placeholder text as an example, not an instruction."
      ],
      "avoid": [
        "Do not use a placeholder as the only label.",
        "Do not show an invalid border without a message in text.",
        "Do not use it for a number the user steps. Use NumberField.",
        "Do not put a button in leading or trailing without giving the button its own label."
      ],
      "accessibility": [
        "It is a native input, so typing, selecting, autofill and spell check work as the browser provides.",
        "The focus ring wraps the whole box, including the icon and unit, and shows for keyboard focus.",
        "invalid sets aria-invalid on the input.",
        "A disabled input is skipped by the Tab key. A read-only input can be focused and its text copied.",
        "The box is 44px tall on coarse pointers.",
        "The leading slot is hidden from screen readers."
      ],
      "accessibilityYouDo": [
        "Give it a name: wrap it in Field, or pass aria-label (for example on a search box with no visible label).",
        "When invalid is set, also show a message in text. Field does this when you pass error.",
        "Pick the right type, such as email or tel, so phones show the right keyboard.",
        "Password has no show or hide button. Add one yourself if you need it, and keep it a real button with a label."
      ],
      "onPhone": [
        "It fills the width of its container and is 44px tall on touch screens, even at size sm.",
        "Text is 16px on touch screens, so iPhones do not zoom in when the field gets focus.",
        "The type prop picks the on-screen keyboard (email, tel, url, search), and extra attributes such as inputMode pass through to the input."
      ],
      "importPath": "import { TextField } from \"./index\"",
      "sourcePath": "src/atoms/TextField.tsx",
      "props": [
        {
          "name": "type",
          "type": "\"text\" | \"email\" | \"password\" | \"search\" | \"url\" | \"tel\"",
          "required": false,
          "default": "\"text\"",
          "description": "Native input type. Password shows dots and has no built-in show or hide button."
        },
        {
          "name": "invalid",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Danger border and aria-invalid. Always explain the problem in text too. A passed aria-invalid has the same effect, which is how Field sets it."
        },
        {
          "name": "leading",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Content before the text, such as an icon. It is hidden from screen readers."
        },
        {
          "name": "trailing",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Content after the text, such as a unit. It is not hidden from screen readers."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "default": "\"md\"",
          "description": "md is 40px tall, sm is 32px. Both are 44px on coarse pointers such as touch screens."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer box, so width and margins apply to it."
        },
        {
          "name": "ref",
          "type": "Ref<HTMLInputElement>",
          "required": false,
          "default": null,
          "description": "Reaches the native input. A normal prop in React 19."
        },
        {
          "name": "...rest",
          "type": "Omit<InputHTMLAttributes<HTMLInputElement>, \"size\" | \"type\">",
          "required": false,
          "default": null,
          "description": "Everything else goes to the <input>: value, onChange, placeholder, disabled, readOnly, required, id, aria-label, aria-describedby and so on."
        }
      ],
      "variantsHint": {
        "type": [
          "text",
          "email",
          "password",
          "search",
          "url",
          "tel"
        ],
        "size": [
          "sm",
          "md"
        ]
      },
      "tokens": [
        "--border",
        "--border-strong",
        "--danger",
        "--surface",
        "--surface-sunken",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--focus-ring",
        "--dur-fast",
        "--radius-field"
      ],
      "related": [
        "field",
        "number-field",
        "select",
        "composer",
        "prompt-box"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Search with an icon",
          "description": "The icon is decorative. The box needs its own name because there is no visible label.",
          "code": "function Example() {\n  const [q, setQ] = useState(\"\");\n  return (\n    <div className=\"w-72\">\n      <TextField type=\"search\" aria-label=\"Search chats\" placeholder=\"Search chats\" leading={<SearchIcon />} value={q} onChange={(e) => setQ(e.target.value)} />\n    </div>\n  );\n}"
        },
        {
          "title": "In a Field with a hint and an error",
          "description": "Field adds the label, hint and error and passes the wiring to the input.",
          "code": "<div className=\"w-72\">\n  <Field label=\"Email\" hint=\"We only use it to send receipts.\" error=\"Enter an email like name@example.com.\">\n    {(a) => <TextField {...a} type=\"email\" defaultValue=\"name@\" />}\n  </Field>\n</div>"
        },
        {
          "title": "Sizes and states",
          "description": "Small and medium, a trailing unit, then disabled and read-only.",
          "code": "<div className=\"flex w-72 flex-col gap-3\">\n  <TextField size=\"sm\" aria-label=\"Small\" placeholder=\"Small\" />\n  <TextField aria-label=\"Amount\" placeholder=\"0.00\" leading=\"₹\" />\n  <TextField aria-label=\"Disabled\" defaultValue=\"Disabled\" disabled />\n  <TextField aria-label=\"Read only\" defaultValue=\"Read only\" readOnly />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-text-field/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/text-field.md"
    },
    {
      "id": "number-field",
      "name": "NumberField",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "number input",
        "numeric input",
        "stepper input",
        "spin button"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "error",
          "note": "Set with the invalid prop.",
          "source": "prop"
        },
        {
          "name": "disabled",
          "note": "Set with the disabled prop.",
          "source": "prop"
        },
        {
          "name": "hover",
          "note": "The border gets stronger when the pointer is over the box, and a step button shades when the pointer is over it.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A focus ring is drawn around the whole box when the number is focused with the keyboard.",
          "source": "documented"
        },
        {
          "name": "at limit",
          "note": "The Decrease button is disabled at min and the Increase button at max.",
          "source": "documented"
        },
        {
          "name": "editing",
          "note": "While you type, the text is kept as typed. It is checked, clamped and rounded when you leave the field or press Enter.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "A value of null shows an empty box. Arrow keys and the buttons then start from min, or 0 if there is no min.",
          "source": "documented"
        }
      ],
      "summary": "A number box with minus and plus buttons, keyboard stepping, limits and an optional unit.",
      "description": "NumberField lets people type a number or step it with the buttons or the keyboard. The value is checked when they leave the box or press Enter: it is kept between min and max and rounded to the decimals you set. Your code keeps the value, which is null when the box is empty.",
      "purpose": "A number the user may know exactly (type it) or want to nudge (step it), such as a quantity or a price, with limits that are enforced for them.",
      "useFor": [
        "A quantity, count or size with a minimum and maximum.",
        "A price or other decimal value with a fixed number of places.",
        "A value with a unit shown after it."
      ],
      "notFor": [
        {
          "text": "Digits that are not a quantity, like a phone number or a code",
          "use": "text-field"
        },
        {
          "text": "A choice between a few named options",
          "use": "segmented-control"
        },
        {
          "text": "Picking from a list of named values",
          "use": "select"
        }
      ],
      "whenToUse": [
        "Set min and max when the number has real limits.",
        "Set precision for money or other fixed decimals.",
        "Use unit instead of putting the unit in the label alone.",
        "Say the limits in a hint, so people know them before they hit one."
      ],
      "avoid": [
        "Do not use it for phone numbers, codes or ids. Use TextField.",
        "Do not use it for a huge range where stepping is useless.",
        "Do not expect onChange on every key press. Typing is applied on blur and Enter.",
        "Do not rely on the buttons alone. The buttons are skipped by the Tab key, because the arrow keys do the same job."
      ],
      "accessibility": [
        "The input has the spinbutton role with aria-valuenow, aria-valuemin and aria-valuemax, as the WAI-ARIA pattern asks.",
        "aria-valuetext adds the unit to the spoken value.",
        "Arrow Up and Down change the value by one step. Shift with an arrow, Page Up and Page Down change it by ten steps. Home and End jump to min and max when they are set.",
        "The buttons are named Decrease and Increase with the label, and are disabled at the limits.",
        "The buttons are skipped by the Tab key so the field is one stop. They are still pressable with mouse and touch.",
        "The input is a text input with a decimal keyboard on phones.",
        "Numbers use tabular figures so digits line up.",
        "The step buttons are 44px wide on coarse pointers."
      ],
      "accessibilityYouDo": [
        "Pass label. It is the accessible name, and the step buttons are named from it, for example \"Decrease Lots\".",
        "Inside Field, pass the same text as label and spread the wiring props so the hint and error are read.",
        "Tell people the limits in a hint, since min and max only clamp the value."
      ],
      "onPhone": [
        "The box is 44px tall and the minus and plus buttons are at least 44px wide on touch screens.",
        "Typing opens the decimal keypad when the minimum is 0 or more, and otherwise the normal keyboard, and the value is checked on Enter or when the field loses focus, so tell users to tap away to confirm.",
        "When the minimum is below 0 the field uses the normal keyboard so a minus can be typed; the minus and plus buttons work either way."
      ],
      "importPath": "import { NumberField } from \"./index\"",
      "sourcePath": "src/atoms/NumberField.tsx",
      "props": [
        {
          "name": "value",
          "type": "number | null",
          "required": true,
          "default": null,
          "description": "Current value. null shows an empty box. The component is controlled."
        },
        {
          "name": "onChange",
          "type": "(value: number | null) => void",
          "required": true,
          "default": null,
          "description": "Called with the new value after a step, or after typing is applied on blur or Enter. Called with null if the box was cleared."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name of the input, also used in the button names (Decrease and Increase)."
        },
        {
          "name": "min",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Lowest allowed value. Also the target of the Home key."
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Highest allowed value. Also the target of the End key."
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "default": "1",
          "description": "Amount for the buttons and Arrow keys. Shift with an arrow, Page Up and Page Down move ten steps."
        },
        {
          "name": "precision",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Decimals shown and kept. Defaults to the decimals in step."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Text shown after the number. It is added to the spoken value."
        },
        {
          "name": "invalid",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Danger border and aria-invalid."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Dims the box and disables the input and buttons."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "default": "\"md\"",
          "description": "md is 40px tall, sm is 32px. Both are 44px on coarse pointers."
        },
        {
          "name": "id",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id on the input, for a label that uses htmlFor. Field passes this."
        },
        {
          "name": "aria-describedby",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Ids of hint or error text. Field passes this."
        },
        {
          "name": "aria-invalid",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Same effect as invalid. Field passes this when it has an error."
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Sets required on the input."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer box."
        }
      ],
      "variantsHint": {
        "size": [
          "sm",
          "md"
        ]
      },
      "tokens": [
        "--border",
        "--border-strong",
        "--danger",
        "--surface",
        "--surface-hover",
        "--fg",
        "--fg-muted",
        "--focus-ring",
        "--dur-fast",
        "--radius-field"
      ],
      "related": [
        "field",
        "text-field",
        "select",
        "segmented-control"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Quantity with limits",
          "description": "The buttons stop at 1 and 50. Type a number and press Enter or leave the box to apply it.",
          "code": "function Example() {\n  const [n, setN] = useState<number | null>(2);\n  return <NumberField label=\"Lots\" value={n} onChange={setN} min={1} max={50} unit=\"lots\" />;\n}"
        },
        {
          "title": "Decimal steps",
          "description": "Step by 0.25 and always show two decimals. Shift with an arrow key moves ten steps.",
          "code": "function Example() {\n  const [n, setN] = useState<number | null>(12.5);\n  return <NumberField label=\"Price\" value={n} onChange={setN} min={0} step={0.25} precision={2} unit=\"USD\" />;\n}"
        },
        {
          "title": "In a Field with a hint and an error",
          "description": "Field adds the visible label, hint and error. Here the error shows at 0.",
          "code": "function Example() {\n  const [n, setN] = useState<number | null>(0);\n  return (\n    <div className=\"w-72\">\n      <Field label=\"Quantity\" hint=\"Between 1 and 20.\" error={n === 0 ? \"Choose at least 1.\" : undefined}>\n        {({ id, ...a }) => <NumberField {...a} id={id} label=\"Quantity\" value={n} onChange={setN} min={0} max={20} />}\n      </Field>\n    </div>\n  );\n}"
        },
        {
          "title": "Disabled and invalid",
          "description": "",
          "code": "<div className=\"flex flex-col items-start gap-3\">\n  <NumberField label=\"Seats\" value={5} onChange={() => {}} disabled />\n  <NumberField label=\"Seats\" value={0} onChange={() => {}} invalid />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-number-field/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/number-field.md"
    },
    {
      "id": "select",
      "name": "Select",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "dropdown",
        "dropdown list",
        "picker",
        "native select"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "error",
          "note": "Pass invalid for a danger border and aria-invalid.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The border gets stronger when the pointer is over the select.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The global focus ring shows around the select when you reach it with the keyboard.",
          "source": "documented"
        },
        {
          "name": "disabled",
          "note": "Pass disabled. The select is dimmed and skipped by the Tab key.",
          "source": "documented"
        },
        {
          "name": "placeholder",
          "note": "While the value is empty and placeholder is set, the placeholder shows in a muted colour.",
          "source": "documented"
        }
      ],
      "summary": "A styled native dropdown with a drawn chevron, a placeholder and an invalid state.",
      "description": "Select is the browser's own dropdown with a matching look. Because the menu is native, it works with every screen reader and shows the phone's own picker on touch screens. Give it a list of options or write <option> elements yourself.",
      "purpose": "Picks one value from a list that is too long for side-by-side buttons, using the platform's own menu so it is reliable everywhere.",
      "useFor": [
        "Choosing a model, language or time zone from a list.",
        "Any single choice from more than about five options.",
        "A form where the native picker on phones is an advantage."
      ],
      "notFor": [
        {
          "text": "Two to four options that should all stay visible",
          "use": "segmented-control"
        },
        {
          "text": "A menu with actions, icons or rich content",
          "use": "popover"
        },
        {
          "text": "Free text the user types",
          "use": "text-field"
        }
      ],
      "whenToUse": [
        "Use it for a list the user chooses from once.",
        "Put the most likely choice first, or set a value up front.",
        "Wrap it in Field so it has a visible label.",
        "Use children with optgroup to group long lists."
      ],
      "avoid": [
        "Do not use it for two or three options. Use SegmentedControl.",
        "Do not put actions in it, such as Delete. Use a menu.",
        "Do not use placeholder as the label.",
        "Do not try to style the open menu. It belongs to the browser."
      ],
      "accessibility": [
        "It is a native select, so the keyboard, type-ahead and screen reader behaviour come from the browser.",
        "The chevron is hidden from screen readers and does not catch clicks.",
        "invalid sets aria-invalid.",
        "The focus ring is the global one and shows for keyboard focus.",
        "A disabled select is skipped by the Tab key.",
        "The select is 44px tall on coarse pointers, and the browser's own picker opens on touch."
      ],
      "accessibilityYouDo": [
        "Give it a name: wrap it in Field, or pass aria-label.",
        "Use a placeholder only as a prompt. Do not rely on it to say what the field is.",
        "Keep option labels short and unique."
      ],
      "onPhone": [
        "It is a native select, so a phone shows its own picker and the open list is not styled by this system.",
        "It fills the width of its container, is 44px tall on touch screens, and its text is 16px so iPhones do not zoom."
      ],
      "importPath": "import { Select } from \"./index\"",
      "sourcePath": "src/atoms/Select.tsx",
      "props": [
        {
          "name": "options",
          "type": "{ value: string; label: string; disabled?: boolean }[]",
          "required": false,
          "default": null,
          "description": "The choices. Use this or children. If both are given, options come first."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "<option> and <optgroup> elements, for cases options cannot express."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "default": null,
          "description": "A hidden, disabled first option shown while the value is empty. It shows in a muted colour."
        },
        {
          "name": "invalid",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Danger border and aria-invalid."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\"",
          "required": false,
          "default": "\"md\"",
          "description": "md is 40px tall, sm is 32px. Both are 44px on coarse pointers."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the wrapper around the select."
        },
        {
          "name": "ref",
          "type": "Ref<HTMLSelectElement>",
          "required": false,
          "default": null,
          "description": "Reaches the native select. A normal prop in React 19."
        },
        {
          "name": "...rest",
          "type": "Omit<SelectHTMLAttributes<HTMLSelectElement>, \"size\">",
          "required": false,
          "default": null,
          "description": "Everything else goes to the <select>: value, defaultValue, onChange, disabled, required, id, aria-label, aria-describedby and so on."
        }
      ],
      "variantsHint": {
        "size": [
          "sm",
          "md"
        ]
      },
      "tokens": [
        "--border",
        "--border-strong",
        "--danger",
        "--surface",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--dur-fast",
        "--radius-field"
      ],
      "related": [
        "field",
        "text-field",
        "number-field",
        "segmented-control",
        "popover"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Options from data",
          "description": "Pass options. An option can be disabled. Pass an aria-label when there is no visible label.",
          "code": "function Example() {\n  const [v, setV] = useState(\"balanced\");\n  return (\n    <div className=\"w-60\">\n      <Select aria-label=\"Model\" value={v} onChange={(e) => setV(e.target.value)} options={models} />\n    </div>\n  );\n}"
        },
        {
          "title": "In a Field with a placeholder",
          "description": "While nothing is picked, the placeholder shows. Field adds the label and hint.",
          "code": "function Example() {\n  const [v, setV] = useState(\"\");\n  return (\n    <div className=\"w-72\">\n      <Field label=\"Model\" optional hint=\"You can change this later.\">\n        {(a) => <Select {...a} value={v} onChange={(e) => setV(e.target.value)} options={models} placeholder=\"Choose a model\" />}\n      </Field>\n    </div>\n  );\n}"
        },
        {
          "title": "Children, invalid and disabled",
          "description": "Write <option> elements yourself if you need groups. Invalid and disabled are shown here.",
          "code": "<div className=\"flex w-60 flex-col gap-3\">\n  <Select aria-label=\"Region\" invalid defaultValue=\"eu\">\n    <optgroup label=\"Europe\"><option value=\"eu\">Frankfurt</option></optgroup>\n    <optgroup label=\"Asia\"><option value=\"in\">Mumbai</option></optgroup>\n  </Select>\n  <Select aria-label=\"Plan\" disabled options={models} defaultValue=\"fast\" />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-select/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/select.md"
    },
    {
      "id": "checkbox",
      "name": "Checkbox",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "check box",
        "tick box"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the checked prop.",
          "source": "prop"
        },
        {
          "name": "error",
          "note": "Set with the invalid prop.",
          "source": "prop"
        },
        {
          "name": "disabled",
          "note": "Set with the disabled prop.",
          "source": "prop"
        },
        {
          "name": "hover",
          "note": "The pointer shows a hand over the whole row, and the label is part of the click target.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears around the box when you reach it with the keyboard.",
          "source": "documented"
        },
        {
          "name": "indeterminate",
          "note": "Pass indeterminate to show a dash for a partly selected group. Pressing it calls onChange.",
          "source": "documented"
        }
      ],
      "summary": "A tick box with a visible label and hint. It can show a partly selected state.",
      "description": "Checkbox is a native checkbox drawn as a 20px box. The label and hint sit beside it, and pressing them toggles the box. Your code keeps track of whether it is checked. Use it for choices that are saved later, or for picking several items from a list.",
      "purpose": "Lets a person tick or untick one option. It is for choices that take effect when a form is sent, and for picking several items at once.",
      "useFor": [
        "A choice in a form, such as agreeing to terms.",
        "Picking several items from a list.",
        "A select-all box above a list, using the partly selected state."
      ],
      "notFor": [
        {
          "text": "A setting that applies the moment it is flipped",
          "use": "switch"
        },
        {
          "text": "Exactly one choice from a few options",
          "use": "radio-group"
        },
        {
          "text": "One choice from a long list",
          "use": "select"
        }
      ],
      "whenToUse": [
        "Write the label so it is true when the box is ticked, such as \"Share this chat\".",
        "Use the partly selected state only for a box that controls a list of other boxes.",
        "Keep the checked value in your own code and pass it back as checked.",
        "Say why a box is disabled in text nearby."
      ],
      "avoid": [
        "Do not use a Checkbox for a setting that applies at once. Use a Switch.",
        "Do not use one for a choice between exclusive options. Use a RadioGroup.",
        "Do not leave out both the label and ariaLabel.",
        "Do not show an error with the red border alone."
      ],
      "accessibility": [
        "It is a real checkbox, so the Tab key reaches it and Space flips it.",
        "The label is the name. The hint is read after it.",
        "The partly selected state is announced as mixed.",
        "On touch screens the row is at least 44px tall.",
        "In high-contrast mode the box and the checked state use system colors, so they stay visible.",
        "The focus ring comes from the global focus rule."
      ],
      "accessibilityYouDo": [
        "Give it a visible label. If you cannot, pass ariaLabel so it still has a name.",
        "When it is invalid, show the error as text next to it. The red border is not enough on its own.",
        "In a group, wrap the boxes in a fieldset with a legend so the group has a name."
      ],
      "onPhone": [
        "The whole row is the tap target and is at least 44px tall on touch screens, while the box stays 20px.",
        "The label and description wrap inside the row instead of running off the screen.",
        "Nothing depends on hover."
      ],
      "importPath": "import { Checkbox } from \"./index\"",
      "sourcePath": "src/atoms/Checkbox.tsx",
      "props": [
        {
          "name": "checked",
          "type": "boolean",
          "required": true,
          "default": null,
          "description": "Current state. The component is fully controlled."
        },
        {
          "name": "onChange",
          "type": "(checked: boolean) => void",
          "required": true,
          "default": null,
          "description": "Called with the new checked value when the box is pressed."
        },
        {
          "name": "label",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Visible label. Pressing it toggles the box, and it is the accessible name."
        },
        {
          "name": "description",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Visible hint under the label. Linked with aria-describedby."
        },
        {
          "name": "indeterminate",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Shows a dash instead of a check and sets aria-checked to mixed. Pressing the box still calls onChange."
        },
        {
          "name": "invalid",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Red border and aria-invalid. Show the error text yourself."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Dims the row, skips it in the tab order and blocks changes."
        },
        {
          "name": "ariaLabel",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Accessible name for when there is no visible label."
        },
        {
          "name": "id",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id on the input."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer label row."
        },
        {
          "name": "...rest",
          "type": "InputHTMLAttributes<HTMLInputElement>",
          "required": false,
          "default": null,
          "description": "Other native input props, such as name or required, passed to the input."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--accent",
        "--fg-on-accent",
        "--border-strong",
        "--danger",
        "--surface",
        "--dur-fast",
        "--focus-ring"
      ],
      "related": [
        "switch",
        "radio-group",
        "select"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Controlled checkbox",
          "description": "Your code keeps the checked value and updates it when the box changes.",
          "code": "function Example() {\n  const [on, setOn] = useState(true);\n  return <Checkbox checked={on} onChange={setOn} label=\"Remember my choice\" />;\n}"
        },
        {
          "title": "With a hint",
          "description": "The hint shows under the label and is read after the name by screen readers.",
          "code": "function Example() {\n  const [on, setOn] = useState(false);\n  return <Checkbox checked={on} onChange={setOn} label=\"Share this chat\" description=\"Anyone with the link can read it.\" />;\n}"
        },
        {
          "title": "Select all",
          "description": "The top box shows a dash when only some items are picked. Pressing it picks all, or clears all.",
          "code": "function Example() {\n  const [items, setItems] = useState({ a: true, b: false, c: false });\n  const values = Object.values(items);\n  const all = values.every(Boolean);\n  const some = values.some(Boolean) && !all;\n  const toggleAll = (v: boolean) => setItems({ a: v, b: v, c: v });\n  return (\n    <div className=\"flex flex-col gap-2\">\n      <Checkbox checked={all} indeterminate={some} onChange={toggleAll} label=\"All sources\" />\n      <div className=\"ml-8 flex flex-col gap-2\">\n        <Checkbox checked={items.a} onChange={(v) => setItems({ ...items, a: v })} label=\"Web\" />\n        <Checkbox checked={items.b} onChange={(v) => setItems({ ...items, b: v })} label=\"Files\" />\n        <Checkbox checked={items.c} onChange={(v) => setItems({ ...items, c: v })} label=\"Email\" />\n      </div>\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-checkbox/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/checkbox.md"
    },
    {
      "id": "radio-group",
      "name": "RadioGroup",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "radio",
        "radio buttons",
        "option buttons"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "error",
          "note": "Set with the invalid prop.",
          "source": "prop"
        },
        {
          "name": "disabled",
          "note": "Set with the disabled prop.",
          "source": "prop"
        },
        {
          "name": "hover",
          "note": "The pointer shows a hand over an enabled row, and the label is part of the click target.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears around the circle in focus when you reach the group with the keyboard.",
          "source": "documented"
        },
        {
          "name": "nothing picked",
          "note": "When value matches no option, no circle is filled and Tab reaches the first enabled option.",
          "source": "documented"
        }
      ],
      "summary": "A set of radio buttons for picking exactly one option, with a visible group name.",
      "description": "RadioGroup shows a few options and lets a person pick one. It is a fieldset with a legend, and every option is a native radio that shares one name. The browser gives it the arrow-key behaviour. Your code keeps track of the picked value.",
      "purpose": "Lets a person pick exactly one of a few options, with every option visible at once. Each option can carry a short hint.",
      "useFor": [
        "A few options that need explaining, such as a response style.",
        "A form choice where the person should see all the options before picking.",
        "A choice that is saved later, not applied at once."
      ],
      "notFor": [
        {
          "text": "A compact switch between views that applies at once",
          "use": "segmented-control"
        },
        {
          "text": "Many options",
          "use": "select"
        },
        {
          "text": "Picking several options",
          "use": "checkbox"
        },
        {
          "text": "A single on or off setting",
          "use": "switch"
        }
      ],
      "whenToUse": [
        "Start with the most common option picked.",
        "Keep labels short and put detail in the hint.",
        "Keep the value in your own code and pass it back as value.",
        "Use the same shape of label for every option."
      ],
      "avoid": [
        "Do not use a RadioGroup for a long list of options. Use a Select.",
        "Do not use it for a setting that applies the moment it is picked. Use a SegmentedControl or a Switch.",
        "Do not hide the legend if people may not know what the question is.",
        "Do not disable the picked option without saying why."
      ],
      "accessibility": [
        "It is a fieldset with a legend, so screen readers announce the group name.",
        "The options are native radios with one shared name. Tab moves into the group, and the arrow keys move between options and pick them. This comes from the browser.",
        "Only the picked option is in the tab order. When nothing is picked, Tab reaches the first option.",
        "Each label is the name of its radio, and the hint is read after it.",
        "On touch screens each row is at least 44px tall.",
        "In high-contrast mode the circle and the dot use system colors."
      ],
      "accessibilityYouDo": [
        "Give the group a label that names the question, such as \"Response style\". It is required.",
        "When the group is invalid, show the error as text. The red border is not enough on its own.",
        "Pick a starting value when you can. A group with nothing picked is easy to skip."
      ],
      "onPhone": [
        "Each option row is at least 44px tall on touch screens, and the whole row is tappable.",
        "The vertical layout is a single column; the horizontal layout wraps onto new lines when the options do not fit.",
        "Option labels and descriptions wrap rather than truncate."
      ],
      "importPath": "import { RadioGroup } from \"./index\"",
      "sourcePath": "src/molecules/RadioGroup.tsx",
      "props": [
        {
          "name": "options",
          "type": "{ value: string; label: ReactNode; description?: string; disabled?: boolean }[]",
          "required": true,
          "default": null,
          "description": "The choices. Each value must be unique in the group."
        },
        {
          "name": "value",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The picked value. The component is fully controlled. Use a value that matches no option for nothing picked."
        },
        {
          "name": "onChange",
          "type": "(value: string) => void",
          "required": true,
          "default": null,
          "description": "Called with the value of the option the person picked."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Name of the group, drawn as the legend."
        },
        {
          "name": "hideLabel",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Hides the legend visually. Screen readers still read it."
        },
        {
          "name": "orientation",
          "type": "\"vertical\" | \"horizontal\"",
          "required": false,
          "default": "\"vertical\"",
          "description": "Stack the options or lay them in a row that wraps."
        },
        {
          "name": "invalid",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Red border on every circle and aria-invalid on each radio. Show the error text yourself."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Disables the whole group through the fieldset."
        },
        {
          "name": "name",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shared name of the radios. Set it if the group is inside a native form. Otherwise one is made for you."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the fieldset."
        }
      ],
      "variantsHint": {
        "orientation": [
          "vertical",
          "horizontal"
        ]
      },
      "tokens": [
        "--accent",
        "--border-strong",
        "--danger",
        "--surface",
        "--dur-fast",
        "--focus-ring"
      ],
      "related": [
        "segmented-control",
        "select",
        "checkbox"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With descriptions",
          "description": "Each option has a short hint under its label.",
          "code": "function Example() {\n  const [v, setV] = useState(\"balanced\");\n  return (\n    <RadioGroup label=\"Response style\" value={v} onChange={setV} options={[\n      { value: \"fast\", label: \"Fast\", description: \"Short answers, less checking.\" },\n      { value: \"balanced\", label: \"Balanced\", description: \"A mix of speed and care.\" },\n      { value: \"careful\", label: \"Careful\", description: \"Slower, checks its work.\" },\n    ]} />\n  );\n}"
        },
        {
          "title": "Horizontal",
          "description": "Short labels can sit in a row. The row wraps on narrow screens.",
          "code": "function Example() {\n  const [v, setV] = useState(\"week\");\n  return (\n    <RadioGroup label=\"Keep history for\" orientation=\"horizontal\" value={v} onChange={setV} options={[\n      { value: \"day\", label: \"A day\" },\n      { value: \"week\", label: \"A week\" },\n      { value: \"month\", label: \"A month\" },\n    ]} />\n  );\n}"
        },
        {
          "title": "Hidden legend and a disabled option",
          "description": "When nearby text already names the group, hide the legend. Screen readers still hear it.",
          "code": "function Example() {\n  const [v, setV] = useState(\"light\");\n  return (\n    <RadioGroup label=\"Theme\" hideLabel orientation=\"horizontal\" value={v} onChange={setV} options={[\n      { value: \"light\", label: \"Light\" },\n      { value: \"dark\", label: \"Dark\" },\n      { value: \"auto\", label: \"Match system\", disabled: true },\n    ]} />\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-radio-group/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/radio-group.md"
    },
    {
      "id": "slider",
      "name": "Slider",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "range",
        "range slider",
        "range input"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "disabled",
          "note": "Set with the disabled prop.",
          "source": "prop"
        },
        {
          "name": "hover",
          "note": "The pointer shows a hand over the slider.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears when you reach the slider with the keyboard.",
          "source": "documented"
        },
        {
          "name": "dragging",
          "note": "While the thumb moves, onChange is called with each new value and the filled track follows it.",
          "source": "documented"
        }
      ],
      "summary": "A single-thumb slider for picking a number in a range, with an optional value readout.",
      "description": "Slider is a native range input with a restyled track and a 20px thumb. The part of the track before the thumb is filled. It can show the value beside it, and a format function turns the number into text such as 40%. Use it when the exact number matters less than the feel of the range.",
      "purpose": "Lets a person pick a number from a range by dragging or using the arrow keys. It suits settings where a rough value is fine.",
      "useFor": [
        "A setting with a clear minimum and maximum, such as creativity or volume.",
        "A value that people adjust and compare by feel.",
        "A number with steps, such as a length in rounds of 256."
      ],
      "notFor": [
        {
          "text": "A number where the exact value must be typed",
          "use": "number-field"
        },
        {
          "text": "A few named levels",
          "use": "segmented-control"
        },
        {
          "text": "Free text",
          "use": "text-field"
        }
      ],
      "whenToUse": [
        "Show the value, so people know what they have picked.",
        "Pick a step that matches how precise the setting needs to be.",
        "Use formatValue when the number has a unit.",
        "Give the slider a width. It fills the space it is given."
      ],
      "avoid": [
        "Do not use a Slider when people need to enter an exact number. Use a NumberField.",
        "Do not use it for a few named choices. Use a SegmentedControl.",
        "Do not use it for a range with two ends. It has one thumb.",
        "Do not rely on the filled track alone to show the value."
      ],
      "accessibility": [
        "It is a native range input, so the Tab key reaches it. Arrow keys change the value by one step, and Home and End jump to the ends.",
        "Screen readers say the label and the value. With formatValue they say the formatted text.",
        "The visible value is linked to the input as its output.",
        "On touch screens the control is 44px tall.",
        "In high-contrast mode the track and thumb use system colors.",
        "The focus ring comes from the global focus rule."
      ],
      "accessibilityYouDo": [
        "Give it a label that names the setting. It is required.",
        "Turn on showValue or show the value nearby. Dragging alone does not tell people the number.",
        "If the unit matters, pass formatValue so the unit is in the visible text and in what screen readers say."
      ],
      "onPhone": [
        "The slider is 44px tall on touch screens, so the thumb is easy to grab even though it is drawn 20px wide.",
        "It stretches to fill the width of its container, with the value shown beside it when showValue is on.",
        "It is the browser's own range input, so a finger drags it directly; for exact numbers, pair it with a NumberField."
      ],
      "importPath": "import { Slider } from \"./index\"",
      "sourcePath": "src/atoms/Slider.tsx",
      "props": [
        {
          "name": "value",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Current value. The component is fully controlled."
        },
        {
          "name": "onChange",
          "type": "(value: number) => void",
          "required": true,
          "default": null,
          "description": "Called with the new number while the thumb moves."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name, applied as aria-label."
        },
        {
          "name": "min",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Lowest value."
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "default": "100",
          "description": "Highest value."
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "default": "1",
          "description": "Size of each step."
        },
        {
          "name": "showValue",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Shows the current value as text to the right."
        },
        {
          "name": "formatValue",
          "type": "(value: number) => string",
          "required": false,
          "default": null,
          "description": "Turns the number into text. Used for the visible value and for aria-valuetext."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Dims the slider, skips it in the tab order and blocks changes."
        },
        {
          "name": "id",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id on the input. A generated one is used if you leave it out."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the row that holds the slider and the value text."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--accent",
        "--border-strong",
        "--surface",
        "--fg-muted",
        "--focus-ring"
      ],
      "related": [
        "number-field",
        "segmented-control",
        "text-field"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Basic slider",
          "description": "The value runs from 0 to 100 by default.",
          "code": "function Example() {\n  const [v, setV] = useState(40);\n  return <div className=\"w-72\"><Slider value={v} onChange={setV} label=\"Volume\" /></div>;\n}"
        },
        {
          "title": "Percent with a readout",
          "description": "formatValue adds the unit to the visible text and to what screen readers say.",
          "code": "function Example() {\n  const [v, setV] = useState(60);\n  return (\n    <div className=\"w-72\">\n      <Slider value={v} onChange={setV} label=\"Creativity\" showValue step={5} formatValue={(n) => `${n}%`} />\n    </div>\n  );\n}"
        },
        {
          "title": "Custom range and step",
          "description": "Set min, max and step. The thumb snaps to each step.",
          "code": "function Example() {\n  const [v, setV] = useState(2048);\n  return (\n    <div className=\"w-72\">\n      <Slider value={v} onChange={setV} label=\"Reply length\" min={256} max={4096} step={256} showValue formatValue={(n) => `${n} tokens`} />\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-slider/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/slider.md"
    },
    {
      "id": "tabs",
      "name": "Tabs",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "tab bar",
        "tab list",
        "tab view"
      ],
      "category": "overlays",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "The tab matching value gets a heavier underline (or a filled pill), stronger text and aria-selected. When value changes, the underline or pill slides to the new tab in --dur-base; the first placement does not slide. In forced colours the tab gets a highlight border or background instead.",
          "source": "documented"
        },
        {
          "name": "disabled",
          "note": "A tab with disabled looks dimmed, ignores clicks and is skipped by the arrow keys.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "An unselected tab's text darkens under the pointer, and a pill tab gets a soft background.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears on the focused tab. Only the selected tab is in the Tab order.",
          "source": "documented"
        },
        {
          "name": "overflow",
          "note": "When the tabs are wider than the space, the list scrolls sideways without a scrollbar and keeps the selected tab in view.",
          "source": "documented"
        }
      ],
      "summary": "A row of tabs that switch between views, with arrow-key movement and optional panels.",
      "description": "Tabs shows one view at a time out of several. Each tab can carry a count. You can pass a panel with each tab and Tabs shows only the active one, or you can leave panels out and draw the content yourself. The line or pill under the active tab slides to the new tab. The row scrolls sideways on narrow screens and keeps the active tab in view.",
      "purpose": "Switches between views that belong together on one page, such as Inbox, Sent and Drafts. The active tab is marked by more than colour, and keyboard users get one tab stop and arrow keys.",
      "useFor": [
        "Views of the same kind of content, like folders or report sections.",
        "Tabs with a count, such as unread items.",
        "Showing only one panel at a time to keep a page short."
      ],
      "notFor": [
        {
          "text": "Choosing a setting with two or three short options",
          "use": "segmented-control"
        },
        {
          "text": "Content that readers should open and close in place",
          "use": "collapsible"
        },
        {
          "text": "A list of actions",
          "use": "menu"
        }
      ],
      "whenToUse": [
        "Keep the labels short, ideally one or two words.",
        "Use manual activation when showing a panel is slow or has a cost.",
        "Show a count only when it helps people choose, and keep it a plain number.",
        "Keep the same tab order every time the page loads."
      ],
      "avoid": [
        "Do not nest tabs inside tabs.",
        "Do not use tabs to move through steps in order. Show the steps as a list.",
        "Do not disable a tab without explaining why elsewhere on the page.",
        "Do not put actions in the tab list. Use a Menu or Buttons."
      ],
      "accessibility": [
        "The row is a tab list with a name. Each tab is a button with role tab, and screen readers say whether it is selected.",
        "Only the active tab is in the Tab order. Left and Right Arrow move between tabs and wrap around, and Home and End jump to the first and last.",
        "Disabled tabs are skipped by the arrow keys.",
        "The active tab points to its panel with aria-controls. The panel has role tabpanel and is named by its tab.",
        "Only the active panel is drawn, so hidden panels are not in the page.",
        "On touch screens each tab is 44px tall.",
        "The active tab uses a heavier line or fill as well as colour.",
        "The line or fill slides to the new tab in --dur-base on --ease-arrive. It does not slide when it is first placed, and it moves at once when people turn off motion in their system settings.",
        "In forced-colors mode the sliding line or fill is hidden, and the active tab gets a highlight-coloured border (underline) or background (pill) instead."
      ],
      "accessibilityYouDo": [
        "Give the tab list a label that says what the tabs choose between, such as \"Mail folders\".",
        "Keep tab names short and different from each other.",
        "Keep the value in your own state. If a tab opens a page, change the page address as well.",
        "If you draw the content yourself instead of passing panel, give your content role tabpanel and label it with the tab."
      ],
      "onPhone": [
        "The row of tabs scrolls sideways when it is too wide, with no scrollbar, and the selected tab is scrolled into view.",
        "The row fades at an edge where more tabs are hidden.",
        "Each tab is 44px tall on touch screens and never wraps its label.",
        "The gap between underline tabs is smaller on a phone and grows from the sm breakpoint."
      ],
      "importPath": "import { Tabs } from \"./index\"",
      "sourcePath": "src/molecules/Tabs.tsx",
      "props": [
        {
          "name": "tabs",
          "type": "{ id: string; label: ReactNode; count?: number; disabled?: boolean; panel?: ReactNode }[]",
          "required": true,
          "default": null,
          "description": "The tabs in order. panel is drawn only while its tab is active."
        },
        {
          "name": "value",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Id of the active tab. The component is fully controlled."
        },
        {
          "name": "onChange",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Called with the tab id when a tab is clicked, or reached by arrow keys in auto mode."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name of the tab list."
        },
        {
          "name": "variant",
          "type": "\"underline\" | \"pill\"",
          "required": false,
          "default": "\"underline\"",
          "description": "Underline for page sections, pill for a small switch."
        },
        {
          "name": "activation",
          "type": "\"auto\" | \"manual\"",
          "required": false,
          "default": "\"auto\"",
          "description": "auto picks a tab as soon as arrow keys reach it. manual only moves focus; Enter or Space picks it."
        },
        {
          "name": "fill",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Tabs share the full width instead of sizing to their label."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer wrapper."
        }
      ],
      "variantsHint": {
        "variant": [
          "underline",
          "pill"
        ],
        "activation": [
          "auto",
          "manual"
        ]
      },
      "tokens": [
        "--fg",
        "--fg-muted",
        "--border",
        "--surface-hover",
        "--accent",
        "--on-accent",
        "--dur-fast",
        "--dur-base",
        "--ease-arrive",
        "--focus-ring",
        "--radius-control"
      ],
      "related": [
        "segmented-control",
        "collapsible",
        "menu"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Underline with counts",
          "description": "The default look. A count sits after the name. A disabled tab is skipped by the arrow keys.",
          "code": "function Example() {\n  const [tab, setTab] = useState(\"inbox\");\n  return (\n    <Tabs\n      label=\"Mail folders\"\n      value={tab}\n      onChange={setTab}\n      tabs={[\n        { id: \"inbox\", label: \"Inbox\", count: 12 },\n        { id: \"sent\", label: \"Sent\" },\n        { id: \"drafts\", label: \"Drafts\", count: 3 },\n        { id: \"archive\", label: \"Archive\", disabled: true },\n      ]}\n    />\n  );\n}"
        },
        {
          "title": "Pill, filling the width",
          "description": "Use the pill look for a small switch inside a card. fill makes the tabs share the width.",
          "code": "function Example() {\n  const [range, setRange] = useState(\"week\");\n  return (\n    <Tabs\n      label=\"Time range\"\n      variant=\"pill\"\n      fill\n      value={range}\n      onChange={setRange}\n      tabs={[\n        { id: \"day\", label: \"Day\" },\n        { id: \"week\", label: \"Week\" },\n        { id: \"month\", label: \"Month\" },\n      ]}\n    />\n  );\n}"
        },
        {
          "title": "With panels and manual activation",
          "description": "Each tab brings its panel and only the active panel is drawn. With manual activation the arrow keys move focus and Enter or Space picks the tab.",
          "code": "function Example() {\n  const [tab, setTab] = useState(\"summary\");\n  return (\n    <Tabs\n      label=\"Report\"\n      value={tab}\n      onChange={setTab}\n      activation=\"manual\"\n      tabs={[\n        { id: \"summary\", label: \"Summary\", panel: <p className=\"text-body leading-5 text-fg\">Three tasks finished and one is waiting for you.</p> },\n        { id: \"steps\", label: \"Steps\", count: 4, panel: <p className=\"text-body leading-5 text-fg\">Four steps ran. None of them changed a file.</p> },\n        { id: \"sources\", label: \"Sources\", panel: <p className=\"text-body leading-5 text-fg\">Two pages were read.</p> },\n      ]}\n    />\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-tabs/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/tabs.md"
    },
    {
      "id": "tooltip",
      "name": "Tooltip",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "hint",
        "tip",
        "hover text"
      ],
      "category": "overlays",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "The tip appears after the delay when the pointer rests on the trigger, and stays while the pointer is on it or on the tip.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The tip appears at once when the trigger gets keyboard focus and hides when focus leaves.",
          "source": "documented"
        },
        {
          "name": "dismissed",
          "note": "Escape hides the tip without moving focus.",
          "source": "documented"
        },
        {
          "name": "touch",
          "note": "On coarse pointers a tap or hover does not show the tip.",
          "source": "documented"
        }
      ],
      "summary": "A short text label that appears on hover and on keyboard focus.",
      "description": "Tooltip names or briefly explains a control, usually one that shows only an icon. It appears after a short delay on hover and at once on keyboard focus. It holds plain text only. People on touch screens never see it from a tap, so it can never carry anything they need.",
      "purpose": "A short extra hint for a control, such as the name of an icon button. It is a bonus for people with a mouse or keyboard. Everything it says must also be available some other way.",
      "useFor": [
        "Naming an icon-only button for people who hover over it.",
        "A short reminder of what a control does.",
        "Telling people about a shortcut next to the control."
      ],
      "notFor": [
        {
          "text": "Anything with a link, button or formatting inside",
          "use": "popover"
        },
        {
          "text": "Information people must read to finish a task"
        },
        {
          "text": "A shortcut hint shown beside an item",
          "use": "kbd"
        }
      ],
      "whenToUse": [
        "Keep it to a few words.",
        "Use it on controls that have little or no visible text.",
        "Place it so it does not cover what the person is trying to read.",
        "Give the trigger its own name and use the tooltip only as extra."
      ],
      "avoid": [
        "Do not put essential information, errors or instructions in it.",
        "Do not put links, buttons or other controls inside it.",
        "Do not use it on something that cannot take focus.",
        "Do not use it to show a longer explanation. Use a Popover or text on the page."
      ],
      "accessibility": [
        "The tip has role tooltip and is linked to the trigger with aria-describedby, so screen readers read it as a description.",
        "It is always in the page, hidden until shown, so the description is available even before it appears.",
        "Hover shows it after the delay. Keyboard focus shows it at once. Blur, moving the pointer away and Escape hide it.",
        "Escape closes the tip and leaves focus where it was.",
        "The pointer can move onto the tip without it closing.",
        "On touch screens (coarse pointer) a tap does not show it. It can still show on keyboard focus.",
        "The fade runs only when motion is allowed. Forced-colors mode adds a border."
      ],
      "accessibilityYouDo": [
        "Never put essential information in a tooltip. Touch users do not see it.",
        "The trigger must be focusable, such as a button or a link. A tooltip on plain text cannot be reached by keyboard.",
        "An icon-only trigger still needs its own accessible name. The tooltip adds a description, it does not replace the name.",
        "Keep it to a few words. Long text belongs on the page or in a Popover."
      ],
      "onPhone": [
        "Hover is ignored on touch screens, so the tip never shows from a touch alone.",
        "It still shows when the control gets focus, but do not rely on that: put anything the user must know in visible text.",
        "The tip is at most 16rem wide and wraps."
      ],
      "importPath": "import { Tooltip } from \"./index\"",
      "sourcePath": "src/atoms/Tooltip.tsx",
      "props": [
        {
          "name": "content",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Plain text for the tip. No markup, links or buttons."
        },
        {
          "name": "children",
          "type": "ReactElement",
          "required": true,
          "default": null,
          "description": "The trigger. It gets aria-describedby added, so it must accept that prop and be focusable."
        },
        {
          "name": "side",
          "type": "\"top\" | \"bottom\" | \"left\" | \"right\"",
          "required": false,
          "default": "\"top\"",
          "description": "Which side of the trigger the tip appears on. It does not flip when space runs out."
        },
        {
          "name": "delay",
          "type": "number",
          "required": false,
          "default": "400",
          "description": "Milliseconds to wait before a hover shows the tip. Focus shows it at once."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the tip itself."
        }
      ],
      "variantsHint": {
        "side": [
          "top",
          "bottom",
          "left",
          "right"
        ]
      },
      "tokens": [
        "--fg",
        "--bg",
        "--shadow-md",
        "--animate-fade"
      ],
      "related": [
        "icon-button",
        "popover",
        "kbd"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "On an icon button",
          "description": "The tooltip repeats the button name for sighted mouse users. The button keeps its own label.",
          "code": "<Tooltip content=\"Copy reply\">\n  <IconButton label=\"Copy reply\"><CopyIcon width={16} height={16} /></IconButton>\n</Tooltip>"
        },
        {
          "title": "Long delay, below the trigger",
          "description": "A longer delay keeps tips from flashing as the pointer crosses a toolbar. side places the tip below.",
          "code": "<Tooltip content=\"Runs the whole plan again\" side=\"bottom\" delay={1000}>\n  <Button variant=\"secondary\">Run again</Button>\n</Tooltip>"
        },
        {
          "title": "Beside a toolbar",
          "description": "Use left or right when there is no room above or below, for example in a vertical toolbar.",
          "code": "<div className=\"flex gap-2\">\n  <Tooltip content=\"Share\" side=\"right\">\n    <IconButton label=\"Share\"><ShareIcon width={16} height={16} /></IconButton>\n  </Tooltip>\n  <Tooltip content=\"Download\" side=\"right\">\n    <IconButton label=\"Download\"><DownloadIcon width={16} height={16} /></IconButton>\n  </Tooltip>\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-tooltip/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/tooltip.md"
    },
    {
      "id": "menu",
      "name": "Menu",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "dropdown menu",
        "context menu",
        "action menu",
        "overflow menu",
        "more menu"
      ],
      "category": "overlays",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open",
          "note": "The trigger reports aria-expanded and the list shows below or above it. Focus moves to an item.",
          "source": "documented"
        },
        {
          "name": "closing",
          "note": "After it closes the list fades out in --dur-fast and cannot be pressed meanwhile. Under reduced motion it goes at once.",
          "source": "documented"
        },
        {
          "name": "disabled item",
          "note": "An item with disabled looks dimmed, does nothing, is skipped by the arrow keys and is announced as disabled.",
          "source": "documented"
        },
        {
          "name": "danger item",
          "note": "An item with danger uses the danger text colour and keeps its words.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "An enabled item gets a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The focused item gets a soft background and a visible focus ring.",
          "source": "documented"
        }
      ],
      "summary": "A button that opens a list of actions, with full keyboard support.",
      "description": "Menu is a button that opens a short list of actions. Each item can have an icon and a shortcut hint. Items can be grouped with separators and small labels, disabled, or marked as dangerous. It is built on Popover and follows the menu button pattern. Closing is a quick fade.",
      "purpose": "Holds actions that would crowd the screen, such as Rename, Share and Delete. Choosing an item runs it and closes the menu.",
      "useFor": [
        "A More actions button on a card or message.",
        "A short list of related actions behind one button.",
        "Actions with shortcuts, a dangerous action set apart at the bottom."
      ],
      "notFor": [
        {
          "text": "Panels with forms, text or other controls",
          "use": "popover"
        },
        {
          "text": "Choosing one value that stays selected",
          "use": "segmented-control"
        },
        {
          "text": "The one main action on a screen",
          "use": "button"
        }
      ],
      "whenToUse": [
        "Keep the list short, and put the most used items first.",
        "Put dangerous actions last, after a separator.",
        "Use verbs for labels, such as Rename and Export.",
        "Confirm a destructive action in a follow-up step if it cannot be undone."
      ],
      "avoid": [
        "Do not put forms, long text or other controls in a menu. Use a Popover.",
        "Do not use a menu for two choices. Show two buttons.",
        "Do not rely on red alone. A danger item must say what it does.",
        "Do not nest menus inside menus."
      ],
      "accessibility": [
        "The trigger is a button with aria-haspopup=\"menu\" and aria-expanded. The list has role menu and a name, and items have role menuitem.",
        "Enter, Space and Down Arrow on the trigger open the menu and focus the first item. Up Arrow opens it and focuses the last.",
        "Up and Down Arrow move between items and wrap. Home and End jump to the first and last. Typing letters jumps to an item that starts with them.",
        "Enter or Space on an item runs it and closes the menu. Escape closes it and returns focus to the trigger. Tab closes it and moves on.",
        "Disabled items are skipped by the arrow keys and are marked aria-disabled, so they are still announced.",
        "Group labels are plain text and are not focusable. Separators have role separator.",
        "Items are 44px tall and the trigger is 44px on touch screens.",
        "Danger items keep their words and also use the danger text colour.",
        "Closing is a quick plain fade in --dur-fast, faster than opening. The menu cannot be pressed while it fades. When people turn off motion in their system settings, it goes at once."
      ],
      "accessibilityYouDo": [
        "When the trigger is only an icon, set triggerLabel to the action it opens, such as \"More actions\".",
        "Write item labels as actions, such as \"Delete chat\". Danger styling is colour on top of the words, never instead of them.",
        "Do not hide an action only in a menu if people need it often. Show it on the page as well.",
        "The shortcut text is only a hint. You must wire the shortcut yourself."
      ],
      "onPhone": [
        "On a phone the menu opens as a bottom sheet with a backdrop, in reach of the thumb; set phoneSheet to false to keep it anchored to the trigger.",
        "The trigger and every item are at least 44px tall on touch screens.",
        "Keyboard shortcuts shown beside items are hints for keyboards, so do not make an action available only through a shortcut."
      ],
      "importPath": "import { Menu } from \"./index\"",
      "sourcePath": "src/molecules/Menu.tsx",
      "props": [
        {
          "name": "trigger",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Content of the trigger button: text, an icon, or both."
        },
        {
          "name": "triggerLabel",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Accessible name of the trigger button. Set it whenever the trigger is only an icon."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Accessible name of the menu list. Falls back to triggerLabel, then \"Menu\"."
        },
        {
          "name": "items",
          "type": "({ type?: \"item\"; id: string; label: string; icon?: ReactNode; shortcut?: string; disabled?: boolean; danger?: boolean; onSelect: () => void } | { type: \"separator\" } | { type: \"label\"; label: string })[]",
          "required": true,
          "default": null,
          "description": "Menu content in order. Items run onSelect and then close the menu."
        },
        {
          "name": "align",
          "type": "\"start\" | \"end\"",
          "required": false,
          "default": "\"start\"",
          "description": "Which edge of the trigger the menu lines up with."
        },
        {
          "name": "side",
          "type": "\"bottom\" | \"top\"",
          "required": false,
          "default": "\"bottom\"",
          "description": "Preferred side. Popover flips it when there is no room."
        },
        {
          "name": "portal",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Draws the menu on the page itself so a scrolling or clipped parent cannot cut it off. Use it for menus inside lists and side bars."
        },
        {
          "name": "phoneSheet",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "On a phone the menu opens as a bottom sheet with a backdrop, within thumb reach. Set false to keep a small floating menu there too."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the wrapper around the trigger and menu."
        }
      ],
      "variantsHint": {
        "align": [
          "start",
          "end"
        ],
        "side": [
          "bottom",
          "top"
        ]
      },
      "tokens": [
        "--surface",
        "--surface-raised",
        "--surface-hover",
        "--border",
        "--fg",
        "--fg-subtle",
        "--danger-fg",
        "--dur-fast",
        "--focus-ring",
        "--radius-control"
      ],
      "related": [
        "popover",
        "icon-button",
        "button",
        "segmented-control"
      ],
      "composedOf": [
        "popover",
        "kbd"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Icon-only trigger",
          "description": "triggerLabel gives the icon button its name. The menu opens below and lines up with the start of the button.",
          "code": "<Menu\n  triggerLabel=\"More actions\"\n  trigger={<MoreIcon width={16} height={16} />}\n  items={[\n    { id: \"copy\", label: \"Copy link\", icon: <CopyIcon width={16} height={16} />, onSelect: () => setLast(\"Copy link\") },\n    { id: \"rename\", label: \"Rename\", icon: <EditIcon width={16} height={16} />, onSelect: () => setLast(\"Rename\") },\n    { id: \"share\", label: \"Share\", icon: <ShareIcon width={16} height={16} />, onSelect: () => setLast(\"Share\") },\n  ]}\n/>"
        },
        {
          "title": "Label, separator, shortcuts and a danger item",
          "description": "A group label names the section. A separator sets the dangerous action apart. The disabled item is announced but cannot be chosen.",
          "code": "<Menu\n  trigger=\"Chat options\"\n  items={[\n    { type: \"label\", label: \"This chat\" },\n    { id: \"rename\", label: \"Rename\", shortcut: \"R\", icon: <EditIcon width={16} height={16} />, onSelect: () => setLast(\"Rename\") },\n    { id: \"export\", label: \"Export\", icon: <DownloadIcon width={16} height={16} />, disabled: true, onSelect: () => setLast(\"Export\") },\n    { type: \"separator\" },\n    { id: \"delete\", label: \"Delete chat\", shortcut: \"Del\", danger: true, onSelect: () => setLast(\"Delete chat\") },\n  ]}\n/>"
        },
        {
          "title": "Text and icon trigger, aligned to the end",
          "description": "Use align end when the button sits at the right edge, so the menu grows to the left.",
          "code": "<Menu\n  align=\"end\"\n  trigger={<><SettingsIcon width={16} height={16} />Settings</>}\n  items={[\n    { id: \"regen\", label: \"Regenerate\", icon: <RefreshIcon width={16} height={16} />, onSelect: () => setLast(\"Regenerate\") },\n    { id: \"reset\", label: \"Reset to default\", onSelect: () => setLast(\"Reset to default\") },\n  ]}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-menu/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/menu.md"
    },
    {
      "id": "splitter",
      "name": "Splitter",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "resizable",
        "resizable panels",
        "split view",
        "split pane",
        "divider"
      ],
      "category": "overlays",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hover",
          "note": "The line darkens when the pointer is over the bar.",
          "source": "documented"
        },
        {
          "name": "dragging",
          "note": "While the bar is pressed and moved, the line is thicker and uses the accent colour.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears on the bar when you reach it with the keyboard.",
          "source": "documented"
        },
        {
          "name": "at limit",
          "note": "At min or max the size stops changing, and aria-valuenow stays at that limit.",
          "source": "documented"
        }
      ],
      "summary": "A draggable bar between two panes that people can also move with the keyboard.",
      "description": "Splitter is only the bar. You draw the two panes and set the size of the first from the value. Dragging, the arrow keys, Home and End change that value. A double click or Enter can collapse the first pane if you handle onToggle.",
      "purpose": "Lets people change how much room a side pane or panel gets. It works with a mouse, a finger and the keyboard.",
      "useFor": [
        "A resizable sidebar next to the main area.",
        "A split between an editor and its output.",
        "A pane people can collapse with a double click."
      ],
      "notFor": [
        {
          "text": "Showing or hiding content without resizing",
          "use": "collapsible"
        },
        {
          "text": "Switching between views",
          "use": "tabs"
        }
      ],
      "whenToUse": [
        "Put the bar in a flex row or column between the two panes.",
        "Set min and max so neither pane can disappear by accident.",
        "Save the size so it comes back next visit.",
        "Offer a button as well when a pane can collapse."
      ],
      "avoid": [
        "Do not expect it to size the panes. You set the first pane from value.",
        "Do not leave out the label.",
        "Do not use it to switch views. Use Tabs.",
        "Do not make the first pane smaller than its content needs without a way to bring it back."
      ],
      "accessibility": [
        "The bar has role separator with aria-orientation, aria-valuenow, aria-valuemin and aria-valuemax, and can be focused with Tab.",
        "Arrow keys along the bar's axis resize by step. Shift makes the step four times larger. Home goes to min and End goes to max.",
        "Enter calls onToggle when you pass it.",
        "Dragging uses pointer capture, so the drag keeps working if the pointer leaves the bar. It works with mouse, pen and touch.",
        "The size is always held between min and max.",
        "The bar is 12px wide, and 44px wide on touch screens through an invisible larger target.",
        "The line darkens on hover, focus and drag, and the focus ring is the global one. The cursor shows col-resize or row-resize."
      ],
      "accessibilityYouDo": [
        "Give the bar a label that names what it resizes, such as \"Resize sidebar\".",
        "Draw the panes yourself and size the first from the same value. Splitter does not lay anything out.",
        "Clamp saved sizes when you restore them, and offer a way to reset.",
        "If a pane can collapse, tell people how, because the double click and Enter are not visible."
      ],
      "onPhone": [
        "It is dragged with a finger, and the handle gets a wider invisible touch area on touch screens.",
        "The drag handles pointer events only and stops the page from scrolling while the finger is on the handle.",
        "Arrow keys and double click do not exist on a phone, so offer a button that does the same job as onToggle.",
        "The value, min and max are pixels that you pass in, so choose limits that leave room on a 360px screen."
      ],
      "importPath": "import { Splitter } from \"./index\"",
      "sourcePath": "src/molecules/Splitter.tsx",
      "props": [
        {
          "name": "direction",
          "type": "\"horizontal\" | \"vertical\"",
          "required": true,
          "default": null,
          "description": "horizontal is a vertical bar between left and right panes. vertical is a horizontal bar between top and bottom panes."
        },
        {
          "name": "value",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Size of the first pane in px."
        },
        {
          "name": "onChange",
          "type": "(px: number) => void",
          "required": true,
          "default": null,
          "description": "Called with the new size, already clamped between min and max."
        },
        {
          "name": "min",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Smallest size of the first pane in px."
        },
        {
          "name": "max",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Largest size of the first pane in px."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name of the bar."
        },
        {
          "name": "onToggle",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called on double click and on Enter. Use it to collapse or restore the first pane. Without it neither does anything."
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "default": "16",
          "description": "Keyboard step in px. Shift multiplies it by 4."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the bar. In a flex row it fills the height; in a flex column it fills the width."
        }
      ],
      "variantsHint": {
        "direction": [
          "horizontal",
          "vertical"
        ]
      },
      "tokens": [
        "--border",
        "--border-strong",
        "--accent",
        "--dur-fast",
        "--focus-ring"
      ],
      "related": [
        "collapsible",
        "tabs",
        "popover"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Sidebar and main area",
          "description": "Horizontal means a vertical bar between left and right panes. The first pane gets its width from the value.",
          "code": "function Example() {\n  const [w, setW] = useState(160);\n  return (\n    <div className=\"flex h-40 w-full max-w-lg overflow-hidden rounded-card border border-line bg-surface text-body leading-5 text-fg\">\n      <div style={{ width: w }} className=\"shrink-0 overflow-hidden bg-sunken p-3\">Sidebar ({w}px)</div>\n      <Splitter direction=\"horizontal\" label=\"Resize sidebar\" value={w} onChange={setW} min={96} max={300} />\n      <div className=\"min-w-0 flex-1 p-3\">Main area. Drag the bar, or focus it and press the arrow keys.</div>\n    </div>\n  );\n}"
        },
        {
          "title": "Top and bottom",
          "description": "Vertical means a horizontal bar between top and bottom panes. The value is the height of the top pane.",
          "code": "function Example() {\n  const [h, setH] = useState(90);\n  return (\n    <div className=\"flex h-56 w-full max-w-lg flex-col overflow-hidden rounded-card border border-line bg-surface text-body leading-5 text-fg\">\n      <div style={{ height: h }} className=\"shrink-0 overflow-hidden bg-sunken p-3\">Editor ({h}px)</div>\n      <Splitter direction=\"vertical\" label=\"Resize editor\" value={h} onChange={setH} min={48} max={160} />\n      <div className=\"min-h-0 flex-1 p-3\">Output</div>\n    </div>\n  );\n}"
        },
        {
          "title": "Collapse with double click or Enter",
          "description": "onToggle runs on a double click and on Enter. Here it hides the first pane and brings it back at its old size.",
          "code": "function Example() {\n  const [w, setW] = useState(160);\n  const [saved, setSaved] = useState(160);\n  const toggle = () => {\n    if (w === 0) setW(saved || 160);\n    else { setSaved(w); setW(0); }\n  };\n  return (\n    <div className=\"flex h-40 w-full max-w-lg overflow-hidden rounded-card border border-line bg-surface text-body leading-5 text-fg\">\n      <div style={{ width: w }} className=\"shrink-0 overflow-hidden bg-sunken\">\n        <div className=\"w-40 p-3\">Files</div>\n      </div>\n      <Splitter direction=\"horizontal\" label=\"Resize file list\" value={w} onChange={setW} onToggle={toggle} min={0} max={240} />\n      <div className=\"min-w-0 flex-1 p-3\">Double click the bar, or press Enter on it, to {w === 0 ? \"show\" : \"hide\"} the file list.</div>\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-splitter/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/splitter.md"
    },
    {
      "id": "dialog",
      "name": "Dialog",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "modal",
        "modal dialog",
        "popup window",
        "alert dialog"
      ],
      "category": "overlays",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open or closed",
          "note": "The page behind is inert, cannot scroll, and focus moves into the window. Focus returns to the opener once it has gone.",
          "source": "documented"
        },
        {
          "name": "closed",
          "note": "With open false nothing is drawn and the page behaves as normal.",
          "source": "documented"
        },
        {
          "name": "closing",
          "note": "After open turns false the panel fades out in --dur-fast and cannot be pressed. It stays modal until it has gone. Under reduced motion it goes at once.",
          "source": "documented"
        },
        {
          "name": "scrolling body",
          "note": "When the body is taller than the window, only the body scrolls and the title and footer stay in view.",
          "source": "documented"
        },
        {
          "name": "bottom sheet",
          "note": "Under the sm breakpoint the panel is full width with rounded top corners and sits at the bottom of the screen.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring shows on the Close button, on footer buttons and on controls in the body when reached by keyboard.",
          "source": "documented"
        }
      ],
      "summary": "A modal window on the native dialog element, with a title, a body that scrolls and a footer for buttons.",
      "description": "Dialog asks for one decision or one short task and blocks the page until it is done. It is built on the browser's own dialog element, so the page behind is inert and Tab stays inside. On a phone it becomes a sheet that rises from the bottom. Closing is a quick fade. Your code keeps the open value.",
      "purpose": "Stops the person to ask for a decision or a small amount of input that must be settled before they go on. Everything behind it is locked until it closes.",
      "useFor": [
        "Confirming an action that is hard to undo, such as deleting a project.",
        "A short form that must be finished before moving on.",
        "Long content that should stay out of the page until asked for, such as terms."
      ],
      "notFor": [
        {
          "text": "A message that does not need an answer",
          "use": "toast"
        },
        {
          "text": "A note that stays on the page",
          "use": "callout"
        },
        {
          "text": "A short list of choices next to a button",
          "use": "popover"
        },
        {
          "text": "A menu of actions for one item",
          "use": "tool-menu"
        }
      ],
      "whenToUse": [
        "Keep the open value in your own code and set it to false in onClose.",
        "Name the footer buttons after what they do.",
        "Put one clear main action in the footer and make the way out equally easy to find.",
        "Keep the body short. If it needs a full page, make it a page.",
        "Use tone destructive for actions that cannot be undone."
      ],
      "avoid": [
        "Do not open a Dialog for a message that needs no answer. Use a Toast or a Callout.",
        "Do not stack one Dialog on top of another.",
        "Do not use it for a short list of choices. Use a Popover or a menu.",
        "Do not set dismissible to false unless leaving without choosing would cause harm.",
        "Do not rely on the red tone alone to warn. The label and your wording must say it too."
      ],
      "accessibility": [
        "It uses the native dialog element opened with showModal, so the page behind is inert and Tab stays inside the window.",
        "The title is the dialog's name and the description is read after it. A destructive Dialog has role alertdialog.",
        "Focus moves to the first control in the body or footer, or the dialog itself when there is none, and returns to the element that opened it once the dialog has gone.",
        "Escape asks to close through onClose. With dismissible false it does nothing.",
        "The Close button is labelled \"Close\" and is 44px on touch screens.",
        "The danger icon is hidden from screen readers; the word Destructive carries the meaning for everyone.",
        "The page behind cannot scroll while the dialog is open.",
        "On phones the panel is a bottom sheet with a maximum height in dvh units, so the on-screen keyboard and browser bars do not hide the footer. Movement only plays when motion is not reduced.",
        "Closing is a quick plain fade in --dur-fast, faster than opening. The panel cannot be pressed while it fades, and the dialog stays modal until it has gone. When people turn off motion in their system settings, it goes at once."
      ],
      "accessibilityYouDo": [
        "Write a title that says what is being decided, such as \"Delete this project?\". The title is the dialog's name.",
        "Label the footer buttons with the outcome (\"Delete project\"), not Yes and No.",
        "Put the safe choice first in the reading order, and never make the destructive button the only way to leave.",
        "If you set dismissible to false, give a footer button that closes it.",
        "A toast raised while a Dialog is open sits behind the inert page. Show it after the Dialog closes."
      ],
      "onPhone": [
        "On a phone the dialog opens from the bottom as a sheet with rounded top corners; from 640px it is centered.",
        "It is at most 90% of the screen height, the body scrolls inside, and scrolling does not pass through to the page.",
        "The footer buttons stack in a column on a phone, sit side by side from 640px, and stay clear of the bottom safe area.",
        "Tapping the backdrop closes it unless you made it not dismissible."
      ],
      "importPath": "import { Dialog } from \"./index\"",
      "sourcePath": "src/organisms/Dialog.tsx",
      "props": [
        {
          "name": "open",
          "type": "boolean",
          "required": true,
          "default": null,
          "description": "Whether the dialog is showing. Your code owns it."
        },
        {
          "name": "onClose",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called when the person asks to close: Escape, a click on the backdrop or the Close button. Set open to false in response."
        },
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Rendered as the h2 heading and used as the dialog's name through aria-labelledby."
        },
        {
          "name": "description",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shown under the title and linked with aria-describedby."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The body. It scrolls when it is too tall."
        },
        {
          "name": "footer",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Usually buttons. They sit in a bar under the body, stacked on phones and in a row from the sm breakpoint."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "default": "\"md\"",
          "description": "Maximum width from the sm breakpoint up: 24rem, 32rem or 42rem. On phones it is always full width."
        },
        {
          "name": "dismissible",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "When false, Escape and backdrop clicks do nothing and the Close button is hidden. Only your footer buttons can close it."
        },
        {
          "name": "tone",
          "type": "\"default\" | \"destructive\"",
          "required": false,
          "default": "\"default\"",
          "description": "Destructive adds a danger icon and the word Destructive above the title, and sets role=\"alertdialog\"."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes added to the panel."
        }
      ],
      "variantsHint": {
        "size": [
          "sm",
          "md",
          "lg"
        ],
        "tone": [
          "default",
          "destructive"
        ]
      },
      "tokens": [
        "--surface-raised",
        "--border",
        "--scrim",
        "--shadow-lg",
        "--danger-fg",
        "--fg",
        "--fg-muted",
        "--dur-fast",
        "--dur-base",
        "--dur-slow",
        "--focus-ring",
        "--radius-overlay"
      ],
      "related": [
        "popover",
        "tool-menu",
        "callout",
        "toast",
        "command-palette"
      ],
      "composedOf": [
        "icon-button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Confirm with a footer",
          "description": "The simplest use: a title, a line of context and two buttons. The main action goes last.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <Button variant=\"secondary\" onClick={() => setOpen(true)}>Publish changes</Button>\n      <Dialog open={open} onClose={() => setOpen(false)} title=\"Publish these changes?\" description=\"People with the link will see the new version right away.\"\n        footer={<>\n          <Button variant=\"secondary\" onClick={() => setOpen(false)}>Cancel</Button>\n          <Button onClick={() => setOpen(false)}>Publish</Button>\n        </>}>\n        <p>You can go back to the earlier version from the history list.</p>\n      </Dialog>\n    </>\n  );\n}"
        },
        {
          "title": "Destructive with typed confirmation",
          "description": "Tone destructive adds a danger icon and the word Destructive to the title. The delete button stays disabled until the person types the word. Focus starts in the field.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  const [typed, setTyped] = useState(\"\");\n  const close = () => { setOpen(false); setTyped(\"\"); };\n  return (\n    <>\n      <Button variant=\"danger\" onClick={() => setOpen(true)}>Delete project</Button>\n      <Dialog open={open} onClose={close} tone=\"destructive\" size=\"sm\" title=\"Delete this project?\" description=\"All chats and files in it are removed. This cannot be undone.\"\n        footer={<>\n          <Button variant=\"secondary\" onClick={close}>Cancel</Button>\n          <Button variant=\"danger\" disabled={typed !== \"DELETE\"} onClick={close}>Delete project</Button>\n        </>}>\n        <label htmlFor=\"confirm-delete\" className=\"block text-fg\">Type DELETE to confirm</label>\n        <input id=\"confirm-delete\" value={typed} onChange={(e) => setTyped(e.target.value)} autoComplete=\"off\"\n          className=\"mt-1.5 h-9 w-full rounded-field border border-line-strong bg-surface px-3 text-fg pointer-coarse:min-h-11\" />\n      </Dialog>\n    </>\n  );\n}"
        },
        {
          "title": "Long content that scrolls",
          "description": "When the body is taller than the screen, only the body scrolls. The title and the footer stay in view. Try it on a narrow screen to see the bottom sheet.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <Button variant=\"secondary\" onClick={() => setOpen(true)}>Read the terms</Button>\n      <Dialog open={open} onClose={() => setOpen(false)} title=\"Terms of use\" size=\"lg\"\n        footer={<Button onClick={() => setOpen(false)}>I have read this</Button>}>\n        <div className=\"space-y-3 text-fg-muted\">\n          {Array.from({ length: 12 }, (_, i) => (\n            <p key={i}>Section {i + 1}. This is placeholder text for a long page. The title and the buttons stay in place while this part scrolls.</p>\n          ))}\n        </div>\n      </Dialog>\n    </>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-dialog/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/dialog.md"
    },
    {
      "id": "toast",
      "name": "Toast",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "snackbar",
        "notification",
        "toast notification",
        "flash message"
      ],
      "category": "overlays",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "paused",
          "note": "While the pointer is over a toast, focus is inside it, a finger is on it, or the page is hidden, its timer stops. It carries on with the time left, and the time-left bar holds too.",
          "source": "documented"
        },
        {
          "name": "persistent",
          "note": "A toast with duration 0 stays until it is closed. Without a duration it stays 4s for a title, 6s with a description and 8s with an action.",
          "source": "documented"
        },
        {
          "name": "updated",
          "note": "update(id, options) changes the toast in place and starts its timer again with the new content.",
          "source": "documented"
        },
        {
          "name": "time left",
          "note": "A toast with an action shows a thin bar along the bottom that shrinks until it closes. Hidden under reduced motion.",
          "source": "documented"
        },
        {
          "name": "leaving",
          "note": "A dismissed toast fades out in --dur-fast before it leaves the list. Under reduced motion it goes at once.",
          "source": "documented"
        },
        {
          "name": "queued",
          "note": "Only three toasts show at once. Older ones wait out of sight and appear when there is room.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring shows on the action and close buttons when reached by keyboard.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The action and close buttons get a tinted background under the pointer.",
          "source": "documented"
        }
      ],
      "summary": "A short message that appears at the edge of the screen and goes away by itself. Includes the provider and the useToast hook.",
      "description": "A toast confirms something that just happened, like saving or copying. Wrap your app in ToastProvider, then call show from useToast. At most three show at once, newest on top. How long it stays follows its content, and update changes a toast in place. The timer pauses while the pointer, focus or a finger is on a toast, and while the page is hidden.",
      "purpose": "Confirms a result without taking over the screen. It is for information the person can safely miss, so nothing important lives only in a toast.",
      "useFor": [
        "Confirming a quick action: saved, copied, sent.",
        "Offering Undo right after a delete.",
        "Telling the person that something finished in the background."
      ],
      "notFor": [
        {
          "text": "An error the person has to fix",
          "use": "callout"
        },
        {
          "text": "An error that replaces a whole area",
          "use": "error-state"
        },
        {
          "text": "Asking the person to approve an AI action",
          "use": "approval-prompt"
        },
        {
          "text": "Asking for permission before an AI uses a tool",
          "use": "permission-prompt"
        },
        {
          "text": "A choice that blocks the page until it is made",
          "use": "dialog"
        }
      ],
      "whenToUse": [
        "Use it for results the person can safely miss.",
        "Keep the title to a few words.",
        "Leave duration out so it follows the content. Pass 0 for an action the person must not miss.",
        "Use update to turn a Saving toast into Saved, instead of showing a second toast.",
        "Mount ToastProvider once, near the root of the app.",
        "Show an error on the page too when something failed."
      ],
      "avoid": [
        "Do not use a toast as the only place for an error or a needed decision.",
        "Do not use it to ask for approval of an AI action.",
        "Do not fire several toasts for one event.",
        "Do not use it for long text. Use a Callout or a Dialog.",
        "Do not use danger tone for ordinary results. Keep it for failures."
      ],
      "accessibility": [
        "The region has role region and the name Notifications. Its list is always on the page with aria-live polite, so new toasts are announced.",
        "A danger toast has role alert, so it is announced at once.",
        "Success, danger and info show an icon and the title, so colour is never the only cue. A neutral toast has no status to show and has no icon.",
        "The timer pauses while the pointer is over a toast, while focus is inside it, while a finger is on it, and while the page is hidden. It carries on from the time left.",
        "A toast with an action shows a thin bar along the bottom with the time left. It is hidden from screen readers and when motion is reduced.",
        "A dismissed toast fades out in --dur-fast before it leaves. When motion is reduced it goes at once.",
        "Escape on a focused control inside a toast closes it.",
        "The close button is labelled with the title and the action is a real button. Both are 44px on touch screens.",
        "More than three toasts: the newest three show and older ones wait.",
        "The region sits bottom centre on phones and bottom right from the md breakpoint. It lets clicks pass through everywhere except the toasts. Entry movement only plays when motion is not reduced."
      ],
      "accessibilityYouDo": [
        "A toast must never be the only place an error or a needed decision appears. Show it on the page as well.",
        "A toast with an action stays 8 seconds by default. If missing the action would cost the person something, pass duration 0 so it stays until dismissed.",
        "Do not put anything in a toast that the person must read in a few seconds. Pausing helps, but a toast can still be missed.",
        "Keep titles short and in plain words, because screen readers read them out as they appear.",
        "Call show from an event handler or an effect, not during render."
      ],
      "onPhone": [
        "Toasts sit at the bottom edge, fill the width up to 384px, and stay above the bottom safe area.",
        "Toasts pause their timer while the pointer is over them, while focus is inside, while a finger is held on the toast, and while the page is hidden, so touch the toast to keep a long message on screen.",
        "The action and close buttons are 44px tall on a touch screen.",
        "Up to three show at once, newest on top."
      ],
      "importPath": "import { Toast } from \"./index\"",
      "sourcePath": "src/molecules/Toast.tsx",
      "props": [
        {
          "name": "ToastProvider children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "ToastProvider props: your app. The notification region is drawn after it."
        },
        {
          "name": "ToastProvider className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes added to the fixed region, for example to change its offset."
        },
        {
          "name": "useToast().show",
          "type": "(options: ToastOptions) => string",
          "required": false,
          "default": null,
          "description": "Adds a toast and returns its id."
        },
        {
          "name": "useToast().dismiss",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Removes the toast with that id. It fades out in --dur-fast first, or goes at once under reduced motion. Does nothing if it is already gone."
        },
        {
          "name": "useToast().update",
          "type": "(id: string, options: Partial<ToastOptions>) => void",
          "required": false,
          "default": null,
          "description": "Changes a toast in place, for example Saving to Saved. Its timer starts again with the new content."
        },
        {
          "name": "options.title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The short message. Always shown."
        },
        {
          "name": "options.description",
          "type": "string",
          "required": false,
          "default": null,
          "description": "One more line of detail."
        },
        {
          "name": "options.tone",
          "type": "\"neutral\" | \"success\" | \"danger\" | \"info\"",
          "required": false,
          "default": "\"neutral\"",
          "description": "Success, danger and info add an icon. Danger toasts have role alert."
        },
        {
          "name": "options.action",
          "type": "{ label: string; onClick(): void }",
          "required": false,
          "default": null,
          "description": "A button in the toast. Pressing it runs onClick and closes the toast."
        },
        {
          "name": "options.duration",
          "type": "number",
          "required": false,
          "default": "follows content",
          "description": "Milliseconds before it closes. 0 keeps it until dismissed. Left out, it follows the content: 4000 for a title only, 6000 with a description, 8000 with an action. The exported toastDuration(options) gives the same number."
        },
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Toast component: the message."
        },
        {
          "name": "description",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Toast component: one more line of detail."
        },
        {
          "name": "tone",
          "type": "\"neutral\" | \"success\" | \"danger\" | \"info\"",
          "required": false,
          "default": "\"neutral\"",
          "description": "Toast component: icon and role, as above."
        },
        {
          "name": "action",
          "type": "{ label: string; onClick(): void }",
          "required": false,
          "default": null,
          "description": "Toast component: a button in the toast. It does not close the toast by itself."
        },
        {
          "name": "onDismiss",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Toast component: shows the close button and is called by it and by Escape. Without it neither works."
        },
        {
          "name": "timeLeft",
          "type": "{ duration: number; paused: boolean }",
          "required": false,
          "default": null,
          "description": "Toast component: shows a thin bar along the bottom that shrinks over duration milliseconds. paused holds it. Hidden from screen readers and under reduced motion. The provider passes it for toasts with an action."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Toast component: classes added to the toast."
        },
        {
          "name": "...rest",
          "type": "Omit<HTMLAttributes<HTMLDivElement>, \"title\">",
          "required": false,
          "default": null,
          "description": "Toast component: passed to the outer <div>."
        }
      ],
      "variantsHint": {
        "options.tone": [
          "neutral",
          "success",
          "danger",
          "info"
        ],
        "tone": [
          "neutral",
          "success",
          "danger",
          "info"
        ]
      },
      "tokens": [
        "--surface-raised",
        "--border",
        "--border-strong",
        "--shadow-lg",
        "--success-fg",
        "--danger-fg",
        "--info-fg",
        "--accent-fg",
        "--fg",
        "--fg-muted",
        "--dur-fast",
        "--dur-base",
        "--focus-ring",
        "--radius-overlay"
      ],
      "related": [
        "callout",
        "error-state",
        "dialog",
        "approval-prompt"
      ],
      "composedOf": [
        "icon-button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Each tone",
          "description": "Neutral, success, info and danger. Every tone except neutral has an icon, and every toast has a title, so colour is never the only cue. A danger toast is announced straight away.",
          "code": "function Buttons() {\n  const toast = useToast();\n  return (\n    <div className=\"flex flex-wrap gap-2\">\n      <Button variant=\"secondary\" onClick={() => toast.show({ title: \"Copied to clipboard\" })}>Neutral</Button>\n      <Button variant=\"secondary\" onClick={() => toast.show({ title: \"Saved\", description: \"Your changes are on the server.\", tone: \"success\" })}>Success</Button>\n      <Button variant=\"secondary\" onClick={() => toast.show({ title: \"New version available\", tone: \"info\" })}>Info</Button>\n      <Button variant=\"secondary\" onClick={() => toast.show({ title: \"Could not save\", description: \"The error is also shown on the page.\", tone: \"danger\" })}>Danger</Button>\n    </div>\n  );\n}\n\nfunction Example() {\n  return <ToastProvider><Buttons /></ToastProvider>;\n}"
        },
        {
          "title": "With an Undo action",
          "description": "Delete first, then offer Undo. The action is a real button. The toast stays for 8 seconds, the default for a toast with an action, and a thin bar along the bottom shows the time left.",
          "code": "function List() {\n  const toast = useToast();\n  const [items, setItems] = useState([\"Weekly plan\", \"Meeting notes\"]);\n  const remove = (name: string) => {\n    const at = items.indexOf(name);\n    setItems((l) => l.filter((x) => x !== name));\n    toast.show({\n      title: `Deleted \"${name}\"`, duration: 8000,\n      action: { label: \"Undo\", onClick: () => setItems((l) => [...l.slice(0, at), name, ...l.slice(at)]) },\n    });\n  };\n  return (\n    <ul className=\"flex w-64 flex-col gap-2 text-body leading-5 text-fg\">\n      {items.map((n) => (\n        <li key={n} className=\"flex items-center justify-between rounded-card border border-line px-3 py-1.5\">\n          {n}\n          <Button size=\"sm\" variant=\"ghost\" onClick={() => remove(n)}>Delete</Button>\n        </li>\n      ))}\n      {items.length === 0 && <li className=\"text-fg-muted\">Nothing left.</li>}\n    </ul>\n  );\n}\n\nfunction Example() {\n  return <ToastProvider><List /></ToastProvider>;\n}"
        },
        {
          "title": "Saving, then saved",
          "description": "Show one toast while the work runs, with duration 0 so it stays. When it ends, update changes the same toast in place. Its timer starts again, here with the 6 seconds a toast with a description gets.",
          "code": "function SaveButton() {\n  const toast = useToast();\n  const save = () => {\n    const id = toast.show({ title: \"Saving\", duration: 0 });\n    window.setTimeout(() => {\n      toast.update(id, { title: \"Saved\", description: \"Your changes are on the server.\", tone: \"success\", duration: undefined });\n    }, 1500);\n  };\n  return <Button variant=\"secondary\" onClick={save}>Save changes</Button>;\n}\n\nfunction Example() {\n  return <ToastProvider><SaveButton /></ToastProvider>;\n}"
        },
        {
          "title": "The Toast on its own",
          "description": "The Toast component has no timer and no region. Use it to draw a toast in a page, or to build your own host. Pass onDismiss to show the close button.",
          "code": "<div className=\"flex flex-col gap-3\">\n  <Toast tone=\"success\" title=\"Saved\" description=\"Your changes are on the server.\" onDismiss={() => {}} />\n  <Toast tone=\"danger\" title=\"Could not save\" action={{ label: \"Try again\", onClick: () => {} }} onDismiss={() => {}} />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-toast/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/toast.md"
    },
    {
      "id": "command-palette",
      "name": "CommandPalette",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "command menu",
        "command k",
        "cmd k",
        "spotlight",
        "quick open"
      ],
      "category": "overlays",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open or closed",
          "note": "Set with the open prop.",
          "source": "prop"
        },
        {
          "name": "closed",
          "note": "With open false nothing is drawn. The search text and active option reset the next time it opens.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "When no command matches the typed text, or none are passed, the empty text is shown and the result count says No results.",
          "source": "documented"
        },
        {
          "name": "active option",
          "note": "One option is highlighted and named by aria-activedescendant. Arrow keys, Home, End and the pointer move it.",
          "source": "documented"
        },
        {
          "name": "disabled option",
          "note": "A command with disabled is dimmed, marked aria-disabled, skipped by the arrow keys and ignored on click.",
          "source": "documented"
        },
        {
          "name": "filtered",
          "note": "Once text is typed, the list is narrowed and ranked, with label-prefix matches first, and the count is announced.",
          "source": "documented"
        }
      ],
      "summary": "A search box and a list of actions in a modal window. Type to narrow the list, then press Enter.",
      "description": "CommandPalette lets people reach any action by typing a few letters. It follows the combobox and listbox pattern: focus stays in the search field while the arrow keys move through the options. It adds no keyboard shortcut of its own, so your app decides how it opens.",
      "purpose": "Gives quick, keyboard-first access to many actions without a menu for each. Typing narrows the list and Enter runs the top choice.",
      "useFor": [
        "An app with many actions that people would rather type than hunt for.",
        "Jumping between places, or running an action from the keyboard.",
        "Showing shortcuts people can learn over time."
      ],
      "notFor": [
        {
          "text": "A short menu of actions for one item",
          "use": "tool-menu"
        },
        {
          "text": "A small panel of choices next to a button",
          "use": "popover"
        },
        {
          "text": "A confirm or a form",
          "use": "dialog"
        }
      ],
      "whenToUse": [
        "Add a visible button that opens it, as well as any shortcut.",
        "Group commands under short headings.",
        "Add keywords for the other names people might type.",
        "Show a shortcut only when it really works.",
        "Keep labels short and start them with a verb or a place."
      ],
      "avoid": [
        "Do not make it the only way to reach an action.",
        "Do not register a shortcut inside it. Your app owns that.",
        "Do not use it for a form or a confirm. Use a Dialog.",
        "Do not put long text or controls inside an option.",
        "Do not give two commands the same label."
      ],
      "accessibility": [
        "The search field is a combobox with aria-expanded, aria-controls and aria-activedescendant. The list is a listbox with options, and groups are labelled by their headings.",
        "Focus stays in the search field. Arrow Up and Down move the active option and wrap round. Home and End jump to the first and last. Enter runs the active command and closes. Escape closes.",
        "Typing filters by label, keywords and group. The count (\"5 results\") is announced in a polite live region.",
        "It is a native modal dialog, so the page behind is inert. Focus returns to the element that opened it when it closes, and the page does not scroll behind it.",
        "The active option scrolls into view inside the list.",
        "Options are 44px tall on touch screens. The active option also gets an outline in forced-colors mode.",
        "Movement only plays when motion is not reduced."
      ],
      "accessibilityYouDo": [
        "Give it a label that says what it does, such as \"Commands\".",
        "Add a visible button that opens it. A shortcut alone does not help people who cannot use the keyboard shortcut.",
        "Wire your shortcut yourself, for example Ctrl or Cmd plus K on the document, and call preventDefault on it.",
        "Make each shortcut shown in a command real. The palette only displays it.",
        "Keep command labels short and unique, because they are the options' names."
      ],
      "onPhone": [
        "The palette fills the width with a 12px margin and sits at the top, taking up to the full screen height; from 640px it is centered lower and capped at 30rem.",
        "The search field is 16px so the page does not zoom, the Esc hint is hidden on a phone, and results scroll inside the list.",
        "Result rows are 44px tall on a touch screen, and the shortcut hints on each row are hidden on a phone.",
        "There is no keyboard shortcut to open it on a phone, so give people a button that opens it."
      ],
      "importPath": "import { CommandPalette } from \"./index\"",
      "sourcePath": "src/organisms/CommandPalette.tsx",
      "props": [
        {
          "name": "open",
          "type": "boolean",
          "required": true,
          "default": null,
          "description": "Whether the palette is showing. Your code owns it. The search text resets each time it opens."
        },
        {
          "name": "onClose",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called on Escape, a click on the backdrop, and after a command is chosen (just before its onSelect)."
        },
        {
          "name": "commands",
          "type": "PaletteCommand[]",
          "required": true,
          "default": null,
          "description": "The actions. Each has id, label, onSelect, and optionally group, hint, icon, shortcut, keywords and disabled."
        },
        {
          "name": "commands[].id",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Unique key for the command."
        },
        {
          "name": "commands[].label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The name shown and searched. Matches at the start rank first."
        },
        {
          "name": "commands[].group",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Heading it is listed under. Commands without a group sit above the headings."
        },
        {
          "name": "commands[].hint",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Small helper text under the label."
        },
        {
          "name": "commands[].icon",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Shown before the label. Hidden from screen readers."
        },
        {
          "name": "commands[].shortcut",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shown as a key hint. Display only."
        },
        {
          "name": "commands[].keywords",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Extra words that find the command."
        },
        {
          "name": "commands[].disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Shown dimmed, marked aria-disabled, skipped by the arrow keys and ignored on click."
        },
        {
          "name": "commands[].onSelect",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Runs when the command is chosen with Enter or a click."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "default": "\"Type a command…\"",
          "description": "Hint text in the search field."
        },
        {
          "name": "emptyText",
          "type": "string",
          "required": false,
          "default": "\"No matching commands\"",
          "description": "Shown when nothing matches."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name of the dialog, the search field and the list."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes added to the panel."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-raised",
        "--surface-hover",
        "--border",
        "--scrim",
        "--shadow-lg",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--dur-base",
        "--focus-ring",
        "--radius-overlay"
      ],
      "related": [
        "dialog",
        "tool-menu",
        "popover",
        "kbd"
      ],
      "composedOf": [
        "kbd"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Grouped commands opened by a button",
          "description": "Commands under a group name get a heading. Typing narrows the list, matches at the start of a label come first, and a disabled command is shown but skipped by the arrow keys.",
          "code": "const commands: PaletteCommand[] = [\n  { id: \"new\", label: \"New chat\", group: \"Create\", icon: <PlusIcon width={16} height={16} />, shortcut: \"N\", keywords: [\"start\", \"conversation\"], onSelect: () => setLast(\"New chat\") },\n  { id: \"file\", label: \"Open file\", group: \"Create\", hint: \"Pick a file from your project\", icon: <FileIcon width={16} height={16} />, onSelect: () => setLast(\"Open file\") },\n  { id: \"search\", label: \"Search chats\", group: \"Go to\", icon: <SearchIcon width={16} height={16} />, shortcut: \"/\", onSelect: () => setLast(\"Search chats\") },\n  { id: \"settings\", label: \"Settings\", group: \"Go to\", icon: <SettingsIcon width={16} height={16} />, keywords: [\"preferences\"], onSelect: () => setLast(\"Settings\") },\n  { id: \"undo\", label: \"Undo last change\", group: \"Go to\", icon: <UndoIcon width={16} height={16} />, disabled: true, onSelect: () => setLast(\"Undo\") },\n];\n\nfunction Example() {\n  const [open, setOpen] = useState(false);\n  const [last, setLast] = useState(\"Nothing yet\");\n  return (\n    <div className=\"flex flex-col items-start gap-2 text-body leading-5 text-fg\">\n      <Button variant=\"secondary\" onClick={() => setOpen(true)}>Open commands <Kbd className=\"ml-1\">Ctrl K</Kbd></Button>\n      <p aria-live=\"polite\" className=\"text-fg-muted\">Last command: {last}</p>\n      <CommandPalette open={open} onClose={() => setOpen(false)} label=\"Commands\" placeholder=\"Type a command…\" commands={commands} />\n    </div>\n  );\n}"
        },
        {
          "title": "Opened with Ctrl K or Cmd K",
          "description": "The palette registers no shortcut. Your app adds one. In an app, attach the listener to the document; here it is on the box so it does not clash with this site's own search.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <div className=\"flex flex-col items-start gap-2 text-body leading-5 text-fg\"\n      onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"k\") { e.preventDefault(); e.stopPropagation(); setOpen((o) => !o); } }}>\n      <Button variant=\"secondary\" onClick={() => setOpen(true)}>Open commands</Button>\n      <p className=\"text-fg-muted\">Focus the button, then press Ctrl K or Cmd K.</p>\n      <CommandPalette open={open} onClose={() => setOpen(false)} label=\"Commands\" commands={commands} />\n    </div>\n  );\n}"
        },
        {
          "title": "No results",
          "description": "When nothing matches, or no commands are passed, the list shows the empty text. Screen readers hear \"No results\" once someone has typed.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <Button variant=\"secondary\" onClick={() => setOpen(true)}>Open empty palette</Button>\n      <CommandPalette open={open} onClose={() => setOpen(false)} label=\"Commands\" commands={[]} emptyText=\"No commands here yet\" />\n    </>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-command-palette/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/command-palette.md"
    },
    {
      "id": "date-picker",
      "name": "DatePicker",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "calendar",
        "date input",
        "date selector"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "With value null no day is selected and the line under the grid says Pick a date.",
          "source": "documented"
        },
        {
          "name": "today",
          "note": "Today has a ring around its number and aria-current set to date, so it is not shown by colour alone.",
          "source": "documented"
        },
        {
          "name": "selected",
          "note": "The chosen day is filled with the accent colour, bold, and its cell has aria-selected.",
          "source": "documented"
        },
        {
          "name": "out of range",
          "note": "Days before min or after max are dimmed, have aria-disabled, and cannot be picked by click or keyboard.",
          "source": "documented"
        },
        {
          "name": "month at limit",
          "note": "Previous month is disabled when the whole earlier month is before min, and Next month when the whole later month is after max.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "A day that can be picked gets a shaded background when the pointer is over it.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The day with focus shows the global focus ring. Only one day is in the Tab order.",
          "source": "documented"
        }
      ],
      "summary": "An inline month calendar for recent dates, with day buttons, quick Today and Yesterday, and the choice repeated in words.",
      "description": "DatePicker shows one month at a time, right in the page, not in a pop-up. It opens on the chosen day, or on today. Days outside min and max are shown but cannot be picked, so a wrong date cannot be entered. Below the grid it says the choice in words, such as the weekday, the date and how many days ago, which is easier to check than a highlighted square.",
      "purpose": "Pick one date that is almost always recent, with big targets for a thumb and a plain-words check of what was chosen.",
      "useFor": [
        "The date something happened or was reported, when it was in the last few weeks.",
        "A date that must stay inside a range, such as not in the future or not before another date.",
        "A form on a phone, where the browser's own date picker looks different on every device."
      ],
      "notFor": [
        {
          "text": "A date far in the past or future, such as a birth date, where typing is faster",
          "use": "text-field"
        },
        {
          "text": "A calendar that opens from a button as a floating panel",
          "use": "popover"
        },
        {
          "text": "A whole number, such as a day count",
          "use": "number-field"
        },
        {
          "text": "Choosing from a short fixed list",
          "use": "select"
        }
      ],
      "whenToUse": [
        "Use it for dates that are almost always recent.",
        "Set max or min when a day is not allowed, so a wrong date cannot be picked.",
        "Leave the confirmation line on. It is the quickest way to check the choice.",
        "Keep the calendar in the page, close to the question it answers."
      ],
      "avoid": [
        "Do not use it for dates years away. Typing is faster there.",
        "Do not hide days that are off. Showing them tells people the limits.",
        "Do not use it as a pop-up. It is built to sit in the page.",
        "Do not use colour alone to say which day is today or chosen. The part already adds a ring and a bold number."
      ],
      "accessibility": [
        "The days sit in a grid with column headers and rows. Each header has an abbreviation and the full weekday name for screen readers.",
        "Each day is a button with a full name such as Saturday, 20 September 2026. The cell around it has aria-selected, and today has aria-current set to date.",
        "There is one tab stop in the grid. Arrow keys move by day and week, Home and End go to the start and end of the week, Page Up and Page Down move by month, and Shift with them moves by year. Enter or Space picks the day.",
        "Days outside min and max have aria-disabled, ignore clicks, and are skipped by the keyboard. A key that would land outside the range goes to the nearest allowed day, or stays put.",
        "The month and year and the confirmation line are polite live regions, so changes are read out without moving focus.",
        "Previous month and Next month are buttons with names, and are disabled when the whole month is out of range.",
        "Today has a ring and the chosen day has a filled shape and bold number, so neither relies on colour alone. In forced colours the chosen day uses the system highlight.",
        "Day buttons are 40px, and 44px on coarse pointers."
      ],
      "accessibilityYouDo": [
        "Pass a label that says what is being picked, such as Date it happened. It is the name of the grid.",
        "Say why days are off when it is not obvious, for example in a hint under the question.",
        "Keep value in the parent and pass it back, so the confirmation line and the selected day agree.",
        "Pass today and the same value for tests and docs so the result does not change from day to day."
      ],
      "onPhone": [
        "The calendar is seven columns of 44px days on a touch screen, so it needs about 335px of width and fits a 360px phone.",
        "Month arrows and day buttons are 44px, and the Today and Yesterday buttons are 44px tall.",
        "Days are chosen by tapping; the arrow-key moves are for keyboards only.",
        "The Today and Yesterday row does not wrap."
      ],
      "importPath": "import { DatePicker } from \"./index\"",
      "sourcePath": "src/molecules/DatePicker.tsx",
      "props": [
        {
          "name": "value",
          "type": "string | null",
          "required": true,
          "default": null,
          "description": "The chosen day as an ISO date (yyyy-mm-dd), or null when nothing is chosen."
        },
        {
          "name": "onChange",
          "type": "(value: string) => void",
          "required": true,
          "default": null,
          "description": "Called with the ISO date when a day, Today or Yesterday is picked. It is never called for a disabled day."
        },
        {
          "name": "min",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Earliest day that can be picked. Earlier days are shown but disabled. The previous month button is disabled when the whole earlier month is out of range."
        },
        {
          "name": "max",
          "type": "string",
          "required": false,
          "default": "today",
          "description": "Latest day that can be picked. Later days are shown but disabled. The next month button is disabled when the whole later month is out of range."
        },
        {
          "name": "today",
          "type": "string",
          "required": false,
          "default": "the date on this device",
          "description": "Today as an ISO date. It sets the ring on today's day, the quick buttons and the days-ago words. Set it for tests and docs."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name of the calendar grid, such as Date it happened."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "default": "\"en-IN\"",
          "description": "Locale for month names, weekday names and the confirmation line. Weeks always start on Monday."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer box."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--surface-hover",
        "--accent",
        "--on-accent",
        "--dur-fast",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "field",
        "text-field",
        "select",
        "number-field",
        "popover"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With a range and quick buttons",
          "description": "Days before 15 August 2026 and after today are shown but off. Today and Yesterday sit above the grid because both are allowed.",
          "code": "function Example() {\n  const [d, setD] = useState<string | null>(null);\n  return <DatePicker label=\"Date it happened\" value={d} onChange={setD} today=\"2026-10-07\" min=\"2026-08-15\" />;\n}"
        },
        {
          "title": "With a chosen value",
          "description": "It opens on the month of the chosen day. The line under the grid says the date in words and how long ago it was.",
          "code": "function Example() {\n  const [d, setD] = useState<string | null>(\"2026-10-01\");\n  return <DatePicker label=\"Date you first complained\" value={d} onChange={setD} today=\"2026-10-07\" />;\n}"
        },
        {
          "title": "Inside a Field with a hint",
          "description": "The calendar is not a single input, so Field gives it the label and the hint. Put the ids on a wrapper and keep the grid's own label.",
          "code": "function Example() {\n  const [d, setD] = useState<string | null>(null);\n  return (\n    <div className=\"w-80 max-w-full\">\n      <Field label=\"When did it go wrong?\" hint=\"Days after today are off because this has already happened.\">\n        {({ id, \"aria-describedby\": describedBy }) => (\n          <div id={id} role=\"group\" aria-describedby={describedBy}>\n            <DatePicker label=\"Date it went wrong\" value={d} onChange={setD} today=\"2026-10-07\" />\n          </div>\n        )}\n      </Field>\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-date-picker/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/date-picker.md"
    },
    {
      "id": "combobox",
      "name": "Combobox",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "autocomplete",
        "typeahead",
        "searchable select",
        "auto complete",
        "select with search"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "error",
          "note": "Pass invalid, or aria-invalid from Field, for a danger border.",
          "source": "documented"
        },
        {
          "name": "disabled",
          "note": "Pass disabled. The box is dimmed and the list never opens.",
          "source": "documented"
        },
        {
          "name": "closed",
          "note": "The list is hidden until the box has focus and there is something to show. Escape closes it again until you type or press an arrow key.",
          "source": "documented"
        },
        {
          "name": "popular",
          "note": "Focused and empty, the popular names show with the empty hint under them.",
          "source": "documented"
        },
        {
          "name": "typing",
          "note": "Up to 6 matches show, starting-with matches first, followed by a row that keeps exactly what was typed.",
          "source": "documented"
        },
        {
          "name": "no match",
          "note": "When nothing matches, only the row that keeps the typed text shows, and the live region says there are no suggestions.",
          "source": "documented"
        },
        {
          "name": "active option",
          "note": "The row reached with the arrow keys is shaded, has a bar on its left edge and aria-selected, and is named by aria-activedescendant.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "A row is shaded when the pointer is over it.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A focus ring is drawn around the whole box when the input is reached with the keyboard.",
          "source": "documented"
        }
      ],
      "summary": "A search box with suggestions that is never a closed list: whatever is typed can be used.",
      "description": "Combobox is a text box with a list of suggestions under it. Before typing it shows popular names and a line saying any name can be typed. While typing it shows the best matches, then a last row that keeps exactly what was typed. Enter picks the top suggestion when the text is the start of its name (ama picks Amazon), and otherwise accepts the text as typed, so nobody is blocked by the list.",
      "purpose": "Help people name something quickly, such as a company or a bank, without forcing them to choose from a list that may not contain it.",
      "useFor": [
        "Asking which company, bank or shop, where the list can never be complete.",
        "A name field where common answers are one tap and any other answer is still welcome.",
        "Search where the text itself is the answer, not an id."
      ],
      "notFor": [
        {
          "text": "A fixed set where only the listed values are valid",
          "use": "select"
        },
        {
          "text": "Running commands or jumping around the product",
          "use": "command-palette"
        },
        {
          "text": "A plain text answer with no suggestions",
          "use": "text-field"
        },
        {
          "text": "A list of actions opened from a button",
          "use": "menu"
        }
      ],
      "whenToUse": [
        "Show popular names for the answers most people give.",
        "Keep the last row that accepts the typed text, so the list never blocks anyone.",
        "Use the text as the answer. Do not turn it into an id the person cannot see.",
        "Say what the last row does in the words of your product with useTypedLabel."
      ],
      "avoid": [
        "Do not use it when only the listed values are allowed. Use a Select.",
        "Do not show more than a handful of matches. The list stops at 6.",
        "Do not clear the box when the typed text is not in the list.",
        "Do not use it to run commands. A command palette has a different job."
      ],
      "accessibility": [
        "The input has role combobox with aria-expanded, aria-controls, aria-autocomplete set to list, and aria-activedescendant naming the active option. Focus stays in the input.",
        "The list has role listbox. Each row has role option, and the active row has aria-selected.",
        "Arrow Down and Arrow Up move through the rows and wrap round. Enter picks the active row. Escape closes the list, and pressing it again does nothing.",
        "With no active row, Enter picks the top suggestion when the typed text is the start of its label. Otherwise it accepts the typed text.",
        "A polite live region says how many suggestions there are, or that there are none and Enter will use what was typed.",
        "Clicking or tapping a row picks it without taking focus out of the input.",
        "Rows are 44px tall on coarse pointers. The active row has a bar on its left edge as well as a shade, and uses the system highlight in forced colours."
      ],
      "accessibilityYouDo": [
        "Pass a label. It is the accessible name of the box. Put it inside Field to also show a visible label.",
        "Spread the props from Field onto the Combobox so the hint and error are linked.",
        "Keep value in the parent. The text is the answer, so read it from there as well as from onSelect.",
        "If the answer is invalid, say why in text (Field does this with error) and also set invalid."
      ],
      "onPhone": [
        "The field is 16px on a touch screen, so the page does not zoom when it takes focus.",
        "The suggestion list opens under the field, shows at most six rows, and each row is 44px tall.",
        "The list always opens below the field, so near the bottom of a phone the on-screen keyboard can cover it; scroll the field up first.",
        "Tapping a suggestion does not take focus from the field."
      ],
      "importPath": "import { Combobox } from \"./index\"",
      "sourcePath": "src/molecules/Combobox.tsx",
      "props": [
        {
          "name": "value",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The text in the box. It is always the text, never an id."
        },
        {
          "name": "onChange",
          "type": "(value: string) => void",
          "required": true,
          "default": null,
          "description": "Called on every keystroke, and with the option's label when a suggestion is picked."
        },
        {
          "name": "options",
          "type": "{ value: string; label: string; hint?: string }[]",
          "required": true,
          "default": null,
          "description": "The suggestions. Matching uses the label only, ignoring case. hint shows small on the right of the row."
        },
        {
          "name": "popular",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Labels shown before typing. A label that matches an option uses that option's value."
        },
        {
          "name": "onSelect",
          "type": "(value: string, how: \"option\" | \"typed\") => void",
          "required": false,
          "default": null,
          "description": "Called when a suggestion is picked (how is option, with the option's value) or the typed text is accepted (how is typed, with the text)."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name of the box."
        },
        {
          "name": "placeholder",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Hint text shown while the box is empty."
        },
        {
          "name": "useTypedLabel",
          "type": "(typed: string) => string",
          "required": false,
          "default": "Use “typed”",
          "description": "Words for the last row while typing, which keeps exactly what was typed."
        },
        {
          "name": "emptyHint",
          "type": "string",
          "required": false,
          "default": "\"Not here? Type its name above.\"",
          "description": "Line shown under the popular names, saying any name can be typed."
        },
        {
          "name": "invalid",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Danger border and aria-invalid on the input. Explain the problem in text too."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Dims the box, ignores typing and keeps the list closed."
        },
        {
          "name": "id",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id of the input. Field sets it."
        },
        {
          "name": "aria-describedby",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Ids of the hint and error. Field sets it."
        },
        {
          "name": "aria-invalid",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Marks the input invalid. Field sets it when it has an error."
        },
        {
          "name": "required",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Passed to the input. Field sets it."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-raised",
        "--border",
        "--fg",
        "--fg-muted",
        "--surface-hover",
        "--shadow-md",
        "--focus-ring",
        "--danger",
        "--radius-overlay"
      ],
      "related": [
        "text-field",
        "select",
        "field",
        "command-palette",
        "menu"
      ],
      "composedOf": [
        "text-field"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Company search with popular names",
          "description": "Focus the empty box to see the popular names and the hint. Type ama and press Enter to pick Amazon.",
          "code": "function Example() {\n  const [v, setV] = useState(\"\");\n  return (\n    <div className=\"w-72 max-w-full\">\n      <Combobox label=\"Company\" placeholder=\"Search for a company\" value={v} onChange={setV} options={companies} popular={[\"Amazon\", \"Flipkart\", \"Swiggy\"]}\n        onSelect={(val, how) => console.log(how, val)} />\n    </div>\n  );\n}"
        },
        {
          "title": "Free text is accepted",
          "description": "A name that is not in the list stays as typed. The last row says so, and its words can be changed with useTypedLabel.",
          "code": "function Example() {\n  const [v, setV] = useState(\"Corner Bookshop\");\n  return (\n    <div className=\"w-72 max-w-full\">\n      <Combobox label=\"Seller\" value={v} onChange={setV} options={companies} useTypedLabel={(t) => `Use \\u201C${t}\\u201D as the seller`} />\n    </div>\n  );\n}"
        },
        {
          "title": "Inside a Field with an error",
          "description": "Field adds the label, hint and error, and passes the id and aria props to the input. The error shows until something is typed.",
          "code": "function Example() {\n  const [v, setV] = useState(\"\");\n  return (\n    <div className=\"w-72 max-w-full\">\n      <Field label=\"Bank\" hint=\"Pick one, or type its name.\" error={v.trim().length === 0 ? \"Choose or type a bank.\" : undefined} required>\n        {(a) => <Combobox {...a} label=\"Bank\" value={v} onChange={setV} options={companies} popular={[\"Amazon\", \"Flipkart\", \"Swiggy\"]} placeholder=\"Search for a bank\" />}\n      </Field>\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-combobox/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/combobox.md"
    },
    {
      "id": "inline-edit",
      "name": "InlineEdit",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "editable text",
        "click to edit",
        "in place edit"
      ],
      "category": "forms",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "reading",
          "note": "The value shows as plain text, with a visible Edit button on the right.",
          "source": "documented"
        },
        {
          "name": "editing",
          "note": "After Edit is pressed the value becomes an input with Done and Cancel buttons, and focus moves to the input with its text selected.",
          "source": "documented"
        },
        {
          "name": "edited",
          "note": "When the value differs from the original, or edited is true, an Edited tag shows next to the label.",
          "source": "documented"
        },
        {
          "name": "error",
          "note": "When validate returns a message on save, the edit stays open, the input is invalid and the message shows under it with an icon.",
          "source": "documented"
        },
        {
          "name": "saved",
          "note": "After a changed value is saved, a hidden polite live region says Saved.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "An empty value without display shows Not set in muted text.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The Edit button is shaded when the pointer is over it.",
          "source": "documented"
        }
      ],
      "summary": "A labelled value that reads as plain text and turns into an input in place when you press Edit.",
      "description": "InlineEdit is for checking, not filling in. The value shows as plain text with a visible Edit button. Pressing it turns the value into an input in the same place. Enter or Done saves, Escape cancels, and a changed value gets an Edited tag so people can see their fix registered. A validate function can keep the edit open and show a message.",
      "purpose": "Let people confirm facts that were read or worked out for them, and fix only the ones that are wrong, without a form full of empty boxes.",
      "useFor": [
        "A short list of facts to check before a next step, such as an amount, a seller and a date.",
        "A value that is usually right and only sometimes needs changing.",
        "A value shown in a special way, such as money, that turns into plain text when edited."
      ],
      "notFor": [
        {
          "text": "Collecting new information from an empty form",
          "use": "field"
        },
        {
          "text": "A number that is stepped up and down",
          "use": "number-field"
        },
        {
          "text": "A choice between a few options",
          "use": "segmented-control"
        },
        {
          "text": "Choosing from a fixed list",
          "use": "select"
        }
      ],
      "whenToUse": [
        "Show the value as plain text first. Checking is easier than typing.",
        "Keep a visible Edit button on every row.",
        "Let the Edited tag show what was fixed.",
        "Keep each value short, as a single line."
      ],
      "avoid": [
        "Do not show every value as an empty input box.",
        "Do not make the whole row the only thing you can press.",
        "Do not use the lime AI colour for the Edited tag. It is neutral on purpose.",
        "Do not use it for long text. Use a full form for that."
      ],
      "accessibility": [
        "Edit is a real button with a name such as Edit Amount, and it has a 44px tap area on coarse pointers.",
        "The input is named by the visible label. Focus moves to it when editing starts.",
        "Enter or Done saves and Escape cancels. After either, focus returns to the Edit button.",
        "An error shows under the field with a warning icon and words, is linked to the input with aria-describedby, and sets aria-invalid. The area is a polite live region.",
        "After a change is saved, a polite live region says Saved.",
        "The Edited tag is text, so it does not rely on colour. It has a border in forced colours."
      ],
      "accessibilityYouDo": [
        "Give each row a clear label. It is also the accessible name of the input.",
        "Write validate messages that say what is wrong and how to fix it.",
        "Use inputMode that matches the value, such as decimal for an amount, so phones show the right keyboard.",
        "If display shows something different from the text, make sure it still reads correctly for screen readers.",
        "Pass editLabel when Edit and the label are not enough to know what will change."
      ],
      "onPhone": [
        "The edit button keeps its size and has a 44px tap area on a touch screen, and the value wraps on the left of it.",
        "Editing swaps the value for a text box with Done and Cancel in one row, and the text box is 16px so the page does not zoom.",
        "Set inputMode so a phone shows the right keyboard, such as numbers."
      ],
      "importPath": "import { InlineEdit } from \"./index\"",
      "sourcePath": "src/molecules/InlineEdit.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Visible label, small and muted. It is also the accessible name of the input."
        },
        {
          "name": "value",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The current text."
        },
        {
          "name": "onChange",
          "type": "(value: string) => void",
          "required": true,
          "default": null,
          "description": "Called with the new text when an edit is saved and the text changed."
        },
        {
          "name": "display",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "How to show the value when not editing, such as a Money. Defaults to the text, or Not set when empty."
        },
        {
          "name": "edited",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Forces the Edited tag on or off. When left out, the part compares value with originalValue."
        },
        {
          "name": "originalValue",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The value before anyone changed it. When left out, the first value the part received is used."
        },
        {
          "name": "inputMode",
          "type": "\"text\" | \"numeric\" | \"decimal\" | \"tel\" | \"email\" | \"url\" | \"search\"",
          "required": false,
          "default": null,
          "description": "The keyboard to show on phones while editing."
        },
        {
          "name": "validate",
          "type": "(value: string) => string | null",
          "required": false,
          "default": null,
          "description": "Returns an error message, or null when the value is fine. On an error the edit stays open and the message shows under the field."
        },
        {
          "name": "editLabel",
          "type": "string",
          "required": false,
          "default": "`Edit ${label}`",
          "description": "Accessible name of the Edit button."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer row."
        }
      ],
      "variantsHint": {
        "inputMode": [
          "text",
          "numeric",
          "decimal",
          "tel",
          "email",
          "url",
          "search"
        ]
      },
      "tokens": [
        "--fg",
        "--fg-muted",
        "--border",
        "--surface-hover",
        "--surface-sunken",
        "--danger-fg",
        "--dur-fast",
        "--radius-control"
      ],
      "related": [
        "text-field",
        "field",
        "number-field",
        "segmented-control",
        "select"
      ],
      "composedOf": [
        "text-field",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A three-row fact check",
          "description": "Change one value and press Done. The Edited tag appears next to its label. The rows stack in a Stack, with a small gap, and each row draws its own divider.",
          "code": "function Example() {\n  const [amount, setAmount] = useState(\"3499\");\n  const [seller, setSeller] = useState(\"Flipkart\");\n  const [when, setWhen] = useState(\"7 Oct 2026\");\n  return (\n    <Stack gap={1} className=\"w-80 max-w-full\">\n      <InlineEdit label=\"Amount\" value={amount} onChange={setAmount} inputMode=\"decimal\" originalValue=\"3499\" display={<Money value={Number(amount) || 0} />} />\n      <InlineEdit label=\"Seller\" value={seller} onChange={setSeller} originalValue=\"Flipkart\" />\n      <InlineEdit label=\"Date\" value={when} onChange={setWhen} originalValue=\"7 Oct 2026\" />\n    </Stack>\n  );\n}"
        },
        {
          "title": "With validation",
          "description": "Clear the digits or type letters and press Done. The edit stays open and the message appears under the field with an icon and words.",
          "code": "function Example() {\n  const [v, setV] = useState(\"9876543210\");\n  return (\n    <div className=\"w-80 max-w-full\">\n      <InlineEdit label=\"Phone number\" value={v} onChange={setV} inputMode=\"tel\" validate={(x) => (/^\\d{10}$/.test(x) ? null : \"Enter 10 digits, with no spaces.\")} />\n    </div>\n  );\n}"
        },
        {
          "title": "Custom display and edit label",
          "description": "display shows the value as large money. editLabel gives the button a longer name for screen readers.",
          "code": "function Example() {\n  const [v, setV] = useState(\"1250\");\n  return (\n    <div className=\"w-80 max-w-full\">\n      <InlineEdit label=\"Refund amount\" value={v} onChange={setV} inputMode=\"decimal\" editLabel=\"Change the refund amount\" originalValue=\"1250\"\n        display={<span className=\"text-title-xs leading-7\"><Money value={Number(v) || 0} /></span>} validate={(x) => (x.trim() !== \"\" && !Number.isNaN(Number(x)) ? null : \"Enter a number, like 1250.\")} />\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-inline-edit/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/inline-edit.md"
    },
    {
      "id": "question-set",
      "name": "QuestionSet",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "questionnaire",
        "clarifying questions",
        "ask user question",
        "multi step form",
        "wizard",
        "survey",
        "form",
        "follow up questions"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "focus",
          "note": "A visible focus ring appears on the focused tab, option, input or button. Only the selected tab is in the Tab order.",
          "source": "documented"
        },
        {
          "name": "answered tab",
          "note": "A tab whose question has an answer shows a check instead of its number, and screen readers hear \"answered\".",
          "source": "documented"
        },
        {
          "name": "option selected",
          "note": "The chosen option gets a stronger border, a check and aria-pressed.",
          "source": "documented"
        },
        {
          "name": "something else",
          "note": "Choosing Something else reveals a text, number or date input under the options and moves focus into it.",
          "source": "documented"
        },
        {
          "name": "button waiting",
          "note": "Until the current question is answered the button looks dimmed and is marked aria-disabled. Pressing it does nothing.",
          "source": "documented"
        },
        {
          "name": "error",
          "note": "When errors has a message for a question, the card jumps to it and shows the message with an icon. The input gets a danger border and aria-invalid, and the tab shows an alert icon.",
          "source": "documented"
        },
        {
          "name": "optional",
          "note": "A question with required set to false shows Optional beside it and does not block the final button.",
          "source": "documented"
        }
      ],
      "summary": "One card that asks every missing fact at once, one question at a time, with a single final button.",
      "description": "QuestionSet is for when an agent has read the request once and needs several facts. It asks them all in one card instead of one message each. A row of tabs shows the questions. Tapping an option answers and moves to the next unanswered question. One button says Next until the last answer, then shows the final action.",
      "purpose": "Read once, ask once. The person answers everything the agent is missing in one place, and the agent gets one submit, not a string of follow-ups.",
      "useFor": [
        "An agent that is missing two or more facts before it can act.",
        "Short answers: a pick from a few options, a number, a date, or a few words.",
        "Facts the agent already has, listed above the questions so the person does not repeat them."
      ],
      "notFor": [
        {
          "text": "A single question in the middle of a task",
          "use": "agent-question-card"
        },
        {
          "text": "Approving or denying an action",
          "use": "approval-prompt"
        },
        {
          "text": "A list of things to tick off",
          "use": "checklist"
        }
      ],
      "whenToUse": [
        "Ask everything in one card, after the agent has read the request once.",
        "Offer two or three likely options, and use input for the rest.",
        "Put what you already know in known so the person never repeats it.",
        "Replace the card with a Receipt once it is submitted.",
        "Mark questions that can be skipped with required: false."
      ],
      "avoid": [
        "Do not ask one question per message when you can ask them together.",
        "Do not use it for a yes or no on a risky action. Use ApprovalPrompt.",
        "Do not ask for passwords or codes here.",
        "Do not pile on questions. If the list grows long, split the work.",
        "Do not rely on the amber border alone to say a person must act. The badge says it in words."
      ],
      "accessibility": [
        "The tabs follow the WAI-ARIA tabs pattern through the Tabs part: arrow keys, Home and End move between tabs, and only the selected tab is in the Tab order.",
        "Each tab shows a number or a check, and screen readers hear whether it is answered, not answered yet, or needs another answer. Colour is never the only cue.",
        "Only the current question's panel is shown. A polite live region says \"Question 2 of 3\" and the tab name when the question changes.",
        "Options are buttons that report aria-pressed. After you tap an option, focus moves to the next tab, or to the final button when the set is complete.",
        "Enter in a text, number or date input does the same as the button. The button uses aria-disabled until the current question is answered, so it can still be reached.",
        "An error shows an icon and text in a role=alert region, and the input gets aria-invalid and is described by the message.",
        "Options and the button are 44px tall on coarse pointers."
      ],
      "accessibilityYouDo": [
        "Keep each tab label short and different from the others. It is the tab's name for screen readers.",
        "Write error messages that say what to change, such as \"That is more than the order total\". The card shows them with an icon and words.",
        "After submit, replace the card with a Receipt so the thread stays short, and move focus to something sensible.",
        "To use a date picker, pass renderInput. The built-in date input is the browser's own."
      ],
      "onPhone": [
        "Only the current question shows its name in the step row on a phone, so every step fits; the others show a number or tick and keep their name for screen readers.",
        "Options are one column on a phone and two columns from the sm breakpoint, and each option is at least 44px tall.",
        "The date answer fills the width on a phone and is capped at 14rem from sm up.",
        "After a tap on an option the set moves to the next open question and moves focus with it, so the on-screen keyboard only appears for questions that take typing."
      ],
      "importPath": "import { QuestionSet } from \"./index\"",
      "sourcePath": "src/organisms/QuestionSet.tsx",
      "props": [
        {
          "name": "questions",
          "type": "QuestionSetQuestion[]",
          "required": true,
          "default": null,
          "description": "The questions. Each has id, label (the tab name), question, optional options ({ value, label, description? }), optional input ({ kind: \"text\" | \"number\" | \"date\", placeholder?, prefix? }) and optional required (default true). Two more are optional: multiple: true lets several options be picked (the answer joins their values with commas; read them with answerValues), and recommended names the option to suggest, marked Recommended and listed first."
        },
        {
          "name": "answers",
          "type": "Record<string, string>",
          "required": true,
          "default": null,
          "description": "The answer for each question id. Empty or missing means unanswered."
        },
        {
          "name": "onAnswer",
          "type": "(id: string, value: string) => void",
          "required": true,
          "default": null,
          "description": "Called when an option is tapped or something is typed. A number is passed as text."
        },
        {
          "name": "onSubmit",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "The single final action. Called by the button on the question that completes the set."
        },
        {
          "name": "known",
          "type": "{ label: string; value: ReactNode }[]",
          "required": false,
          "default": null,
          "description": "Facts the agent already has, shown above the questions as Already have."
        },
        {
          "name": "errors",
          "type": "Record<string, string>",
          "required": false,
          "default": null,
          "description": "Messages for answers the parent found invalid. The card jumps to the first one and shows the message under it. Clear a message by setting it to an empty string."
        },
        {
          "name": "renderInput",
          "type": "(q: QuestionSetQuestion, value: string, onChange: (value: string) => void) => ReactNode",
          "required": false,
          "default": null,
          "description": "Replaces the built-in input when a question's input is showing. Use it to drop in a DatePicker or any other control."
        },
        {
          "name": "submitLabel",
          "type": "string",
          "required": false,
          "default": "\"Continue\"",
          "description": "Words on the button for the question that completes the set."
        },
        {
          "name": "nextLabel",
          "type": "string",
          "required": false,
          "default": "\"Next\"",
          "description": "Words on the button while questions remain."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"A few quick questions\"",
          "description": "The card heading."
        },
        {
          "name": "runName",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Name of the run that is asking, shown beside the badge."
        },
        {
          "name": "showProgress",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Shows \"Question 2 of 3\" above the question."
        },
        {
          "name": "allowSkip",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Offers Skip on questions that are not required. The answer becomes QS_SKIPPED."
        },
        {
          "name": "allowAgentDecides",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Offers You decide on every question, so the assistant picks. The answer becomes QS_AGENT_DECIDES."
        },
        {
          "name": "review",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Shows every answer for a last look, each with a Change link, before the final button."
        },
        {
          "name": "onUseDefaults",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Use the defaults, which ends the questions so the assistant fills in the rest."
        },
        {
          "name": "labels",
          "type": "Partial<Record<\"back\" | \"skip\" | \"agentDecides\" | \"recommended\" | \"useDefaults\" | \"review\" | \"change\" | \"skipped\" | \"agentDecided\" | \"of\" | \"question\", string>>",
          "required": false,
          "default": null,
          "description": "Words on the buttons and labels, for localisation."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the card."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--attention",
        "--surface",
        "--border",
        "--border-strong",
        "--surface-sunken",
        "--danger-fg",
        "--fg",
        "--fg-muted",
        "--focus-ring",
        "--dur-fast",
        "--radius-field",
        "--radius-card"
      ],
      "related": [
        "agent-question-card",
        "approval-prompt",
        "checklist",
        "tabs",
        "number-field",
        "text-field"
      ],
      "composedOf": [
        "badge",
        "button",
        "tabs",
        "text-field",
        "number-field"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "An agent's clarifying questions",
          "description": "One question at a time with options, as agent tools ask them. The suggested option is marked Recommended and listed first. The trust question takes several answers. Every question offers You decide, the optional one offers Skip, and Use the defaults ends the questions. Number keys pick an option. Before the final button, a last look lists every answer with a Change link.",
          "code": "const questions = [\n  { id: \"kind\", label: \"Kind\", question: \"What are you building?\", recommended: \"chat\", options: [\n    { value: \"chat\", label: \"Chat assistant\", description: \"People ask, it answers with sources\" },\n    { value: \"agent\", label: \"Agent console\", description: \"It plans, acts and asks before big steps\" },\n  ] },\n  { id: \"trust\", label: \"Trust\", question: \"What helps people trust the answers? Pick any.\", multiple: true, recommended: \"sources\", options: [\n    { value: \"sources\", label: \"Sources\" }, { value: \"feedback\", label: \"Feedback\" }, { value: \"approvals\", label: \"Approvals\" },\n  ] },\n  { id: \"mode\", label: \"Look\", question: \"Light, dark or both?\", required: false, recommended: \"both\", options: [\n    { value: \"both\", label: \"Both, following the device\" }, { value: \"dark\", label: \"Dark only\" },\n  ] },\n];\n\nfunction Example() {\n  const [answers, setAnswers] = useState<Record<string, string>>({});\n  return (\n    <QuestionSet title=\"Three quick questions\" questions={questions} answers={answers}\n      onAnswer={(id, v) => setAnswers((a) => ({ ...a, [id]: v }))}\n      allowAgentDecides allowSkip review onUseDefaults={useDefaults}\n      onSubmit={() => send({ ...answers, trust: answerValues(answers.trust) })} submitLabel=\"Start building\" />\n  );\n}"
        },
        {
          "title": "Three questions with a number and a date",
          "description": "Each question has options and a Something else choice. The amount takes a number with a rupee sign. The date takes a date. After submit the parent swaps the card for a Receipt.",
          "code": "const questions = [\n  { id: \"why\", label: \"Reason\", question: \"Why are you asking for a refund?\", options: [\n    { value: \"damaged\", label: \"It arrived damaged\", description: \"Broken, torn or not working\" },\n    { value: \"wrong\", label: \"Wrong item\", description: \"Not what you ordered\" },\n  ], input: { kind: \"text\", placeholder: \"Tell us in a few words\" } },\n  { id: \"amount\", label: \"Amount\", question: \"How much did you pay?\", options: [\n    { value: \"2340\", label: \"₹2,340\" },\n    { value: \"1200\", label: \"₹1,200\" },\n  ], input: { kind: \"number\", prefix: \"₹\" } },\n  { id: \"date\", label: \"Date\", question: \"When did it arrive?\", options: [\n    { value: \"today\", label: \"Today\" },\n    { value: \"yesterday\", label: \"Yesterday\" },\n  ], input: { kind: \"date\" } },\n];\n\nfunction Example() {\n  const [answers, setAnswers] = useState<Record<string, string>>({});\n  const [done, setDone] = useState(false);\n  if (done) {\n    return (\n      <Receipt title=\"Answered 3 questions\" meta=\"Just now\">\n        <ul className=\"m-0 list-none space-y-1 p-0 text-body leading-5\">{questions.map((q) => <li key={q.id}><span className=\"text-fg-muted\">{q.label}: </span>{answers[q.id]}</li>)}</ul>\n      </Receipt>\n    );\n  }\n  return (\n    <QuestionSet title=\"Three quick questions\" runName=\"Refund request\" questions={questions} answers={answers}\n      onAnswer={(id, v) => setAnswers((a) => ({ ...a, [id]: v }))} onSubmit={() => setDone(true)} submitLabel=\"Send to the agent\" />\n  );\n}"
        },
        {
          "title": "With facts already known",
          "description": "Pass known to list what the agent already has. It shows above the tabs so the person does not type it again.",
          "code": "// \"questions\" is the same list as the first example; here only the last two are used.\nfunction Example() {\n  const [answers, setAnswers] = useState<Record<string, string>>({});\n  return (\n    <QuestionSet title=\"Two things I could not find\" questions={questions.slice(1)} answers={answers}\n      onAnswer={(id, v) => setAnswers((a) => ({ ...a, [id]: v }))} onSubmit={() => {}}\n      known={[{ label: \"Order\", value: \"#48213\" }, { label: \"Item\", value: \"Kettle\" }, { label: \"Reason\", value: \"Arrived damaged\" }]} />\n  );\n}"
        },
        {
          "title": "An answer sent back",
          "description": "The answers start filled in, with an amount that is too high. Press Continue: the parent reports the problem through errors, and the card jumps to that question and shows the message.",
          "code": "// \"questions\" is the same list as the first example.\nfunction Example() {\n  const [answers, setAnswers] = useState<Record<string, string>>({ why: \"damaged\", amount: \"99999\", date: \"yesterday\" });\n  const [errors, setErrors] = useState<Record<string, string>>({});\n  return (\n    <QuestionSet title=\"Check your answers\" questions={questions} answers={answers} errors={errors}\n      onAnswer={(id, v) => { setAnswers((a) => ({ ...a, [id]: v })); setErrors((e) => ({ ...e, [id]: \"\" })); }}\n      onSubmit={() => setErrors(Number(answers.amount) > 5000 ? { amount: \"That is more than the order total of ₹2,340. Check the amount.\" } : {})} />\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-question-set/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/question-set.md"
    },
    {
      "id": "activity-trace",
      "name": "ActivityTrace",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "activity feed",
        "trace",
        "steps log"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "streaming",
          "note": "The list is held open, the summary button ignores presses, and each new row slides in and is announced politely.",
          "source": "documented"
        },
        {
          "name": "open or closed",
          "note": "The step list shows under the summary and the chevron turns down.",
          "source": "documented"
        },
        {
          "name": "folded",
          "note": "Only the summary line shows, with the chevron pointing right and aria-expanded false.",
          "source": "documented"
        },
        {
          "name": "step running",
          "note": "A step with status running shows a spinner (still when motion is reduced) and the word Running for screen readers.",
          "source": "documented"
        },
        {
          "name": "step failed",
          "note": "A failed step shows an X, the word Failed and a danger-coloured title.",
          "source": "documented"
        },
        {
          "name": "step pending",
          "note": "A pending step shows an empty circle and a muted title.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears on the summary button.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The summary row gets a soft background under the pointer when not streaming.",
          "source": "documented"
        }
      ],
      "summary": "The agent's work shown step by step, each step tagged with who did it, then folded to one line.",
      "description": "ActivityTrace lists what the agent did, in order. Every step has a tag in words that says who did the work: AI, Rule sheet, Calculated, Safety check or You. While work is arriving the list stays open and new rows slide in. When it is done it folds to one line that the person can open again.",
      "purpose": "Show how an answer was reached without filling the thread. The tag on each step tells the reader how much to trust it: an AI judgment can be wrong, a rule sheet lookup was checked, a calculation was done by code, and a safety check is a guardrail.",
      "useFor": [
        "An agent that reads, looks things up and calculates before it answers.",
        "A thread that must stay short once the work is finished.",
        "Showing which steps were the AI's judgment and which were fixed rules or code."
      ],
      "notFor": [
        {
          "text": "A plan the person approves step by step",
          "use": "step-timeline"
        },
        {
          "text": "One tool call with its input and result",
          "use": "tool-call-card"
        },
        {
          "text": "The model's free-form reasoning text",
          "use": "thinking-block"
        },
        {
          "text": "Any content that only needs to open and close",
          "use": "collapsible"
        }
      ],
      "whenToUse": [
        "Tag every step honestly. Use AI only for judgments the model made.",
        "Fold the trace when the work ends so the thread stays short.",
        "Keep titles to one short line and put extra words in detail.",
        "Show a failed step and say what failed."
      ],
      "avoid": [
        "Do not tag a model guess as Rule sheet or Calculated. That would make it look more certain than it is.",
        "Do not use it for steps the person must approve. Use StepTimeline or ApprovalPrompt.",
        "Do not put long reasoning text in a step. Use ThinkingBlock.",
        "Do not rely on the status icon colour alone. Keep the words."
      ],
      "accessibility": [
        "The folded line is a real button with aria-expanded and aria-controls, and the chevron is decorative.",
        "Steps are an ordered list. Each status icon has a word for screen readers: Waiting, Running, Done or Failed. A failed step also shows the word Failed.",
        "Each step has a text tag, so who did it is never shown by colour alone.",
        "While streaming, a polite live region reads out each new step once. A trace that mounts already full is not read out.",
        "While streaming, the button is marked aria-disabled and the list stays open.",
        "The spinner and sliding rows only move when the system allows motion. The button is 44px tall on coarse pointers."
      ],
      "accessibilityYouDo": [
        "Write step titles as short sentences that make sense when read out one after another.",
        "Keep summary specific, such as \"Read your message · 8 steps\", because it is the button's name.",
        "Set status on every step while streaming, so the running one is marked.",
        "Set streaming back to false when the work ends, so the person can fold the list."
      ],
      "onPhone": [
        "The summary row is at least 44px tall on touch screens and spans the full width.",
        "Summaries, step titles and details wrap onto new lines instead of being cut off.",
        "Opening and closing needs a tap, not hover."
      ],
      "importPath": "import { ActivityTrace } from \"./index\"",
      "sourcePath": "src/organisms/ActivityTrace.tsx",
      "props": [
        {
          "name": "steps",
          "type": "{ id: string; actor: \"ai\" | \"rule\" | \"calculated\" | \"check\" | \"you\"; title: string; detail?: ReactNode; status?: \"pending\" | \"running\" | \"done\" | \"failed\" }[]",
          "required": true,
          "default": null,
          "description": "The steps in order. status defaults to done."
        },
        {
          "name": "summary",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The one line shown when folded, such as \"Read your message · 8 steps\". It is also the button's name."
        },
        {
          "name": "streaming",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "True while steps are still arriving. The list is held open, new rows slide in (when motion is allowed) and each new step is announced."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Start open. Ignored when open is passed."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Controlled open state."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when the person opens or folds the list."
        },
        {
          "name": "actorLabels",
          "type": "Partial<Record<TraceActor, string>>",
          "required": false,
          "default": null,
          "description": "Rename the tags. The defaults are AI, Rule sheet, Calculated, Safety check and You."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the outer box."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--border-strong",
        "--fg-muted",
        "--fg-subtle",
        "--success-fg",
        "--danger-fg",
        "--surface-hover",
        "--dur-fast",
        "--animate-rise",
        "--lime",
        "--radius-card"
      ],
      "related": [
        "step-timeline",
        "tool-call-card",
        "thinking-block",
        "collapsible",
        "provenance-popover",
        "ai-label"
      ],
      "composedOf": [
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Folded, then open",
          "description": "Folded, the trace is one line. Press it to see every step. The second trace starts open.",
          "code": "const steps = [\n  { id: \"1\", actor: \"ai\", title: \"Read your message\", detail: \"You said the kettle arrived with a cracked lid.\" },\n  { id: \"2\", actor: \"rule\", title: \"Looked up the return window\", detail: \"Damaged items can be returned within 30 days.\" },\n  { id: \"3\", actor: \"calculated\", title: \"Worked out the refund\", detail: \"₹2,340 minus ₹0 shipping = ₹2,340\" },\n  { id: \"4\", actor: \"check\", title: \"Checked the amount against the order\" },\n];\n\n<div className=\"space-y-3\">\n  <ActivityTrace steps={steps} summary=\"Read your message · 4 steps\" />\n  <ActivityTrace steps={steps} summary=\"Read your message · 4 steps\" defaultOpen />\n</div>"
        },
        {
          "title": "Streaming",
          "description": "A step arrives every 700 milliseconds. While streaming the list is open, the newest step is marked running, and each row is announced. At the end the summary changes and the person can fold it. With reduced motion on, rows appear without sliding.",
          "code": "function Example() {\n  const [count, setCount] = useState(1);\n  const [run, setRun] = useState(0);\n  useEffect(() => {\n    if (count >= liveSteps.length) return;\n    const t = setTimeout(() => setCount((c) => c + 1), 700);\n    return () => clearTimeout(t);\n  }, [count, run]);\n  const streaming = count < liveSteps.length;\n  const shown = liveSteps.slice(0, count).map((s, i) => (streaming && i === count - 1 ? { ...s, status: \"running\" as const } : s));\n  return (\n    <div className=\"space-y-3\">\n      <ActivityTrace steps={shown} streaming={streaming} summary={streaming ? \"Working on it…\" : `Finished · ${liveSteps.length} steps`} />\n      <Button variant=\"secondary\" size=\"sm\" onClick={() => { setCount(1); setRun((r) => r + 1); }}>Restart</Button>\n    </div>\n  );\n}"
        },
        {
          "title": "A failed step",
          "description": "A failed step shows an X, the word Failed and a detail line. Pending steps show an empty circle. The icons and words carry the meaning, not the colour.",
          "code": "<ActivityTrace defaultOpen summary=\"Could not finish · 3 steps\" steps={[\n  { id: \"1\", actor: \"ai\", title: \"Read your message\" },\n  { id: \"2\", actor: \"rule\", title: \"Looked up the return window\", status: \"failed\", detail: \"The rule sheet could not be reached. Try again.\" },\n  { id: \"3\", actor: \"calculated\", title: \"Work out the refund\", status: \"pending\" },\n]} />"
        },
        {
          "title": "Controlled",
          "description": "Pass open and onOpenChange when the parent needs to know, for example to fold every trace at once.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <div className=\"space-y-3\">\n      <ActivityTrace steps={steps} summary=\"Read your message · 4 steps\" open={open} onOpenChange={setOpen} />\n      <p className=\"text-small leading-4 text-fg-muted\">The parent says it is {open ? \"open\" : \"folded\"}.</p>\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-activity-trace/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/activity-trace.md"
    },
    {
      "id": "receipt",
      "name": "Receipt",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "confirmation",
        "order summary",
        "summary"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open or closed",
          "note": "The children show under the row, the chevron turns down and aria-expanded is true.",
          "source": "documented"
        },
        {
          "name": "closed",
          "note": "Only the row shows. The children are not mounted unless keepMounted is set.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The row gets a soft background under the pointer.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring appears on the row when reached with the keyboard.",
          "source": "documented"
        }
      ],
      "summary": "Collapse a card the person already answered into one line. It opens again to show the full card.",
      "description": "Receipt keeps a thread short. Once a person has acted on a card, the card is replaced by one line: a check, what was settled and a short note on the right. Pressing the line opens the original card in place. Nothing is mounted until it is opened, unless you ask for it.",
      "purpose": "The pattern: every decision card in a thread becomes a Receipt after the person acts. The thread then reads as a short list of what happened, and the detail is still one press away.",
      "useFor": [
        "A question card, approval or form that the person has finished.",
        "A step the agent finished that the person may want to check later.",
        "Any card that matters while it is open and only for reference afterwards."
      ],
      "notFor": [
        {
          "text": "A section that opens and closes as part of the page",
          "use": "collapsible"
        },
        {
          "text": "A card the person still has to act on",
          "use": "approval-prompt"
        },
        {
          "text": "A history of events over time",
          "use": "timeline"
        }
      ],
      "whenToUse": [
        "Turn a decision card into a Receipt as soon as the person acts.",
        "Name the receipt for what happened, not for the card.",
        "Put the one fact people look for in meta, such as the amount."
      ],
      "avoid": [
        "Do not use a Receipt for something the person still has to do.",
        "Do not hide a failure inside a closed Receipt. Show it.",
        "Do not nest Receipts."
      ],
      "accessibility": [
        "The whole row is one button with aria-expanded and aria-controls pointing at the opened region.",
        "The opened content is a region named by the button, so screen readers announce what it is.",
        "The check and chevron are decorative. The title is the button's name.",
        "Opening does not move or trap focus. Focus stays on the button.",
        "The row is at least 40px tall, and 44px on coarse pointers."
      ],
      "accessibilityYouDo": [
        "Write the title as what happened, in the past tense, such as \"Confirmed the facts\".",
        "Keep meta short. It is read out as part of the button."
      ],
      "onPhone": [
        "The header row is at least 44px tall on touch screens and spans the full width.",
        "The title wraps, while the meta text on the right keeps its size and does not wrap.",
        "It opens with a tap, and whatever you put inside needs to fit a phone width."
      ],
      "importPath": "import { Receipt } from \"./index\"",
      "sourcePath": "src/molecules/Receipt.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "What was settled, such as \"Confirmed the facts\"."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The full card, shown when opened."
        },
        {
          "name": "meta",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Short note on the right, such as a date or an amount."
        },
        {
          "name": "icon",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Replaces the default check. It is decorative and hidden from screen readers."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Start open. Ignored when open is passed."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Controlled open state."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when the person opens or closes the receipt."
        },
        {
          "name": "keepMounted",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Keep children mounted, hidden, while closed. Use it to keep state inside the card."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the outer box."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--surface-hover",
        "--success-fg",
        "--fg-muted",
        "--fg-subtle",
        "--dur-fast",
        "--radius-card"
      ],
      "related": [
        "collapsible",
        "agent-question-card",
        "approval-prompt",
        "timeline"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Closed",
          "description": "The default is one line. The check is decorative; the title says what happened.",
          "code": "<div className=\"w-full max-w-md\">\n  <Receipt title=\"Confirmed the facts\" meta=\"₹2,340\">\n    <p className=\"text-body leading-5 text-fg-muted\">Order #48213, kettle, arrived damaged, paid ₹2,340.</p>\n  </Receipt>\n</div>"
        },
        {
          "title": "Open",
          "description": "Use defaultOpen to start open. The full card shows below the line, and the chevron turns.",
          "code": "<div className=\"w-full max-w-md\">\n  <Receipt title=\"Confirmed the facts\" meta=\"Today\" defaultOpen>\n    <p className=\"text-body leading-5 text-fg-muted\">Order #48213, kettle, arrived damaged, paid ₹2,340.</p>\n  </Receipt>\n</div>"
        },
        {
          "title": "Inside a thread",
          "description": "Settled cards become Receipts between messages, so the conversation stays short. The second one is controlled.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <div className=\"flex w-full max-w-md flex-col gap-3 text-body leading-5\">\n      <p className=\"self-end rounded-card bg-sunken px-4 py-2\">My kettle arrived broken.</p>\n      <Receipt title=\"Read your message\" meta=\"8 steps\">\n        <p className=\"text-fg-muted\">The agent read your message and found the order.</p>\n      </Receipt>\n      <Receipt title=\"Confirmed the facts\" meta=\"₹2,340\" open={open} onOpenChange={setOpen}>\n        <p className=\"text-fg-muted\">Order #48213, kettle, arrived damaged, paid ₹2,340.</p>\n      </Receipt>\n      <p className=\"rounded-card border border-line px-4 py-2\">I have sent the request. You will hear back in two days.</p>\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-receipt/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/receipt.md"
    },
    {
      "id": "sheet",
      "name": "Sheet",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "drawer",
        "bottom sheet",
        "side sheet",
        "off canvas",
        "panel"
      ],
      "category": "overlays",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open or closed",
          "note": "The page behind is inert and cannot scroll, and focus moves into the sheet. Focus returns to the opener on close.",
          "source": "documented"
        },
        {
          "name": "closed",
          "note": "With open false nothing is drawn and the page behaves as normal.",
          "source": "documented"
        },
        {
          "name": "dragging",
          "note": "On a bottom sheet, holding the handle moves the sheet down with the pointer. Past 80px, or a fast flick, it closes. Otherwise it springs back.",
          "source": "documented"
        },
        {
          "name": "closing",
          "note": "After open turns false the sheet slides back out (about a third of a second) before it is removed, and focus returns to the opener once it has gone. With reduced motion it is removed at once.",
          "source": "documented"
        },
        {
          "name": "scrolling body",
          "note": "When the body is taller than the sheet, only the body scrolls and the title and footer stay in view.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring shows on the handle, the Close button, footer buttons and body controls when reached by keyboard.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The Close button on a side sheet gets a tinted background under the pointer.",
          "source": "documented"
        }
      ],
      "summary": "A panel that slides over the page from the bottom, right or left edge, with a drag handle on bottom sheets.",
      "description": "Sheet is a modal surface built on the native dialog element. The page behind it cannot be reached or scrolled while it is open. A bottom sheet has a handle you can drag down to close, and the same handle is a Close button for keyboard and screen readers. Side sheets run the full height of the screen.",
      "purpose": "Shows a short task or a set of details on top of the page without leaving it. It feels close to the page behind it, so it suits quick looks and short decisions.",
      "useFor": [
        "A case summary or detail view that opens from a list on a phone.",
        "A side panel for filters, settings or order details.",
        "A step the person must finish, with dismissing turned off and buttons in the footer."
      ],
      "notFor": [
        {
          "text": "A short question that needs a yes or no",
          "use": "dialog"
        },
        {
          "text": "A panel that stays beside the page while people keep working",
          "use": "canvas-panel"
        },
        {
          "text": "A list of actions on one item",
          "use": "menu"
        }
      ],
      "whenToUse": [
        "Keep the content short enough to scan in one sitting.",
        "Put the main action last in the footer.",
        "Use a bottom sheet on phones and a side sheet for details beside a list.",
        "Give the sheet a title that says what it is, such as Case summary."
      ],
      "avoid": [
        "Do not stack one sheet on another.",
        "Do not use it for a one-line confirmation. Use a Dialog.",
        "Do not turn off dismissing without a visible button that closes it.",
        "Do not put long forms in a bottom sheet. Use a page."
      ],
      "accessibility": [
        "It is a native dialog opened with showModal, so the page behind is inert and Tab stays inside.",
        "The title is the heading and the name of the dialog.",
        "Focus moves to the first control in the body or footer, or to the sheet itself, and returns to the opener on close.",
        "Escape closes it when dismissible is true.",
        "On a bottom sheet, the drag handle is a button named Close, so a keyboard or screen reader user can close it without dragging.",
        "Page scrolling is locked while it is open.",
        "Sliding only happens when the person has not asked for reduced motion.",
        "Touch targets of the handle and the side sheet Close button grow to 44px on touch screens."
      ],
      "accessibilityYouDo": [
        "Pass a title that names the sheet. It is the heading and the accessible name.",
        "Set open to false when onClose is called, or the sheet cannot close.",
        "When dismissible is false, give people a visible way out in the footer."
      ],
      "onPhone": [
        "A bottom sheet rises from the screen edge, is capped at 40, 65 or 90 percent of the screen height by size, and scrolls inside its body.",
        "Drag the handle down more than 80px, or flick it, to close. The handle is 44px tall on a touch screen, and a tap on it also closes.",
        "The footer buttons stack in a column on a phone and sit in a row from 640px wide, and the footer keeps clear of the home bar with the bottom safe area.",
        "Side sheets have no drag, leave a 40px strip of the page showing, and close with a 44px button or a tap on that strip."
      ],
      "importPath": "import { Sheet } from \"./index\"",
      "sourcePath": "src/organisms/Sheet.tsx",
      "props": [
        {
          "name": "open",
          "type": "boolean",
          "required": true,
          "default": null,
          "description": "Whether the sheet is showing. Fully controlled."
        },
        {
          "name": "onClose",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called when the sheet asks to close: Escape, a scrim tap, the Close control or a drag past the limit. Set open to false in response."
        },
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Rendered as the heading and used as the sheet's name through aria-labelledby."
        },
        {
          "name": "side",
          "type": "\"bottom\" | \"right\" | \"left\"",
          "required": false,
          "default": "\"bottom\"",
          "description": "Which edge the sheet slides in from."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The body. It scrolls when it is taller than the sheet."
        },
        {
          "name": "footer",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Usually buttons, pinned under the body."
        },
        {
          "name": "dismissible",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "When false, Escape, a scrim tap and dragging do nothing and the Close control is not drawn."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\"",
          "required": false,
          "default": "\"md\"",
          "description": "Width of a side sheet. Height cap of a bottom sheet, as a share of the screen height (40, 65 or 90 percent)."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the sheet panel."
        }
      ],
      "variantsHint": {
        "side": [
          "bottom",
          "right",
          "left"
        ],
        "size": [
          "sm",
          "md",
          "lg"
        ]
      },
      "tokens": [
        "--scrim",
        "--surface-raised",
        "--border",
        "--border-strong",
        "--ease-out",
        "--dur-slow",
        "--shadow-lg",
        "--radius-overlay"
      ],
      "related": [
        "dialog",
        "canvas-panel",
        "menu",
        "tabs"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Bottom sheet with a case summary",
          "description": "Opens from the bottom edge. Drag the handle down, press Escape, tap the scrim or use the Close handle to dismiss it.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <Button variant=\"secondary\" onClick={() => setOpen(true)}>Open case summary</Button>\n      <Sheet open={open} onClose={() => setOpen(false)} title=\"Case summary\"\n        footer={<Button onClick={() => setOpen(false)}>Done</Button>}>\n        <div className=\"flex flex-col gap-3\">\n          <Countdown deadline=\"2026-10-27\" today=\"2026-10-07\" start=\"2026-09-27\" label=\"They must reply by\" />\n          <p className=\"text-fg-muted\">Flipkart, <Money value={2340} calculated /> refund requested.</p>\n        </div>\n      </Sheet>\n    </>\n  );\n}"
        },
        {
          "title": "Side sheet on the right",
          "description": "Full height, sliding in from the right. Use size to choose the width.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <Button variant=\"secondary\" onClick={() => setOpen(true)}>Open details</Button>\n      <Sheet open={open} onClose={() => setOpen(false)} side=\"right\" title=\"Order details\" size=\"sm\">\n        <dl className=\"grid grid-cols-[auto_1fr] gap-x-4 gap-y-2\">\n          <dt className=\"text-fg-muted\">Item</dt><dd>Aero 12 blender</dd>\n          <dt className=\"text-fg-muted\">Bought</dt><dd><Mono>2 Sep 2026</Mono></dd>\n          <dt className=\"text-fg-muted\">Paid</dt><dd><Money value={2340} /></dd>\n        </dl>\n      </Sheet>\n    </>\n  );\n}"
        },
        {
          "title": "Not dismissible, with a footer",
          "description": "Escape, the scrim and dragging do nothing, and there is no Close control. Only the footer buttons close it.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <Button variant=\"secondary\" onClick={() => setOpen(true)}>Review before sending</Button>\n      <Sheet open={open} onClose={() => setOpen(false)} dismissible={false} title=\"Send this email?\"\n        footer={<>\n          <Button variant=\"secondary\" onClick={() => setOpen(false)}>Not yet</Button>\n          <Button onClick={() => setOpen(false)}>Send</Button>\n        </>}>\n        <p>Nothing is sent until you press Send. Escape, a tap outside and dragging do not close this sheet.</p>\n      </Sheet>\n    </>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-sheet/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/sheet.md"
    },
    {
      "id": "draft-preview",
      "name": "DraftPreview",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "preview",
        "draft",
        "review before send"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open or closed",
          "note": "Only the first previewLines lines show, with a fade at the bottom. This is the starting state unless defaultOpen or open is set.",
          "source": "documented"
        },
        {
          "name": "expanded",
          "note": "The full text shows, the fade is gone and the button reads Show less.",
          "source": "documented"
        },
        {
          "name": "fits",
          "note": "When the text is no longer than the preview lines, there is no fade and no button.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "A visible focus ring shows on the expand button when reached by keyboard.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The expand button text is underlined under the pointer.",
          "source": "documented"
        }
      ],
      "summary": "A drafted message shown like an email, with headers and a body that opens up to the full text.",
      "description": "DraftPreview shows text an AI wrote on a person's behalf in the shape of the real thing: To, From and Subject rows, then the body. The body shows the first few lines and fades out until the person chooses to read the rest. It never opens by itself.",
      "purpose": "Makes sure nobody approves a message they have not read. The draft looks like what it is, and the full text is one press away.",
      "useFor": [
        "An email or letter an agent drafted, shown before it is sent.",
        "A message with To, From and Subject lines.",
        "Any long generated text that needs a read-before-approve step."
      ],
      "notFor": [
        {
          "text": "A hidden section of ordinary content",
          "use": "collapsible"
        },
        {
          "text": "The approve and deny buttons themselves",
          "use": "approval-prompt"
        },
        {
          "text": "A note or warning in the page",
          "use": "callout"
        }
      ],
      "whenToUse": [
        "Show the real To, From and Subject so the draft looks like the sent message.",
        "Tell people what happens next in the footer, such as when it is sent.",
        "Keep approval off until the person has had the chance to open the draft."
      ],
      "avoid": [
        "Do not open it for the person. It stays collapsed until they choose.",
        "Do not give a made-up word count.",
        "Do not use it for text the person wrote themselves."
      ],
      "accessibility": [
        "The collapsed text is only clipped by size and a fade. Screen readers still read all of it.",
        "The fade is hidden from screen readers.",
        "The expand control is a real button with aria-expanded and aria-controls pointing at the body.",
        "Headers are a definition list, so each label is tied to its value.",
        "The button is at least 44px tall on touch screens.",
        "When the text fits in the preview lines, no button is drawn."
      ],
      "accessibilityYouDo": [
        "Put the approve button after the preview, so it comes later in the reading order.",
        "Pass a wordCount that is true, since it appears in the expand button."
      ],
      "onPhone": [
        "It shows the first few lines (4 by default) with a fade, and the show more button is at least 44px tall on a touch screen.",
        "The header rows and the text break long words, so an address or link cannot push the card wider than the screen.",
        "The line count is measured again when the width changes, so the button disappears if the text fits."
      ],
      "importPath": "import { DraftPreview } from \"./index\"",
      "sourcePath": "src/molecules/DraftPreview.tsx",
      "props": [
        {
          "name": "headers",
          "type": "{ label: string; value: ReactNode }[]",
          "required": false,
          "default": null,
          "description": "Rows such as To, From and Subject, drawn as a labelled definition list above the body."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The body text."
        },
        {
          "name": "wordCount",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Shown in the expand button, such as Read all 95 words."
        },
        {
          "name": "previewLines",
          "type": "number",
          "required": false,
          "default": "4",
          "description": "How many lines show before the text fades out."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Starting state when you do not control open."
        },
        {
          "name": "open",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Controlled open state. Pair it with onOpenChange."
        },
        {
          "name": "onOpenChange",
          "type": "(open: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called with the new state when the button is pressed."
        },
        {
          "name": "expandLabel",
          "type": "(words: number | undefined) => string",
          "required": false,
          "default": "Read all N words",
          "description": "Text of the button while collapsed. Receives wordCount."
        },
        {
          "name": "collapseLabel",
          "type": "string",
          "required": false,
          "default": "\"Show less\"",
          "description": "Text of the button while open."
        },
        {
          "name": "footer",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "A line under the message, such as Nothing goes out without your tap."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer card."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--fg",
        "--fg-muted",
        "--dur-fast",
        "--radius-card"
      ],
      "related": [
        "collapsible",
        "approval-prompt",
        "callout"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Email style with headers",
          "description": "The headers sit above the body. Only the first four lines show until the person presses the button.",
          "code": "<DraftPreview\n  className=\"w-full max-w-md\"\n  headers={[\n    { label: \"To\", value: \"support@aerohome.example\" },\n    { label: \"From\", value: \"meera@example.com\" },\n    { label: \"Subject\", value: \"Refund request for Aero 12 blender\" },\n  ]}\n  wordCount={102}\n  footer=\"Sent from your email. Nothing goes out without your tap.\"\n>\n  {`Hello Priya, I bought the Aero 12 blender on 2 September and it stopped working after four days. The motor hums but the blades do not turn. I have attached the invoice and a short video. Under the Consumer Protection Act I am asking for a full refund of ₹2,340 to my original payment method. Please reply by 27 October 2026. If I do not hear back by then I will raise the matter with the consumer helpline. I would much prefer to settle this directly, so please tell me what you need from me. Thank you for your time, Meera Shah`}\n</DraftPreview>"
        },
        {
          "title": "Short and plain",
          "description": "A text that fits in the preview lines shows in full, and no expand button is drawn.",
          "code": "<DraftPreview className=\"w-full max-w-md\">\n  Thanks for the update. I will wait for your reply until 27 October.\n</DraftPreview>"
        },
        {
          "title": "Controlled open state",
          "description": "Your code keeps the open value, so it can, for example, keep Approve off until the draft has been opened.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <div className=\"flex w-full max-w-md flex-col gap-3\">\n      <DraftPreview\n        headers={[\n          { label: \"To\", value: \"support@aerohome.example\" },\n          { label: \"From\", value: \"meera@example.com\" },\n          { label: \"Subject\", value: \"Refund request for Aero 12 blender\" },\n        ]}\n        wordCount={102} open={open} onOpenChange={setOpen}\n      >\n        {`Hello Priya, I bought the Aero 12 blender on 2 September and it stopped working after four days. The motor hums but the blades do not turn. I have attached the invoice and a short video. Under the Consumer Protection Act I am asking for a full refund of ₹2,340 to my original payment method. Please reply by 27 October 2026. If I do not hear back by then I will raise the matter with the consumer helpline. I would much prefer to settle this directly, so please tell me what you need from me. Thank you for your time, Meera Shah`}\n      </DraftPreview>\n      <Button disabled={!open} onClick={() => {}}>Approve and send</Button>\n      <p className=\"text-small leading-4 text-fg-muted\">{open ? \"Read in full. You can approve now.\" : \"Approve stays off until the draft is opened.\"}</p>\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-draft-preview/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/draft-preview.md"
    },
    {
      "id": "countdown",
      "name": "Countdown",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "timer",
        "deadline",
        "time remaining"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "days left",
          "note": "The default. The date is large and the days left are written in words.",
          "source": "documented"
        },
        {
          "name": "due today",
          "note": "When the deadline is today the line reads Due today.",
          "source": "documented"
        },
        {
          "name": "missed",
          "note": "After the deadline the missed label shows with an icon, followed by the days past.",
          "source": "documented"
        },
        {
          "name": "needs action",
          "note": "With needsAction, an amber chip shows. If the deadline is today or past, the border and bar turn amber too.",
          "source": "documented"
        },
        {
          "name": "no date",
          "note": "If the deadline cannot be read as a date, an em dash shows in place of the date and the days line is left out.",
          "source": "documented"
        }
      ],
      "summary": "A deadline as a large date, the days left in words, and an optional bar of time used.",
      "description": "Countdown puts the date first and biggest, then says how many days are left in words: 20 days left, Due today, or 3 days past. Pass a start date to add a thin bar of time used. After the deadline it shows a label with an icon, and it turns amber only when the page says a person must act.",
      "purpose": "Answers the first question about a case: by when. The date is the largest text and the days left are written out, so nothing depends on colour or a bar.",
      "useFor": [
        "A reply-by or pay-by date at the top of a case.",
        "A deadline with a start date, to show how much time has gone.",
        "A missed deadline that now needs a person to take a step."
      ],
      "notFor": [
        {
          "text": "A plain number with how it changed",
          "use": "stat-tile"
        },
        {
          "text": "How much of an allowance is used",
          "use": "meter"
        },
        {
          "text": "A status label such as Overdue in a row",
          "use": "badge"
        }
      ],
      "whenToUse": [
        "Put it near the top, since the date is the first thing people look for.",
        "Pass start when the person needs to see how much time has gone.",
        "Use needsAction only when the next step belongs to a person."
      ],
      "avoid": [
        "Do not use amber because a date is close. Use it when a person must act.",
        "Do not rely on the bar. The days are always written in words.",
        "Do not use it for dates measured in hours or minutes."
      ],
      "accessibility": [
        "The card is a group named by the label.",
        "The date is a time element with the ISO date.",
        "Days left, due today and days past are written in words, so colour is never the only cue.",
        "A missed deadline shows an icon and the missed label.",
        "The time bar is a meter with the text N of M days used.",
        "Dates are worked out from the calendar day only, so time zones and daylight saving cannot shift the count."
      ],
      "accessibilityYouDo": [
        "Give a label that says whose date it is, such as They must reply by.",
        "Pass needsAction only when a person has to act. Amber is not decoration.",
        "Pass today in docs and tests so the days left do not change under you."
      ],
      "onPhone": [
        "It is a card that fills its container, with the date in large text that breaks anywhere if it is too long.",
        "The days left are in words and the bar is a picture of the same thing, so a small screen loses nothing.",
        "Nothing in it is tapped or hovered."
      ],
      "importPath": "import { Countdown } from \"./index\"",
      "sourcePath": "src/molecules/Countdown.tsx",
      "props": [
        {
          "name": "deadline",
          "type": "string",
          "required": true,
          "default": null,
          "description": "ISO date, such as 2026-10-27. Only the calendar day is used."
        },
        {
          "name": "today",
          "type": "string",
          "required": false,
          "default": null,
          "description": "ISO date to count from. Defaults to today on the device. Pass it in docs and tests."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "What the date is, such as They must reply by. Also the name of the group."
        },
        {
          "name": "start",
          "type": "string",
          "required": false,
          "default": null,
          "description": "ISO date the clock started. Turns on the time bar."
        },
        {
          "name": "unit",
          "type": "\"days\"",
          "required": false,
          "default": "\"days\"",
          "description": "Only days for now."
        },
        {
          "name": "missedLabel",
          "type": "string",
          "required": false,
          "default": "\"Deadline passed\"",
          "description": "Shown with an icon once the deadline has passed."
        },
        {
          "name": "formatDate",
          "type": "(iso: string) => string",
          "required": false,
          "default": "long date in en-IN",
          "description": "Turns the ISO date into the large text."
        },
        {
          "name": "needsAction",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Adds the amber chip. When the deadline is today or past it also adds an amber border and bar. Leave it off unless a person must act."
        },
        {
          "name": "actionText",
          "type": "string",
          "required": false,
          "default": "\"Needs action\"",
          "description": "Text of the amber chip."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer card."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--attention",
        "--attention-soft",
        "--attention-fg",
        "--fg",
        "--fg-muted",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "stat-tile",
        "meter",
        "badge",
        "callout"
      ],
      "composedOf": [
        "meter"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With a time bar",
          "description": "A start date adds the bar. The bar has the words 10 of 30 days used for screen readers.",
          "code": "<Countdown className=\"w-full max-w-sm\" deadline=\"2026-10-27\" today=\"2026-10-07\" start=\"2026-09-27\" label=\"They must reply by\" />"
        },
        {
          "title": "Past the deadline",
          "description": "The missed label comes with an icon. The amber treatment shows only because needsAction is passed.",
          "code": "<Countdown className=\"w-full max-w-sm\" deadline=\"2026-10-04\" today=\"2026-10-07\" start=\"2026-09-04\" label=\"They had to reply by\" needsAction />"
        },
        {
          "title": "Due today",
          "description": "On the day itself it reads Due today. Without needsAction it stays in plain ink.",
          "code": "<Countdown className=\"w-full max-w-sm\" deadline=\"2026-10-07\" today=\"2026-10-07\" label=\"Reply due\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-countdown/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/countdown.md"
    },
    {
      "id": "status-line",
      "name": "StatusLine",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "status bar",
        "status text",
        "breadcrumb"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the current prop.",
          "source": "prop"
        },
        {
          "name": "truncated",
          "note": "When the items do not fit the width, the line is cut off with an ellipsis and stays on one row.",
          "source": "documented"
        }
      ],
      "summary": "One quiet line of status for the top of a screen, such as Case, Flipkart, an amount and Waiting.",
      "description": "StatusLine joins a few short pieces with a middle dot and keeps them on one line. One piece can be marked as the current state, which stays in normal ink while the others are muted. When there is not enough room, the line cuts off with an ellipsis instead of wrapping.",
      "purpose": "Tells the person where they are and what state it is in, in one line that never takes more than one row.",
      "useFor": [
        "The top of a screen, as a sticky header child.",
        "A case line with a name, an amount and its state.",
        "A breadcrumb-like summary that does not need links."
      ],
      "notFor": [
        {
          "text": "A single state label inside a row",
          "use": "badge"
        },
        {
          "text": "Moving between sections",
          "use": "tabs"
        }
      ],
      "whenToUse": [
        "Keep each item to a word or two, with the state last.",
        "Put the line in a sticky header so it stays in view.",
        "Give the width from the parent. It shrinks and truncates by itself."
      ],
      "avoid": [
        "Do not let it wrap to two lines.",
        "Do not use it to announce changes. It is a plain element, not a live region.",
        "Do not use colour to show the state. The current item is marked by weight and aria-current."
      ],
      "accessibility": [
        "It is a group named by label, with a hidden Status: prefix read first.",
        "It is not a live region, so it does not speak on every render.",
        "The separator dots are hidden from screen readers.",
        "The current item is marked with aria-current and a heavier weight, not colour alone."
      ],
      "accessibilityYouDo": [
        "Keep it short. A truncated item is cut off visually, and the full text is still read aloud.",
        "Do not put links or buttons in the items that you need people to find. Use real navigation for that."
      ],
      "onPhone": [
        "It stays on one line and cuts the end with an ellipsis, so on a phone the last items can disappear.",
        "Nothing shows the cut text, so put the current state early or keep the items short.",
        "It has no tap action."
      ],
      "importPath": "import { StatusLine } from \"./index\"",
      "sourcePath": "src/atoms/StatusLine.tsx",
      "props": [
        {
          "name": "items",
          "type": "ReactNode[]",
          "required": true,
          "default": null,
          "description": "The pieces of the line, in order. A middle dot is drawn between them."
        },
        {
          "name": "current",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Index of the current state. It keeps the normal ink, turns medium weight and is marked aria-current. The other items are muted."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Status\"",
          "description": "Accessible name of the group."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the line."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg",
        "--fg-muted",
        "--fg-subtle"
      ],
      "related": [
        "badge",
        "tabs",
        "text-field"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With a current item",
          "description": "The current state keeps the normal ink and a heavier weight. The others are muted.",
          "code": "<StatusLine items={[\"Case\", \"Flipkart\", \"₹2,340\", \"Waiting\"]} current={3} />"
        },
        {
          "title": "Truncating in a narrow box",
          "description": "The line stays on one row and ends with an ellipsis when it does not fit.",
          "code": "<div className=\"w-48 border border-line p-2\">\n  <StatusLine items={[\"Case\", \"Flipkart Internet Private Limited\", \"₹2,340\", \"Waiting for a reply\"]} current={3} />\n</div>"
        },
        {
          "title": "No current item",
          "description": "Without current, every item is in normal ink.",
          "code": "<StatusLine label=\"Order\" items={[\"Order 4821\", \"Aero 12 blender\", \"Delivered 6 Sep\"]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-status-line/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/status-line.md"
    },
    {
      "id": "money",
      "name": "Money",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "currency",
        "amount",
        "price format"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "not a number",
          "note": "A value that is not a finite number shows an em dash, never NaN.",
          "source": "documented"
        },
        {
          "name": "negative",
          "note": "A negative amount shows the true minus sign before the currency.",
          "source": "documented"
        }
      ],
      "summary": "An amount that code worked out, in mono with tabular figures, in rupees by default.",
      "description": "Money shows a number in a mono face with figures of equal width, so it looks calculated and not guessed by an AI. It formats with the locale, so rupees read as ₹2,34,000. It can use lakh and crore short forms, a sign, and an arrow with the up or down colour. Mono is a smaller sibling for dates and ids.",
      "purpose": "Marks a figure as something code counted. Mono and a dotted underline say it is exact, and a hidden note tells screen readers the same.",
      "useFor": [
        "Refund amounts, totals and balances.",
        "A change in an amount, with an arrow and a sign.",
        "A date or id that code computed (use Mono)."
      ],
      "notFor": [
        {
          "text": "A headline number with its trend",
          "use": "stat-tile"
        },
        {
          "text": "Many amounts in rows and columns",
          "use": "data-table"
        },
        {
          "text": "A trend over time",
          "use": "sparkline"
        }
      ],
      "whenToUse": [
        "Use it for every figure that code calculated.",
        "Pass the currency and locale when the money is not rupees.",
        "Use Mono for dates and ids that were computed."
      ],
      "avoid": [
        "Do not use it for numbers the AI guessed.",
        "Do not use direction colours without the arrow and sign. Direction adds both for you.",
        "Do not print a raw number next to a currency sign yourself."
      ],
      "accessibility": [
        "The amount is plain text, so it is read as written.",
        "With direction, the arrow is hidden and a hidden word Up or Down is read before the sign and amount.",
        "A negative amount uses the true minus sign.",
        "The calculated note is linked with aria-describedby and is not shown as a tooltip.",
        "A value that is not a number shows an em dash, never NaN."
      ],
      "accessibilityYouDo": [
        "Use direction only when the sign and arrow add meaning, and do not rely on its colour alone.",
        "Do not use Money for amounts the AI estimated. The mono look promises that code worked it out."
      ],
      "onPhone": [
        "It is plain text in a monospace font with no fixed width, so it takes the size of the text around it.",
        "Use the compact prop for tight spaces, which gives lakh and crore forms for INR.",
        "The note that code calculated the amount is read by screen readers only, so a phone shows just the dotted underline."
      ],
      "importPath": "import { Money } from \"./index\"",
      "sourcePath": "src/atoms/Money.tsx",
      "props": [
        {
          "name": "value",
          "type": "number",
          "required": true,
          "default": null,
          "description": "The amount. A value that is not a finite number shows an em dash."
        },
        {
          "name": "currency",
          "type": "string",
          "required": false,
          "default": "\"INR\"",
          "description": "ISO 4217 currency code."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "default": "\"en-IN\"",
          "description": "Locale for grouping and symbols."
        },
        {
          "name": "compact",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Short form. Lakh (L) and crore (Cr) for INR, K and M through Intl for others."
        },
        {
          "name": "showSign",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Prefix + for a positive number. A negative number always shows the true minus sign."
        },
        {
          "name": "direction",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "For a non-zero number, adds an arrow, a sign and the up or down text colour."
        },
        {
          "name": "calculated",
          "type": "boolean | string",
          "required": false,
          "default": null,
          "description": "Adds a dotted underline and a hidden note. A string replaces the default phrase Calculated by code."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the span."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--font-mono",
        "--up-fg",
        "--down-fg",
        "--fg-subtle"
      ],
      "related": [
        "stat-tile",
        "data-table",
        "sparkline",
        "badge"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Rupees",
          "description": "Formatted with the en-IN locale, so groups follow the lakh pattern.",
          "code": "<p className=\"text-fg\">Refund of <Money value={234000} /> and a fee of <Money value={2340.5} />.</p>"
        },
        {
          "title": "Compact",
          "description": "Lakh and crore for rupees. Other currencies use K, M and so on.",
          "code": "<p className=\"text-fg\"><Money value={120000} compact /> · <Money value={34000000} compact /> · <Money value={1250} currency=\"USD\" locale=\"en-US\" compact /></p>"
        },
        {
          "title": "Signed, with direction",
          "description": "The arrow and the sign carry the meaning. The colour is extra.",
          "code": "<p className=\"flex gap-4 text-fg\"><Money value={5400} direction /> <Money value={-1200} direction /> <Money value={0} direction showSign /></p>"
        },
        {
          "title": "Calculated note",
          "description": "A dotted underline for sighted people, and the phrase Calculated by code for screen readers.",
          "code": "<p className=\"text-fg\">Total <Money value={234000} calculated /> and <Mono>27 Oct 2026</Mono></p>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-money/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/money.md"
    },
    {
      "id": "price",
      "name": "Price",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "price tag",
        "quote",
        "live price"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "first render",
          "note": "No arrow and no tint show until the value changes for the first time.",
          "source": "documented"
        },
        {
          "name": "flashing",
          "note": "For about 600 ms after a change the background tints green or red. It is skipped with reduced motion.",
          "source": "documented"
        },
        {
          "name": "direction held",
          "note": "The arrow for the last change stays until the next change.",
          "source": "documented"
        },
        {
          "name": "not a number",
          "note": "A value that is not a finite number shows an em dash, never NaN.",
          "source": "documented"
        }
      ],
      "summary": "A live price that tints briefly and shows an arrow for its last change.",
      "description": "Price shows one number that changes over time. When the value changes it tints green or red for a moment and shows an arrow for the direction. The arrow stays until the next change, so the cue never depends on the tint. Figures use equal widths so the number does not jump.",
      "purpose": "Shows a number that keeps changing, and makes each change easy to notice without relying on colour.",
      "useFor": [
        "A share price or rate that updates while the page is open.",
        "A live balance or quote inside a card or a table row.",
        "Any number where the last move up or down matters."
      ],
      "notFor": [
        {
          "text": "A fixed amount that does not change",
          "use": "money"
        },
        {
          "text": "A headline number with a trend and a comparison",
          "use": "stat-tile"
        },
        {
          "text": "The shape of change over time",
          "use": "sparkline"
        }
      ],
      "whenToUse": [
        "Give it a label so the number has a name.",
        "Keep the arrow on. It is the cue that works without colour.",
        "Set the currency and locale when the price is not in rupees."
      ],
      "avoid": [
        "Do not use it for amounts that never change. Use Money.",
        "Do not turn announce on for every row of a table.",
        "Do not rely on the tint to show direction."
      ],
      "accessibility": [
        "The number is plain text. The label and the words Up or Down are read before it.",
        "The arrow is hidden from screen readers. The words Up and Down replace it.",
        "The tint only shows when motion is allowed. With reduced motion the arrow still shows.",
        "With announce on, a polite live region repeats the value, at most once every 5 seconds.",
        "A value that is not a finite number shows an em dash, never NaN."
      ],
      "accessibilityYouDo": [
        "Pass label so the number has a name, for example Reliance price.",
        "Turn on announce only for one or two key numbers. Many live regions are noisy.",
        "Do not feed it faster than people can read. Throttle updates where you can."
      ],
      "onPhone": [
        "It never wraps and keeps the number on one line, so make sure its container is wide enough for the longest price.",
        "It has no hover or tap behaviour. The brief colour flash on a change is the same on a phone."
      ],
      "importPath": "import { Price } from \"./index\"",
      "sourcePath": "src/atoms/Price.tsx",
      "props": [
        {
          "name": "value",
          "type": "number",
          "required": true,
          "default": null,
          "description": "The price. A value that is not a finite number shows an em dash."
        },
        {
          "name": "currency",
          "type": "string",
          "required": false,
          "default": "\"INR\"",
          "description": "ISO 4217 currency code."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "default": "\"en-IN\"",
          "description": "Locale for grouping and symbols."
        },
        {
          "name": "decimals",
          "type": "number",
          "required": false,
          "default": "2",
          "description": "Number of decimal places."
        },
        {
          "name": "showDirection",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Shows an arrow before the number for the direction of the last change. It stays until the next change."
        },
        {
          "name": "flash",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Tints the background for about 600 ms on a change. Only when the person allows motion."
        },
        {
          "name": "announce",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Announces the new value in a hidden polite live region, at most once every 5 seconds."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Accessible name, such as Reliance price. Read before the number."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the span."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--up-soft",
        "--down-soft",
        "--up-fg",
        "--down-fg",
        "--dur-slow"
      ],
      "related": [
        "money",
        "stat-tile",
        "sparkline",
        "badge"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Simulated feed",
          "description": "A random walk every 1.2 seconds. Press Start to see the tint and the arrow.",
          "code": "function PriceFeedDemo() {\n  const [running, setRunning] = useState(false);\n  const [value, setValue] = useState(2480.5);\n  useEffect(() => {\n    if (!running) return;\n    const id = setInterval(() => setValue((v) => Math.round((v + (Math.random() - 0.5) * 12) * 100) / 100), 1200);\n    return () => clearInterval(id);\n  }, [running]);\n  return (\n    <div className=\"flex items-center gap-4\">\n      <Price value={value} label=\"Sample price\" className=\"text-title-sm leading-8 text-fg\" />\n      <Button variant=\"secondary\" size=\"sm\" onClick={() => setRunning(!running)}>{running ? \"Stop feed\" : \"Start feed\"}</Button>\n    </div>\n  );\n}"
        },
        {
          "title": "Static",
          "description": "No change yet, so there is no arrow and no tint. It never flashes on first render.",
          "code": "<p className=\"text-title-xs leading-7 text-fg\"><Price value={2480.5} label=\"Sample price\" /></p>"
        },
        {
          "title": "Announce on",
          "description": "Screen readers hear the new value in a polite message, at most once every 5 seconds.",
          "code": "function PriceAnnounceDemo() {\n  const [value, setValue] = useState(1312.4);\n  return (\n    <div className=\"flex items-center gap-4\">\n      <Price value={value} announce label=\"Sample price\" className=\"text-title-xs leading-7 text-fg\" />\n      <Button variant=\"secondary\" size=\"sm\" onClick={() => setValue(Math.round((value + (Math.random() - 0.4) * 20) * 100) / 100)}>Change price</Button>\n    </div>\n  );\n}"
        },
        {
          "title": "Dollars, no arrow",
          "description": "Another currency and locale, with the arrow and the tint turned off.",
          "code": "<p className=\"text-title-xs leading-7 text-fg\"><Price value={182.3} currency=\"USD\" locale=\"en-US\" showDirection={false} flash={false} /></p>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-price/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/price.md"
    },
    {
      "id": "price-change",
      "name": "PriceChange",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "delta",
        "change percent",
        "trend"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "flat",
          "note": "When both numbers are zero, a dash replaces the arrow and flatLabel replaces the numbers if it is set.",
          "source": "documented"
        },
        {
          "name": "not a number",
          "note": "If no shown number is finite, the part shows an em dash.",
          "source": "documented"
        }
      ],
      "summary": "A signed change with an arrow, as an amount, a percent or both.",
      "description": "PriceChange shows how far a price moved. It draws an arrow, a plus or a true minus sign, and the number. Up uses the up colour and down uses the down colour, but the arrow and the sign carry the meaning. When nothing changed it shows a dash.",
      "purpose": "Shows the size and direction of a move in a way that does not depend on colour.",
      "useFor": [
        "The change beside a price in a list or a ticker.",
        "A day change in a table column.",
        "A percent move on its own."
      ],
      "notFor": [
        {
          "text": "A headline number with its comparison text",
          "use": "stat-tile"
        },
        {
          "text": "An amount that is not a change",
          "use": "money"
        },
        {
          "text": "Many changes in rows and columns",
          "use": "data-table"
        }
      ],
      "whenToUse": [
        "Pass the real signed change and let the part add the sign.",
        "Use percent only in tight spaces.",
        "Set flatLabel when a zero change needs a word."
      ],
      "avoid": [
        "Do not show a change as colour alone.",
        "Do not type the arrow or the minus yourself.",
        "Do not use it for a total. Use Money."
      ],
      "accessibility": [
        "The words Up, Down or Unchanged are read before the numbers.",
        "The arrow is an svg hidden from screen readers.",
        "Direction shows as an arrow and a sign, with the colour as extra. Down uses the true minus sign.",
        "A value that is not a finite number shows an em dash, never NaN."
      ],
      "accessibilityYouDo": [
        "Pass the change as a signed number. Do not add your own plus or minus signs.",
        "Say what the change is measured against nearby, for example Today."
      ],
      "onPhone": [
        "It never wraps and keeps the arrow and the numbers on one line, so make sure its container is wide enough.",
        "It has no hover or tap behaviour, and the direction is shown with an arrow and words, not colour alone."
      ],
      "importPath": "import { PriceChange } from \"./index\"",
      "sourcePath": "src/atoms/PriceChange.tsx",
      "props": [
        {
          "name": "change",
          "type": "number",
          "required": true,
          "default": null,
          "description": "The change as an absolute amount. Its sign sets the direction. If it is 0, the sign of percent is used."
        },
        {
          "name": "percent",
          "type": "number",
          "required": false,
          "default": null,
          "description": "The change in percent, for example 1.14. Shown with a percent sign."
        },
        {
          "name": "currency",
          "type": "string",
          "required": false,
          "default": "\"INR\"",
          "description": "ISO 4217 currency code for the amount."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "default": "\"en-IN\"",
          "description": "Locale for grouping and symbols."
        },
        {
          "name": "decimals",
          "type": "number",
          "required": false,
          "default": "2",
          "description": "Decimal places for the amount and the percent."
        },
        {
          "name": "format",
          "type": "\"both\" | \"percent\" | \"amount\"",
          "required": false,
          "default": "\"both\"",
          "description": "Which numbers to show. Both shows the amount and the percent in brackets."
        },
        {
          "name": "flatLabel",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shown instead of the numbers when the change is flat, next to a dash."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the span."
        }
      ],
      "variantsHint": {
        "format": [
          "both",
          "percent",
          "amount"
        ]
      },
      "tokens": [
        "--up-fg",
        "--down-fg",
        "--fg-muted"
      ],
      "related": [
        "money",
        "stat-tile",
        "data-table",
        "badge"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Up, down and flat",
          "description": "The arrow and the sign show the direction. Zero shows a dash and a label.",
          "code": "<p className=\"flex flex-wrap gap-4 text-body leading-5\"><PriceChange change={32.5} percent={1.14} /> <PriceChange change={-18.4} percent={-0.47} /> <PriceChange change={0} percent={0} flatLabel=\"No change\" /></p>"
        },
        {
          "title": "Percent only",
          "description": "Use the percent format when space is tight.",
          "code": "<p className=\"flex gap-4 text-body leading-5\"><PriceChange change={32.5} percent={1.14} format=\"percent\" /> <PriceChange change={-18.4} percent={-0.47} format=\"percent\" /></p>"
        },
        {
          "title": "In a table row",
          "description": "A symbol, a price and a change on one row. The column lines up because the figures are tabular.",
          "code": "<div className=\"grid w-full max-w-sm grid-cols-[1fr_auto_auto] items-baseline gap-x-4 gap-y-2 text-body leading-5 text-fg\">\n  <span>TCS</span><span className=\"font-mono tabular-nums\">3,921.60</span><PriceChange change={-18.4} format=\"amount\" />\n  <span>ITC</span><span className=\"font-mono tabular-nums\">462.30</span><PriceChange change={3.1} format=\"amount\" />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-price-change/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/price-change.md"
    },
    {
      "id": "ticker-tape",
      "name": "TickerTape",
      "level": "molecule",
      "since": "0.6",
      "aka": [
        "marquee",
        "stock ticker",
        "scrolling ticker"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "paused",
          "note": "Pass paused, or press the button. The strip stops and the button reads Play ticker.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The strip stops while the pointer is over it.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The strip stops while focus is inside it. The button shows a visible focus ring.",
          "source": "documented"
        },
        {
          "name": "reduced motion",
          "note": "The strip does not move. It is one row you can scroll sideways, with no second copy.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "With no items the strip stays still and shows only the button.",
          "source": "documented"
        }
      ],
      "summary": "A looping strip of symbols, prices and changes, with a visible Pause button.",
      "description": "TickerTape scrolls a row of prices to the left in a seamless loop. It stops when it is paused, hovered or has focus inside. A real Pause button sits at the end. With reduced motion it does not move and becomes a row you can scroll by hand.",
      "purpose": "Gives a glanceable stream of many prices in a small space, while keeping control with the person.",
      "useFor": [
        "A strip across the top of a market screen.",
        "Showing a watchlist when space is tight."
      ],
      "notFor": [
        {
          "text": "Numbers people must compare or sort",
          "use": "data-table"
        },
        {
          "text": "One key number with its trend",
          "use": "stat-tile"
        },
        {
          "text": "The shape of one price over time",
          "use": "sparkline"
        }
      ],
      "whenToUse": [
        "Keep the Pause button visible.",
        "Use a list of unique symbols.",
        "Keep the same data available in a still view."
      ],
      "avoid": [
        "Do not remove the button to save space.",
        "Do not use it as the only place a price appears.",
        "Do not set a very high speed."
      ],
      "accessibility": [
        "It is a region named by label.",
        "A real button reads Pause ticker or Play ticker.",
        "It stops while hovered or while focus is inside it.",
        "With reduced motion it does not move and can be scrolled sideways.",
        "The second copy is hidden from screen readers and cannot take focus.",
        "Each change is read as Up, Down or Unchanged, with the numbers."
      ],
      "accessibilityYouDo": [
        "Give it a label such as Market ticker.",
        "Do not hide the Pause button or put important information only in the strip.",
        "Offer the same prices somewhere that does not move."
      ],
      "onPhone": [
        "The strip scrolls by itself and fills the width, with the Pause button fixed at the right edge. The button is 44px on touch screens and shows only its icon below 640px.",
        "Moving the strip by touch does not pause it, because pausing is tied to a mouse pointer or keyboard focus. Tap the Pause button instead.",
        "With reduced motion on, the strip stops and can be swiped sideways by hand."
      ],
      "importPath": "import { TickerTape } from \"./index\"",
      "sourcePath": "src/molecules/TickerTape.tsx",
      "props": [
        {
          "name": "items",
          "type": "{ symbol: string; price: number; change: number; percent: number }[]",
          "required": true,
          "default": null,
          "description": "The items to show, in order. Symbols should be unique."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name of the region, such as Market ticker."
        },
        {
          "name": "speed",
          "type": "number",
          "required": false,
          "default": "40",
          "description": "Scroll speed in pixels per second."
        },
        {
          "name": "paused",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Controlled pause state. Leave out and the strip manages it."
        },
        {
          "name": "onPausedChange",
          "type": "(paused: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when the Pause or Play button is pressed."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the region."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--line",
        "--hover",
        "--fg",
        "--fg-muted",
        "--up-fg",
        "--down-fg",
        "--dur-fast",
        "--radius-card"
      ],
      "related": [
        "price-change",
        "data-table",
        "sparkline",
        "stat-tile"
      ],
      "composedOf": [
        "price-change"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Default",
          "description": "Scrolls at 40 pixels per second. Hover it or tab into it to stop it.",
          "code": "<TickerTape label=\"Market ticker\" items={ITEMS} />"
        },
        {
          "title": "Paused by the page",
          "description": "Controlled with paused and onPausedChange. The button and the page stay in step.",
          "code": "function TickerControlledDemo() {\n  const [paused, setPaused] = useState(true);\n  return (\n    <div className=\"grid gap-2\">\n      <TickerTape label=\"Market ticker\" items={ITEMS} paused={paused} onPausedChange={setPaused} />\n      <p className=\"text-small leading-4 text-fg-muted\">{paused ? \"Paused by the page.\" : \"Moving.\"}</p>\n    </div>\n  );\n}"
        },
        {
          "title": "With reduced motion",
          "description": "Turn on reduced motion in your system settings and this strip stops moving. It becomes a list you can scroll sideways.",
          "code": "<TickerTape label=\"Market ticker\" items={ITEMS} speed={60} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-ticker-tape/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/ticker-tape.md"
    },
    {
      "id": "data-freshness",
      "name": "DataFreshness",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "last updated",
        "live indicator",
        "stale data"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "live",
          "note": "A pulsing dot shows with the word Live. The dot is still with reduced motion.",
          "source": "documented"
        },
        {
          "name": "becomes stale",
          "note": "When a worked-out status turns stale, a hidden polite message is read once.",
          "source": "documented"
        },
        {
          "name": "no time",
          "note": "With no status and no valid asOf, it reads Stale.",
          "source": "documented"
        }
      ],
      "summary": "Says how fresh the numbers are, in words and an icon.",
      "description": "DataFreshness is a small label for the age of data: live, delayed, stale, simulated or closed. It always uses words and an icon, so colour is never the only cue. Delayed and stale use the calm unsure tone. If you pass a time, it works out live or stale by itself.",
      "purpose": "Tells people whether to trust a number as it is, or check its age first.",
      "useFor": [
        "Beside a price or a chart that updates.",
        "Marking demo or simulated data.",
        "Showing that a market is closed."
      ],
      "notFor": [
        {
          "text": "A general status tag",
          "use": "badge"
        },
        {
          "text": "A line of case or order state",
          "use": "status-line"
        },
        {
          "text": "A level or progress value",
          "use": "meter"
        }
      ],
      "whenToUse": [
        "Show it whenever numbers can be old, delayed or made up.",
        "Pass delayMinutes with the real delay.",
        "Use simulated for demo data."
      ],
      "avoid": [
        "Do not mark stale data in a warning or danger tone.",
        "Do not use it as a general badge.",
        "Do not pass a status of live when the feed is down."
      ],
      "accessibility": [
        "It is a plain span with words, so it is not a live region.",
        "Every status has words and a dot or icon. Colour is never the only cue.",
        "A hidden polite message is read once, only when the status changes to stale.",
        "The pulsing dot only pulses when motion is allowed. Otherwise it is still."
      ],
      "accessibilityYouDo": [
        "Place it next to the numbers it describes.",
        "Pass asOf in ISO time when you want it worked out for you."
      ],
      "onPhone": [
        "It stays on one line and fits its text, so it works in a header or a card on a narrow screen.",
        "It shows the status in words and an icon, so nothing depends on hover."
      ],
      "importPath": "import { DataFreshness } from \"./index\"",
      "sourcePath": "src/atoms/DataFreshness.tsx",
      "props": [
        {
          "name": "status",
          "type": "\"live\" | \"delayed\" | \"stale\" | \"simulated\" | \"closed\"",
          "required": false,
          "default": null,
          "description": "The status to show. If left out, it is worked out from asOf and now: live until staleAfter seconds, then stale."
        },
        {
          "name": "asOf",
          "type": "string",
          "required": false,
          "default": null,
          "description": "When the data was last updated, in ISO time."
        },
        {
          "name": "now",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The current time in ISO, for docs and tests. By default the real time, refreshed every second while the status is worked out."
        },
        {
          "name": "delayMinutes",
          "type": "number",
          "required": false,
          "default": "15",
          "description": "Minutes shown in the delayed label."
        },
        {
          "name": "staleAfter",
          "type": "number",
          "required": false,
          "default": "60",
          "description": "Seconds after which computed data counts as stale."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the span."
        }
      ],
      "variantsHint": {
        "status": [
          "live",
          "delayed",
          "stale",
          "simulated",
          "closed"
        ]
      },
      "tokens": [
        "--sunken",
        "--line",
        "--fg-muted",
        "--unsure-soft",
        "--unsure-fg",
        "--up",
        "--radius-control"
      ],
      "related": [
        "badge",
        "status-line",
        "stat-tile",
        "meter"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Each status",
          "description": "Live has a pulsing dot. The others have an icon and their own words.",
          "code": "<div className=\"flex flex-wrap gap-2\"><DataFreshness status=\"live\" /> <DataFreshness status=\"delayed\" delayMinutes={15} /> <DataFreshness status=\"stale\" asOf=\"2026-10-07T09:57:00Z\" now=\"2026-10-07T10:00:00Z\" /> <DataFreshness status=\"simulated\" /> <DataFreshness status=\"closed\" /></div>"
        },
        {
          "title": "Worked out from the time",
          "description": "No status is passed. With a fixed now, 4 seconds is live and 3 minutes is stale.",
          "code": "<div className=\"flex flex-wrap gap-2\"><DataFreshness asOf=\"2026-10-07T09:59:56Z\" now=\"2026-10-07T10:00:00Z\" /> <DataFreshness asOf=\"2026-10-07T09:57:00Z\" now=\"2026-10-07T10:00:00Z\" /></div>"
        },
        {
          "title": "With a price",
          "description": "Put it next to the number it describes.",
          "code": "<div className=\"flex items-center gap-3\"><Price value={2874.15} label=\"Reliance price\" className=\"text-title-xs leading-7 text-fg\" /> <DataFreshness status=\"delayed\" delayMinutes={15} /></div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-data-freshness/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/data-freshness.md"
    },
    {
      "id": "candlestick-chart",
      "name": "CandlestickChart",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "ohlc chart",
        "stock chart"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "streaming",
          "note": "Set with the streaming prop.",
          "source": "prop"
        },
        {
          "name": "focus",
          "note": "A visible ring appears around the plot when you tab to it. The last candle is chosen at once, with its guide line and pop-up.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "Moving the pointer over the plot shows the guide line and pop-up for the candle under it. The pop-up hides when the pointer leaves, unless the chart has focus.",
          "source": "documented"
        },
        {
          "name": "selected candle",
          "note": "Left, Right, Home and End move to a candle. Its guide line, pop-up and the volume bar get stronger, and its details are read out. Escape clears it.",
          "source": "documented"
        },
        {
          "name": "no data",
          "note": "With an empty data list the chart draws only the empty axes.",
          "source": "documented"
        },
        {
          "name": "no volume",
          "note": "With volume on but no v values on any candle, the strip is left out.",
          "source": "documented"
        }
      ],
      "summary": "Price candles for open, high, low and close. Up and down are told apart by shape and by words, not by colour alone.",
      "description": "CandlestickChart draws one candle for each period. A thin line shows the highest and lowest price. The box shows where the price opened and closed. Candles that closed at or above their open are hollow, and candles that closed lower are filled. You can tab to the chart and use the arrow keys to read one candle at a time.",
      "purpose": "Shows how a price moved inside each period, so people can see the range and the direction at once.",
      "useFor": [
        "A share, coin or any price with open, high, low and close for each day or minute.",
        "A live price where the last candle keeps changing: turn on streaming.",
        "Showing how much was traded under each candle: turn on volume."
      ],
      "notFor": [
        {
          "text": "A single trend line with one value per day",
          "use": "line-chart"
        },
        {
          "text": "A trend in a very small space",
          "use": "sparkline"
        },
        {
          "text": "Comparing amounts between groups",
          "use": "bar-chart"
        }
      ],
      "whenToUse": [
        "Put it in a ChartFrame with a summary and the table from candlestickTable.",
        "Keep up as hollow and down as filled, so direction never depends on colour alone.",
        "Turn on streaming when the last candle can still change.",
        "Say the currency or unit in the ChartFrame subtitle."
      ],
      "avoid": [
        "Don't use it for one value per day. Use a line chart.",
        "Don't squeeze hundreds of candles into a narrow space. Pass fewer candles or a longer period.",
        "Don't reverse the meaning of hollow and filled in one product."
      ],
      "accessibility": [
        "You can tab to the chart. Left and Right move between candles, Home and End jump to the ends, and Escape clears.",
        "Each step is read out as time, open, high, low, close, up or down with the size of the change, volume, and \"provisional\" when it applies.",
        "Up candles are hollow with a thin line and down candles are filled, so direction does not depend on colour.",
        "The pop-up and the table say \"up\" or \"down\" in words.",
        "The pop-up is hidden from screen readers, because the same values are read out as you move.",
        "Hovering or tapping a candle shows the same pop-up."
      ],
      "accessibilityYouDo": [
        "Give it an ariaLabel that says what the price is and over what time.",
        "Put it in a ChartFrame with a summary in words, and pass candlestickTable to the table prop."
      ],
      "onPhone": [
        "The chart measures its container and redraws at that width.",
        "Touch or drag across the chart to read a candle. Vertical swipes still scroll the page, and the tooltip stays until you tap elsewhere.",
        "The tooltip flips to the left of your finger past the middle of the chart.",
        "Give the chart a short date range on a phone, because every candle needs room to be seen."
      ],
      "importPath": "import { CandlestickChart } from \"./index\"",
      "sourcePath": "src/organisms/CandlestickChart.tsx",
      "props": [
        {
          "name": "data",
          "type": "{ t: string; o: number; h: number; l: number; c: number; v?: number }[]",
          "required": true,
          "default": null,
          "description": "One entry per candle, oldest first. t is a label such as a date or time. o, h, l and c are open, high, low and close. v is the volume."
        },
        {
          "name": "height",
          "type": "number",
          "required": false,
          "default": "280",
          "description": "Plot height in px, including the volume strip and the bottom labels. Minimum 160."
        },
        {
          "name": "volume",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Adds a thin volume strip under the prices on the same x scale. Needs v on the candles."
        },
        {
          "name": "formatValue",
          "type": "(n: number) => string",
          "required": false,
          "default": "formatNumber",
          "description": "Formats prices on the scale, in the pop-up and in the table."
        },
        {
          "name": "ariaLabel",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Name for the chart region."
        },
        {
          "name": "streaming",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "The last candle may still change. It gets a dashed outline and is called provisional."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--up",
        "--down",
        "--up-fg",
        "--down-fg",
        "--chart-grid",
        "--chart-axis",
        "--surface",
        "--fg",
        "--fg-muted",
        "--focus-ring",
        "--radius-overlay"
      ],
      "related": [
        "line-chart",
        "bar-chart",
        "sparkline",
        "chart-frame",
        "data-table"
      ],
      "composedOf": [
        "chart-frame"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Forty days with volume and a table",
          "description": "The usual setup. The frame adds a title, a summary and a View as table button.",
          "code": "<ChartFrame\n  title=\"Acme price, 40 days\"\n  subtitle=\"Price in dollars, one candle per day\"\n  summary=\"The price drifted within a narrow range. Hollow candles closed up, filled candles closed down.\"\n  table={candlestickTable(CANDLES_40, price)}\n  source=\"Source: demo data\"\n>\n  <CandlestickChart data={CANDLES_40} volume formatValue={price} ariaLabel=\"Acme daily price, 40 days\" />\n</ChartFrame>"
        },
        {
          "title": "A short day",
          "description": "With few candles each one is wider and every label fits. No volume strip.",
          "code": "<div className=\"w-full\">\n  <CandlestickChart data={CANDLES_12} height={220} formatValue={price} ariaLabel=\"Acme price by half hour\" />\n</div>"
        },
        {
          "title": "A candle that is still forming",
          "description": "Press Start. The last candle updates in place and has a dashed outline. A new candle is added every few seconds.",
          "code": "<StreamingCandlesDemo />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-candlestick-chart/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/candlestick-chart.md"
    },
    {
      "id": "heatmap",
      "name": "Heatmap",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "heat map",
        "matrix chart",
        "calendar heatmap"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "focus",
          "note": "One cell has a visible focus ring. Only that cell is in the Tab order, and the arrow keys move the ring.",
          "source": "documented"
        },
        {
          "name": "no data",
          "note": "A cell whose value is null shows a dash on a sunken background with a dashed border, and is named \"No data\".",
          "source": "documented"
        },
        {
          "name": "flat",
          "note": "With the direction scale, a value of exactly 0 gets the plain sunken background and is read as flat.",
          "source": "documented"
        },
        {
          "name": "scrolling",
          "note": "When the columns do not fit, the grid scrolls sideways inside its own box.",
          "source": "documented"
        }
      ],
      "summary": "A grid of coloured cells with the number printed in each one. Use it to spot patterns across two things.",
      "description": "Heatmap shows one number per cell for rows and columns, such as sectors by day. Colour shows how big the number is, and the number is printed in the cell so colour is never the only clue. The direction scale shows ups and downs around zero. The sequential scale shows low to high in one colour. The grid works with the arrow keys.",
      "purpose": "Shows where the high and low spots are in a table of numbers, at a glance, without losing the exact values.",
      "useFor": [
        "Moves by group and day, where gains and losses both matter: direction scale.",
        "A profit and loss calendar: weekday rows by week columns.",
        "Counts or amounts by two categories, such as orders by region and hour: sequential scale."
      ],
      "notFor": [
        {
          "text": "Change over time for a few lines",
          "use": "line-chart"
        },
        {
          "text": "Comparing a handful of amounts",
          "use": "bar-chart"
        },
        {
          "text": "Reading exact values and sorting",
          "use": "data-table"
        }
      ],
      "whenToUse": [
        "Keep the numbers printed in the cells.",
        "Use the direction scale only when zero means something, such as gain against loss.",
        "Put it in a ChartFrame with a summary and a table.",
        "Use null for missing data, not 0."
      ],
      "avoid": [
        "Don't use the direction scale for counts that cannot be negative. Use sequential.",
        "Don't hide the numbers and rely on colour.",
        "Don't use dozens of columns on a phone. The grid scrolls sideways, so keep it short."
      ],
      "accessibility": [
        "The grid has rows, column headings and row headings, so a screen reader can say where you are.",
        "The grid is one tab stop. Arrow keys move between cells, Home and End go to the row ends, Ctrl with Home or End goes to the corners, and PageUp and PageDown go to the top and bottom of the column.",
        "Each cell is named like \"Banks, Mon: up 1.2 percent\". A cell with no data is named \"No data\".",
        "The number is printed in every cell by default, so colour is not the only clue. Direction is also shown with a plus or minus sign.",
        "Cells with no data show a dash and a dashed border.",
        "Cell borders stay visible in forced-colors mode."
      ],
      "accessibilityYouDo": [
        "Give it an ariaLabel that says what the grid holds.",
        "Put it in a ChartFrame with a summary and pass heatmapTable to the table prop.",
        "Pass a unit such as \"percent\" so each cell is read with what it measures."
      ],
      "onPhone": [
        "The grid keeps cells at least 32px square, and when there are too many columns it scrolls sideways inside its own box.",
        "Column headers are cut off with an ellipsis when they do not fit, so keep them short.",
        "Values show inside the cells, so nothing depends on hover.",
        "Cells are 32px by default and have no larger size on touch screens, so raise cellSize if people need to tap them."
      ],
      "importPath": "import { Heatmap } from \"./index\"",
      "sourcePath": "src/organisms/Heatmap.tsx",
      "props": [
        {
          "name": "rows",
          "type": "string[]",
          "required": true,
          "default": null,
          "description": "Row labels, top to bottom."
        },
        {
          "name": "cols",
          "type": "string[]",
          "required": true,
          "default": null,
          "description": "Column labels, left to right."
        },
        {
          "name": "values",
          "type": "(number | null)[][]",
          "required": true,
          "default": null,
          "description": "One array per row, one value per column. null means no data."
        },
        {
          "name": "scale",
          "type": "\"direction\" | \"sequential\"",
          "required": false,
          "default": "\"direction\"",
          "description": "Direction colours around zero in the up and down colours. Sequential uses one hue in five steps."
        },
        {
          "name": "formatValue",
          "type": "(n: number) => string",
          "required": false,
          "default": "formatNumber",
          "description": "Formats the number in a cell. With the direction scale it gets the size of the move, and the + or minus sign is added for you."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "default": "\"\"",
          "description": "Word read after each value by screen readers, for example \"percent\"."
        },
        {
          "name": "showValues",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Prints the number in every cell. Turn it off only when the numbers are given somewhere else."
        },
        {
          "name": "ariaLabel",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Name for the grid."
        },
        {
          "name": "legend",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Shows a small scale bar with the lowest, zero and highest values (or low and high for sequential)."
        },
        {
          "name": "cellSize",
          "type": "number",
          "required": false,
          "default": "44",
          "description": "Smallest cell width and height in px. Values below 32 are raised to 32."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {
        "scale": [
          "direction",
          "sequential"
        ]
      },
      "tokens": [
        "--up",
        "--down",
        "--chart-seq-1",
        "--chart-seq-2",
        "--chart-seq-3",
        "--chart-seq-4",
        "--chart-seq-5",
        "--surface",
        "--surface-sunken",
        "--fg",
        "--fg-muted",
        "--focus-ring"
      ],
      "related": [
        "data-table",
        "bar-chart",
        "line-chart",
        "chart-frame",
        "chart-legend"
      ],
      "composedOf": [
        "chart-frame"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Sector moves by day",
          "description": "The direction scale. The up and down colours are stronger for bigger moves. Each cell prints its signed number. The cell with no data shows a dash.",
          "code": "<ChartFrame\n  title=\"Sector moves this week\"\n  subtitle=\"Change in price, percent\"\n  summary=\"Energy had the biggest swings. Banks and Tech were mixed.\"\n  table={heatmapTable({ rows: SECTORS, cols: WEEKDAYS, values: SECTOR_MOVES, formatValue: pct, unit: \"percent\", rowHeader: \"Sector\", caption: \"Sector moves by day\" })}\n>\n  <Heatmap rows={SECTORS} cols={WEEKDAYS} values={SECTOR_MOVES} formatValue={pct} unit=\"percent\" legend ariaLabel=\"Sector moves by day\" />\n</ChartFrame>"
        },
        {
          "title": "A profit and loss calendar",
          "description": "Feed it weekday rows and week columns. Put each day in the row for its weekday and the column for its week. Use null for a day with no trades.",
          "code": "// PNL_DAYS[weekday][week]: 5 rows (Mon to Fri) by 4 weeks, null for no trades\n<Heatmap rows={WEEKDAYS} cols={WEEKS} values={PNL_DAYS} formatValue={dollars} unit=\"dollars\" legend cellSize={52} ariaLabel=\"Daily profit and loss, four weeks\" />"
        },
        {
          "title": "Counts in one colour",
          "description": "The sequential scale runs from light to dark in one hue. The number sits on a small chip so it stays readable on any shade.",
          "code": "<Heatmap scale=\"sequential\" rows={REGIONS} cols={HOURS} values={ORDERS} unit=\"orders\" legend ariaLabel=\"Orders by region and hour\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-heatmap/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/heatmap.md"
    },
    {
      "id": "payoff-chart",
      "name": "PayoffChart",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "profit loss chart",
        "options payoff"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "focus",
          "note": "A visible ring appears around the plot when you tab to it. The middle price is chosen at once, with its guide line and pop-up.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "Moving the pointer over the plot shows the guide line and pop-up for the nearest price. The pop-up hides when the pointer leaves, unless the chart has focus.",
          "source": "documented"
        },
        {
          "name": "selected price",
          "note": "Left, Right, PageUp, PageDown, Home and End move to a price. A dot sits on the line and the profit or loss is read out. Escape clears it.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "With no points and no legs, only the empty axes are drawn.",
          "source": "documented"
        },
        {
          "name": "price marker hidden",
          "note": "If spot is outside the range, the current price line and its label are not drawn.",
          "source": "documented"
        }
      ],
      "summary": "Shows the profit or loss at the end for a position as the price moves, with break-even prices and best and worst outcomes labelled.",
      "description": "PayoffChart draws a line for profit or loss at each end price. Above the zero line is profit and below it is loss. The loss area is also hatched, so it can be told apart without colour. It marks where you break even and labels the most you can make and lose, or says Unlimited. Give it ready points, or give it option legs and it works the line out.",
      "purpose": "Lets someone see what could happen to a position before they take it, and where it starts to make or lose money.",
      "useFor": [
        "An option or a spread: a long call, a bull call spread, an iron condor.",
        "A share position, alone or combined with options.",
        "Any result that depends on one price, when you already have the points."
      ],
      "notFor": [
        {
          "text": "A price history over time",
          "use": "line-chart"
        },
        {
          "text": "Open, high, low and close for each day",
          "use": "candlestick-chart"
        },
        {
          "text": "One number with a trend",
          "use": "stat-tile"
        }
      ],
      "whenToUse": [
        "Say \"at expiry\" and the unit in the ChartFrame subtitle.",
        "State the break-even prices and the most you can make or lose in the summary.",
        "Use payoffAtExpiry(legs, spots) when your app needs the same numbers elsewhere.",
        "Pass the current price as spot so people see where they are now."
      ],
      "avoid": [
        "Don't use it to promise a result. It shows the end value only, not the chance of reaching it.",
        "Don't leave out the premium on option legs. The line would show the wrong break-even.",
        "Don't hide the table view. Some people need the numbers."
      ],
      "accessibility": [
        "You can tab to the chart. Left and Right move between prices, PageUp and PageDown jump ten steps, Home and End go to the ends, and Escape clears.",
        "Each step is read out as the price and then profit, loss or break even with the amount.",
        "The loss area has a diagonal hatch as well as a tint, so profit and loss do not depend on colour.",
        "Break-even prices, max profit and max loss are written as text on the chart.",
        "Unlimited is only said for the right edge, because a price cannot go below zero.",
        "The pop-up is hidden from screen readers, because the same values are read out as you move."
      ],
      "accessibilityYouDo": [
        "Give it an ariaLabel that names the position.",
        "Put it in a ChartFrame with a summary that states the break-even prices and the most you can make or lose, and pass payoffTable to the table prop."
      ],
      "onPhone": [
        "The chart measures its container and redraws at that width, so it fills a phone screen.",
        "Touch or drag across the chart to read the profit or loss at a price. Vertical swipes still scroll the page, and the tooltip stays until you tap elsewhere.",
        "The tooltip flips to the left of your finger past the middle of the chart."
      ],
      "importPath": "import { PayoffChart } from \"./index\"",
      "sourcePath": "src/organisms/PayoffChart.tsx",
      "props": [
        {
          "name": "points",
          "type": "{ spot: number; pnl: number }[]",
          "required": false,
          "default": null,
          "description": "Ready-made points: the price and the profit or loss at that price. Use this or legs."
        },
        {
          "name": "legs",
          "type": "PayoffLeg[]",
          "required": false,
          "default": null,
          "description": "Positions to work the result out from. Each has kind (call, put or stock), side (buy or sell), strike, premium and qty. Used when points is not given."
        },
        {
          "name": "spotRange",
          "type": "[number, number]",
          "required": false,
          "default": null,
          "description": "Price range to draw when working from legs. Defaults to a band around the strikes."
        },
        {
          "name": "steps",
          "type": "number",
          "required": false,
          "default": "120",
          "description": "Number of steps across the range when working from legs. Every strike is added as an extra point."
        },
        {
          "name": "spot",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Current price. Drawn as a labelled dashed line when it is inside the range."
        },
        {
          "name": "formatValue",
          "type": "(n: number) => string",
          "required": false,
          "default": "formatNumber",
          "description": "Formats prices and amounts everywhere in the chart."
        },
        {
          "name": "ariaLabel",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Name for the chart region."
        },
        {
          "name": "height",
          "type": "number",
          "required": false,
          "default": "260",
          "description": "Plot height in px. Minimum 180."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--up-soft",
        "--down-soft",
        "--up-fg",
        "--down-fg",
        "--down",
        "--chart-1",
        "--chart-grid",
        "--chart-axis",
        "--surface",
        "--fg",
        "--fg-muted",
        "--focus-ring",
        "--radius-overlay"
      ],
      "related": [
        "line-chart",
        "chart-frame",
        "data-table",
        "stat-tile",
        "money"
      ],
      "composedOf": [
        "chart-frame"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A long call",
          "description": "You pay 5 for the right to buy at 100. The most you lose is what you paid. The profit has no top. The dashed line is today's price.",
          "code": "<ChartFrame\n  title=\"Long call, strike 100\"\n  subtitle=\"Profit or loss at expiry, dollars per share\"\n  summary=\"You lose at most 5. You break even at 105. Above that the profit keeps growing.\"\n  table={payoffTable({ legs: LONG_CALL, spotRange: [80, 130] })}\n>\n  <PayoffChart legs={LONG_CALL} spotRange={[80, 130]} spot={98} ariaLabel=\"Long call, result at expiry\" />\n</ChartFrame>"
        },
        {
          "title": "A bull call spread",
          "description": "Built from two legs. Selling the higher call caps the profit, so it says a number instead of Unlimited.",
          "code": "<PayoffChart legs={BULL_SPREAD} spotRange={[85, 125]} spot={102} ariaLabel=\"Bull call spread, result at expiry\" />"
        },
        {
          "title": "An iron condor",
          "description": "Four legs. Profit only between the two inner strikes, with two break-even prices and both ends capped.",
          "code": "<PayoffChart legs={CONDOR} spotRange={[80, 120]} spot={100} ariaLabel=\"Iron condor, result at expiry\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-payoff-chart/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/payoff-chart.md"
    },
    {
      "id": "order-ticket",
      "name": "OrderTicket",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "trade form",
        "buy sell form",
        "order form"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "disabled",
          "note": "Pass disabled. Every field and the button lock, and disabledReason shows in words with a lock icon.",
          "source": "documented"
        },
        {
          "name": "limit or stop",
          "note": "When order type is Limit or Stop, a price field appears (Limit price or Trigger price). On Market it is hidden.",
          "source": "documented"
        },
        {
          "name": "invalid",
          "note": "A message in errors shows under its field with a warning icon. A quantity that is not a multiple of lotSize gets an error even if you pass none.",
          "source": "documented"
        },
        {
          "name": "submitting",
          "note": "Pass submitting. The button shows a spinner, is marked busy and cannot be pressed again.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The global focus ring shows on whichever field, segment or button you reach with the keyboard.",
          "source": "documented"
        }
      ],
      "summary": "A buy or sell form to place a trade: side, quantity, order type, price, product, a review summary and one submit button.",
      "description": "OrderTicket collects the choices for one order and hands them back to your code. It never sends anything. The app computes cost and risk and passes them in as summary rows. A pressed submit button calls onSubmit and nothing more.",
      "purpose": "Lets a person set up an order by hand, with every figure in view before they press the one button.",
      "useFor": [
        "A manual order the person types in themselves.",
        "The inside of an ActionDraft when an AI drafted the order and the person edits it.",
        "A locked ticket that explains in words why trading is paused."
      ],
      "notFor": [
        {
          "text": "Asking a person to approve something an agent wants to do",
          "use": "approval-prompt"
        },
        {
          "text": "Showing a finished order as a record",
          "use": "receipt"
        },
        {
          "text": "A single number input",
          "use": "number-field"
        }
      ],
      "whenToUse": [
        "Pass cost and risk in summary so the person sees them before pressing.",
        "Say why the ticket is locked with disabledReason.",
        "Put it inside ActionDraft for an order an AI drafted.",
        "Keep the button text specific, such as Buy RELIANCE."
      ],
      "avoid": [
        "Do not place the order inside onChange. Only onSubmit, from a press, may start it.",
        "Do not rely on the Buy and Sell words being coloured. They are not.",
        "Do not compute money inside this part. Pass it in.",
        "Do not add a second submit button."
      ],
      "accessibility": [
        "It is a real form with a name. Enter in a field submits it, and a locked form does not.",
        "Each number field has a visible label, a hint where given and its error linked by aria-describedby.",
        "Side, order type and product are radio groups with arrow-key movement. Sell is a word, not just a colour.",
        "When locked, the fieldset is disabled and the reason is linked to the button.",
        "While submitting the button is marked busy and cannot be pressed again."
      ],
      "accessibilityYouDo": [
        "Compute the summary rows yourself and keep them in words and numbers. This part does no money maths.",
        "Send errors in plain words, one per field. Do not rely on colour.",
        "When the app places the order, tell the person in text what happened.",
        "For an order an AI drafted, put this inside ActionDraft so the person must confirm."
      ],
      "onPhone": [
        "The form is one column and fills the width of its container up to 430px. Only Stop and Target sit side by side.",
        "The submit button is full width and 48px tall on touch screens.",
        "Quantity and price fields use the number fields, which are 16px on touch screens so the page does not zoom when they get focus.",
        "Put the ticket where the on-screen keyboard will not hide the submit button, for example by letting the page scroll."
      ],
      "importPath": "import { OrderTicket } from \"./index\"",
      "sourcePath": "src/organisms/OrderTicket.tsx",
      "props": [
        {
          "name": "symbol",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The instrument, such as RELIANCE. Used in the heading and the default button text."
        },
        {
          "name": "price",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Last price, shown at the top. Display only."
        },
        {
          "name": "value",
          "type": "OrderTicketValue",
          "required": true,
          "default": null,
          "description": "The whole form: side, quantity, orderType, limitPrice, product, stop, target."
        },
        {
          "name": "onChange",
          "type": "(v: OrderTicketValue) => void",
          "required": true,
          "default": null,
          "description": "Called with the full new value on every change."
        },
        {
          "name": "lotSize",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Quantity moves in steps of this size and the hint reads \"1 lot = 75\". A quantity that is not a multiple shows an error."
        },
        {
          "name": "tickSize",
          "type": "number",
          "required": false,
          "default": "0.05",
          "description": "Step for the price, stop and target fields."
        },
        {
          "name": "summary",
          "type": "{ label: string; value: ReactNode; hint?: string }[]",
          "required": false,
          "default": null,
          "description": "What the order will cost or risk, worked out by the app. Shown as a definition list."
        },
        {
          "name": "errors",
          "type": "Partial<Record<keyof OrderTicketValue, string>>",
          "required": false,
          "default": "{}",
          "description": "One sentence per field, shown under it."
        },
        {
          "name": "onSubmit",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called when the submit button is pressed. The app decides what happens."
        },
        {
          "name": "submitLabel",
          "type": "string",
          "required": false,
          "default": "\"Buy RELIANCE\" style, from side and symbol",
          "description": "Button text."
        },
        {
          "name": "submitting",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Shows a spinner in the button and blocks another press."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Locks every field and the button."
        },
        {
          "name": "disabledReason",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why it is locked, in words next to the button. Shown only when disabled."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the form."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--text",
        "--text-muted",
        "--danger-fg",
        "--radius-card"
      ],
      "related": [
        "field",
        "number-field",
        "segmented-control",
        "approval-prompt",
        "money"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Market buy",
          "description": "The simple case. The app computes the summary rows and passes them in.",
          "code": "function Example() {\n  const [v, setV] = useState<OrderTicketValue>({ side: \"buy\", quantity: 10, orderType: \"market\", limitPrice: null, product: \"intraday\", stop: null, target: null });\n  return (\n    <OrderTicket symbol=\"RELIANCE\" price={2945.5} value={v} onChange={setV} onSubmit={() => {}}\n      summary={[{ label: \"Margin needed\", value: \"29,455.00\", hint: \"Worked out by the app.\" }, { label: \"Charges\", value: \"41.20\" }]} />\n  );\n}"
        },
        {
          "title": "Limit sell with errors",
          "description": "Quantity moves in lots. A limit order shows the price field. Each error is a sentence under its field.",
          "code": "function Example() {\n  const [v, setV] = useState<OrderTicketValue>({ side: \"sell\", quantity: 100, orderType: \"limit\", limitPrice: 0, product: \"intraday\", stop: null, target: null });\n  return (\n    <OrderTicket symbol=\"NIFTY 24500 CE\" price={142.5} lotSize={75} value={v} onChange={setV} onSubmit={() => {}}\n      errors={{ limitPrice: \"Enter a price above 0.\", stop: \"A sell order needs a stop above the limit price.\" }} />\n  );\n}"
        },
        {
          "title": "Paused with a reason",
          "description": "Every field and the button lock. The reason sits next to the button in words.",
          "code": "<OrderTicket symbol=\"RELIANCE\" price={2945.5} value={{ side: \"buy\", quantity: 10, orderType: \"market\", limitPrice: null, product: \"intraday\", stop: null, target: null }}\n  onChange={() => {}} onSubmit={() => {}} disabled disabledReason=\"Trading is paused: daily loss limit reached\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-order-ticket/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/order-ticket.md"
    },
    {
      "id": "option-chain",
      "name": "OptionChain",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "options chain",
        "options table"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "The price cell matching selected is filled with the accent and set aria-pressed.",
          "source": "documented"
        },
        {
          "name": "at the money",
          "note": "The row with the strike nearest spot gets a strong border, the mark ATM, and the words At the money for screen readers.",
          "source": "documented"
        },
        {
          "name": "in the money",
          "note": "Calls below spot and puts above spot get a grey tint and a small diamond, with the words In the money for screen readers.",
          "source": "documented"
        },
        {
          "name": "empty value",
          "note": "A null or missing value shows a dash. A null price is plain text, not a button.",
          "source": "documented"
        },
        {
          "name": "scrolling",
          "note": "The region scrolls both ways. The header rows stay at the top and the strike column stays at the edge.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "A price button shades when the pointer is over it.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The scroll region and each price button show the focus ring. Up and Down arrows move between rows in the same column.",
          "source": "documented"
        }
      ],
      "summary": "Calls on the left, puts on the right, strike in the middle, as a real table.",
      "description": "OptionChain shows the prices and numbers for each strike. The row nearest the spot price is marked. Cells in the money get a quiet grey tint and a small diamond, not a gain or loss colour. On a phone the table scrolls sideways inside its own region while the header and the strike stay in view.",
      "purpose": "Lets a person scan strikes and tap one price to start an order.",
      "useFor": [
        "Choosing a strike and a side for an order.",
        "Comparing open interest and implied volatility across strikes.",
        "A small chain in a narrow phone screen."
      ],
      "notFor": [
        {
          "text": "A general table of records",
          "use": "data-table"
        },
        {
          "text": "A single price with a change",
          "use": "money"
        }
      ],
      "whenToUse": [
        "Keep Price in the columns when you pass onPick.",
        "Show the expiry and the spot price.",
        "Use a maxHeight for long chains.",
        "Pass null for a missing value and let the dash show."
      ],
      "avoid": [
        "Do not use gain and loss colours to mark in the money.",
        "Do not hide the scroll region on a phone. It is the way to reach the far columns.",
        "Do not place an order from onPick. Open an order ticket instead.",
        "Do not pass more columns than the width can hold without scrolling in mind."
      ],
      "accessibility": [
        "It is a real table with a caption, two header rows and a row header for each strike.",
        "The scroll region has a name and can be reached by keyboard, so the sideways scroll works without a mouse.",
        "The nearest strike carries the visible mark ATM and the words At the money for screen readers.",
        "In the money cells say so in words for screen readers beside the tint and the diamond.",
        "Price buttons have names such as Call 24,350, price 142.50. Up and Down arrows move between rows in the same column. The chosen one is aria-pressed.",
        "Values are right aligned with tabular figures. A missing value is a dash."
      ],
      "accessibilityYouDo": [
        "Give it an ariaLabel that names the chain, such as NIFTY option chain.",
        "When a price is picked, say what was picked in text. Do not rely on the highlight.",
        "Show the expiry in expiryLabel so the table is not read out without it."
      ],
      "onPhone": [
        "The chain scrolls both ways inside its own box, because the table is as wide as its columns. The strike column stays pinned and the header rows stay at the top.",
        "Price buttons grow to 44px by 44px on touch screens, so rows get taller there.",
        "Show few columns on a phone, since each extra column adds to the sideways scroll."
      ],
      "importPath": "import { OptionChain } from \"./index\"",
      "sourcePath": "src/organisms/OptionChain.tsx",
      "props": [
        {
          "name": "rows",
          "type": "{ strike: number; call: ChainSide; put: ChainSide }[]",
          "required": true,
          "default": null,
          "description": "One row per strike. ChainSide has ltp (required, may be null) and optional oi, oiChange, iv, delta."
        },
        {
          "name": "spot",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Price of the underlying. The nearest strike is marked, and calls below it and puts above it are in the money."
        },
        {
          "name": "columns",
          "type": "(\"oi\" | \"oiChange\" | \"iv\" | \"delta\" | \"ltp\")[]",
          "required": false,
          "default": "[\"oi\", \"iv\", \"ltp\"]",
          "description": "Columns on each side, in order for calls. Puts show them mirrored."
        },
        {
          "name": "onPick",
          "type": "(pick: { side: \"call\" | \"put\"; strike: number; ltp: number }) => void",
          "required": false,
          "default": null,
          "description": "Makes price cells buttons. Called with the side, strike and price."
        },
        {
          "name": "selected",
          "type": "{ side: \"call\" | \"put\"; strike: number }",
          "required": false,
          "default": null,
          "description": "Marks one price cell as chosen."
        },
        {
          "name": "expiryLabel",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shown above the table beside the spot price."
        },
        {
          "name": "ariaLabel",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Accessible name of the scrolling region."
        },
        {
          "name": "maxHeight",
          "type": "number | string",
          "required": false,
          "default": null,
          "description": "Height limit of the scrolling region. The header stays in view."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the outer box."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--border-strong",
        "--text",
        "--text-muted",
        "--accent",
        "--hover",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "data-table",
        "number-field",
        "money"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Pick a price",
          "description": "With onPick the price cells are buttons. The chosen one is marked and written out under the table.",
          "code": "function Example() {\n  const [pick, setPick] = useState<ChainPick | null>(null);\n  return (\n    <OptionChain ariaLabel=\"NIFTY option chain\" expiryLabel=\"Expiry 30 Oct\" spot={24342} rows={rows}\n      selected={pick ? { side: pick.side, strike: pick.strike } : undefined} onPick={setPick} />\n  );\n}"
        },
        {
          "title": "More columns",
          "description": "Choose columns and their order. Puts mirror the order so Price sits next to the strike on both sides. A missing value shows a dash.",
          "code": "<OptionChain ariaLabel=\"NIFTY option chain\" spot={24342} rows={rows} columns={[\"oi\", \"oiChange\", \"iv\", \"delta\", \"ltp\"]} />"
        },
        {
          "title": "On a phone width",
          "description": "In a 360px box the table scrolls sideways in its own region. maxHeight keeps the header in view when rows scroll.",
          "code": "<div className=\"w-90\">\n  <OptionChain ariaLabel=\"NIFTY option chain\" spot={24342} rows={rows} maxHeight={260} onPick={() => {}} />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-option-chain/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/option-chain.md"
    },
    {
      "id": "action-draft",
      "name": "ActionDraft",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "action preview",
        "draft action",
        "prepared action"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "error",
          "note": "A field error shows in words under the field and disables Confirm. The note then says to fix the marked fields.",
          "source": "documented"
        },
        {
          "name": "editing",
          "note": "Pressing Edit on a field turns it into an input in place. Enter or Done saves, Escape or Cancel keeps the old value.",
          "source": "documented"
        },
        {
          "name": "edited",
          "note": "A field whose value differs from the first value this part saw gets an Edited tag, also after confirming.",
          "source": "documented"
        },
        {
          "name": "confirming",
          "note": "Fields are read only, both buttons are disabled and Confirm shows a spinner.",
          "source": "documented"
        },
        {
          "name": "step states",
          "note": "Each step shows Waiting, In progress, Done or Failed as an icon plus a word for screen readers.",
          "source": "documented"
        }
      ],
      "summary": "The draft an AI proposes, edited in place, confirmed by the person, then shown as progress.",
      "description": "ActionDraft shows what the AI plans to do as a short review. The person edits a value in place, then presses Confirm. The same card then shows the steps and the result. Nothing runs until the person presses Confirm, and the part has no way to confirm by itself.",
      "purpose": "Keeps a person in charge of an action an AI prepared: they read it, fix it and decide.",
      "useFor": [
        "An order the AI drafted from a message.",
        "Any action with values the person may want to change before it runs.",
        "Showing progress and the result in the same place the person confirmed."
      ],
      "notFor": [
        {
          "text": "A yes or no on something an agent is about to do",
          "use": "approval-prompt"
        },
        {
          "text": "Asking for access to a tool or data",
          "use": "permission-prompt"
        },
        {
          "text": "A list of what already happened",
          "use": "action-log"
        }
      ],
      "whenToUse": [
        "Show the facts the app worked out in rows so the person sees them first.",
        "Move status to confirming while you wait for the app to accept the press.",
        "Put OrderTicket inside it when the AI drafted an order.",
        "Show the result in words when done."
      ],
      "avoid": [
        "Do not confirm for the person, even behind a setting.",
        "Do not let the AI write dates, deadlines or amounts into fields. Code computes them.",
        "Do not make the discard button small or hidden.",
        "Do not remove the sentence that nothing happens until they confirm."
      ],
      "accessibility": [
        "The card is a section named by its title.",
        "In draft, values read as plain text and each has an Edit button. Enter saves, Escape cancels, and focus returns to Edit.",
        "Confirm and the other button are the same size. Only Confirm is filled. Confirm is disabled while any field has an error, and the note says so.",
        "After Confirm, focus moves to the title because the buttons go away.",
        "Steps have an icon and a word (Waiting, In progress, Done, Failed), never colour alone. A polite live region announces progress and done.",
        "A failure appears in an element with role alert.",
        "The spinning step icon stops when the person has reduced motion on."
      ],
      "accessibilityYouDo": [
        "Write the title as a full action with its numbers, such as Buy 10 RELIANCE at market.",
        "Keep the title in step with the field values so it is true when the person confirms.",
        "Write error messages as sentences that say what to change.",
        "Never call onConfirm from code. Only the button press may do it."
      ],
      "onPhone": [
        "The card fills the width of its container up to 430px, and Confirm and Not now sit side by side as two 48px buttons on touch screens.",
        "Editable fields open as inline inputs and use the decimal keypad for number fields.",
        "Put the card where the on-screen keyboard will not hide Confirm, for example by letting the page scroll."
      ],
      "importPath": "import { ActionDraft } from \"./index\"",
      "sourcePath": "src/organisms/ActionDraft.tsx",
      "props": [
        {
          "name": "status",
          "type": "\"draft\" | \"confirming\" | \"working\" | \"done\" | \"failed\"",
          "required": true,
          "default": null,
          "description": "Where the action is. Only draft allows editing and pressing the buttons."
        },
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The action in one line, such as Buy 10 RELIANCE at market."
        },
        {
          "name": "summary",
          "type": "string",
          "required": false,
          "default": null,
          "description": "One or two sentences of context."
        },
        {
          "name": "fields",
          "type": "{ id: string; label: string; value: string; kind?: \"text\" | \"number\"; editable?: boolean; unit?: string; error?: string }[]",
          "required": true,
          "default": null,
          "description": "The values. Editable ones get an Edit button in draft. Any error locks Confirm."
        },
        {
          "name": "onFieldChange",
          "type": "(id: string, value: string) => void",
          "required": true,
          "default": null,
          "description": "Called when an edit is saved."
        },
        {
          "name": "rows",
          "type": "{ label: string; value: ReactNode }[]",
          "required": false,
          "default": null,
          "description": "Facts the app worked out, such as margin and charges. Read only."
        },
        {
          "name": "steps",
          "type": "{ id: string; label: string; state: \"pending\" | \"running\" | \"done\" | \"failed\"; detail?: string }[]",
          "required": false,
          "default": null,
          "description": "Progress once confirmed. Each has an icon and a word for screen readers."
        },
        {
          "name": "result",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Shown when status is done."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shown in an alert when status is failed."
        },
        {
          "name": "onConfirm",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called when Confirm is pressed."
        },
        {
          "name": "onDiscard",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called when the other button is pressed."
        },
        {
          "name": "confirmLabel",
          "type": "string",
          "required": false,
          "default": "\"Confirm\"",
          "description": "Text of the filled button."
        },
        {
          "name": "discardLabel",
          "type": "string",
          "required": false,
          "default": "\"Not now\"",
          "description": "Text of the other button. It has the same size as Confirm."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the card."
        }
      ],
      "variantsHint": {
        "status": [
          "draft",
          "confirming",
          "working",
          "done",
          "failed"
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--text",
        "--text-muted",
        "--success-soft",
        "--success-fg",
        "--danger-soft",
        "--danger-fg",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "approval-prompt",
        "permission-prompt",
        "action-log",
        "activity-trace",
        "inline-edit",
        "receipt"
      ],
      "composedOf": [
        "inline-edit"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Draft with an edited field",
          "description": "Press Edit on Quantity, change it and press Done. The Edited tag appears. Enter 0 to see Confirm lock.",
          "code": "function Example() {\n  const [qty, setQty] = useState(\"10\");\n  return (\n    <ActionDraft status=\"draft\" title={`Buy ${qty} RELIANCE at market`} summary=\"Drafted from your message. Check it before you confirm.\"\n      fields={[{ id: \"qty\", label: \"Quantity\", value: qty, kind: \"number\", editable: true, unit: \"shares\" }, { id: \"type\", label: \"Order type\", value: \"Market\" }]}\n      onFieldChange={(_, v) => setQty(v)} rows={[{ label: \"Margin needed\", value: \"29,455.00\" }, { label: \"Charges\", value: \"41.20\" }]}\n      onConfirm={() => {}} onDiscard={() => {}} />\n  );\n}"
        },
        {
          "title": "Working, then done",
          "description": "After Confirm the fields are read only and the steps advance. A live region announces the change. Restart plays it again.",
          "code": "<ActionDraft status=\"working\" title=\"Buy 10 RELIANCE at market\" fields={fields} onFieldChange={() => {}}\n  steps={[\n    { id: \"a\", label: \"Check your limits\", state: \"done\" },\n    { id: \"b\", label: \"Send the order\", state: \"running\" },\n    { id: \"c\", label: \"Wait for the exchange\", state: \"pending\" },\n  ]} onConfirm={() => {}} onDiscard={() => {}} />"
        },
        {
          "title": "Failed",
          "description": "The error is in words in an alert, with the step that failed. Nothing is hidden behind colour.",
          "code": "<ActionDraft status=\"failed\" title=\"Buy 10 RELIANCE at market\" fields={fields} onFieldChange={() => {}}\n  steps={[{ id: \"a\", label: \"Check your limits\", state: \"done\" }, { id: \"b\", label: \"Send the order\", state: \"failed\", detail: \"The broker did not answer.\" }]}\n  error=\"The order was not placed. Nothing was bought. You can start again.\" onConfirm={() => {}} onDiscard={() => {}} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-action-draft/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/action-draft.md"
    },
    {
      "id": "risk-limits",
      "name": "RiskLimits",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "limits",
        "guardrails",
        "risk settings"
      ],
      "category": "markets",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "near the limit",
          "note": "At 80 percent used or more, a limit shows Near the limit with an info icon in a calm tone.",
          "source": "documented"
        },
        {
          "name": "limit reached",
          "note": "At 100 percent or more, a limit shows Limit reached with a lock icon. The meter is capped at full.",
          "source": "documented"
        },
        {
          "name": "paused",
          "note": "Pass paused. A Paused tag shows at the top and on each limit, the reason shows in words and the kill switch is removed.",
          "source": "documented"
        },
        {
          "name": "asking",
          "note": "Pressing the kill switch swaps it for a question with Stop trading and Keep trading. Focus moves to Keep trading, and Escape also keeps trading.",
          "source": "documented"
        }
      ],
      "summary": "The limits that protect the person, with a kill switch that asks first, in place.",
      "description": "RiskLimits lists each limit with how much is used, as words and a meter. Near a limit and at a limit it says so in words with an icon, in a calm tone. The kill switch swaps itself for a short question with two equal buttons, so one tap can never stop trading by mistake.",
      "purpose": "Shows how close the person is to their own limits, and gives one clear way to stop.",
      "useFor": [
        "Daily loss and order count limits in a trading app.",
        "A paused state that explains why in words.",
        "A stop-everything control that is safe to put on screen."
      ],
      "notFor": [
        {
          "text": "A spending cap the person sets for an agent",
          "use": "budget-control"
        },
        {
          "text": "A single progress bar",
          "use": "meter"
        },
        {
          "text": "A message that is not about limits",
          "use": "callout"
        }
      ],
      "whenToUse": [
        "Say what stays possible, such as exits, in note.",
        "Pass the real used and max, and let the part word the status.",
        "Explain a pause with pausedReason.",
        "Keep the kill switch in the same place on every screen."
      ],
      "avoid": [
        "Do not make a one-tap kill switch.",
        "Do not add red or amber to the meters. This is a calm part.",
        "Do not hide a limit once it is reached.",
        "Do not use it to block exits."
      ],
      "accessibility": [
        "Each meter has the limit name and a text value such as 4,200 of 20,000.",
        "Near the limit, Limit reached and Paused are words with icons, never colour alone.",
        "The kill question appears in place as a named group. Focus moves to Keep trading, Escape also keeps trading, and focus goes back to the kill button.",
        "Stop trading and Keep trading are the same size and style.",
        "After trading stops, focus moves to the heading and a polite live region announces that trading is paused."
      ],
      "accessibilityYouDo": [
        "Give each limit a clear label such as Daily loss.",
        "Pass a format function for money so the numbers read in your locale.",
        "Say in pausedReason why trading is paused.",
        "Keep exits open in your own logic and say so in note."
      ],
      "onPhone": [
        "The card fills the width of its container up to 430px, and each limit shows its label and amount on one row that wraps if needed.",
        "Stop trading, Keep trading and the first stop button are 48px tall on touch screens.",
        "The confirmation shows in the card itself, not in a popup, so nothing covers the limits."
      ],
      "importPath": "import { RiskLimits } from \"./index\"",
      "sourcePath": "src/organisms/RiskLimits.tsx",
      "props": [
        {
          "name": "limits",
          "type": "{ id: string; label: string; used: number; max: number; unit?: string; format?(n: number): string }[]",
          "required": true,
          "default": null,
          "description": "One row per limit. format turns a number into text, for money."
        },
        {
          "name": "paused",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "True when trading is stopped. Every limit then says Paused, and the kill switch is not shown."
        },
        {
          "name": "pausedReason",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why it is paused, in words."
        },
        {
          "name": "onKill",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called only after the person answers Stop trading. Without it, no kill switch is shown."
        },
        {
          "name": "killLabel",
          "type": "string",
          "required": false,
          "default": "\"Stop all trading\"",
          "description": "Text of the kill button, also used in the question."
        },
        {
          "name": "note",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "A line under the limits, such as Exits are never blocked."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the card."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--border-strong",
        "--text",
        "--text-muted",
        "--unsure-soft",
        "--unsure-fg",
        "--radius-card"
      ],
      "related": [
        "budget-control",
        "meter",
        "callout",
        "approval-prompt"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Normal",
          "description": "Two limits well below their maximum. Money uses a format function.",
          "code": "<RiskLimits limits={[\n  { id: \"loss\", label: \"Daily loss\", used: 4200, max: 20000, format: (n) => \"₹\" + n.toLocaleString(\"en-IN\") },\n  { id: \"orders\", label: \"Orders today\", used: 6, max: 25, unit: \"orders\" },\n]} note=\"Exits are never blocked.\" />"
        },
        {
          "title": "Near the limit",
          "description": "At 80 percent or more a limit says Near the limit with an icon. The meter stays plain.",
          "code": "<RiskLimits limits={[{ id: \"loss\", label: \"Daily loss\", used: 17000, max: 20000, format: (n) => \"₹\" + n.toLocaleString(\"en-IN\") }]} />"
        },
        {
          "title": "Reached and paused",
          "description": "At 100 percent it says Limit reached with a lock. Paused shows in words on each limit and the kill switch is gone.",
          "code": "<RiskLimits paused pausedReason=\"Trading is paused: daily loss limit reached.\"\n  limits={[{ id: \"loss\", label: \"Daily loss\", used: 20000, max: 20000, format: (n) => \"₹\" + n.toLocaleString(\"en-IN\") }]}\n  note=\"Exits are never blocked.\" onKill={() => {}} />"
        },
        {
          "title": "Kill switch flow",
          "description": "Press Stop all trading. It asks in place, and focus lands on Keep trading. Choosing Stop trading pauses.",
          "code": "function Example() {\n  const [paused, setPaused] = useState(false);\n  return (\n    <RiskLimits paused={paused} pausedReason={paused ? \"You stopped trading.\" : undefined} onKill={() => setPaused(true)} note=\"Exits are never blocked.\"\n      limits={[{ id: \"orders\", label: \"Orders today\", used: 6, max: 25, unit: \"orders\" }]} />\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-risk-limits/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/risk-limits.md"
    },
    {
      "id": "scroll-edge",
      "name": "ScrollEdge",
      "level": "atom",
      "since": "0.6",
      "aka": [
        "scroll shadow",
        "scroll fade",
        "overflow indicator"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "at the start",
          "note": "The top edge still shows its fade, so keep padding at the top of the content so the first line is not softened.",
          "source": "documented"
        },
        {
          "name": "reduced transparency",
          "note": "When the system asks for reduced transparency, only a plain fade from the page colour to clear is drawn, with no blur.",
          "source": "documented"
        },
        {
          "name": "no backdrop-filter",
          "note": "In a browser without backdrop-filter, the same plain fade is drawn.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The scrolling region shows a visible focus ring, drawn inside the box, when reached by keyboard.",
          "source": "documented"
        }
      ],
      "summary": "A scrolling area whose top and bottom edges blur gradually, so content softens near the edge instead of meeting a hard line.",
      "description": "ScrollEdge wraps content that scrolls and draws a soft blur over the top edge, the bottom edge or both. The blur is built from five layers that get stronger towards the edge, with a tint of the page colour on top. Use it behind a fixed header or composer, where a hard panel line would cut the content. Where blur is unavailable, or the person asked for less transparency, it shows a plain fade with no blur.",
      "purpose": "Lets a long list scroll under a header or footer without a hard line, so the screen feels like one surface. The fade is decoration only: it never takes focus and never blocks a tap.",
      "useFor": [
        "A chat thread that scrolls under a header and a message box.",
        "A panel body that scrolls under a fixed title.",
        "Any long list where a border line at the edge feels heavy."
      ],
      "notFor": [
        {
          "text": "A panel that stays beside the page while people keep working",
          "use": "canvas-panel"
        },
        {
          "text": "A surface that slides over the page",
          "use": "sheet"
        },
        {
          "text": "Content that hides and shows on request",
          "use": "collapsible"
        }
      ],
      "whenToUse": [
        "Give the outer box a fixed or flexible height, or the content cannot scroll.",
        "Pad the content by at least the fade size at the ends that fade.",
        "Keep the header or footer you sit under transparent, so the blur shows through.",
        "Use it only on edges where content really passes underneath."
      ],
      "avoid": [
        "Do not rely on the blur to hide anything private.",
        "Do not stack several ScrollEdge boxes inside each other.",
        "Do not put the only copy of important text in the fade zone.",
        "Do not use it where the edge has a border you want people to see."
      ],
      "accessibility": [
        "The scrolling element is a region with an accessible name and tabIndex 0, so keyboard users can reach it and scroll with the arrow keys, Page Up and Page Down.",
        "The edge layers are aria-hidden and ignore the pointer, so they never take focus or block taps.",
        "Scrolling itself is the browser's own, so keyboard scrolling is unchanged.",
        "With reduced transparency, or without backdrop-filter, only a plain fade is drawn.",
        "The focus ring is drawn inside the box so the clipped edge does not hide it."
      ],
      "accessibilityYouDo": [
        "Give the region a label that says what scrolls, such as Conversation, so screen reader users hear it.",
        "Add padding of at least the fade size inside the content, so the first and last lines rest in clear space.",
        "Do not put anything people must read inside the fade zone only. Text there is softened on purpose."
      ],
      "onPhone": [
        "The fade at the edge is drawn over the content and ignores touch, so a finger scrolls the content underneath as normal.",
        "Scrolling stops at the end of the area instead of carrying on into the page behind it.",
        "Give it a height, or place it in a parent that has one, or it will not scroll."
      ],
      "importPath": "import { ScrollEdge } from \"./index\"",
      "sourcePath": "src/atoms/ScrollEdge.tsx",
      "props": [
        {
          "name": "edge",
          "type": "\"top\" | \"bottom\" | \"both\"",
          "required": false,
          "default": "\"both\"",
          "description": "Which edge softens."
        },
        {
          "name": "size",
          "type": "number",
          "required": false,
          "default": "48",
          "description": "Height of the fade zone in px."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The content that scrolls."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Scrollable content\"",
          "description": "Accessible name of the scrolling region."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer box. Give it a height, or place it in a cell that has one."
        },
        {
          "name": "contentClassName",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the element that scrolls."
        }
      ],
      "variantsHint": {
        "edge": [
          "top",
          "bottom",
          "both"
        ]
      },
      "tokens": [
        "--bg"
      ],
      "related": [
        "sheet",
        "canvas-panel",
        "collapsible"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Both edges on a chat-like list",
          "description": "Scroll the list. Lines passing the top and bottom soften gradually. The padding inside keeps the first and last lines clear.",
          "code": "<ScrollEdge label=\"Conversation\" className=\"h-64 w-full max-w-md rounded-card border border-line\">\n  <ul className=\"flex flex-col gap-3 px-4 py-12 text-body leading-5\">\n    {lines.map((l, i) => (\n      <li key={l} className={i % 2 ? \"ml-8 rounded-card bg-sunken px-3 py-2\" : \"mr-8 rounded-card border border-line px-3 py-2\"}>{l}</li>\n    ))}\n  </ul>\n</ScrollEdge>"
        },
        {
          "title": "Top edge only",
          "description": "Use this under a fixed header when the bottom has its own control. A taller fade makes a softer join.",
          "code": "<ScrollEdge edge=\"top\" size={72} label=\"Messages\" className=\"h-64 w-full max-w-md rounded-card border border-line\">\n  <ChatList />\n</ScrollEdge>"
        },
        {
          "title": "Reduced transparency and the fallback",
          "description": "When the system asks for reduced transparency, or the browser has no backdrop-filter, the blur layers are not drawn. A plain fade from the page colour to clear is shown instead. Nothing is lost: the content still scrolls the same way. To see it, turn on Reduce transparency in your system settings.",
          "code": "<ScrollEdge edge=\"bottom\" size={40} label=\"Notes\" className=\"h-48 w-full max-w-md rounded-card border border-line\">\n  <ChatList />\n</ScrollEdge>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-scroll-edge/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/scroll-edge.md"
    },
    {
      "id": "glance-panel",
      "name": "GlancePanel",
      "level": "organism",
      "since": "0.6",
      "aka": [
        "summary panel",
        "side summary",
        "at a glance"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty section",
          "note": "A section whose data is missing or empty is left out, so the panel can start with only a headline or only steps.",
          "source": "documented"
        },
        {
          "name": "step: done",
          "note": "The step shows a check and the words Done for screen readers.",
          "source": "documented"
        },
        {
          "name": "step: now",
          "note": "The step shows its number inside a ring that breathes, is set as the current step, and is bold. The ring stays still with reduced motion.",
          "source": "documented"
        },
        {
          "name": "step: next",
          "note": "The step shows its number in muted text.",
          "source": "documented"
        },
        {
          "name": "edited fact",
          "note": "A fact with edited true shows a small Edited tag before its value.",
          "source": "documented"
        },
        {
          "name": "link focus",
          "note": "When the rule has an href, the title is a link with a visible focus ring and an underline that goes on hover.",
          "source": "documented"
        },
        {
          "name": "sheet closed or open",
          "note": "GlanceSheet draws nothing while closed. When open, the page behind is inert and focus returns to the opener on close.",
          "source": "documented"
        }
      ],
      "summary": "The case at a glance: the deadline, the facts, the rule and the steps, kept in view beside a chat. GlanceSheet is the phone version.",
      "description": "GlancePanel keeps what a worried person keeps coming back to in one place. The headline comes first, usually a Countdown. Then come the facts under What I know, the rule with its citation, and the whole journey under Where it's at, with Done, Now and Next marked in words as well as shapes. GlanceSheet puts the same panel in a bottom Sheet for phones, opened from a button in the page header.",
      "purpose": "Answers the three worries that repeat in a long chat: how long is left, is the case right, and what happens next. It reads from the same data as the conversation, so the two never disagree.",
      "useFor": [
        "A case file beside a chat on a wide screen.",
        "The same case file in a bottom sheet on a phone, opened from the header.",
        "A short summary that people check again and again while waiting."
      ],
      "notFor": [
        {
          "text": "Only the deadline, with no facts or steps",
          "use": "countdown"
        },
        {
          "text": "A list of steps with no other facts",
          "use": "step-timeline"
        },
        {
          "text": "A to-do list people tick off themselves",
          "use": "checklist"
        },
        {
          "text": "A record of what happened, with times",
          "use": "timeline"
        },
        {
          "text": "A single card that wraps up a result",
          "use": "summary-card"
        }
      ],
      "whenToUse": [
        "Read the panel from the same data as the chat, so they agree.",
        "Put the deadline first, as the headline.",
        "Show every step in the journey, so people see what comes next.",
        "Use GlanceSheet on phones and GlancePanel beside the chat on wide screens."
      ],
      "avoid": [
        "Do not show private numbers such as card or bank details in facts.",
        "Do not mark a step as Now when nothing is happening.",
        "Do not add long text to facts. Link to the full page instead.",
        "Do not use the panel for things people must tick off themselves."
      ],
      "accessibility": [
        "It is an aside landmark named by the title.",
        "Section headings are real headings at the chosen level.",
        "Facts are a definition list, so each label is tied to its value.",
        "Steps are an ordered list. Each step has the word Done, Now or Next for screen readers, and the current step has aria-current set to step.",
        "State is shown by a check, a number or a ring, and by words for screen readers, never colour alone.",
        "The ring on the current step only breathes when the person has not asked for reduced motion. With reduced motion it stays still.",
        "GlanceSheet is a Sheet: a native dialog that makes the page behind inert, closes on Escape and returns focus to the opener."
      ],
      "accessibilityYouDo": [
        "Give the panel a title that names it. It is the landmark name.",
        "Pass states that match the real case. The panel does not work them out.",
        "Keep fact values short and in plain words. Mark a fact as edited only when the person changed it.",
        "In GlanceSheet, set open to false when onClose is called, and give the opener button an accessible name."
      ],
      "onPhone": [
        "The panel has no frame or fixed width and fills its container, with long values breaking onto the next line.",
        "On a phone, put it in GlanceSheet, which shows it in a bottom Sheet that opens from a button in the page header.",
        "Each fact label and value wrap onto two lines when they do not fit side by side."
      ],
      "importPath": "import { GlancePanel } from \"./index\"",
      "sourcePath": "src/organisms/GlancePanel.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Case file\"",
          "description": "Names the panel as a landmark and shows at the top."
        },
        {
          "name": "headline",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The first and biggest thing, usually a Countdown."
        },
        {
          "name": "facts",
          "type": "{ label: string; value: ReactNode; edited?: boolean }[]",
          "required": false,
          "default": null,
          "description": "Shown as a labelled list under What I know. A fact with edited gets a small Edited tag."
        },
        {
          "name": "rule",
          "type": "{ title: string; cite?: string; href?: string }",
          "required": false,
          "default": null,
          "description": "The rule the case rests on. With href the title is a link."
        },
        {
          "name": "steps",
          "type": "{ id: string; label: string; state: \"done\" | \"now\" | \"next\"; detail?: string }[]",
          "required": false,
          "default": null,
          "description": "The whole journey, numbered, under Where it's at. Done shows a check, Now shows a ring that breathes, and all three are also written for screen readers."
        },
        {
          "name": "headings",
          "type": "{ facts?: string; rule?: string; steps?: string }",
          "required": false,
          "default": null,
          "description": "Renames the section headings. Defaults are What I know, The rule and Where it's at."
        },
        {
          "name": "hideTitle",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Hides the visible title but keeps it as the landmark name."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4",
          "required": false,
          "default": "2",
          "description": "Heading level of the section headings."
        },
        {
          "name": "scrollable",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Set when the panel scrolls, so the keyboard can reach its content."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the panel. It has no border or background of its own, so add them here."
        },
        {
          "name": "GlanceSheet open",
          "type": "boolean",
          "required": true,
          "default": null,
          "description": "Whether the sheet is showing. Fully controlled."
        },
        {
          "name": "GlanceSheet onClose",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called when the sheet asks to close. Set open to false in response."
        },
        {
          "name": "GlanceSheet (other props)",
          "type": "GlancePanelProps",
          "required": false,
          "default": null,
          "description": "title, headline, facts, rule, steps, headings, headingLevel and className go to the panel inside. The sheet uses the title as its heading."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--border-strong",
        "--accent",
        "--success",
        "--success-soft",
        "--success-fg",
        "--fg-muted",
        "--fg-subtle"
      ],
      "related": [
        "countdown",
        "sheet",
        "status-line",
        "step-timeline",
        "stat-tile",
        "summary-card",
        "timeline",
        "checklist"
      ],
      "composedOf": [
        "badge",
        "section-label",
        "sheet"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A refund case",
          "description": "A Countdown headline, four facts with one edited, the rule cited with a link, and the journey with one step happening now.",
          "code": "<GlancePanel\n  title=\"Case file\"\n  headline={<Countdown deadline=\"2026-10-27\" today=\"2026-10-07\" start=\"2026-09-27\" label=\"They must reply by\" />}\n  facts={[\n    { label: \"Seller\", value: \"Aero Home\" },\n    { label: \"Item\", value: \"Aero 12 blender\" },\n    { label: \"Paid\", value: \"₹2,340\", edited: true },\n    { label: \"Bought\", value: \"2 September 2026\" },\n  ]}\n  rule={{ title: \"Consumer Protection Act, 2019\", cite: \"Section 2(9): right to be heard and to seek redressal\", href: \"#rule\" }}\n  steps={[\n    { id: \"told\", label: \"You told me\", state: \"done\" },\n    { id: \"facts\", label: \"Facts checked\", state: \"done\" },\n    { id: \"rule\", label: \"Rule found\", state: \"now\", detail: \"Checking the seller's return terms.\" },\n    { id: \"sent\", label: \"Complaint sent\", state: \"next\" },\n    { id: \"reply\", label: \"Their reply\", state: \"next\" },\n  ]}\n  className=\"w-full max-w-sm rounded-card border border-line bg-surface p-4\"\n/>"
        },
        {
          "title": "Steps only",
          "description": "Every section is optional. With only steps, the panel is a small progress view. Use headings to rename the section.",
          "code": "<GlancePanel\n  title=\"Progress\"\n  headings={{ steps: \"What happens next\" }}\n  steps={[\n    { id: \"a\", label: \"You told me\", state: \"done\" },\n    { id: \"b\", label: \"Facts checked\", state: \"now\" },\n    { id: \"c\", label: \"Complaint sent\", state: \"next\" },\n  ]}\n  className=\"w-full max-w-xs rounded-card border border-line bg-surface p-4\"\n/>"
        },
        {
          "title": "Two columns beside a message list",
          "description": "On a wide screen the chat takes the main column and the panel sits on the right. ScrollEdge keeps the chat soft under the edges.",
          "code": "<div className=\"grid w-full max-w-3xl grid-cols-[1fr_20rem] overflow-hidden rounded-card border border-line\">\n  <ScrollEdge label=\"Conversation\" className=\"h-96\">\n    <ChatList />\n  </ScrollEdge>\n  <GlancePanel title=\"Case file\" headline={<CaseHeadline />} facts={facts} steps={steps} scrollable\n    className=\"overflow-y-auto border-l border-line bg-surface p-4\" />\n</div>"
        },
        {
          "title": "GlanceSheet opened from a status line",
          "description": "On a phone the page header shows a StatusLine as a button. Pressing it opens the same panel in a bottom sheet. Focus returns to the button on close.",
          "code": "function Example() {\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <button type=\"button\" onClick={() => setOpen(true)} aria-label=\"Open case file\"\n        className=\"flex min-h-9 w-full cursor-pointer items-center rounded-control border border-line bg-surface px-3 text-left pointer-coarse:min-h-11\">\n        <StatusLine items={[\"Case\", \"Aero Home\", \"₹2,340\", \"Rule found\"]} current={3} />\n      </button>\n      <GlanceSheet open={open} onClose={() => setOpen(false)} title=\"Case file\"\n        headline={<CaseHeadline />} facts={facts} rule={rule} steps={steps} />\n    </>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-glance-panel/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/glance-panel.md"
    },
    {
      "id": "status-ticker",
      "name": "StatusTicker",
      "level": "atom",
      "since": "0.7",
      "aka": [
        "status updates",
        "live status"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the current prop.",
          "source": "prop"
        },
        {
          "name": "working",
          "note": "While done is false the line shimmers softly and carries aria-busy.",
          "source": "documented"
        },
        {
          "name": "changing",
          "note": "When current changes, the old line fades out over it while the new one settles in from a 2px blur.",
          "source": "documented"
        },
        {
          "name": "throttled",
          "note": "If steps change faster than every 2 seconds, screen readers hear only the latest one when the gap ends.",
          "source": "documented"
        },
        {
          "name": "truncated",
          "note": "A step wider than the space it has is cut with an ellipsis instead of wrapping.",
          "source": "documented"
        },
        {
          "name": "reduced motion",
          "note": "With reduced motion on, the text swaps with no blur, no fade and no shimmer.",
          "source": "documented"
        }
      ],
      "summary": "One line that shows the real current step of an AI run, such as Reading 3 files, and keeps its width so nothing beside it moves.",
      "description": "StatusTicker shows the step an AI run is on right now. The app passes each real step as it happens; the ticker never moves on a timer of its own. When the step changes, the old line fades out and the new one settles in from a light blur. The box keeps the width of the widest text it has shown, so the things beside it stay still.",
      "purpose": "Tells people what the AI is doing at this moment, in words, without making the layout jump. It only ever shows what the app says is true.",
      "useFor": [
        "The current step of an agent run, such as Reading 3 files or Running tests.",
        "A search or tool call that has a few named steps.",
        "A final still line when the run ends, such as Done in 12s."
      ],
      "notFor": [
        {
          "text": "A wait with no named steps",
          "use": "typing-indicator"
        },
        {
          "text": "A fixed summary line at the top of a screen",
          "use": "status-line"
        },
        {
          "text": "A short loading state inside a button",
          "use": "spinner"
        },
        {
          "text": "Marking the surface the AI is changing",
          "use": "working-edge"
        }
      ],
      "whenToUse": [
        "Pass the step the app is really on, the moment it starts.",
        "Keep steps short and plain: a verb and an object, such as Reading 3 files.",
        "Pass steps or reserve when the ticker sits beside other content.",
        "End with a still line that says the result, such as Done in 12s."
      ],
      "avoid": [
        "Do not cycle through steps on a timer to look busy.",
        "Do not put links or buttons in the line.",
        "Do not use it for errors that need action.",
        "Do not show more than one ticker for the same run."
      ],
      "accessibility": [
        "The line is a group with the label as its name, and carries aria-busy until done is true.",
        "A separate polite live region announces a new step at most once every 2 seconds. If steps change faster, the latest one is announced when the gap ends.",
        "When done turns true the final line is announced at once.",
        "The fading old line and the width placeholders are aria-hidden, so the text is never read twice inside the line.",
        "Under reduced motion there is no blur, no fade and no shimmer: the text simply changes."
      ],
      "accessibilityYouDo": [
        "Pass real steps only. Never cycle through made-up steps on a timer.",
        "Give a label that says whose status it is, such as Assistant status, when more than one runs on a screen.",
        "Pass done and a final line, such as Done in 12s, when the run ends, so people hear that it finished.",
        "Show errors with a proper error message as well. The ticker is not an error surface."
      ],
      "onPhone": [
        "The line never grows wider than its parent: long steps are cut with an ellipsis instead of wrapping.",
        "It is text only and has no touch targets of its own, so it fits in a narrow header or a chat row.",
        "Pass steps or reserve so the width is set from the start and the row does not shift on a small screen."
      ],
      "importPath": "import { StatusTicker } from \"./index\"",
      "sourcePath": "src/atoms/StatusTicker.tsx",
      "props": [
        {
          "name": "current",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The step the run is on right now. When done, pass the final line, such as Done in 12s."
        },
        {
          "name": "steps",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Every step text the run may show. Used only to reserve the width of the widest one. It does not drive the line."
        },
        {
          "name": "done",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "The run has finished. The shimmer stops, the text turns full ink, aria-busy is removed and the line is announced at once."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"AI status\"",
          "description": "Accessible name of the line."
        },
        {
          "name": "reserve",
          "type": "string",
          "required": false,
          "default": null,
          "description": "A text whose width the box keeps from the start, such as the longest step you expect."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer box."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--dur-base",
        "--dur-fast",
        "--ease-arrive",
        "--ease-in",
        "--blur-arrive"
      ],
      "related": [
        "shimmer-text",
        "status-line",
        "typing-indicator",
        "spinner",
        "agent-status-icon",
        "working-edge"
      ],
      "composedOf": [
        "shimmer-text"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Steps from the app",
          "description": "Press Next step to move the run on, the way an app would when a real step starts. After the last step it settles on a still final line.",
          "code": "const run = [\"Reading 3 files\", \"Checking the return rule\", \"Writing the draft\", \"Running tests\"];\nconst [i, setI] = useState(0);\nconst done = i >= run.length;\n\n<StatusTicker current={done ? \"Done in 12s\" : run[i]} steps={run} done={done} label=\"Assistant status\" />\n<Button size=\"sm\" variant=\"secondary\" onClick={() => setI((n) => Math.min(n + 1, run.length))} disabled={done}>Next step</Button>\n<Button size=\"sm\" variant=\"ghost\" onClick={() => setI(0)}>Start again</Button>"
        },
        {
          "title": "In a row with other content",
          "description": "The reserve text holds the width, so the count beside the ticker does not move when the line changes to the shorter final text.",
          "code": "<div className=\"flex items-center gap-3 rounded-card border border-line bg-surface px-3 py-2\">\n  <StatusTicker current={done ? \"Done in 8s\" : \"Searching the web\"} reserve=\"Searching the web\" done={done} label=\"Search status\" />\n  <span className=\"text-body leading-5 text-fg-subtle\">3 sources</span>\n</div>\n<Button size=\"sm\" variant=\"secondary\" onClick={() => setDone((d) => !d)}>{done ? \"Run again\" : \"Finish\"}</Button>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-status-ticker/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/status-ticker.md"
    },
    {
      "id": "working-edge",
      "name": "WorkingEdge",
      "level": "atom",
      "since": "0.7",
      "aka": [
        "working glow",
        "busy indicator",
        "activity border"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the active prop.",
          "source": "prop"
        },
        {
          "name": "inactive",
          "note": "With active false the ring is hidden and the surface looks as it does on its own.",
          "source": "documented"
        },
        {
          "name": "fading out",
          "note": "When active turns false the ring fades out in the fast duration.",
          "source": "documented"
        },
        {
          "name": "reduced motion",
          "note": "With reduced motion on, the ring is a still solid line in the AI colour with no turning highlight.",
          "source": "documented"
        },
        {
          "name": "forced colours",
          "note": "In forced colours mode the ring is a plain border in the system text colour.",
          "source": "documented"
        }
      ],
      "summary": "A calm moving ring in the AI colour around a surface, shown only while the AI is working on it.",
      "description": "WorkingEdge wraps a card, a field or a panel and draws a thin ring around it while active is true. A soft highlight turns slowly around the ring, about once every 2.4 seconds, so people can see which surface the AI is changing. When the work stops, the ring fades out quickly. It never changes the size of what it wraps.",
      "purpose": "Marks the one surface the AI is working on right now, so people know where to look and what may change. The ring is tied to real work and stops when the work stops.",
      "useFor": [
        "A draft card the AI is writing or rewriting.",
        "A form field the AI is filling in.",
        "A panel the AI is updating after a request."
      ],
      "notFor": [
        {
          "text": "Saying what step the AI is on",
          "use": "status-ticker"
        },
        {
          "text": "A placeholder before content exists",
          "use": "skeleton"
        },
        {
          "text": "Showing that the AI is present but idle",
          "use": "ai-presence"
        },
        {
          "text": "Asking a person to act on something",
          "use": "attention-dot"
        }
      ],
      "whenToUse": [
        "Show it on the one surface the AI is changing.",
        "Turn it off the moment the work ends or fails.",
        "Pair it with a StatusTicker or a message that says what is happening."
      ],
      "avoid": [
        "Do not leave it on as decoration.",
        "Do not put it on several surfaces at once for one task.",
        "Do not use it to ask a person to act. That is amber, not the AI colour.",
        "Do not use it on ordinary controls that the AI is not changing."
      ],
      "accessibility": [
        "The ring is aria-hidden and ignores the pointer, so it never takes focus or blocks a tap.",
        "A visually hidden polite live region says the label when active turns true, and is emptied when it turns false.",
        "Under reduced motion the ring is still and solid, with no turning highlight.",
        "In forced colours mode the ring is a plain border in the system text colour.",
        "The ring fades out in the fast duration when active turns false."
      ],
      "accessibilityYouDo": [
        "Set active from the real state of the work, and turn it off as soon as the work ends or fails.",
        "Give a label that names the work, such as AI is writing the draft, when the default is too vague.",
        "Tell people what changed when the work ends, for example with a StatusTicker or a message.",
        "Match radius to the corner of the surface inside, so the ring sits on its edge."
      ],
      "onPhone": [
        "The ring is drawn over the edge of the surface, so the surface keeps its size and the layout does not shift on a narrow screen.",
        "The ring ignores touch, so taps reach the card or field underneath as normal.",
        "The wrapper is a block that follows the width of its parent, so it fits one-column phone layouts."
      ],
      "importPath": "import { WorkingEdge } from \"./index\"",
      "sourcePath": "src/atoms/WorkingEdge.tsx",
      "props": [
        {
          "name": "active",
          "type": "boolean",
          "required": true,
          "default": null,
          "description": "True only while the AI is really working on this surface. The ring shows while true."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "The surface the ring goes around, such as a card or a field."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"AI is working on this\"",
          "description": "Said once by screen readers when active turns true."
        },
        {
          "name": "radius",
          "type": "\"card\" | \"overlay\" | \"control\" | \"field\" | \"sm\" | \"md\" | \"lg\" | \"xl\" | \"2xl\" | \"3xl\" | \"4xl\" | \"full\"",
          "required": false,
          "default": "\"card\"",
          "description": "Corner of the ring. Match the corner of the surface inside. The role names (card, overlay, control, field) follow the shape setting."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the wrapper."
        },
        {
          "name": "...rest",
          "type": "HTMLAttributes<HTMLDivElement>",
          "required": false,
          "default": null,
          "description": "Other attributes go on the wrapper div."
        }
      ],
      "variantsHint": {
        "radius": [
          "card",
          "overlay",
          "control",
          "field",
          "sm",
          "md",
          "lg",
          "xl",
          "2xl",
          "3xl",
          "4xl",
          "full"
        ]
      },
      "tokens": [
        "--lime",
        "--fg",
        "--focus-width",
        "--dur-base",
        "--dur-fast",
        "--ease-arrive",
        "--ease-in",
        "--radius-2xl"
      ],
      "related": [
        "status-ticker",
        "shimmer-text",
        "skeleton",
        "ai-presence",
        "agent-status-icon"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Around a card",
          "description": "Press Stop and Start. The ring fades in while the AI works and fades out quickly when it stops. The card does not move.",
          "code": "const [active, setActive] = useState(true);\n\n<WorkingEdge active={active}>\n  <div className=\"rounded-card border border-line bg-surface p-4\">\n    <p className=\"text-body leading-5 font-medium text-fg\">Refund request</p>\n    <p className=\"mt-1 text-body leading-5 text-fg-muted\">Aero Home, Aero 12 blender, ₹2,340.</p>\n  </div>\n</WorkingEdge>\n<Button size=\"sm\" variant=\"secondary\" onClick={() => setActive((a) => !a)}>{active ? \"Stop\" : \"Start\"}</Button>"
        },
        {
          "title": "With a StatusTicker inside",
          "description": "The ring shows where the work is and the ticker says what it is. Both follow the same real steps, and the ring stops on the final line.",
          "code": "<WorkingEdge active={!done} radius=\"xl\" label=\"AI is writing the draft\">\n  <div className=\"flex flex-col gap-2 rounded-card border border-line bg-surface p-4\">\n    <StatusTicker current={done ? \"Done in 12s\" : run[i]} steps={run} done={done} label=\"Draft status\" />\n    <p className=\"text-body leading-5 text-fg-muted\">The draft appears here when it is ready.</p>\n  </div>\n</WorkingEdge>\n<Button size=\"sm\" variant=\"secondary\" onClick={() => setI((n) => Math.min(n + 1, run.length))} disabled={done}>Next step</Button>\n<Button size=\"sm\" variant=\"ghost\" onClick={() => setI(0)}>Start again</Button>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-working-edge/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/working-edge.md"
    },
    {
      "id": "prompt-index",
      "name": "PromptIndex",
      "level": "organism",
      "since": "0.7",
      "aka": [
        "prompt navigator",
        "message index",
        "table of contents"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "With no items the list says \"No questions yet.\" The rail and the phone Questions button are not drawn at all.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "An item gets a soft background and darker text under the pointer. Over the rail, the pointer opens it into a list.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The focused item shows the focus ring and its text wraps to show in full. Keyboard focus inside the rail opens it.",
          "source": "documented"
        },
        {
          "name": "open",
          "note": "The rail opens into a raised list on hover, keyboard focus or a tap, and closes on Escape, when the pointer and focus leave, or on a tap elsewhere.",
          "source": "documented"
        },
        {
          "name": "phone",
          "note": "Below the sm breakpoint the rail is replaced by a Questions button that opens the list in a bottom sheet.",
          "source": "documented"
        }
      ],
      "summary": "A short list of the person's own messages in a long thread, so they can jump back to one.",
      "description": "PromptIndex lists what the person asked, oldest first, one line each. Pressing a line calls onJump, and your code scrolls to that message. As a list it sits in a side panel or sheet. As a rail it is a column of short marks on the right edge of the thread that opens into the list on hover, on keyboard focus or on a tap; on a phone the rail becomes a Questions button that opens the list in a bottom sheet.",
      "purpose": "Long threads bury the person's own questions. This gives them a way back to any one of them without scrolling through every answer.",
      "useFor": [
        "A long chat or agent thread where people want to return to an earlier question.",
        "A side panel or sheet that lists the questions in this thread.",
        "A quiet rail on the right edge of a wide thread view."
      ],
      "notFor": [
        {
          "text": "Switching between different conversations",
          "use": "conversation-list"
        },
        {
          "text": "Getting back to the newest message",
          "use": "jump-to-latest"
        },
        {
          "text": "Going back to a saved point where the agent's work can be undone",
          "use": "checkpoint-list"
        }
      ],
      "whenToUse": [
        "Move focus to the message after a jump, not just the scroll position.",
        "Keep currentId in step with scrolling, so the rail shows where the person is.",
        "Use the rail on wide thread views and the list in side panels."
      ],
      "avoid": [
        "Do not list the AI's answers. This is the person's own questions.",
        "Do not use it to switch conversations.",
        "Do not put the rail over text the person needs to read; leave room on the right of the thread."
      ],
      "accessibility": [
        "It is a nav element. The list variant is named by its visible heading; the rail is named by label.",
        "Each item is a button inside an ordered list. Its name is the full question and the time, even when the text is cut short on screen.",
        "The current item has aria-current=\"location\" and a darker, longer mark, so it is not shown by colour alone.",
        "Only one item is in the Tab order (the current one, or the first). Arrow Up and Down, Home and End move focus between items. Enter and Space jump.",
        "A cut-off question shows in full as a tooltip on hover, and wraps to show in full when the item has keyboard focus.",
        "The rail opens when the pointer is over it or an item has keyboard focus. Escape closes it again and keeps focus where it was.",
        "On a phone, the Questions button reports aria-haspopup=\"dialog\" and aria-expanded. The sheet puts focus on the current question. After a jump the sheet closes, focus returns to the button, and only then is onJump called, so your own focus move is not undone.",
        "The mark grows over --dur-fast. Under reduced motion it changes at once."
      ],
      "accessibilityYouDo": [
        "In onJump, scroll to the message and move focus to it (give the message tabIndex={-1}), so keyboard and screen reader users land there too.",
        "Keep currentId in step with the message in view, so aria-current stays true.",
        "Place the rail inside a positioned box that has a height, such as the thread's wrapper, so it can sit on the right edge and scroll when there are many questions.",
        "Pass only the person's own messages, not the AI's answers."
      ],
      "onPhone": [
        "Below the sm breakpoint the rail is hidden and a Questions button with the count is shown in its place.",
        "The button opens the list in a bottom sheet, one question per row, and the sheet closes after a jump.",
        "Every item grows to at least 44px tall on a touch screen. The Questions button keeps its size and has a 44px tap area.",
        "On a wider touch screen the first tap on the rail only opens it; a second tap on a question jumps. Tapping elsewhere closes it."
      ],
      "importPath": "import { PromptIndex } from \"./index\"",
      "sourcePath": "src/organisms/PromptIndex.tsx",
      "props": [
        {
          "name": "items",
          "type": "{ id: string; text: string; time?: string }[]",
          "required": true,
          "default": null,
          "description": "The person's own messages in this thread, oldest first. time is shown as given, so format it first."
        },
        {
          "name": "currentId",
          "type": "string",
          "required": false,
          "default": null,
          "description": "id of the message in view. Gets aria-current and the longer mark, and is the item reached with Tab."
        },
        {
          "name": "onJump",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Called with the id of the chosen question. Scroll to that message and move focus to it."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Your questions\"",
          "description": "Name of the navigation. Also the visible heading of the list and the title of the phone sheet."
        },
        {
          "name": "variant",
          "type": "\"list\" | \"rail\"",
          "required": false,
          "default": "\"list\"",
          "description": "list: a plain list for a side panel or sheet. rail: short marks on the right edge that open into the list; a Questions button and sheet on a phone."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer box. For the rail, use it to position the box on the thread's right edge and give it a height."
        }
      ],
      "variantsHint": {
        "variant": [
          "list",
          "rail"
        ]
      },
      "tokens": [
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--line",
        "--line-strong",
        "--hover",
        "--raised",
        "--surface",
        "--dur-fast",
        "--ease-out",
        "--radius-control",
        "--radius-overlay"
      ],
      "related": [
        "conversation-list",
        "jump-to-latest",
        "message-list",
        "checkpoint-list"
      ],
      "composedOf": [
        "sheet"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "List in a side panel",
          "description": "The plain list. Use it inside a side panel or your own sheet. The current question is marked.",
          "code": "function Example() {\n  const [current, setCurrent] = useState(\"q3\");\n  return (\n    <div className=\"w-72 rounded-card border border-line bg-surface p-2\">\n      <PromptIndex items={questions} currentId={current} onJump={setCurrent} />\n    </div>\n  );\n}"
        },
        {
          "title": "Rail on a long thread",
          "description": "Hover, tab into or tap the marks on the right to open the list. Picking a question scrolls the thread and moves focus to that message. Scrolling updates the current mark. On a phone a Questions button takes the rail's place.",
          "code": "function Example() {\n  const thread = useRef<HTMLDivElement>(null);\n  const [current, setCurrent] = useState(\"q1\");\n  const jump = (id: string) => {\n    const el = thread.current?.querySelector<HTMLElement>(`[data-q=\"${id}\"]`);\n    el?.scrollIntoView({ block: \"start\" });\n    el?.focus({ preventScroll: true });\n    setCurrent(id);\n  };\n  // Marks the last question whose top has scrolled past the top of the thread.\n  const trackCurrent = () => {\n    const box = thread.current;\n    if (!box) return;\n    let seen = questions[0].id;\n    for (const q of questions) {\n      const el = box.querySelector<HTMLElement>(`[data-q=\"${q.id}\"]`);\n      if (el && el.offsetTop - box.offsetTop <= box.scrollTop + 24) seen = q.id;\n    }\n    setCurrent(seen);\n  };\n  return (\n    <div className=\"relative w-full max-w-lg\">\n      <div ref={thread} onScroll={trackCurrent} tabIndex={0} aria-label=\"Conversation\" className=\"h-80 overflow-y-auto rounded-card border border-line bg-surface py-4 pr-10 pl-4\">\n        {questions.map((q, i) => (\n          <div key={q.id} className=\"mb-6 flex flex-col gap-3\">\n            <p data-q={q.id} tabIndex={-1} className=\"self-end rounded-card bg-sunken px-3 py-2 text-body text-fg\">{q.text}</p>\n            <p className=\"text-body text-fg\">{answers[i]}</p>\n          </div>\n        ))}\n      </div>\n      <PromptIndex variant=\"rail\" items={questions} currentId={current} onJump={jump}\n        className=\"absolute top-3 right-1 bottom-3 max-sm:top-auto max-sm:right-3 max-sm:bottom-3\" />\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-prompt-index/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/prompt-index.md"
    },
    {
      "id": "live-log",
      "name": "LiveLog",
      "level": "organism",
      "since": "0.7",
      "aka": [
        "log viewer",
        "console",
        "terminal output",
        "logs"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "With no lines the log says \"Nothing yet.\"",
          "source": "documented"
        },
        {
          "name": "following",
          "note": "The log stays scrolled to the newest line and is polite to screen readers. This is the default.",
          "source": "documented"
        },
        {
          "name": "scrolled up",
          "note": "Scrolling away from the bottom stops following, silences announcements and shows a Jump to latest button with a count of lines that arrived since.",
          "source": "documented"
        },
        {
          "name": "paused with held lines",
          "note": "New lines are held back, a Paused note shows in the header, and an \"N new lines\" button resumes and jumps to the newest line.",
          "source": "documented"
        },
        {
          "name": "burst",
          "note": "When several lines arrive within 2 seconds, the log stops reading each one and a hidden status reads one count summary instead.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The scrolling area shows the focus ring and scrolls with the arrow keys, Page Up, Page Down, Home and End.",
          "source": "documented"
        }
      ],
      "summary": "A streaming, monospaced log of what an agent is doing, with pause and follow.",
      "description": "LiveLog shows tool output, commands and other lines as they arrive. It keeps the newest line in view until the person scrolls up, then offers a Jump to latest button. Pause holds new lines back behind an \"N new lines\" button. Warnings and errors carry an icon and a word, never colour alone.",
      "purpose": "Lets people watch a running agent's raw output, stop it from moving to read it, and catch up when they are ready.",
      "useFor": [
        "Command and test output while a coding agent runs.",
        "A browser or data agent's step-by-step log.",
        "Any stream of short lines that people may want to stop and read."
      ],
      "notFor": [
        {
          "text": "A lasting record of what the agent changed, with undo",
          "use": "action-log"
        },
        {
          "text": "A readable summary of the agent's steps",
          "use": "activity-trace"
        },
        {
          "text": "A finished block of code or output to copy",
          "use": "code-block"
        }
      ],
      "whenToUse": [
        "Keep the log for raw, fast output. Summarise the same work elsewhere for people who will not read it.",
        "Give warnings and errors a level so they are marked with a word.",
        "Turn on wrap where lines are long and the screen is narrow."
      ],
      "avoid": [
        "Do not use it as the only record of what an agent changed.",
        "Do not edit or remove earlier lines; only append.",
        "Do not colour whole lines to show status."
      ],
      "accessibility": [
        "The scrolling area has role=\"log\", is named by the visible heading, and can be focused with Tab. Arrow keys, Page Up, Page Down, Home and End then scroll it.",
        "While following and not paused, the log is polite, so a single new line is read out. When several lines arrive within 2 seconds of the last one read, the log goes quiet and a hidden status line reads one summary, such as \"4 new lines. Latest: Error: Expected status 200\", at most every 2 seconds.",
        "Nothing is read out while paused or while the person has scrolled up.",
        "Warning and error lines show an icon and the word Warning or Error. Errors also use the danger text colour. Info lines have no mark.",
        "The Pause button's name includes the log's name, such as \"Pause Test run\", and changes to Resume. A visible Paused note appears while paused.",
        "New lines fade in from a slight blur, the first four of a burst a little apart. Under reduced motion they just appear.",
        "Numbers use tabular figures in a monospaced font, so columns of times line up."
      ],
      "accessibilityYouDo": [
        "Give it a label that says what is being logged, such as \"Test run\".",
        "Only append lines. The component holds paused lines by count, so changing earlier lines while paused shows the wrong ones.",
        "Keep each line short and give warnings and errors a level, so they get their icon and word.",
        "Format time before passing it in."
      ],
      "onPhone": [
        "It fills the width of its container in one column. Long lines scroll sideways inside the log unless wrap is on, so the page never scrolls sideways.",
        "The Pause button and the N new lines button keep their size and have a 44px tap area on a touch screen.",
        "Dragging the log up stops following, and the Jump to latest button appears near the bottom.",
        "Turn on wrap on narrow screens when lines are long and reading matters more than lining up."
      ],
      "importPath": "import { LiveLog } from \"./index\"",
      "sourcePath": "src/organisms/LiveLog.tsx",
      "props": [
        {
          "name": "lines",
          "type": "{ id: string; text: string; time?: string; level?: \"info\" | \"warn\" | \"error\" }[]",
          "required": true,
          "default": null,
          "description": "Every line so far, oldest first. Append new lines to the end."
        },
        {
          "name": "follow",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Keep the newest line in view. Scrolling up turns it off and scrolling back to the bottom turns it on. Works without onFollowChange."
        },
        {
          "name": "onFollowChange",
          "type": "(follow: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when following stops or starts, from scrolling or the Jump to latest button."
        },
        {
          "name": "paused",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Hold new lines back. They are counted on an \"N new lines\" button that resumes. Works without onPauseChange."
        },
        {
          "name": "onPauseChange",
          "type": "(paused: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when the Pause, Resume or N new lines button is pressed."
        },
        {
          "name": "maxHeight",
          "type": "number | string",
          "required": false,
          "default": "64",
          "description": "Tallest the log grows before it scrolls. A number is spacing steps (64 is 16rem); a string is any CSS length, such as a token in var()."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Shown as the heading and used as the log's name and in the Pause button's name."
        },
        {
          "name": "wrap",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Wrap long lines. When off, long lines keep their spacing and the log scrolls sideways."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer box."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--font-mono",
        "--text-small",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--danger-fg",
        "--sunken",
        "--raised",
        "--line",
        "--line-strong",
        "--hover",
        "--spacing",
        "--stagger",
        "--blur-arrive",
        "--dur-base",
        "--ease-arrive",
        "--radius-control",
        "--radius-card"
      ],
      "related": [
        "action-log",
        "activity-trace",
        "tool-call-card",
        "code-block"
      ],
      "composedOf": [
        "jump-to-latest"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Lines arriving, with pause",
          "description": "A new line arrives every 0.9 seconds. Press Pause to hold them; new ones wait behind the \"N new lines\" button. Scroll up to stop following.",
          "code": "function Example() {\n  const [lines, setLines] = useState<LiveLogLine[]>([]);\n  const [paused, setPaused] = useState(false);\n  const [run, setRun] = useState(0);\n  useEffect(() => {\n    let i = 0;\n    const t = window.setInterval(() => {\n      if (i >= script.length) { window.clearInterval(t); return; }\n      const s = script[i];\n      const sec = String(i).padStart(2, \"0\");\n      setLines((prev) => [...prev, { ...s, id: `${run}-${i}`, time: `10:42:${sec}` }]);\n      i += 1;\n    }, 900);\n    return () => window.clearInterval(t);\n  }, [run]);\n  return (\n    <div className=\"flex w-full max-w-lg flex-col gap-3\">\n      <LiveLog label=\"Test run\" lines={lines} paused={paused} onPauseChange={setPaused} maxHeight={48} />\n      <Button variant=\"secondary\" size=\"sm\" className=\"self-start\" onClick={() => { setLines([]); setPaused(false); setRun((r) => r + 1); }}>\n        Run again\n      </Button>\n    </div>\n  );\n}"
        },
        {
          "title": "Wrapped lines with levels",
          "description": "With wrap, long lines break instead of scrolling sideways. Warnings and errors carry an icon and a word.",
          "code": "<LiveLog label=\"Report export\" wrap maxHeight={40} lines={[\n  { id: \"b1\", time: \"14:03:10\", text: \"Fetching https://example.com/reports/quarterly/2026/q3/summary.json?include=regions,teams,targets\" },\n  { id: \"b2\", time: \"14:03:11\", text: \"Response 200 in 412 ms\" },\n  { id: \"b3\", time: \"14:03:11\", level: \"warn\", text: \"Field 'region' is empty in 3 of 120 rows. Those rows are skipped.\" },\n  { id: \"b4\", time: \"14:03:12\", text: \"Wrote 117 rows to quarterly.csv\" },\n  { id: \"b5\", time: \"14:03:12\", level: \"error\", text: \"Could not upload quarterly.csv: the shared folder is read only\" },\n]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-live-log/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/live-log.md"
    },
    {
      "id": "json-view",
      "name": "JsonView",
      "level": "molecule",
      "since": "0.7",
      "aka": [
        "json viewer",
        "object inspector",
        "tree view"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "expanded",
          "note": "Click a branch or press Right, Enter or Space to open it. The marker turns and the children appear below, indented.",
          "source": "documented"
        },
        {
          "name": "collapsed",
          "note": "A closed object or array shows its count, such as { 4 keys } or [ 12 items ].",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "An empty object or array shows { } or [ ] and cannot be opened.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "One row at a time takes the focus ring. Arrow keys, Home and End move it.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "The row under the pointer gets a light background.",
          "source": "documented"
        },
        {
          "name": "truncated",
          "note": "A string longer than maxStringLength ends in an ellipsis with Show all. Click the row or press Enter to show all of it, and again to shorten it.",
          "source": "documented"
        },
        {
          "name": "copied",
          "note": "After a copy, the words Copied show next to the button with a tick for two seconds, and are announced.",
          "source": "documented"
        },
        {
          "name": "copy failed",
          "note": "If the clipboard or onCopy fails, Could not copy shows in the danger colour for two seconds, and is announced.",
          "source": "documented"
        },
        {
          "name": "circular",
          "note": "A value that contains itself shows [circular] at the point where it repeats, instead of opening forever.",
          "source": "documented"
        }
      ],
      "summary": "Shows a tool call's input or output as a tree you can open level by level, with a copy button.",
      "description": "JsonView turns any JSON value into a tree. Objects and arrays start closed below the first level and show how many keys or items they hold. Strings, numbers, true or false and null each have their own colour and their own mark, such as quotes around strings. Long strings are cut short until you ask for all of it, and the whole value can be copied as JSON.",
      "purpose": "Lets people read and check the structured data an agent sent to a tool or got back, without reading raw text.",
      "useFor": [
        "The input and output of a tool call in an agent run.",
        "A debug panel that shows a request or response body.",
        "Any nested data where people need to open one part and ignore the rest."
      ],
      "notFor": [
        {
          "text": "Code or a flat block of text to copy",
          "use": "code-block"
        },
        {
          "text": "Many records with the same fields, to scan and compare",
          "use": "data-table"
        },
        {
          "text": "A short summary of a tool call for most readers",
          "use": "tool-call-card"
        }
      ],
      "whenToUse": [
        "Name the label after what the data is, not after the tool.",
        "Open one level by default and let people open the rest.",
        "Lower maxStringLength in a narrow side panel.",
        "Put it inside ToolCallCard or a Collapsible when most readers do not need the raw data."
      ],
      "avoid": [
        "Do not use it for prose or code. Use CodeBlock.",
        "Do not show secrets, tokens or personal data that the reader should not copy.",
        "Do not open every level of a large value by default."
      ],
      "accessibility": [
        "Follows the WAI-ARIA tree view pattern: role tree, role treeitem rows with aria-level, aria-posinset, aria-setsize, and aria-expanded on objects and arrays that have children.",
        "One row is in the Tab order at a time. Up and Down move between rows, Right opens a branch or moves into it, Left closes it or moves to its parent, Home and End go to the first and last row.",
        "Enter and Space open or close a branch. On a shortened string they show all of it, or shorten it again.",
        "Each row has a spoken name with the key, the type word and the value or count, such as \"filters, object, 2 keys\", and says when a string is shortened.",
        "Types are told apart by marks as well as colour: quotes around strings, the words true, false and null, braces with a key count and brackets with an item count.",
        "The tree sits in a region named by the label, which scrolls sideways when needed.",
        "Copy results show as text and are announced politely: Copied, or Could not copy when the clipboard or onCopy fails.",
        "New rows fade in only when the person has not asked for reduced motion. The open marker turns without motion in that case too."
      ],
      "accessibilityYouDo": [
        "Give a label that says what the data is, such as \"Search input\" or \"Search result\". It is the name of the tree and the region.",
        "If you pass onCopy, throw or reject when copying fails, so the failure is shown and announced.",
        "Do not put secrets in the value. Everything shown can be copied."
      ],
      "onPhone": [
        "Long values wrap onto more lines instead of running off the screen, and the indent per level is smaller than on wide screens.",
        "Rows and the Copy button are at least 44px tall on touch screens. Tapping a row opens it, and tapping a shortened string shows all of it.",
        "When the nesting is very deep, the tree scrolls sideways inside its own box, and the page does not."
      ],
      "importPath": "import { JsonView } from \"./index\"",
      "sourcePath": "src/molecules/JsonView.tsx",
      "props": [
        {
          "name": "value",
          "type": "unknown",
          "required": true,
          "default": null,
          "description": "The value to show. Objects and arrays become branches; everything else is a leaf. A value that contains itself shows [circular] instead of looping."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Shown in the header. It is also the accessible name of the tree and of the scroll region."
        },
        {
          "name": "defaultExpandDepth",
          "type": "number",
          "required": false,
          "default": "1",
          "description": "How many levels start open. 1 opens the top level, 0 starts fully closed. Later changes to this prop only affect branches nobody has opened or closed."
        },
        {
          "name": "maxStringLength",
          "type": "number",
          "required": false,
          "default": "160",
          "description": "Strings longer than this many characters are cut short with a Show all choice."
        },
        {
          "name": "onCopy",
          "type": "(text: string) => void | Promise<void>",
          "required": false,
          "default": null,
          "description": "Replaces the built-in clipboard copy. Gets the value as JSON indented with two spaces. Throw or reject to show Could not copy."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer box."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken",
        "--surface-hover",
        "--border",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--success-fg",
        "--info-fg",
        "--accent-fg",
        "--danger-fg",
        "--dur-fast",
        "--radius-card"
      ],
      "related": [
        "tool-call-card",
        "code-block",
        "data-table",
        "collapsible"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Tool input",
          "description": "The top level is open. The filters object shows its key count until you open it.",
          "code": "<JsonView label=\"Search input\" value={{ query: \"refund policy\", limit: 5, filters: { region: \"EU\", since: \"2026-01-01\" }, exact: false }} />"
        },
        {
          "title": "Tool output with a long string",
          "description": "Two levels start open. The note is longer than 80 characters, so it is cut short with Show all.",
          "code": "<JsonView label=\"Search result\" value={searchOutput} defaultExpandDepth={2} maxStringLength={80} />"
        },
        {
          "title": "All closed",
          "description": "With defaultExpandDepth set to 0 only the summary row shows. Use it where space is tight.",
          "code": "<JsonView label=\"Search result\" value={searchOutput} defaultExpandDepth={0} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-json-view/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/json-view.md"
    },
    {
      "id": "inline-confirm",
      "name": "InlineConfirm",
      "level": "molecule",
      "since": "0.7",
      "aka": [
        "popconfirm",
        "confirm",
        "are you sure"
      ],
      "category": "flow",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "asking",
          "note": "After the first press, the question, the cancel button and the confirm button show in the same place and focus moves to cancel.",
          "source": "documented"
        },
        {
          "name": "timed out",
          "note": "If nobody answers within timeout, the question quietly turns back into the first button.",
          "source": "documented"
        },
        {
          "name": "paused",
          "note": "The timer stops while a mouse is over the control, a finger is on it, it has keyboard focus, or the page is hidden, and keeps the time left.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The global focus ring shows on whichever button has keyboard focus.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "Each button shades when the pointer is over it.",
          "source": "documented"
        }
      ],
      "summary": "A button that asks \"Delete this?\" in its own place, with the safe choice focused, instead of opening a dialog.",
      "description": "InlineConfirm starts as one button. When pressed, it turns into a short question with two buttons in the same spot, and focus moves to the safe button. If nobody answers, it quietly goes back after a few seconds. Use it for small actions that people rarely mean to undo, such as deleting a chat or discarding a draft.",
      "purpose": "Adds one small step before an unwanted action, without taking people out of what they were doing.",
      "useFor": [
        "Deleting a chat, a file or an item from a list.",
        "Discarding a draft or clearing a form.",
        "Removing something that can be recreated, but with some effort."
      ],
      "notFor": [
        {
          "text": "An action that cannot be undone and has big effects, such as deleting an account",
          "use": "dialog"
        },
        {
          "text": "Letting an agent act on someone's behalf",
          "use": "approval-prompt"
        },
        {
          "text": "An action that is easy to undo, where an Undo afterwards is kinder",
          "use": "toast"
        }
      ],
      "whenToUse": [
        "Ask a short, specific question, such as \"Delete this chat?\".",
        "Use the danger tone when the action removes something.",
        "Use a dialog instead when people need to read more before deciding."
      ],
      "avoid": [
        "Do not use it for actions that cannot be undone and matter a lot.",
        "Do not chain two inline confirms one after the other.",
        "Do not set the timeout so short that people cannot read the question."
      ],
      "accessibility": [
        "Built from native buttons. When the question shows, focus moves to the cancel button, so pressing Enter twice does not confirm.",
        "Escape, or the cancel button, goes back to the first button and returns focus to it. Confirming also returns focus to it.",
        "The question is announced through a polite live region and is linked to the confirm button with aria-describedby.",
        "The confirm button sits to the right of where the first button was, so a double click cannot press it.",
        "The hidden state uses visibility hidden, so it cannot be focused or read while it is not shown.",
        "The timer pauses for keyboard focus, mouse hover, touch and a hidden page. When it runs out with focus inside, focus goes back to the first button.",
        "The question fades in only when the person has not asked for reduced motion."
      ],
      "accessibilityYouDo": [
        "Make the first button label say what it does, such as \"Delete chat\", and keep the question specific.",
        "If the item disappears after confirming, move focus somewhere sensible, such as the next item in the list.",
        "Keep onConfirm quick. Show any later failure with an error message."
      ],
      "onPhone": [
        "All three buttons keep their size and have a 44px tap area on touch screens.",
        "A finger on the control pauses the timer, and it starts again when the finger lifts.",
        "The control keeps the width of its widest state, so the question does not push nearby items around. Keep the question short on a phone."
      ],
      "importPath": "import { InlineConfirm } from \"./index\"",
      "sourcePath": "src/molecules/InlineConfirm.tsx",
      "props": [
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Label of the first button."
        },
        {
          "name": "confirmLabel",
          "type": "string",
          "required": false,
          "default": "\"Delete\"",
          "description": "Label of the button that does the action."
        },
        {
          "name": "cancelLabel",
          "type": "string",
          "required": false,
          "default": "\"Keep it\"",
          "description": "Label of the safe button. It gets focus when the question shows."
        },
        {
          "name": "question",
          "type": "string",
          "required": false,
          "default": "confirmLabel + \" this?\"",
          "description": "The question shown in place of the first button, for example \"Delete this?\"."
        },
        {
          "name": "onConfirm",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Called when the confirm button is pressed. The control goes back to the first button at the same time."
        },
        {
          "name": "tone",
          "type": "\"danger\" | \"default\"",
          "required": false,
          "default": "\"default\"",
          "description": "danger draws the confirm button in the danger colour. default uses the accent colour."
        },
        {
          "name": "timeout",
          "type": "number",
          "required": false,
          "default": "6000",
          "description": "Milliseconds before an unanswered question goes back to the first button. 0 turns it off. The time pauses while a mouse is over the control, a finger is on it, it has keyboard focus, or the page is hidden."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer wrapper."
        }
      ],
      "variantsHint": {
        "tone": [
          "danger",
          "default"
        ]
      },
      "tokens": [
        "--fg",
        "--surface",
        "--border",
        "--accent",
        "--danger",
        "--dur-fast"
      ],
      "related": [
        "dialog",
        "approval-prompt",
        "toast",
        "action-log",
        "button"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Delete a chat",
          "description": "Each row has its own control. The confirm button uses the danger colour.",
          "code": "function Example() {\n  const [chats, setChats] = useState([\"Trip to Lisbon\", \"Quarterly report\", \"Gift ideas\"]);\n  return (\n    <ul className=\"w-80 divide-y divide-line rounded-card border border-line bg-surface\">\n      {chats.map((c) => (\n        <li key={c} className=\"flex items-center justify-between gap-3 px-3 py-2\">\n          <span className=\"min-w-0 truncate text-body text-fg\">{c}</span>\n          <InlineConfirm tone=\"danger\" question=\"Delete this chat?\" onConfirm={() => setChats((l) => l.filter((x) => x !== c))}>Delete</InlineConfirm>\n        </li>\n      ))}\n      {chats.length === 0 && <li className=\"px-3 py-2 text-body text-fg-muted\">No chats left.</li>}\n    </ul>\n  );\n}"
        },
        {
          "title": "Discard a draft",
          "description": "Custom labels and a longer timeout. The default tone draws the confirm button in the accent colour.",
          "code": "function Example() {\n  const [draft, setDraft] = useState(\"Thanks for the update. I will look at the numbers tomorrow.\");\n  return (\n    <div className=\"flex w-80 flex-col gap-2\">\n      <p className=\"rounded-card border border-line bg-surface p-3 text-body text-fg\">{draft || \"Draft discarded.\"}</p>\n      <InlineConfirm confirmLabel=\"Discard\" cancelLabel=\"Keep editing\" question=\"Discard this draft?\" timeout={10000} onConfirm={() => setDraft(\"\")}>Discard draft</InlineConfirm>\n    </div>\n  );\n}"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-inline-confirm/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/inline-confirm.md"
    },
    {
      "id": "chart-range-brush",
      "name": "ChartRangeBrush",
      "level": "molecule",
      "since": "0.7",
      "aka": [
        "range selector",
        "brush",
        "zoom range"
      ],
      "category": "numbers",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "focus",
          "note": "Tabbing reaches the start handle, the window and the end handle in turn. Each shows the global focus ring.",
          "source": "documented"
        },
        {
          "name": "hover",
          "note": "Pointing at a handle darkens its grip border. The handles show a resize cursor and the window a grab cursor.",
          "source": "documented"
        },
        {
          "name": "dragging",
          "note": "While a handle or the window is dragged it follows the pointer at once, with no easing, even outside the strip.",
          "source": "documented"
        },
        {
          "name": "full range",
          "note": "When the window covers every point, nothing is dimmed and the Reset button looks faded and does nothing.",
          "source": "documented"
        },
        {
          "name": "at the limit",
          "note": "A handle stops where the window would get shorter than minSpan, and the window stops at either end of the strip.",
          "source": "documented"
        }
      ],
      "summary": "A strip under a long chart with two handles. The part between them is what the chart above shows.",
      "description": "ChartRangeBrush draws a small line of the whole series and a window over it. Drag a handle to change one end, or drag the window to move both. Each handle is a slider that works with the keyboard, and the chosen range is written out as text. The parent keeps the range and passes the matching slice to the chart.",
      "purpose": "Lets someone zoom into part of a long series, such as the week a model changed, without leaving the chart.",
      "useFor": [
        "Cost, usage or latency charts that cover 30 to 365 days.",
        "Any long line chart where people need to look closely at a short stretch.",
        "Picking a stretch of a series to compare or export."
      ],
      "notFor": [
        {
          "text": "Picking one value on a scale",
          "use": "slider"
        },
        {
          "text": "Picking calendar dates when there is no chart",
          "use": "date-picker"
        },
        {
          "text": "A short series that already fits on screen",
          "use": "line-chart"
        }
      ],
      "whenToUse": [
        "Put it straight under the chart it controls, the same width.",
        "Pass the full series as values, and only the sliced part to the chart.",
        "Start on a useful window, such as the last 30 days.",
        "Say the unit in formatCount, such as days or hours."
      ],
      "avoid": [
        "Don't use it for a series of a dozen points. Show the whole chart.",
        "Don't animate the chart above when the range changes. It should follow the hand at once.",
        "Don't hide the range text. It is the only exact reading of the window."
      ],
      "accessibility": [
        "Each handle has role slider with aria-valuemin, aria-valuemax and aria-valuenow, and aria-valuetext such as \"Start, 3 August\". Its name is the label plus start or end.",
        "The handles' limits follow each other, so the start can never pass the end, and the gap set by minSpan is kept.",
        "Arrow keys move one point, PageUp and PageDown move a tenth of the series, Home and End go to the limits.",
        "The window is its own slider in the Tab order between the handles. Arrow keys, PageUp, PageDown, Home and End move the whole window, and it reads as \"3 August to 1 September\".",
        "The chosen range is always written as text above the strip, so it never depends on the drawing.",
        "Pointer drags use pointer capture, so the drag keeps working when the pointer leaves the strip. Pressing the bare track brings the nearer handle there and moves focus to it.",
        "Handles get 44px wide hit areas on coarse pointers. The handle line and grip keep a visible colour in forced-colors mode, and the window border uses the Highlight colour.",
        "The visible focus ring comes from the global focus style.",
        "The Reset button uses aria-disabled when the range is already full, so it keeps focus after you press it."
      ],
      "accessibilityYouDo": [
        "Give it a label that says what is being narrowed, such as \"Date range\".",
        "Pass labels (or formatLabel) so the handles read out dates, not positions.",
        "Pass formatCount so the range text says the right unit, such as days.",
        "Slice the chart's data yourself from range, so the chart and the brush always agree."
      ],
      "onPhone": [
        "The strip fills the width, and the label and Reset button wrap above it.",
        "Each handle's hit area is 44px wide on touch screens and the strip is 48px tall, even though the handles look thin.",
        "A touch that starts on the strip does not scroll or zoom the page, so dragging a handle stays put. Touches anywhere else scroll as usual.",
        "The Reset button grows to 44px tall on touch screens."
      ],
      "importPath": "import { ChartRangeBrush } from \"./index\"",
      "sourcePath": "src/molecules/ChartRangeBrush.tsx",
      "props": [
        {
          "name": "values",
          "type": "number[]",
          "required": true,
          "default": null,
          "description": "The overview series, one value per position. Drawn as a small line in the strip."
        },
        {
          "name": "labels",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "One label per value, for example dates. Shown in the range text and read out by the handles."
        },
        {
          "name": "range",
          "type": "[number, number]",
          "required": true,
          "default": null,
          "description": "Start and end index, both inclusive. Values out of bounds, in the wrong order or closer than minSpan are corrected for drawing."
        },
        {
          "name": "onRangeChange",
          "type": "(range: [number, number]) => void",
          "required": true,
          "default": null,
          "description": "Called with the new start and end after a drag, a key press or Reset. Only called when the range really changes."
        },
        {
          "name": "minSpan",
          "type": "number",
          "required": false,
          "default": "2",
          "description": "The fewest points the window may hold. 2 means the start and end can never meet."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Name shown above the strip and used in each handle's name, for example \"Date range\"."
        },
        {
          "name": "formatLabel",
          "type": "(index: number) => string",
          "required": false,
          "default": null,
          "description": "Turns an index into the text shown and read out. Defaults to labels[index], or the position counted from 1."
        },
        {
          "name": "formatCount",
          "type": "(count: number) => string",
          "required": false,
          "default": null,
          "description": "Turns the number of points in the window into words. Defaults to \"15 points\"."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        },
        {
          "name": "...rest",
          "type": "Omit<HTMLAttributes<HTMLDivElement>, \"onChange\">",
          "required": false,
          "default": null,
          "description": "Other attributes go on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--chart-1",
        "--surface",
        "--surface-sunken",
        "--border",
        "--border-strong",
        "--fg",
        "--fg-muted",
        "--focus-ring",
        "--dur-fast",
        "--text-small"
      ],
      "related": [
        "line-chart",
        "bar-chart",
        "chart-frame",
        "slider",
        "date-picker"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Wired to a LineChart",
          "description": "The parent keeps the range and slices the series for the chart above. Drag the window or a handle and the chart shows only that stretch.",
          "code": "const [range, setRange] = useState<[number, number]>([30, 59]);\nconst [s, e] = range;\n\n<LineChart\n  x={DAYS.slice(s, e + 1)}\n  series={[{ id: \"cost\", label: \"Daily cost\", data: COST.slice(s, e + 1) }]}\n  formatValue={(n) => `$${n}`}\n  unit=\"dollars\"\n  height={200}\n  ariaLabel=\"Daily model cost\"\n/>\n<ChartRangeBrush\n  label=\"Date range\"\n  values={COST}\n  labels={DAYS}\n  range={range}\n  onRangeChange={setRange}\n  formatCount={(n) => `${n} ${n === 1 ? \"day\" : \"days\"}`}\n/>"
        },
        {
          "title": "At least a week",
          "description": "minSpan keeps the window from getting shorter than seven points. It starts on the full range, so Reset looks dimmed.",
          "code": "const [range, setRange] = useState<[number, number]>([0, DAYS.length - 1]);\n\n<ChartRangeBrush\n  label=\"Latency window\"\n  values={LATENCY}\n  labels={DAYS}\n  range={range}\n  onRangeChange={setRange}\n  minSpan={7}\n  formatCount={(n) => `${n} days`}\n/>"
        },
        {
          "title": "Labels made from the index",
          "description": "With no labels array, formatLabel turns each position into text. Here each point is half an hour.",
          "code": "const hourLabel = (i: number) => `${String(Math.floor(i / 2)).padStart(2, \"0\")}:${i % 2 ? \"30\" : \"00\"}`;\nconst [range, setRange] = useState<[number, number]>([18, 35]);\n\n<ChartRangeBrush\n  label=\"Time of day\"\n  values={REQUESTS}\n  range={range}\n  onRangeChange={setRange}\n  formatLabel={hourLabel}\n  formatCount={(n) => `${n} half hours`}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-chart-range-brush/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/chart-range-brush.md"
    },
    {
      "id": "mic-button",
      "name": "MicButton",
      "level": "atom",
      "since": "0.9",
      "aka": [
        "microphone button",
        "record button",
        "push to talk"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "permission prompt open",
          "note": "Set state to requesting while the browser asks. The ring turns and the word says Allow the mic to start.",
          "source": "documented"
        },
        {
          "name": "cancel armed",
          "note": "Hold mode: while the finger is more than 32px off the button, the bars give way to a stop mark and the word says Let go to cancel.",
          "source": "documented"
        }
      ],
      "summary": "The one mic control for dictation and starting voice, with every mic state written next to it: recording, locked, turning speech into text, blocked and no mic.",
      "description": "MicButton starts and stops the mic. In tap mode one press starts and the next stops. In hold mode the person holds to talk, slides up to lock the recording, and slides away to cancel. While recording, three neutral bars inside the button follow the input level and the word Recording sits beside it. The app owns the state: it asks for permission, records and transcribes, and passes the state back in.",
      "purpose": "Gives people one clear, labelled control for the mic, so they always know whether it is listening. The state is in words beside the button, not only in an icon or a colour.",
      "useFor": [
        "Dictating into a message box: the speech fills the field and the person sends it.",
        "Starting a voice conversation from a composer.",
        "Hold-to-talk voice notes on a phone, with slide up to lock and slide away to cancel."
      ],
      "notFor": [
        {
          "text": "Muting the mic during a live voice session",
          "use": "voice-panel"
        },
        {
          "text": "Playing back a recording"
        },
        {
          "text": "A plain toggle that is not about the mic",
          "use": "switch"
        }
      ],
      "whenToUse": [
        "Keep the transcribed words in the field for the person to read and send.",
        "Show blocked with a way to fix it, not a dead button.",
        "Use lg for the main control on a voice screen, md inside a composer.",
        "Give a typed route next to the mic."
      ],
      "avoid": [
        "Do not colour the recording state lime. Lime is for the AI; this is the person's voice.",
        "Do not use amber for recording. Amber is only for blocked, where the person has to act.",
        "Do not rely on the system mic dot alone to show the mic is on.",
        "Do not start recording without a press."
      ],
      "accessibility": [
        "It is a real button. Its name changes with the state: the label when idle, Stop recording while recording, and Mic blocked. Show how to turn it on when blocked.",
        "aria-pressed is true while recording or locked.",
        "Requesting, processing and unavailable set aria-disabled, so the button stays focusable and its name explains why it does nothing.",
        "A polite status region says only Recording and Stopped, once each.",
        "In hold mode, holding Space records and releasing stops; Enter starts and stops like a toggle. No key cancels, so nothing is thrown away by one key.",
        "Every state shows a word beside the button, so the state never depends on colour or the icon alone.",
        "Under reduced motion the level bars are replaced by a still stop mark and the press does not shrink."
      ],
      "accessibilityYouDo": [
        "Set state from what is really happening: requesting while the permission prompt is open, blocked when it was refused, unavailable when there is no input device.",
        "Pass onHelp when the mic can be blocked, and open steps that say how to turn it back on.",
        "Put the transcribed words in the field for the person to check. Never send dictation on their behalf.",
        "Pass level from a real analyser, from 0 to 1. Leave it at 0 if you have none; the word Recording still shows.",
        "Offer a typed route next to it. Voice must never be the only way in."
      ],
      "onPhone": [
        "The button is 44px (md) or 56px (lg), so it meets the touch size without extra padding.",
        "Hold mode stops the page from scrolling or opening the long-press menu while the finger is down.",
        "Slide up 56px to lock, or slide more than 32px off the button and let go to cancel. The words beside the button say which will happen.",
        "Start and stop give a short buzz on phones that support it. Turn it off with haptics={false}."
      ],
      "importPath": "import { MicButton } from \"./index\"",
      "sourcePath": "src/atoms/MicButton.tsx",
      "props": [
        {
          "name": "state",
          "type": "\"idle\" | \"requesting\" | \"recording\" | \"locked\" | \"processing\" | \"blocked\" | \"unavailable\"",
          "required": true,
          "default": null,
          "description": "Where the mic is. The app sets it from the real permission, recorder and transcriber."
        },
        {
          "name": "mode",
          "type": "\"tap\" | \"hold\"",
          "required": false,
          "default": "\"tap\"",
          "description": "tap: press to start, press again to stop. hold: hold to talk, slide up to lock, slide away to cancel."
        },
        {
          "name": "level",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Input level from 0 to 1. Drives the three bars while recording."
        },
        {
          "name": "onStart",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "The person asked to start. Ask for the mic, then set requesting, recording or blocked."
        },
        {
          "name": "onStop",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "The person asked to stop. Keep what was said."
        },
        {
          "name": "onCancel",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Throw the recording away. Called when a held press slides off and lets go, or Cancel is pressed while locked."
        },
        {
          "name": "onLock",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Hold mode: the person slid up. Set state to locked."
        },
        {
          "name": "onHelp",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Pressed while blocked. Open the steps to turn the mic on."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Dictate\"",
          "description": "What the button does when idle, such as Dictate or Start voice. It is the accessible name when idle."
        },
        {
          "name": "showLabel",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Show the label as text beside the button when idle. Other states always show their word."
        },
        {
          "name": "size",
          "type": "\"md\" | \"lg\"",
          "required": false,
          "default": "\"md\"",
          "description": "md is 44px. lg is 56px, for the main control on a voice screen."
        },
        {
          "name": "haptics",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "A short buzz on start and stop where the device supports it."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer row."
        }
      ],
      "variantsHint": {
        "state": [
          "idle",
          "requesting",
          "recording",
          "locked",
          "processing",
          "blocked",
          "unavailable"
        ],
        "mode": [
          "tap",
          "hold"
        ],
        "size": [
          "md",
          "lg"
        ]
      },
      "tokens": [
        "--accent",
        "--on-accent",
        "--surface",
        "--line",
        "--line-strong",
        "--fg",
        "--fg-muted",
        "--danger",
        "--attention",
        "--dur-fast",
        "--dur-instant",
        "--ease-out",
        "--touch-target"
      ],
      "related": [
        "voice-panel",
        "composer",
        "prompt-box",
        "live-captions",
        "attention-dot"
      ],
      "composedOf": [
        "attention-dot",
        "spinner"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Dictate into a message box",
          "description": "Tap once to start and once to stop. The button waits for permission, records, then writes the words into the box. The person still sends it.",
          "code": "const [state, setState] = useState<MicState>(\"idle\");\nconst level = useMicLevel(state === \"recording\"); // your analyser, 0 to 1\n\n<div className=\"flex items-end gap-2 rounded-field border border-line bg-surface p-2\">\n  <textarea value={text} onChange={(e) => setText(e.target.value)} rows={2} />\n  <MicButton\n    state={state}\n    level={level}\n    label=\"Dictate\"\n    onStart={async () => { setState(\"requesting\"); setState(await startRecorder() ? \"recording\" : \"blocked\"); }}\n    onStop={async () => { setState(\"processing\"); setText(await transcribe()); setState(\"idle\"); }}\n  />\n</div>"
        },
        {
          "title": "Hold to talk, with lock and cancel",
          "description": "Press and hold. Slide up to lock and keep recording hands-free, or slide away and let go to cancel. On a keyboard, hold Space, or press Enter to start and again to stop.",
          "code": "<MicButton\n  state={state}\n  mode=\"hold\"\n  level={level}\n  size=\"lg\"\n  label=\"Hold to talk\"\n  showLabel\n  onStart={() => setState(\"recording\")}\n  onStop={finish}\n  onLock={() => setState(\"locked\")}\n  onCancel={() => { discard(); setState(\"idle\"); }}\n/>"
        },
        {
          "title": "Every state",
          "description": "Each state shows its own word. Blocked carries the amber dot because the person has to act; press it to see the help it opens.",
          "code": "<MicButton state=\"idle\" showLabel label=\"Dictate\" onStart={start} onStop={stop} />\n<MicButton state=\"requesting\" onStart={start} onStop={stop} />\n<MicButton state=\"recording\" level={0.6} onStart={start} onStop={stop} />\n<MicButton state=\"locked\" onStart={start} onStop={stop} onCancel={cancel} />\n<MicButton state=\"processing\" onStart={start} onStop={stop} />\n<MicButton state=\"blocked\" onHelp={openMicHelp} onStart={start} onStop={stop} />\n<MicButton state=\"unavailable\" onStart={start} onStop={stop} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-mic-button/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/mic-button.md"
    },
    {
      "id": "live-captions",
      "name": "LiveCaptions",
      "level": "organism",
      "since": "0.9",
      "aka": [
        "captions",
        "subtitles",
        "live transcript"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "Before the first line, the log shows the empty text.",
          "source": "documented"
        },
        {
          "name": "scrolled up",
          "note": "When the person scrolls up, following stops and Jump to latest shows how many captions arrived since.",
          "source": "documented"
        }
      ],
      "summary": "A streaming caption log for voice that marks words still changing, words the assistant never said, and hidden numbers.",
      "description": "LiveCaptions shows both sides of a voice conversation as they speak, with each speaker named in text. Interim words are lighter and have no full stop, then settle in place under the same id. When the person cuts in, the assistant's unspoken words are struck through and marked Not spoken, or left out. Secrets such as card digits show as a bracketed placeholder. It follows the newest line until the person scrolls up, then offers Jump to latest.",
      "purpose": "Makes a voice conversation readable without sound, and honest about what was really said. It is the accessible version of voice.",
      "useFor": [
        "Live captions under a voice assistant.",
        "A support call transcript where the assistant and the caller are both shown.",
        "A recorded call where each line jumps the audio to that point."
      ],
      "notFor": [
        {
          "text": "A typed chat thread",
          "use": "message-list"
        },
        {
          "text": "A stream of agent steps or logs",
          "use": "live-log"
        },
        {
          "text": "The whole voice screen with orb and controls",
          "use": "voice-panel"
        }
      ],
      "whenToUse": [
        "Keep the same id when an interim line becomes final, so it settles in place.",
        "Pass spokenUpTo from the audio that really played.",
        "Name every speaker in words, and list AI speakers in aiSpeakers.",
        "Store the size and visible choices for the person."
      ],
      "avoid": [
        "Do not colour the person's captions lime. Only the AI label carries the AI wash.",
        "Do not live-announce every word. It fights the audio and the screen reader.",
        "Do not show speech the assistant never played as if it was said.",
        "Do not show emotion scores about the person."
      ],
      "accessibility": [
        "The log is a focusable role=log region named by its heading, with aria-live off, so people read it on demand with the arrow keys.",
        "Each speaker is named in text. AI labels add the AI mark, never colour alone.",
        "Interim lines show the words Still changing, interrupted lines say Interrupted, and hidden parts say Part hidden.",
        "Unspoken words are struck through and start with Not spoken for screen readers.",
        "With announceFinal, a polite status region reads each finished AI line once; interim words are never read.",
        "New lines fade in over --dur-fast. Under reduced motion they appear at once.",
        "With onSeek, each line is a real button."
      ],
      "accessibilityYouDo": [
        "Store visible and size for the person and pass them back next time, so the setting persists across sessions.",
        "Announce the session state (listening, speaking) somewhere else, once, in a status region. The log stays quiet.",
        "Turn on announceFinal for people who mute the audio and use a screen reader.",
        "Replace secrets with [hidden] before they reach the caption text. The part never sees the secret.",
        "Set spokenUpTo from the audio that was really played, so the transcript matches what the person heard.",
        "Offer the full transcript after the session ends, and a way to flag wrong words where it becomes a record."
      ],
      "onPhone": [
        "Caption text starts at the large size on screens narrower than 600px, and medium elsewhere.",
        "The header wraps: the size control and the Hide button drop under the label on a narrow screen.",
        "Lines wrap and never scroll sideways, so the log works at 320px wide.",
        "The Hide and Show button and Jump to latest have 44px touch areas."
      ],
      "importPath": "import { LiveCaptions } from \"./index\"",
      "sourcePath": "src/organisms/LiveCaptions.tsx",
      "props": [
        {
          "name": "lines",
          "type": "CaptionLine[]",
          "required": true,
          "default": null,
          "description": "Every line so far, oldest first. Each has id, speaker, text, status (interim, final, interrupted or redacted), and optional spokenUpTo, at and hiddenLabel."
        },
        {
          "name": "visible",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Captions on or off. Uncontrolled when left out, starting on."
        },
        {
          "name": "onVisibleChange",
          "type": "(visible: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called by the Hide and Show button. Store the choice."
        },
        {
          "name": "size",
          "type": "\"sm\" | \"md\" | \"lg\" | \"xl\"",
          "required": false,
          "default": null,
          "description": "Caption text size. Uncontrolled when left out: lg on a narrow screen, md elsewhere."
        },
        {
          "name": "onSizeChange",
          "type": "(size: CaptionSize) => void",
          "required": false,
          "default": null,
          "description": "Called by the size control. Store the choice."
        },
        {
          "name": "follow",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Keep the newest line in view. Scrolling up turns it off; scrolling back to the bottom turns it on."
        },
        {
          "name": "onFollowChange",
          "type": "(follow: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when following starts or stops."
        },
        {
          "name": "onSeek",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Makes each line a button that jumps the recording to it."
        },
        {
          "name": "speakerLabels",
          "type": "Record<string, string>",
          "required": false,
          "default": "{ person: \"You\", assistant: \"Assistant\" }",
          "description": "Names shown for each speaker key."
        },
        {
          "name": "aiSpeakers",
          "type": "string[]",
          "required": false,
          "default": "[\"assistant\"]",
          "description": "Speaker keys that are AI. Their labels carry the AI mark on a soft AI wash."
        },
        {
          "name": "unspoken",
          "type": "\"show\" | \"hide\"",
          "required": false,
          "default": "\"show\"",
          "description": "Interrupted lines: show the unspoken rest struck through, or leave it out."
        },
        {
          "name": "announceFinal",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Read each finished AI line once in a polite status region."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Captions\"",
          "description": "Accessible name of the log, also shown as its heading."
        },
        {
          "name": "emptyText",
          "type": "ReactNode",
          "required": false,
          "default": "\"Captions appear here as you talk.\"",
          "description": "Shown before the first line."
        },
        {
          "name": "controls",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Show the size control and the Hide button."
        },
        {
          "name": "maxHeight",
          "type": "number | string",
          "required": false,
          "default": "64",
          "description": "Tallest the log grows before it scrolls: spacing steps or a CSS length."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer box."
        }
      ],
      "variantsHint": {
        "size": [
          "sm",
          "md",
          "lg",
          "xl"
        ],
        "unspoken": [
          "show",
          "hide"
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--line",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--lime-soft",
        "--dur-fast",
        "--ease-out",
        "--radius-card",
        "--radius-control"
      ],
      "related": [
        "voice-panel",
        "live-log",
        "mic-button",
        "message-list",
        "jump-to-latest"
      ],
      "composedOf": [
        "jump-to-latest",
        "ai-mark",
        "segmented-control",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A live conversation",
          "description": "Step through it. The person's words start light and settle. The assistant is cut off mid-answer, and the part it never said is struck through.",
          "code": "const [visible, setVisible] = useState(savedVisible);\nconst [size, setSize] = useState<CaptionSize>(savedSize);\n\n<LiveCaptions\n  lines={lines}\n  visible={visible}\n  onVisibleChange={setVisible}\n  size={size}\n  onSizeChange={setSize}\n/>\n\n// lines, updated as the speech comes in:\n{ id: \"p1\", speaker: \"person\", text: \"What time does the pharmacy on\", status: \"interim\" }\n{ id: \"a1\", speaker: \"assistant\", text: \"Elm Street Pharmacy closes at 8 pm today. It opens at 9 tomorrow, and…\", status: \"interrupted\", spokenUpTo: 41 }"
        },
        {
          "title": "A call transcript you can play from",
          "description": "Custom speakers, a hidden card number and a line that was cut off. Each line is a button that plays the recording from that point.",
          "code": "<LiveCaptions\n  label=\"Call transcript\"\n  lines={call}\n  speakerLabels={{ agent: \"Assistant\", caller: \"Dana (caller)\" }}\n  aiSpeakers={[\"agent\"]}\n  onSeek={(id) => player.seekToLine(id)}\n  size=\"sm\"\n/>\n\n{ id: \"c4\", speaker: \"caller\", text: \"The one ending [hidden].\", status: \"redacted\", hiddenLabel: \"card digits hidden\" }"
        },
        {
          "title": "Unspoken words left out",
          "description": "With unspoken set to hide, the transcript keeps only what was played. The line still says Interrupted. announceFinal reads finished assistant lines to screen readers.",
          "code": "<LiveCaptions lines={lines} unspoken=\"hide\" announceFinal controls={false} size=\"lg\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-live-captions/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/live-captions.md"
    },
    {
      "id": "waveform",
      "name": "Waveform",
      "level": "atom",
      "since": "0.9",
      "aka": [
        "audio waveform",
        "sound wave",
        "visualizer"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "error",
          "note": "Set with the error prop.",
          "source": "prop"
        }
      ],
      "summary": "The loudness of a clip drawn as bars, with the played part filled. Can also be the seek control.",
      "description": "Waveform draws one clip from precomputed peaks: a call recording, a generated voice or a song. Played bars are ink and the rest are a light line, with a playhead between. Give it onSeek and it becomes one slider you can drag, tap or move with the keys. It can mark stretches (made by AI, made by a person, stale, selected or highlighted) and moments such as a tool call. In live mode it shows the latest levels from a microphone.",
      "purpose": "Shows the shape of a clip so people can see where the talking, the quiet parts and the marked moments are, and jump straight to them.",
      "useFor": [
        "The seek bar of a call recording, a voice clip or a song.",
        "Showing which stretch of a song or voice-over the AI made or changed.",
        "A small level display beside a microphone, with its state written next to it."
      ],
      "notFor": [
        {
          "text": "A full player with play, time and speed",
          "use": "audio-player"
        },
        {
          "text": "Picking a value on a scale",
          "use": "slider"
        },
        {
          "text": "A trend in numbers over time",
          "use": "sparkline"
        }
      ],
      "whenToUse": [
        "Precompute the peaks on the server, 100 to 200 values for a clip, so the page does not decode the audio.",
        "Pass the real media time as position, and let the playhead move with it.",
        "Label every region and marker in plain words.",
        "Use the ai region only for stretches the AI made or changed."
      ],
      "avoid": [
        "Don't colour the bars, the playhead or the played part with the AI colour. Playback is not AI.",
        "Don't use amber for anything but a stretch or moment a person must act on.",
        "Don't animate the bars to the beat as the main sign of state. Write the state in words.",
        "Don't make the live drawing the only sign that the microphone is on."
      ],
      "accessibility": [
        "With onSeek it is one element with role slider, aria-valuemin, aria-valuemax and aria-valuenow in seconds, and aria-valuetext in words, such as \"1 minute 12 seconds of 3 minutes 12 seconds\".",
        "Arrow keys move 5 seconds, Shift with an arrow 15 seconds, Page Up and Page Down 30 seconds, Home and End go to the start and end.",
        "Regions, markers, the drawing state, the error and the still-being-made part are read out as the slider's description.",
        "Without onSeek, or in live mode, the drawing is hidden from screen readers. Region labels stay readable as text.",
        "Played and unplayed bars differ in lightness, and the playhead is a line, so the played part does not depend on hue. AI regions add a dotted edge and words; stale regions a hatch and words; review markers a diamond shape.",
        "A press focuses the slider, and drags use pointer capture so they keep working outside it. The hit area is at least 44px tall.",
        "Under reduced motion the peaks do not fade in, the playhead moves at most four times a second, and a live drawing changes at most once a second.",
        "In forced colours the playhead, played part and selection keep system colours."
      ],
      "accessibilityYouDo": [
        "With onSeek, give it a label that names the clip, such as \"Seek, call with Maya Okafor\".",
        "In live mode or without onSeek it is hidden from screen readers, so write the state (such as \"You, speaking\") beside it.",
        "Give each region and marker a label in plain words. They are read out with the slider.",
        "Update position from the real media time. Do not ease or animate it."
      ],
      "onPhone": [
        "It fills the width. As a seek control its hit area is at least 44px tall, even at the 32px height.",
        "Tap anywhere to seek. While a finger drags, a time bubble shows above it.",
        "A touch that starts on it does not scroll the page, so dragging stays put.",
        "When the waveform is narrower than 256px (a 320px phone inside a card) the bars give way to a plain progress bar."
      ],
      "importPath": "import { Waveform } from \"./index\"",
      "sourcePath": "src/atoms/Waveform.tsx",
      "props": [
        {
          "name": "peaks",
          "type": "number[] | null",
          "required": true,
          "default": null,
          "description": "Loudness per slice, each 0 to 1. null while still being worked out, which shows the drawing state. In live mode the newest level is last."
        },
        {
          "name": "duration",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Length of the clip in seconds. In live mode, the seconds the visible window covers."
        },
        {
          "name": "position",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Playhead in seconds. Bars before it are drawn as played."
        },
        {
          "name": "onSeek",
          "type": "(seconds: number) => void",
          "required": false,
          "default": null,
          "description": "Makes it a seek slider. Called on tap, drag and key presses. Leave out for a drawing only."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Name of the seek slider, such as \"Seek, call with Maya Okafor\". Needed with onSeek."
        },
        {
          "name": "regions",
          "type": "{ start: number; end: number; kind: \"ai\" | \"person\" | \"stale\" | \"selected\" | \"highlight\"; label?: string }[]",
          "required": false,
          "default": null,
          "description": "Marked stretches. ai gets the AI wash and a dotted top edge, stale an amber hatch, selected an ink outline, highlight a neutral wash, person a solid top edge."
        },
        {
          "name": "markers",
          "type": "{ at: number; label: string; tone?: \"neutral\" | \"review\" }[]",
          "required": false,
          "default": null,
          "description": "Moments on the timeline. review is an amber diamond, for a moment a person must check."
        },
        {
          "name": "pendingFrom",
          "type": "number",
          "required": false,
          "default": null,
          "description": "From this second to the end the clip is still being made. Drawn faded behind a dashed line."
        },
        {
          "name": "mode",
          "type": "\"static\" | \"live\"",
          "required": false,
          "default": "\"static\"",
          "description": "live draws the most recent levels, newest on the right, with no playhead or seeking."
        },
        {
          "name": "liveBars",
          "type": "number",
          "required": false,
          "default": "64",
          "description": "In live mode, how many bars to show."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shows a plain bar and this message instead of the peaks. Seeking still works."
        },
        {
          "name": "simple",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Draws a plain progress bar instead of the peaks."
        },
        {
          "name": "height",
          "type": "32 | 48 | 72",
          "required": false,
          "default": "48",
          "description": "Height of the drawing. The seek hit area is at least 44px tall."
        },
        {
          "name": "formatTime",
          "type": "(seconds: number) => string",
          "required": false,
          "default": null,
          "description": "Clock text for the time bubble while dragging. Defaults to formatMediaTime, such as 1:12."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {
        "mode": [
          "static",
          "live"
        ],
        "height": [
          32,
          48,
          72
        ]
      },
      "tokens": [
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--border-strong",
        "--surface",
        "--lime",
        "--attention",
        "--dur-base",
        "--text-small"
      ],
      "related": [
        "audio-player",
        "transcript-sync",
        "slider",
        "chart-range-brush",
        "sparkline"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Seek control with markers",
          "description": "A call recording. Drag, tap or use the arrow keys to seek. Markers show a tool call, an interruption and, as an amber diamond, a transfer someone should review.",
          "code": "const [pos, setPos] = useState(52);\n\n<Waveform\n  label=\"Seek, call with Maya Okafor\"\n  peaks={peaks}\n  duration={192}\n  position={pos}\n  onSeek={setPos}\n  markers={[\n    { at: 41, label: \"Tool call: look up order 4471\" },\n    { at: 96, label: \"Caller interrupted the assistant\" },\n    { at: 158, label: \"Transfer to a person, check the reason\", tone: \"review\" },\n  ]}\n/>"
        },
        {
          "title": "Marked stretches in a song",
          "description": "The AI-made chorus has a faint AI wash, a dotted top edge and a label. The stale part needs a person to regenerate it, so it is hatched amber. The selection is outlined in ink.",
          "code": "<Waveform\n  label=\"Seek, Harbour Lights take 2\"\n  peaks={songPeaks}\n  duration={180}\n  position={pos}\n  onSeek={setPos}\n  height={72}\n  regions={[\n    { start: 48, end: 76, kind: \"ai\", label: \"Chorus rewritten by AI\" },\n    { start: 96, end: 116, kind: \"stale\", label: \"Needs regenerating\" },\n    { start: 136, end: 160, kind: \"selected\", label: \"Your selection\" },\n  ]}\n/>"
        },
        {
          "title": "Drawing, error, live and plain",
          "description": "peaks={null} while the peaks are worked out. error keeps a plain bar you can still seek. mode=\"live\" shows the latest microphone levels in a neutral colour, with the state written beside it. simple forces the plain bar.",
          "code": "<Waveform label=\"Seek, voice note\" peaks={null} duration={64} onSeek={seek} />\n\n<Waveform label=\"Seek, voice note\" peaks={peaks} duration={64} position={20} onSeek={seek}\n  error=\"The waveform could not be drawn. You can still play and seek.\" />\n\n<span>You, speaking</span>\n<Waveform mode=\"live\" peaks={levels} duration={6} liveBars={48} height={32} />\n\n<Waveform label=\"Seek, voice note\" peaks={peaks} duration={64} position={40} onSeek={seek} simple height={32} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-waveform/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/waveform.md"
    },
    {
      "id": "audio-player",
      "name": "AudioPlayer",
      "level": "molecule",
      "since": "0.9",
      "aka": [
        "audio",
        "player",
        "media player"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "selected",
          "note": "Set with the current prop.",
          "source": "prop"
        },
        {
          "name": "one at a time",
          "note": "Starting any player pauses the others on the page.",
          "source": "documented"
        }
      ],
      "summary": "Plays one clip: play and pause, a waveform to seek, time, speed and download.",
      "description": "AudioPlayer plays a call recording, a generated voice or a song. It has a large Play button, a Waveform that is also the seek slider, the time, a speed choice and an optional Download. It covers loading, streaming, ready, playing, paused, buffering, ended, error and unavailable, each written in words. It plays a real file when src is set, and simulates playback from duration when it is not. Only one player on a page plays at a time.",
      "purpose": "Lets someone listen back, jump to a moment and change the speed, with every state written in words.",
      "useFor": [
        "Listening back to a support or sales call.",
        "Playing a voice or song the AI made, with an AI label.",
        "A list of short takes, using the compact row."
      ],
      "notFor": [
        {
          "text": "A live conversation with the assistant",
          "use": "voice-panel"
        },
        {
          "text": "A recording with a transcript that follows along",
          "use": "transcript-sync"
        },
        {
          "text": "Video",
          "use": "media-frame"
        }
      ],
      "whenToUse": [
        "Wait for a press before playing anything.",
        "Mark only the moments people need, such as tool calls, interruptions and the end.",
        "Say why a clip cannot be played or downloaded, and what is still there.",
        "Add the AI label to clips the AI made, and keep the rest of the player neutral."
      ],
      "avoid": [
        "Don't autoplay, or play a preview on hover.",
        "Don't colour the waveform or the Play button with the AI colour.",
        "Don't let two players play at once. This one pauses the others for you.",
        "Don't use amber for \"recording\" or \"new take ready\". Neither needs a person to act."
      ],
      "accessibility": [
        "The player is a region named by its title.",
        "Play is a toggle button named Play with aria-pressed, true while playing. When it cannot play it uses aria-disabled and stays focusable.",
        "Space and K play or pause while focus is in the player, but not in the speed choice or any text field, and Space on a button keeps its usual meaning.",
        "The waveform is a slider with the time in words, such as \"52 seconds of 3 minutes 12 seconds\"; see Waveform for its keys.",
        "Buffering, ended, streaming, error and unavailable are announced once through a status region. The clock is never announced.",
        "The time uses tabular figures, so it does not jitter.",
        "Speed is a native select named Playback speed.",
        "Under reduced motion the simulated clock ticks four times a second, so the playhead moves in steps."
      ],
      "accessibilityYouDo": [
        "Give a title that tells clips apart, such as \"Call with Maya Okafor\". It names the player.",
        "Never start playback without a press. Autoplay talks over screen readers.",
        "Offer a transcript for recorded speech, with TranscriptSync or a link.",
        "Pass the error and unavailable reasons in plain words, and onRetry when trying again can help."
      ],
      "onPhone": [
        "The Play button is 44px. Speed and Download grow to 44px tall on touch screens.",
        "The seek area is at least 44px tall. Tap to seek, and a time bubble shows while dragging.",
        "The bottom row wraps when space is short. On the narrowest phones the waveform becomes a plain bar.",
        "While playing it sets the lock-screen title and play, pause and seek controls where the browser supports them.",
        "The compact row is 56px tall."
      ],
      "importPath": "import { AudioPlayer } from \"./index\"",
      "sourcePath": "src/molecules/AudioPlayer.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Title of the clip. Also names the player region."
        },
        {
          "name": "subtitle",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Second line, such as the date. Without it the line shows the state in words."
        },
        {
          "name": "src",
          "type": "string",
          "required": false,
          "default": null,
          "description": "A real audio file. Leave out to simulate playback from duration."
        },
        {
          "name": "duration",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Length in seconds. Needed without src."
        },
        {
          "name": "peaks",
          "type": "number[] | null",
          "required": false,
          "default": null,
          "description": "Loudness per slice for the waveform. null shows it still drawing. Leave out for a plain bar."
        },
        {
          "name": "state",
          "type": "\"loading\" | \"streaming\" | \"buffering\" | \"error\" | \"unavailable\"",
          "required": false,
          "default": null,
          "description": "Forces a state the player cannot see. Ready, playing, paused and ended are tracked by the player."
        },
        {
          "name": "current",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Position in seconds when the parent keeps it. A change of more than a quarter second seeks."
        },
        {
          "name": "onTimeChange",
          "type": "(seconds: number) => void",
          "required": false,
          "default": null,
          "description": "Called as the position moves."
        },
        {
          "name": "onPlayingChange",
          "type": "(playing: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when playback starts or stops."
        },
        {
          "name": "markers",
          "type": "WaveformMarker[]",
          "required": false,
          "default": null,
          "description": "Moments on the timeline. Passed to the Waveform."
        },
        {
          "name": "regions",
          "type": "WaveformRegion[]",
          "required": false,
          "default": null,
          "description": "Marked stretches. Passed to the Waveform."
        },
        {
          "name": "madeUpTo",
          "type": "number",
          "required": false,
          "default": null,
          "description": "In the streaming state, the seconds made so far."
        },
        {
          "name": "speeds",
          "type": "number[]",
          "required": false,
          "default": "[0.75, 1, 1.25, 1.5, 2]",
          "description": "Speeds offered. Pass one value to hide the choice."
        },
        {
          "name": "aiGenerated",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Adds an AI label beside the title."
        },
        {
          "name": "aiNote",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "What the AI label explains, such as the voice and date."
        },
        {
          "name": "onDownload",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows a Download button."
        },
        {
          "name": "downloadNote",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Note beside Download, such as downloads left."
        },
        {
          "name": "unavailableReason",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why it cannot be played, shown in the unavailable state."
        },
        {
          "name": "errorText",
          "type": "string",
          "required": false,
          "default": "\"This audio could not be loaded.\"",
          "description": "Message in the error state."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Try again in the error state."
        },
        {
          "name": "mediaSession",
          "type": "{ artist?: string; artwork?: string }",
          "required": false,
          "default": null,
          "description": "Extra lock-screen details while playing."
        },
        {
          "name": "compact",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "One 56px row for lists, with no waveform, speed or download."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the player."
        }
      ],
      "variantsHint": {
        "state": [
          "loading",
          "streaming",
          "buffering",
          "error",
          "unavailable"
        ]
      },
      "tokens": [
        "--accent",
        "--fg",
        "--fg-muted",
        "--border",
        "--surface",
        "--surface-sunken",
        "--danger-fg",
        "--dur-instant",
        "--text-compact",
        "--text-small"
      ],
      "related": [
        "waveform",
        "transcript-sync",
        "voice-panel",
        "media-frame"
      ],
      "composedOf": [
        "waveform",
        "button",
        "select",
        "spinner",
        "ai-label"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Call recording",
          "description": "No file is needed for a preview: the player simulates playback from duration. Press Play, then try the arrow keys on the waveform, or Space and K anywhere in the player.",
          "code": "<AudioPlayer\n  title=\"Call with Maya Okafor\"\n  subtitle=\"Support call, 3 October, 14:02\"\n  duration={192}\n  peaks={peaks}\n  markers={[\n    { at: 41, label: \"Tool call: look up order 4471\" },\n    { at: 96, label: \"Caller interrupted the assistant\" },\n    { at: 158, label: \"Transfer to a person, check the reason\", tone: \"review\" },\n  ]}\n  onDownload={download}\n/>\n\n// With a real file, the length comes from the file:\n<AudioPlayer title=\"Call with Maya Okafor\" src=\"/recordings/4471.mp3\" peaks={peaks} />"
        },
        {
          "title": "Made by AI",
          "description": "aiGenerated adds the AI label beside the title; the player itself stays neutral. The second clip is still being made: it can play now and the end is drawn dashed.",
          "code": "<AudioPlayer\n  title=\"Welcome message, Iris voice\"\n  duration={38}\n  peaks={peaks}\n  aiGenerated\n  aiNote=\"Made with the Iris voice from your script on 3 October.\"\n  onDownload={download}\n  downloadNote=\"3 downloads left this month\"\n/>\n<AudioPlayer\n  title=\"Harbour Lights, take 2\"\n  duration={180}\n  peaks={songPeaks}\n  aiGenerated\n  state=\"streaming\"\n  madeUpTo={124}\n/>"
        },
        {
          "title": "Loading, buffering, error, unavailable and compact",
          "description": "Pass state for what the app knows and the player cannot see. Buffering shows a spinner after a short wait once playing. The compact rows are for lists of takes; starting one pauses the other.",
          "code": "<AudioPlayer title=\"Weekly summary, read aloud\" duration={0} state=\"loading\" />\n<AudioPlayer title=\"Call with Ana Lindqvist\" duration={88} peaks={peaks} state=\"buffering\" />\n<AudioPlayer title=\"Voicemail from Tom Becker\" duration={42} state=\"error\"\n  errorText=\"This recording could not be loaded. Check your connection.\" onRetry={retry} />\n<AudioPlayer title=\"Call with Rui Santos, 2 June\" duration={130} state=\"unavailable\"\n  unavailableReason=\"Recordings are kept for 90 days. The transcript is still there.\" />\n\n<AudioPlayer compact title=\"Take 1, warm and slow\" duration={14} aiGenerated />\n<AudioPlayer compact title=\"Take 2, brighter\" duration={12} aiGenerated />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-audio-player/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/audio-player.md"
    },
    {
      "id": "transcript-sync",
      "name": "TranscriptSync",
      "level": "organism",
      "since": "0.9",
      "aka": [
        "synced transcript",
        "interactive transcript"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the current prop.",
          "source": "prop"
        },
        {
          "name": "loading",
          "note": "Set with the loading prop.",
          "source": "prop"
        }
      ],
      "summary": "A recording's transcript that follows playback. Press a line to play from there.",
      "description": "TranscriptSync shows who said what and when, under a player. The line being played is marked with a bar, a tint and the word Now, and the list keeps it in view until the person scrolls away. Pressing a line plays from its start. Speakers are named in words; AI speakers also carry the AI mark in a small AI-coloured circle. Words the assistant never said because it was cut off are struck through and marked Not spoken.",
      "purpose": "Lets someone read a call and listen to any part of it, so a reviewer can check what was really said.",
      "useFor": [
        "Reviewing a support call handled by a voice agent, including the hand-over to a person.",
        "Checking a generated voice-over against its script.",
        "Any recording where people need to find a moment by reading."
      ],
      "notFor": [
        {
          "text": "Captions of a live conversation",
          "use": "voice-panel"
        },
        {
          "text": "A text chat",
          "use": "message-list"
        },
        {
          "text": "Audio with no transcript",
          "use": "audio-player"
        }
      ],
      "whenToUse": [
        "Show both the time from the start and the wall-clock time for calls, so reviewers can match logs.",
        "Mark speech that was never played as Not spoken.",
        "Keep the player above the transcript, so it stays in reach while reading."
      ],
      "avoid": [
        "Don't colour a person's lines with the AI colour, or the current line. The AI colour is only on the AI speaker's mark.",
        "Don't announce each line as it plays. People read the list when they want to.",
        "Don't scroll the page to follow the line. Only the list scrolls, and only until the person scrolls it."
      ],
      "accessibility": [
        "The list is a section named by label. Every line is a button; pressing it seeks to the line's start.",
        "The current line has aria-current, a bar on its left, a tint and the visible word Now, so it never depends on colour.",
        "Speaker names are text. AI speakers also get a hidden \"(AI)\" after the name, and the AI mark is decorative.",
        "Words that were not spoken are struck through and preceded by hidden text \"Not spoken:\", with a visible Not spoken tag.",
        "Following stops when the person scrolls the current line out of view, and Back to current line brings it back. It scrolls only the list, never the page.",
        "Nothing in the list is announced while playing. Loading is announced once through a status region.",
        "Highlight changes take --dur-fast; there is no other motion."
      ],
      "accessibilityYouDo": [
        "Name every speaker in speakers, and set ai on the ones that are AI.",
        "Mark lines the assistant did not finish saying with status interrupted and spokenUpTo, so the record matches what was played.",
        "Hide card numbers and codes in the text before passing it, and set status redacted.",
        "Automatic transcripts make mistakes. Where the transcript becomes a record, give people a way to flag errors."
      ],
      "onPhone": [
        "It fills the width and the list scrolls inside itself, so the player stays put above it.",
        "Each line is a full-width button at least 44px tall.",
        "Scrolling the list stops it following; Back to current line, 44px tall on touch screens, brings it back.",
        "Speaker, time and the Now tag wrap onto two lines when space is short."
      ],
      "importPath": "import { TranscriptSync } from \"./index\"",
      "sourcePath": "src/organisms/TranscriptSync.tsx",
      "props": [
        {
          "name": "lines",
          "type": "TranscriptLine[]",
          "required": true,
          "default": null,
          "description": "Each line: id, speaker, start in seconds, text, and optional end, clock, status (\"final\" | \"interrupted\" | \"redacted\") and spokenUpTo."
        },
        {
          "name": "speakers",
          "type": "Record<string, { label: string; ai?: boolean }>",
          "required": true,
          "default": null,
          "description": "Who is who. ai lines get the AI mark; people stay neutral."
        },
        {
          "name": "audio",
          "type": "Omit<AudioPlayerProps, \"current\" | \"onTimeChange\">",
          "required": false,
          "default": null,
          "description": "Wires in a player above the list. The part then keeps the time itself."
        },
        {
          "name": "current",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Playback position in seconds, when your own player keeps it."
        },
        {
          "name": "onSeek",
          "type": "(seconds: number) => void",
          "required": false,
          "default": null,
          "description": "Called with a line's start when it is pressed."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Transcript\"",
          "description": "Name of the transcript region."
        },
        {
          "name": "loading",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Shows placeholder lines."
        },
        {
          "name": "emptyText",
          "type": "string",
          "required": false,
          "default": "\"There is no transcript for this recording.\"",
          "description": "Shown when there are no lines."
        },
        {
          "name": "listClassName",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the scrolling list, for example a different max height."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--surface",
        "--surface-sunken",
        "--border",
        "--lime",
        "--on-lime",
        "--dur-fast",
        "--text-compact",
        "--text-small"
      ],
      "related": [
        "audio-player",
        "waveform",
        "voice-panel",
        "message-list"
      ],
      "composedOf": [
        "audio-player",
        "waveform",
        "ai-mark",
        "button",
        "skeleton"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Support call with its recording",
          "description": "Pass audio and the part wires in its own player. Press Play and the current line follows; press any line to jump. The assistant was cut off at 1:06, so the rest of that line is marked Not spoken.",
          "code": "<TranscriptSync\n  audio={{ title: \"Call with Maya Okafor\", subtitle: \"Support call, 3 October, 14:02\", duration: 192, peaks, markers }}\n  speakers={{\n    maya: { label: \"Maya Okafor\" },\n    assistant: { label: \"Support assistant\", ai: true },\n    dev: { label: \"Dev Raman\" },\n  }}\n  lines={[\n    { id: \"l1\", speaker: \"assistant\", start: 0, clock: \"14:02:10\", text: \"Hello, this is the Halden Home support assistant. I am an AI. How can I help today?\" },\n    { id: \"l2\", speaker: \"maya\", start: 7, clock: \"14:02:17\", text: \"Hi. My kettle order was meant to arrive on Monday and it still has not come.\" },\n    // ...\n    { id: \"l8\", speaker: \"assistant\", start: 66, text: \"The courier now expects Thursday. I can also send a new kettle today...\", status: \"interrupted\", spokenUpTo: 71 },\n    // ...\n  ]}\n/>"
        },
        {
          "title": "With your own player",
          "description": "Keep the time yourself when the player lives elsewhere on the screen. Pass current, and seek your player in onSeek.",
          "code": "const [t, setT] = useState(0);\nconst [seekTo, setSeekTo] = useState<number | undefined>();\n\n<AudioPlayer title=\"Call with Maya Okafor\" duration={192} peaks={peaks}\n  current={seekTo} onTimeChange={(s) => { setT(s); setSeekTo(undefined); }} />\n<TranscriptSync lines={lines} speakers={speakers} current={t}\n  onSeek={(s) => { setSeekTo(s); setT(s); }} />"
        },
        {
          "title": "Loading and empty",
          "description": "loading shows placeholder lines while the transcript is made. With no lines, it says so in words.",
          "code": "<TranscriptSync loading lines={[]} speakers={speakers} label=\"Transcript, being made\" />\n<TranscriptSync lines={[]} speakers={speakers} emptyText=\"This call was too short to transcribe.\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-transcript-sync/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/transcript-sync.md"
    },
    {
      "id": "latency-breakdown",
      "name": "LatencyBreakdown",
      "level": "molecule",
      "since": "0.9",
      "aka": [
        "latency",
        "timing breakdown",
        "performance"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "within target",
          "note": "The total is at or under the team's target: the total reads in ink with the words Within target.",
          "source": "documented"
        },
        {
          "name": "over target",
          "note": "The total is over the target: it says Over target by and the number of milliseconds, in words, never by colour alone.",
          "source": "documented"
        },
        {
          "name": "slow turns",
          "note": "In call mode, turns over the target are listed as buttons; pressing one calls onTurnSelect so the app can jump to that moment.",
          "source": "documented"
        },
        {
          "name": "no slow turns",
          "note": "In call mode with every turn inside the target, a plain line says so instead of an empty list.",
          "source": "documented"
        },
        {
          "name": "phone",
          "note": "Under 640px the stacked bar is hidden and each row of the table gets its own small bar, so nothing is squeezed.",
          "source": "documented"
        }
      ],
      "summary": "Shows where the time went in a voice reply or a whole call, names the slowest stage in words and lists the turns that were too slow.",
      "description": "LatencyBreakdown splits the wait before a voice agent answers into stages: end of speech, speech to text, model, tools, voice and network. A stacked bar shows the split, and a real table under it gives every stage in milliseconds. The slowest stage is named in words, not only the total. In call mode it adds P50, P90 and P99, and lists the turns over your team's target, which can move the recording to that moment.",
      "purpose": "Helps the people who run a voice agent see why it felt slow, and which part to fix first, without reading a chart.",
      "useFor": [
        "The detail page of one call in an agent dashboard.",
        "Checking one reply that felt slow while testing an agent.",
        "Comparing a change to the model or voice against your team's target."
      ],
      "notFor": [
        {
          "text": "Showing people on the call that the assistant is working",
          "use": "voice-panel"
        },
        {
          "text": "A single number on a dashboard",
          "use": "stat-tile"
        },
        {
          "text": "Response times over days or weeks",
          "use": "line-chart"
        }
      ],
      "whenToUse": [
        "Name the slowest stage. A total alone does not say what to fix.",
        "Measure time to the first real answer, so a filler word cannot hide a slow reply.",
        "Link slow turns to the recording, so people can hear what happened."
      ],
      "avoid": [
        "Do not colour stages lime. Lime marks the AI, and this is about time.",
        "Do not use amber for a slow turn. Nobody has to act on one call.",
        "Do not show the target as a law. It is a goal your team set."
      ],
      "accessibility": [
        "The stacked bar is hidden from screen readers. A real table follows with every stage, its time in ms and its share.",
        "The slowest stage is written in a sentence and marked as slowest in the table, not only shown by colour.",
        "Over and within target are written in words.",
        "Slow turns are buttons with their name, time and slowest stage in the label.",
        "The bar grows in once and stops; under reduced motion it is drawn still."
      ],
      "accessibilityYouDo": [
        "Pass times in milliseconds that your code measured. Do not round them into words yourself.",
        "If you pass onTurnSelect, make it move your audio player and say where it moved, as the example does.",
        "Set targetMs from your team's own goal and call it that. Do not present it as an industry rule."
      ],
      "onPhone": [
        "On a phone the stacked bar is hidden and each table row shows its own small bar, so the split reads as a list.",
        "Slow turns are full-width buttons with a 44px tap area on touch screens.",
        "Padding shrinks a step below 640px wide so the numbers keep their room."
      ],
      "importPath": "import { LatencyBreakdown } from \"./index\"",
      "sourcePath": "src/molecules/LatencyBreakdown.tsx",
      "props": [
        {
          "name": "mode",
          "type": "\"turn\" | \"call\"",
          "required": true,
          "default": null,
          "description": "One reply, or the whole call with medians, percentiles and turns."
        },
        {
          "name": "stages",
          "type": "{ key: \"endpointing\" | \"stt\" | \"llm\" | \"tools\" | \"tts\" | \"transport\" | \"s2s\"; ms: number }[]",
          "required": true,
          "default": null,
          "description": "Time per stage in milliseconds, in the order they happen. For a call, pass medians."
        },
        {
          "name": "totalMs",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Time from the end of the person's speech to the first sound of the reply."
        },
        {
          "name": "percentiles",
          "type": "{ p50: number; p90: number; p99?: number }",
          "required": false,
          "default": null,
          "description": "Spread of reply times across the call."
        },
        {
          "name": "targetMs",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Your team's goal. Shows over or within target, and lists slow turns in call mode."
        },
        {
          "name": "turns",
          "type": "{ id: string; label?: string; totalMs: number; stages: LatencyStage[] }[]",
          "required": false,
          "default": null,
          "description": "Every turn in the call. Those over the target are listed with their slowest stage."
        },
        {
          "name": "onTurnSelect",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Makes slow turns buttons. Use it to seek the recording."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Heading. Defaults to Response time."
        },
        {
          "name": "stageLabels",
          "type": "Partial<Record<LatencyStageKey, string>>",
          "required": false,
          "default": null,
          "description": "Your own names for the stages."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the card."
        }
      ],
      "variantsHint": {
        "mode": [
          "turn",
          "call"
        ]
      },
      "tokens": [
        "--chart-1",
        "--chart-2",
        "--chart-3",
        "--chart-4",
        "--chart-5",
        "--chart-neutral",
        "--chart-seq-4",
        "--surface",
        "--surface-sunken",
        "--border",
        "--danger-fg",
        "--radius-card",
        "--radius-control",
        "--dur-slow"
      ],
      "related": [
        "stat-tile",
        "bar-chart",
        "data-table",
        "call-record"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "One reply",
          "description": "The model took most of the time, so it is named first. The reply was over the team's target.",
          "code": "<LatencyBreakdown\n  mode=\"turn\"\n  totalMs={990}\n  targetMs={800}\n  stages={[\n    { key: \"endpointing\", ms: 180 },\n    { key: \"stt\", ms: 90 },\n    { key: \"llm\", ms: 520 },\n    { key: \"tts\", ms: 140 },\n    { key: \"transport\", ms: 60 },\n  ]}\n/>"
        },
        {
          "title": "A whole call with slow turns",
          "description": "Stages are medians across the call. Two turns went over 800 ms; choose one to play the recording from there.",
          "code": "<LatencyBreakdown\n  mode=\"call\"\n  stages={medians}\n  totalMs={740}\n  percentiles={{ p50: 740, p90: 1020, p99: 1840 }}\n  targetMs={800}\n  turns={turns}\n  onTurnSelect={(id) => player.seekToTurn(id)}\n/>"
        },
        {
          "title": "Speech-to-speech model, within target",
          "description": "With a single speech model there is no separate speech to text or voice stage. No target line turns red.",
          "code": "<LatencyBreakdown mode=\"turn\" totalMs={540} targetMs={800}\n  stages={[{ key: \"endpointing\", ms: 160 }, { key: \"s2s\", ms: 330 }, { key: \"transport\", ms: 50 }]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-latency-breakdown/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/latency-breakdown.md"
    },
    {
      "id": "call-record",
      "name": "CallRecord",
      "level": "organism",
      "since": "0.9",
      "aka": [
        "call log",
        "call summary",
        "recording"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        }
      ],
      "summary": "One call at a glance for the people who run a voice agent: status, why it ended, the AI's verdict with evidence, the cost and anything a person must fix.",
      "description": "CallRecord sums up one call with a voice agent. It shows the status in words, when it started, how long it ran and why it ended. The AI's verdict (goal met, not met or not sure) comes with its reason and a Show in transcript button that marks the lines it is based on. The cost can be shown as money or as usage. If the agent did not say it is an AI, or no recording consent was captured, an amber notice says a person must fix the agent.",
      "purpose": "Lets an operator understand one call in seconds: did it work, why did it end, what did it cost, and is there anything they must fix in the agent.",
      "useFor": [
        "The detail view when someone opens a row in a call log.",
        "Reviewing calls the AI marked as failed or unsure.",
        "Checking that the agent said it is an AI and asked before recording."
      ],
      "notFor": [
        {
          "text": "The live call the customer sees",
          "use": "voice-panel"
        },
        {
          "text": "A list of many calls",
          "use": "data-table"
        },
        {
          "text": "An AI summary of a document or a thread",
          "use": "summary-card"
        }
      ],
      "whenToUse": [
        "Show the reason with every verdict, and link it to the lines it rests on.",
        "Keep a not sure verdict neutral. It is honest, not a warning.",
        "Use amber only for a missing AI disclosure or recording consent: a person must fix the agent."
      ],
      "avoid": [
        "Do not show emotion scores about the caller, such as frustrated 72%.",
        "Do not use amber for a Live badge. Live uses the danger colour or neutral.",
        "Do not show a verdict while the call is still live."
      ],
      "accessibility": [
        "The status, the verdict and each check are written in words with an icon, never colour alone.",
        "Show in transcript marks the evidence lines in words and moves focus to the first one.",
        "Money and Usage is a radio group: arrow keys move between them.",
        "Cost is a real table with a hidden caption that says what it holds.",
        "The live dot stops pulsing under reduced motion, and the word Live stays."
      ],
      "accessibilityYouDo": [
        "Write endedReason in plain words, such as Caller hung up, not a code like customer-ended-call.",
        "Pass disclosureGiven and consentRecorded from what really happened in the call, so the amber notice is true.",
        "If you link an audio player, pass onShowEvidence and move the player to the first evidence line."
      ],
      "onPhone": [
        "The facts fold into two columns, with why it ended on its own line, and the card padding shrinks a step.",
        "The transcript scrolls inside its own area, about 18rem tall, so the cost and outcome stay close.",
        "The Money or Usage switch and Show in transcript keep a 44px tap area on touch screens."
      ],
      "importPath": "import { CallRecord } from \"./index\"",
      "sourcePath": "src/organisms/CallRecord.tsx",
      "props": [
        {
          "name": "status",
          "type": "\"live\" | \"completed\" | \"failed\" | \"voicemail\" | \"transferred\"",
          "required": true,
          "default": null,
          "description": "Where the call is. Always shown in words."
        },
        {
          "name": "endedReason",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Why it ended, in plain words. Hidden while live."
        },
        {
          "name": "duration",
          "type": "string",
          "required": true,
          "default": null,
          "description": "How long it ran, or has run so far."
        },
        {
          "name": "startedAt",
          "type": "string",
          "required": true,
          "default": null,
          "description": "When it started, already formatted."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Call\"",
          "description": "Heading, such as the caller or the agent."
        },
        {
          "name": "outcome",
          "type": "{ verdict: \"success\" | \"failure\" | \"unknown\"; rationale: string; evidenceLineIds?: string[] }",
          "required": false,
          "default": null,
          "description": "The AI's verdict and its reason. Evidence ids link to transcript lines."
        },
        {
          "name": "cost",
          "type": "{ total: number; items: { label: string; amount: number; quantity?: string }[] }",
          "required": false,
          "default": null,
          "description": "What the call cost. The Usage switch shows when any item has a quantity."
        },
        {
          "name": "currency",
          "type": "string",
          "required": false,
          "default": "\"GBP\"",
          "description": "ISO 4217 code for every amount."
        },
        {
          "name": "locale",
          "type": "string",
          "required": false,
          "default": "\"en-GB\"",
          "description": "Number format for amounts."
        },
        {
          "name": "disclosureGiven",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "false shows an amber notice. Leave out if you do not track it."
        },
        {
          "name": "consentRecorded",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "false shows an amber notice. Leave out if you do not track it."
        },
        {
          "name": "data",
          "type": "Record<string, string>",
          "required": false,
          "default": null,
          "description": "Fields the agent took from the call."
        },
        {
          "name": "transcript",
          "type": "{ id: string; speaker: \"agent\" | \"caller\"; text: string; time?: string }[]",
          "required": false,
          "default": null,
          "description": "A simple transcript. Evidence lines are marked when Show in transcript is pressed."
        },
        {
          "name": "speakerLabels",
          "type": "{ agent: string; caller: string }",
          "required": false,
          "default": "Agent, Caller",
          "description": "Names for the two speakers."
        },
        {
          "name": "onShowEvidence",
          "type": "(lineIds: string[]) => void",
          "required": false,
          "default": null,
          "description": "Called by Show in transcript, for example to seek your audio player."
        },
        {
          "name": "actions",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Extra header actions, such as Open recording."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the card."
        }
      ],
      "variantsHint": {
        "status": [
          "live",
          "completed",
          "failed",
          "voicemail",
          "transferred"
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--warning-soft",
        "--warning-fg",
        "--danger",
        "--success-fg",
        "--lime",
        "--radius-card",
        "--radius-control",
        "--dur-base"
      ],
      "related": [
        "latency-breakdown",
        "summary-card",
        "data-table",
        "voice-panel"
      ],
      "composedOf": [
        "badge",
        "ai-badge",
        "button",
        "money",
        "segmented-control"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Completed call with evidence",
          "description": "The AI says the goal was met and why. Show in transcript marks the line it is based on. Switch the cost to Usage to see what was used.",
          "code": "<CallRecord\n  title=\"Call with Hartley Home assistant\"\n  status=\"completed\"\n  startedAt=\"8 Oct, 14:02\"\n  duration=\"1:04\"\n  endedReason=\"Caller hung up after the refund\"\n  disclosureGiven\n  consentRecorded\n  outcome={{ verdict: \"success\", rationale: \"The caller asked for a refund and the agent refunded £24.\", evidenceLineIds: [\"l5\"] }}\n  data={{ \"Order\": \"40-2219\", \"Reason\": \"Cracked lid\", \"Refund\": \"£24\" }}\n  cost={cost}\n  currency=\"GBP\"\n  transcript={transcript}\n/>"
        },
        {
          "title": "Needs you: no AI disclosure",
          "description": "The agent never said it was an AI. That is amber, because a person must fix the greeting. The verdict is unsure, which stays neutral.",
          "code": "<CallRecord\n  status=\"transferred\"\n  startedAt=\"8 Oct, 09:47\"\n  duration=\"3:21\"\n  endedReason=\"Transferred to Priya in returns\"\n  disclosureGiven={false}\n  consentRecorded\n  outcome={{ verdict: \"unknown\", rationale: \"The caller was moved to a person before the problem was settled.\" }}\n/>"
        },
        {
          "title": "Live and failed",
          "description": "A live call has no verdict yet and its cost is so far. A failed call says why in words.",
          "code": "<CallRecord status=\"live\" startedAt=\"8 Oct, 15:30\" duration=\"0:42\" endedReason=\"\" cost={{ total: 0.12, items }} />\n<CallRecord status=\"failed\" startedAt=\"8 Oct, 11:12\" duration=\"0:08\" endedReason=\"The voice service did not answer\"\n  outcome={{ verdict: \"failure\", rationale: \"The call dropped before the agent spoke.\" }} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-call-record/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/call-record.md"
    },
    {
      "id": "voice-card",
      "name": "VoiceCard",
      "level": "molecule",
      "since": "0.9",
      "aka": [
        "voice option",
        "voice sample"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "selected",
          "note": "Ink border and a \"Selected\" tag. Set selected.",
          "source": "documented"
        },
        {
          "name": "previewing",
          "note": "Sample playing: stop icon, moving bars, \"Playing 0:03 of 0:08\" and a thin ink progress line. Set preview.state to \"playing\".",
          "source": "documented"
        },
        {
          "name": "loading",
          "note": "Sample loading: spinner in the button and \"Loading sample…\". Pressing again cancels.",
          "source": "documented"
        },
        {
          "name": "error",
          "note": "Sample failed: alert icon and \"Sample did not load\". Pressing again retries.",
          "source": "documented"
        },
        {
          "name": "withdrawing",
          "note": "\"Available until 3 March 2027\" with a clock icon. Set status and withdrawsOn.",
          "source": "documented"
        },
        {
          "name": "withdrawn",
          "note": "Radio off, no sample, \"Withdrawn by its owner\". If still selected, an amber line asks the person to pick another voice.",
          "source": "documented"
        },
        {
          "name": "locked",
          "note": "Radio off, sample still playable, lock icon and the reason.",
          "source": "documented"
        },
        {
          "name": "not verified",
          "note": "Cloned voice with consent \"none\": amber \"Not verified yet\" line.",
          "source": "documented"
        }
      ],
      "summary": "One voice to hear and choose: name, how it sounds, who owns it, whether you can use it, and a sample button.",
      "description": "VoiceCard shows a voice's name, a short description, tags, where it came from and who it belongs to. A round button plays a short sample and stops it on a second press; while it plays, a small readout shows the time. Give it onSelect and it becomes a radio, with the sample button kept as a separate control. It also shows when a voice is being withdrawn, has been withdrawn, or is not on the person's plan.",
      "purpose": "Lets people hear a voice before they choose it, and see plainly whose voice it is and whether they can keep using it.",
      "useFor": [
        "A voice in a list people pick from, inside VoicePicker.",
        "A single voice on a settings page, with its sample and its owner.",
        "Showing that a shared voice is being withdrawn, and until when."
      ],
      "notFor": [
        {
          "text": "A whole list of voices to choose from",
          "use": "voice-picker"
        },
        {
          "text": "Playing back a long recording with a scrubber"
        },
        {
          "text": "Choosing which AI model answers",
          "use": "model-picker"
        }
      ],
      "whenToUse": [
        "Keep the description to one line about the sound and what it suits.",
        "Say whose voice it is, and for a shared voice how long it stays available.",
        "Keep samples short, a few seconds, so people can compare quickly.",
        "Use useSimulatedSamples only as a stand-in. Pass your real player's state the same way."
      ],
      "avoid": [
        "Do not autoplay a sample, or let two play at once.",
        "Do not colour the playing state lime. A sample is playback, not the AI working.",
        "Do not use real people's names or famous voices as examples or defaults.",
        "Do not hide a withdrawn voice someone is still using. Show it, and ask them to pick another."
      ],
      "accessibility": [
        "With onSelect, the card holds a native radio named by the voice name and described by the rest of the card, so arrow keys move between voices in a group.",
        "The sample button is a separate button with a name that says what it will do: \"Play sample of Juniper\", \"Stop sample of Juniper\" or \"Try the sample of Juniper again\".",
        "A polite status message says once when a sample loads, plays or fails. The running time is not read out every second.",
        "Selected, withdrawn, locked and not verified are written as words, each with an icon.",
        "The bars beside the time move only while playing, and hold still under reduced motion.",
        "Focus on the radio draws a ring round the whole card."
      ],
      "accessibilityYouDo": [
        "Write the withdrawal date in full with your own date code, for example \"3 March 2027\". The card does not work out dates.",
        "Wrap cards that have onSelect in an element with role radiogroup and a name, or use VoicePicker, which does this.",
        "Stop any other sample before you start one. useSimulatedSamples and VoicePicker already do this.",
        "Never start a sample by itself. Play only when the person presses the button."
      ],
      "onPhone": [
        "The card fills the width. Name and tags wrap; the sample button stays on the right.",
        "The sample button grows from 36px to 44px on a touch screen.",
        "With onSelect, a tap anywhere on the card except the sample button chooses the voice."
      ],
      "importPath": "import { VoiceCard } from \"./index\"",
      "sourcePath": "src/molecules/VoiceCard.tsx",
      "props": [
        {
          "name": "name",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Voice name. Also used in the sample button's label, for example \"Play sample of Juniper\"."
        },
        {
          "name": "description",
          "type": "string",
          "required": false,
          "default": null,
          "description": "One short line on how it sounds and what it suits."
        },
        {
          "name": "tags",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Short tags such as accent, age range or use case."
        },
        {
          "name": "source",
          "type": "\"builtIn\" | \"designed\" | \"cloned\" | \"library\"",
          "required": true,
          "default": null,
          "description": "Where the voice came from. Shown as the first tag."
        },
        {
          "name": "owner",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Whose voice it is. On a cloned voice leave it out when it is the person's own: the card then says \"Your voice, verified\"."
        },
        {
          "name": "consent",
          "type": "\"self-verified\" | \"licensed\" | \"none\"",
          "required": false,
          "default": null,
          "description": "How the owner agreed. \"none\" on a cloned voice shows an amber \"Not verified yet\" line, because the person must verify it."
        },
        {
          "name": "preview",
          "type": "{ state: \"idle\" | \"loading\" | \"playing\" | \"error\"; onToggle(): void; elapsed?: number; duration?: number }",
          "required": false,
          "default": null,
          "description": "The sample player. Leave out for a card with no sample."
        },
        {
          "name": "status",
          "type": "\"available\" | \"withdrawing\" | \"withdrawn\" | \"locked\"",
          "required": false,
          "default": "\"available\"",
          "description": "Withdrawing shows the end date. Withdrawn and locked cannot be chosen. Withdrawn has no sample."
        },
        {
          "name": "withdrawsOn",
          "type": "string",
          "required": false,
          "default": null,
          "description": "End date written out by your code, shown as \"Available until …\"."
        },
        {
          "name": "lockedReason",
          "type": "string",
          "required": false,
          "default": "\"Not on your plan\"",
          "description": "Why a locked voice cannot be chosen."
        },
        {
          "name": "selected",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Shows the card as chosen, with a \"Selected\" tag."
        },
        {
          "name": "onSelect",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Makes the card a radio."
        },
        {
          "name": "groupName",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Radio group name, shared by every card in the group."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the card."
        }
      ],
      "variantsHint": {
        "source": [
          "builtIn",
          "designed",
          "cloned",
          "library"
        ],
        "consent": [
          "self-verified",
          "licensed",
          "none"
        ],
        "status": [
          "available",
          "withdrawing",
          "withdrawn",
          "locked"
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--border-strong",
        "--fg",
        "--fg-muted",
        "--warning-soft",
        "--warning-fg",
        "--danger-fg",
        "--radius-card",
        "--radius-control",
        "--dur-fast",
        "--ease-out"
      ],
      "related": [
        "voice-picker",
        "voice-consent-check",
        "badge"
      ],
      "composedOf": [
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Play a sample",
          "description": "Press the round button to hear a sample and again to stop it. Starting one stops the other. Basalt shows a sample that fails to load.",
          "code": "const sim = useSimulatedSamples(); // stand-in for real audio\n\n<VoiceCard name=\"Juniper\" description=\"Warm and unhurried. Good for long reads.\" tags={[\"Calm\", \"Narration\"]}\n  source=\"builtIn\" preview={sim.previewFor(\"juniper\", { seconds: 8 })} />\n<VoiceCard name=\"Wren\" description=\"Bright and quick. Good for short updates.\" tags={[\"Upbeat\"]}\n  source=\"designed\" preview={sim.previewFor(\"wren\", { seconds: 6 })} />\n<VoiceCard name=\"Basalt\" description=\"Low and steady.\" tags={[\"Deep\"]}\n  source=\"builtIn\" preview={sim.previewFor(\"basalt\", { seconds: 6, fails: true })} />"
        },
        {
          "title": "Ownership and availability",
          "description": "Your own verified clone, a licensed library voice being withdrawn, one locked to a plan, a clone not yet verified (a person must act, so amber), and one already withdrawn.",
          "code": "<VoiceCard name=\"My reading voice\" description=\"Recorded on 2 October.\" source=\"cloned\" consent=\"self-verified\" preview={...} />\n<VoiceCard name=\"Marlow\" source=\"library\" owner=\"Rosa Lindqvist\" consent=\"licensed\"\n  status=\"withdrawing\" withdrawsOn=\"3 March 2027\" preview={...} />\n<VoiceCard name=\"Tamsin\" source=\"library\" owner=\"Tomas Okafor\" status=\"locked\" lockedReason=\"Needs the Studio plan\" preview={...} />\n<VoiceCard name=\"Team intro voice\" source=\"cloned\" owner=\"Ines Morrow\" consent=\"none\" preview={...} />\n<VoiceCard name=\"Fennel\" source=\"library\" owner=\"Rosa Lindqvist\" status=\"withdrawn\" preview={...} />"
        },
        {
          "title": "As a radio",
          "description": "With onSelect the card is a radio. Choosing never plays the sample; the sample button is its own control.",
          "code": "const [value, setValue] = useState(\"juniper\");\n\n<div role=\"radiogroup\" aria-label=\"Narrator voice\">\n  <VoiceCard groupName=\"narrator\" name=\"Juniper\" description=\"Warm and unhurried.\" source=\"builtIn\"\n    selected={value === \"juniper\"} onSelect={() => setValue(\"juniper\")} preview={sim.previewFor(\"juniper\", { seconds: 6 })} />\n  <VoiceCard groupName=\"narrator\" name=\"Wren\" description=\"Bright and quick.\" source=\"builtIn\"\n    selected={value === \"wren\"} onSelect={() => setValue(\"wren\")} preview={sim.previewFor(\"wren\", { seconds: 6 })} />\n</div>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-voice-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/voice-card.md"
    },
    {
      "id": "voice-picker",
      "name": "VoicePicker",
      "level": "organism",
      "since": "0.9",
      "aka": [
        "voice selector",
        "voice library"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "loading",
          "note": "Three placeholder rows and a busy list. Set loading.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "A dashed box with emptyText when voices is empty.",
          "source": "documented"
        },
        {
          "name": "no match",
          "note": "A filter shows no voices: \"No voices tagged Calm.\" with Show all voices.",
          "source": "documented"
        },
        {
          "name": "previewing",
          "note": "One card plays its sample; starting another stops it.",
          "source": "documented"
        },
        {
          "name": "filtered",
          "note": "A filter chip is pressed and the list shows only voices with that tag.",
          "source": "documented"
        }
      ],
      "summary": "A list of voices to hear and choose from, with tag filters. Only one sample plays at a time.",
      "description": "VoicePicker lays out VoiceCards as one radio group. Each card has its own sample button, and starting a sample stops any other. Optional filter chips come from the voices' tags. It has a loading state, an empty state, and a \"no match\" state with a way back to all voices. Without your own player it uses a stand-in that simulates each sample with a timer.",
      "purpose": "Helps people choose a voice by hearing it, with ownership and availability in view, without samples talking over each other.",
      "useFor": [
        "Choosing the voice for an assistant, a narrator or a phone agent.",
        "A voice library where people filter by accent, mood or use case.",
        "A settings page that lists the person's own designed and cloned voices."
      ],
      "notFor": [
        {
          "text": "Showing one voice on its own",
          "use": "voice-card"
        },
        {
          "text": "Choosing among a few short text options",
          "use": "radio-group"
        },
        {
          "text": "Choosing which AI model answers",
          "use": "model-picker"
        }
      ],
      "whenToUse": [
        "Put the most likely voices first, and keep the list short enough to compare by ear.",
        "Use tags people understand: mood, accent, use case.",
        "Keep a withdrawn voice in the list while someone still uses it, so they know to switch."
      ],
      "avoid": [
        "Do not play a sample when a voice is chosen. Choosing and hearing are separate.",
        "Do not use colour alone for locked or withdrawn voices. The card writes the reason.",
        "Do not make the filter chips lime or amber. They are neutral controls."
      ],
      "accessibility": [
        "The list is a radiogroup named by the label. Each card is a native radio, so arrow keys move and choose and Tab stops on the chosen one.",
        "Locked and withdrawn voices are disabled radios, skipped by the arrow keys.",
        "Filter chips are toggle buttons that say whether they are pressed. After filtering, a polite message says how many voices are shown.",
        "While loading, the list is marked busy and \"Loading voices\" is announced.",
        "Only one sample plays at a time, and a sample hidden by a filter stops."
      ],
      "accessibilityYouDo": [
        "Give the picker a label that says what the voice is for, such as \"Narrator voice\".",
        "If you pass previewFor, stop the playing sample before starting another, and never start one without a press.",
        "Write withdrawal dates in full with your own date code."
      ],
      "onPhone": [
        "Always one column on a phone. layout=\"grid\" adds a second column only from 640px wide.",
        "Filter chips wrap and keep a 44px tap area.",
        "Sample buttons are 44px on a touch screen, and a tap on the card chooses the voice."
      ],
      "importPath": "import { VoicePicker } from \"./index\"",
      "sourcePath": "src/organisms/VoicePicker.tsx",
      "props": [
        {
          "name": "voices",
          "type": "VoiceOption[]",
          "required": true,
          "default": null,
          "description": "Each has id, name, source, and optional description, tags, owner, consent, status, withdrawsOn, lockedReason, sampleSeconds and sampleFails."
        },
        {
          "name": "value",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Id of the chosen voice."
        },
        {
          "name": "onChange",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Called when a voice is chosen. Never plays its sample."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Voice\"",
          "description": "Name of the group, shown above the list."
        },
        {
          "name": "hideLabel",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Hides the label visually. Screen readers still hear it."
        },
        {
          "name": "filters",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Shows filter chips built from the tags, with All first."
        },
        {
          "name": "loading",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Shows three placeholder rows."
        },
        {
          "name": "layout",
          "type": "\"list\" | \"grid\"",
          "required": false,
          "default": "\"list\"",
          "description": "Grid adds a second column from 640px wide."
        },
        {
          "name": "previewFor",
          "type": "(voice: VoiceOption) => VoicePreview",
          "required": false,
          "default": null,
          "description": "Your own player. Leave out to use the stand-in, which simulates samples with a timer."
        },
        {
          "name": "emptyText",
          "type": "string",
          "required": false,
          "default": "\"No voices yet.\"",
          "description": "Shown when there are no voices at all."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the wrapper."
        }
      ],
      "variantsHint": {
        "layout": [
          "list",
          "grid"
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--fg",
        "--fg-muted",
        "--radius-card",
        "--radius-control",
        "--dur-fast"
      ],
      "related": [
        "voice-card",
        "voice-consent-check",
        "radio-group",
        "model-picker"
      ],
      "composedOf": [
        "voice-card",
        "skeleton",
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "With tag filters",
          "description": "Filter by tag, play samples, and choose. Starting a sample stops the one playing, and a filter that hides the playing voice stops it too.",
          "code": "const [value, setValue] = useState(\"juniper\");\n\n<VoicePicker label=\"Narrator voice\" voices={voices} value={value} onChange={setValue} filters />\n\n// voices: [{ id: \"juniper\", name: \"Juniper\", description: \"Warm and unhurried.\", tags: [\"Calm\", \"Narration\"],\n//   source: \"builtIn\", sampleSeconds: 8 }, ...]"
        },
        {
          "title": "Grid, with locked and withdrawn voices",
          "description": "Two columns from 640px wide. The chosen voice has been withdrawn, so its card asks the person to pick another.",
          "code": "<VoicePicker label=\"Assistant voice\" layout=\"grid\" value={value} onChange={setValue} voices={[\n  { id: \"juniper\", name: \"Juniper\", description: \"Warm and unhurried.\", source: \"builtIn\" },\n  { id: \"wren\", name: \"Wren\", description: \"Bright and quick.\", source: \"builtIn\" },\n  { id: \"tamsin\", name: \"Tamsin\", source: \"library\", owner: \"Tomas Okafor\", status: \"locked\", lockedReason: \"Needs the Studio plan\" },\n  { id: \"fennel\", name: \"Fennel\", source: \"library\", owner: \"Rosa Lindqvist\", status: \"withdrawn\" },\n]} />"
        },
        {
          "title": "Loading and empty",
          "description": "Placeholder rows while voices load, and a plain message when there are none.",
          "code": "<VoicePicker label=\"Your voices\" voices={voices} loading={isLoading} value={value} onChange={setValue}\n  emptyText=\"You have no voices yet. Design or clone one to see it here.\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-voice-picker/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/voice-picker.md"
    },
    {
      "id": "voice-consent-check",
      "name": "VoiceConsentCheck",
      "level": "organism",
      "since": "0.9",
      "aka": [
        "voice consent",
        "voice verification",
        "consent check"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "explain",
          "note": "What will happen and why, the terms, and a tick box. Continue without ticking shows an error in words.",
          "source": "documented"
        },
        {
          "name": "mic check",
          "note": "A three-step level reading, quiet, speaking or loud. A noisy room shows an amber note with Check again and Continue anyway.",
          "source": "documented"
        },
        {
          "name": "reading",
          "note": "One line to read aloud, large. Recording is marked in red with the word Recording; Space starts and stops it when focus is not on a control.",
          "source": "documented"
        },
        {
          "name": "checking",
          "note": "The recording is being compared. A spinner and the word Checking; focus stays on the step heading.",
          "source": "documented"
        },
        {
          "name": "verified",
          "note": "The voice or likeness is confirmed: a plain success message and what happens next.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "The check did not match. The samples are kept, and the number of tries left is shown.",
          "source": "documented"
        },
        {
          "name": "locked out",
          "note": "After too many tries: the exact time to try again, from your code, and a link to support. No retry button.",
          "source": "documented"
        }
      ],
      "summary": "A live check that the person whose voice or likeness is being copied is present and agrees, by reading prompted lines aloud.",
      "description": "VoiceConsentCheck walks through four steps: agree to plain terms (what it is for, who can use it, how to remove it), check the mic, read short lines aloud one at a time, then the result. A noisy room gets an amber notice with Check again or Continue anyway. A failed check keeps the samples and shows the tries left, and a lockout shows the exact time to try again. Set subject to \"likeness\" to use the same flow for face and voice in video. Your code records, checks and counts attempts; the part only shows the step you give it.",
      "purpose": "Makes cloning a voice or a likeness need the person's own live participation and informed agreement, not just a checkbox.",
      "useFor": [
        "Verifying a voice before it is cloned.",
        "Verifying a person's face and voice before their likeness is used in video (subject=\"likeness\").",
        "Re-checking consent after a failed or expired verification."
      ],
      "notFor": [
        {
          "text": "Approving a single action an agent wants to take",
          "use": "approval-prompt"
        },
        {
          "text": "Asking for mic or camera permission alone",
          "use": "permission-prompt"
        },
        {
          "text": "Picking a voice that is already verified",
          "use": "voice-picker"
        }
      ],
      "whenToUse": [
        "Say plainly what the voice or likeness is for, who can use it and how to remove it, before anything is recorded.",
        "Use fresh lines for each attempt so a saved recording cannot pass.",
        "Keep the samples after a failed check, and say so.",
        "Give an exact retry time when locked out, and a way to reach a person."
      ],
      "avoid": [
        "Do not let a checkbox alone stand in for consent. The live reading is the check.",
        "Do not colour the person's level meter lime. It is their voice, not the AI.",
        "Do not use amber for the Recording label. Amber is only for the noisy room, where the person must act.",
        "Do not offer to clone a famous or someone else's voice."
      ],
      "accessibility": [
        "The panel is a section named by the step heading. When the step changes, focus moves to the new heading.",
        "One polite status message per change. While reading, it carries the line itself, so each line is read out once.",
        "Space starts and stops recording from anywhere in the panel that is not a control or a field. On the Start or Stop button Space works as usual.",
        "The steps are an ordered list with the current one marked and \"done\" or \"current\" written for screen readers.",
        "The level meter has a value in words (quiet, speaking, loud) and three still steps, never a moving wave.",
        "Recording and Not recording are written in words. The new line slides in only when motion is allowed.",
        "The agreement box shows an error in words if Continue is pressed without it."
      ],
      "accessibilityYouDo": [
        "Always pass manualReviewHref, so people who cannot read aloud or use a camera have another way in.",
        "Work out retryAt in code and write it in full, such as \"14:20 tomorrow\". The model must never make up the time.",
        "On a phone, show it in a full-screen Sheet or Dialog.",
        "Make the lines new for each attempt, short, and free of words that are hard to say."
      ],
      "onPhone": [
        "Fills the width. Put it in a full-screen Sheet on a phone.",
        "Lines are set large (22px, 26px from 640px wide) so they are easy to read at arm's length.",
        "Action buttons share the row equally on a phone, and checkboxes are 44px tall on touch.",
        "Recording stops if the page goes to the background, and a note asks the person to read the line again."
      ],
      "importPath": "import { VoiceConsentCheck } from \"./index\"",
      "sourcePath": "src/organisms/VoiceConsentCheck.tsx",
      "props": [
        {
          "name": "step",
          "type": "\"explain\" | \"micCheck\" | \"reading\" | \"checking\" | \"verified\" | \"failed\" | \"lockedOut\"",
          "required": true,
          "default": null,
          "description": "The step to show."
        },
        {
          "name": "lines",
          "type": "string[]",
          "required": true,
          "default": null,
          "description": "Lines to read aloud, one at a time."
        },
        {
          "name": "currentLine",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Index of the line being read."
        },
        {
          "name": "subject",
          "type": "\"voice\" | \"likeness\"",
          "required": false,
          "default": "\"voice\"",
          "description": "Likeness changes the copy to cover the camera as well as the mic."
        },
        {
          "name": "consentText",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "What it will be used for, who can use it and how to remove it. Shown first, and again on success."
        },
        {
          "name": "recording",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "True while the current line is being recorded."
        },
        {
          "name": "level",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Input level from 0 to 1, shown as quiet, speaking or loud."
        },
        {
          "name": "noise",
          "type": "\"ok\" | \"noisy\"",
          "required": false,
          "default": null,
          "description": "Mic check result. Leave out while listening."
        },
        {
          "name": "attemptsLeft",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Tries left, shown after a failed check. At 0, Try again is hidden."
        },
        {
          "name": "retryAt",
          "type": "string",
          "required": false,
          "default": null,
          "description": "When the person may try again, written out by your code."
        },
        {
          "name": "onAgree",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "The box is ticked and Agree and continue pressed."
        },
        {
          "name": "onContinue",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Continue, or Continue anyway when noisy."
        },
        {
          "name": "onCheckAgain",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Check again after a noisy result."
        },
        {
          "name": "onStartLine",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Start recording the current line."
        },
        {
          "name": "onStopLine",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Stop recording. Also called when the page goes to the background."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Try again after a failed check."
        },
        {
          "name": "onCancel",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Cancel, or Close when locked out."
        },
        {
          "name": "onDone",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Done after success."
        },
        {
          "name": "manualReviewHref",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Link to a check by a person, for anyone who cannot read aloud."
        },
        {
          "name": "supportHref",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Support link, shown when locked out."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "2",
          "description": "Level of the step heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the panel."
        }
      ],
      "variantsHint": {
        "step": [
          "explain",
          "micCheck",
          "reading",
          "checking",
          "verified",
          "failed",
          "lockedOut"
        ],
        "subject": [
          "voice",
          "likeness"
        ],
        "noise": [
          "ok",
          "noisy"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--fg",
        "--fg-muted",
        "--warning-soft",
        "--warning-fg",
        "--success-fg",
        "--danger",
        "--danger-fg",
        "--radius-card",
        "--dur-base",
        "--dur-fast"
      ],
      "related": [
        "voice-card",
        "voice-picker",
        "approval-prompt",
        "permission-prompt"
      ],
      "composedOf": [
        "button",
        "checkbox",
        "callout",
        "badge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "The whole check",
          "description": "Agree, check the mic (the first check is noisy here; Check again clears it), read three lines, then checking and verified. Timers stand in for the app.",
          "code": "<VoiceConsentCheck step={step} lines={lines} currentLine={line} consentText={terms}\n  recording={recording} level={level} noise={noise} manualReviewHref=\"/help/manual-review\"\n  onAgree={() => setStep(\"micCheck\")} onCheckAgain={checkMic} onContinue={() => setStep(\"reading\")}\n  onStartLine={startRecording} onStopLine={stopRecordingAndAdvance}\n  onCancel={cancel} onDone={close} />"
        },
        {
          "title": "Likeness for video, every step",
          "description": "subject=\"likeness\" changes the copy to cover the camera. Pick any step to see it; this starts on a failed check with two tries left.",
          "code": "<VoiceConsentCheck subject=\"likeness\" step={step} currentLine={0}\n  lines={[\"Turn your head slowly to the left, then say: green kite, seven stones.\"]}\n  consentText={<p>Your face and voice can appear in clips you make. ...</p>}\n  attemptsLeft={2} retryAt=\"14:20 tomorrow\" noise=\"ok\"\n  manualReviewHref=\"/help/manual-review\" supportHref=\"/help\"\n  onAgree={...} onContinue={...} onStartLine={...} onStopLine={...} onRetry={...} onCancel={...} />"
        },
        {
          "title": "Locked out",
          "description": "After too many tries: the exact time to try again, computed by code, plus support and manual review.",
          "code": "<VoiceConsentCheck step=\"lockedOut\" retryAt=\"14:20 tomorrow\" lines={lines} currentLine={0} consentText={terms}\n  manualReviewHref=\"/help/manual-review\" supportHref=\"/help\"\n  onAgree={() => {}} onStartLine={() => {}} onStopLine={() => {}} onCancel={close} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-voice-consent-check/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/voice-consent-check.md"
    },
    {
      "id": "video-player",
      "name": "VideoPlayer",
      "level": "molecule",
      "since": "0.10",
      "aka": [
        "video",
        "media player"
      ],
      "category": "video",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "selected",
          "note": "Set with the current prop.",
          "source": "prop"
        },
        {
          "name": "generating",
          "note": "state=\"generating\": first frame or poster dimmed under a working edge; nothing can play.",
          "source": "documented"
        },
        {
          "name": "loading",
          "note": "state=\"loading\", or while a src loads: a spinner and disabled controls.",
          "source": "documented"
        },
        {
          "name": "paused on a frame",
          "note": "Pausing shows the frame actions with the time and frame number.",
          "source": "documented"
        },
        {
          "name": "error",
          "note": "state=\"error\" or a file that fails: a calm message, statusNote and Try again.",
          "source": "documented"
        },
        {
          "name": "one at a time",
          "note": "Starting any video or audio player pauses the others on the page.",
          "source": "documented"
        }
      ],
      "summary": "Plays a generated clip and lets you act on the exact frame you paused on.",
      "description": "VideoPlayer plays one clip: play and pause, a frame-accurate scrubber, frame steps, loop, mute, captions and full screen. When it is paused, a small toolbar offers actions on that frame, such as Use as start frame or Extend from here. It covers generating, loading, ready, playing, paused, buffering, ended and error, each written in words. It plays a real file when src is set, and without one it draws a stand-in scene and simulates time, so previews need no file. Nothing plays until the person presses Play.",
      "purpose": "Lets someone watch a generated take, stop on the exact frame they want and use it, with every state in words.",
      "useFor": [
        "Reviewing a take the AI made, then using a frame as the start of the next shot.",
        "Picking the point to extend or edit from.",
        "Showing a clip with captions and a transcript as its text alternative."
      ],
      "notFor": [
        {
          "text": "A still image the AI made",
          "use": "media-frame"
        },
        {
          "text": "Audio only, such as a call or a voice",
          "use": "audio-player"
        },
        {
          "text": "Choosing a stretch of the clip to edit or extend",
          "use": "clip-range-selector"
        },
        {
          "text": "Progress of a long job with steps",
          "use": "generation-progress"
        }
      ],
      "whenToUse": [
        "Wait for a press before playing anything.",
        "Offer frame actions that match the next step, such as Use as start frame or Extend from here.",
        "Label every AI clip and say what the file carries.",
        "Give a transcript or captions for clips with speech."
      ],
      "avoid": [
        "Don't autoplay, or play on hover under reduced motion.",
        "Don't colour the player, the scrubber or the progress with the AI colour. Only the label and the working edge use it.",
        "Don't show a countdown or a made-up percentage while generating.",
        "Don't use red or blame words for a blocked take. Say what was blocked and whether credits came back."
      ],
      "accessibility": [
        "The player is a region named by its title.",
        "Play is a toggle button named Play with aria-pressed. When it cannot play it uses aria-disabled and stays focusable.",
        "The scrubber is a slider with the time in words, such as \"3.2 seconds of 8 seconds, frame 77 of 192\".",
        "The frame step buttons say the frame they go to, such as \"Forward one frame, to frame 78\".",
        "Loop, Mute and Captions are toggle buttons with aria-pressed. A clip with no sound says \"No sound in this clip\".",
        "Space plays or pauses while focus is in the player, except on a button. Arrows seek 1 second, Shift with an arrow steps a frame, Home and End jump to the ends.",
        "K, M, C, comma and period work only while focus is in the player and never in a text field. A switch in Keyboard shortcuts turns them off.",
        "The paused-frame actions are a named group that says the frame number.",
        "Ready after generating, ended, buffering and error are announced once. The clock and play or pause are not.",
        "Captions use native tracks for files, and the transcript sits in a collapsible section.",
        "The big play mark on the frame is for pointers only and is hidden from screen readers; the Play button is the real control."
      ],
      "accessibilityYouDo": [
        "Give a label that tells takes apart, such as \"Take 3, harbour at dawn\". It names the player and its scrubber.",
        "Give a transcript, or cues for captions, for any clip with speech. Describe what the clip shows there too: the prompt is not a description.",
        "Never start playback from code. There is no autoplay prop on purpose.",
        "Say what the file carries in provenance (watermark, Content Credentials) and what can strip it. Do not promise more.",
        "Pass statusNote for a failed or blocked take that says whether credits came back."
      ],
      "onPhone": [
        "The Play button is 44px; every other control grows to 44px on touch screens. The controls wrap on narrow screens.",
        "The scrubber's hit area is 44px tall even though the track is 4px.",
        "Tap the frame to play or pause. Double-tap its left or right side to go back or forward 2 seconds.",
        "Paused-frame actions sit in a row under the controls and wrap to full width, with 44px buttons.",
        "A 9:16 clip is capped in width so it does not push the controls off a phone screen."
      ],
      "importPath": "import { VideoPlayer } from \"./index\"",
      "sourcePath": "src/molecules/VideoPlayer.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Names the player and its scrubber, such as \"Take 3, harbour at dawn\"."
        },
        {
          "name": "subtitle",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Second line. Without it the line shows the state in words."
        },
        {
          "name": "src",
          "type": "string",
          "required": false,
          "default": null,
          "description": "A real video file. Leave out to simulate playback with a drawn scene."
        },
        {
          "name": "duration",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Length in seconds. Needed without src."
        },
        {
          "name": "scene",
          "type": "\"harbour\" | \"kite\"",
          "required": false,
          "default": "\"harbour\"",
          "description": "The drawn scene used without src."
        },
        {
          "name": "poster",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Still image shown before playing and while generating."
        },
        {
          "name": "ratio",
          "type": "\"16:9\" | \"9:16\" | \"1:1\"",
          "required": false,
          "default": "\"16:9\"",
          "description": "Shape of the frame. The space is reserved before anything loads."
        },
        {
          "name": "fps",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Frames per second. Turns on frame step buttons and keys, and frame numbers."
        },
        {
          "name": "state",
          "type": "\"generating\" | \"loading\" | \"error\"",
          "required": false,
          "default": null,
          "description": "Forces a state the player cannot see. Ready, playing, paused, buffering and ended are tracked by the player."
        },
        {
          "name": "statusNote",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Words beside generating or error, such as the queue reason or whether credits came back."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Try again in the error state."
        },
        {
          "name": "loop",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Starts with loop on."
        },
        {
          "name": "muted",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Starts muted."
        },
        {
          "name": "hasAudio",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Set false when the clip has no sound. The mute button then says so."
        },
        {
          "name": "captions",
          "type": "{ src: string; lang: string; label: string }[]",
          "required": false,
          "default": null,
          "description": "Caption files for a real src, as native tracks."
        },
        {
          "name": "cues",
          "type": "VideoCue[]",
          "required": false,
          "default": null,
          "description": "Caption lines drawn over the frame, with or without src."
        },
        {
          "name": "transcript",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Spoken words and a description of the clip, in a collapsed section."
        },
        {
          "name": "frameActions",
          "type": "VideoFrameAction[]",
          "required": false,
          "default": null,
          "description": "Actions on the paused frame, such as \"Use as start frame\". Shown only while paused."
        },
        {
          "name": "onFrameAction",
          "type": "(id: string, seconds: number, frame?: number) => void",
          "required": false,
          "default": null,
          "description": "Called with the action id, the time and the frame number."
        },
        {
          "name": "shortcuts",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Single-key shortcuts on at first. The person can turn them off inside the player."
        },
        {
          "name": "aiGenerated",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Adds an AI label beside the title."
        },
        {
          "name": "aiNote",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "What the AI label explains, used when there is no provenance."
        },
        {
          "name": "provenance",
          "type": "ProvenancePopoverProps",
          "required": false,
          "default": null,
          "description": "Opens model, date and what the file carries from the label."
        },
        {
          "name": "current",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Position in seconds when the parent keeps it. A change of more than a frame seeks."
        },
        {
          "name": "onTimeChange",
          "type": "(seconds: number) => void",
          "required": false,
          "default": null,
          "description": "Called as the position moves."
        },
        {
          "name": "onPlayingChange",
          "type": "(playing: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when playback starts or stops."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the player."
        }
      ],
      "variantsHint": {
        "scene": [
          "harbour",
          "kite"
        ],
        "ratio": [
          "16:9",
          "9:16",
          "1:1"
        ],
        "state": [
          "generating",
          "loading",
          "error"
        ]
      },
      "tokens": [
        "--accent",
        "--fg",
        "--fg-muted",
        "--border",
        "--surface",
        "--surface-sunken",
        "--danger-fg",
        "--lime",
        "--chart-seq-1",
        "--chart-seq-5",
        "--chart-2",
        "--chart-4",
        "--dur-fast",
        "--dur-instant"
      ],
      "related": [
        "frame-scrubber",
        "clip-range-selector",
        "audio-player",
        "media-frame",
        "generation-progress"
      ],
      "composedOf": [
        "frame-scrubber",
        "button",
        "kbd",
        "spinner",
        "switch",
        "working-edge",
        "ai-label",
        "provenance-popover",
        "collapsible"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Generated take with frame actions",
          "description": "No file is needed: the player draws a stand-in scene and simulates time. Press Play, pause anywhere, then step frames with the buttons, or with comma and period while focus is in the player. The paused-frame actions return the time and frame number.",
          "code": "<VideoPlayer\n  label=\"Take 3, harbour at dawn\"\n  subtitle=\"8 seconds, 24 frames a second\"\n  duration={8}\n  fps={24}\n  aiGenerated\n  provenance={{\n    madeWith: \"Video model, draft quality\",\n    date: \"3 October 2026\",\n    editedByPerson: false,\n    actions: [\"Made from your prompt and one start frame\"],\n    why: \"The file carries an invisible watermark and Content Credentials. Both can be lost if the clip is re-uploaded or re-encoded.\",\n  }}\n  cues={cues}\n  transcript={transcript}\n  frameActions={[\n    { id: \"start\", label: \"Use as start frame\" },\n    { id: \"extend\", label: \"Extend from here\" },\n    { id: \"save\", label: \"Save frame\" },\n  ]}\n  onFrameAction={(id, seconds, frame) => useFrame(id, seconds, frame)}\n/>\n\n// With a real file, the length comes from the file:\n<VideoPlayer label=\"Take 3, harbour at dawn\" src=\"/takes/3.mp4\" poster=\"/takes/3.jpg\" fps={24}\n  captions={[{ src: \"/takes/3.vtt\", lang: \"en\", label: \"English\" }]} />"
        },
        {
          "title": "Shapes: 9:16 and 1:1",
          "description": "The frame keeps its shape before anything loads. A 9:16 clip is capped in width. A clip with no sound says so on the mute button. Loop starts on when loop is set, and the person can turn it off.",
          "code": "<VideoPlayer label=\"Kite on the hill, vertical\" scene=\"kite\" ratio=\"9:16\"\n  duration={6} fps={24} hasAudio={false} aiGenerated />\n<VideoPlayer label=\"Harbour loop, square\" ratio=\"1:1\" duration={4} loop muted aiGenerated />"
        },
        {
          "title": "Generating, error and loading",
          "description": "While generating, the first frame is dimmed under one calm working edge and nothing can play. Say why someone is waiting, never a countdown. When it finishes, screen readers hear that it is ready. An error says whether credits came back.",
          "code": "<VideoPlayer label=\"Take 4, harbour at dusk\" duration={8} fps={24} aiGenerated\n  state=\"generating\" statusNote=\"Second in the queue, busy period\" />\n<VideoPlayer label=\"Take 2, harbour at dawn\" duration={8} state=\"error\"\n  statusNote=\"Credits for this take were returned.\" onRetry={retry} />\n<VideoPlayer label=\"Take 1, harbour at dawn\" duration={8} state=\"loading\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-video-player/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/video-player.md"
    },
    {
      "id": "frame-scrubber",
      "name": "FrameScrubber",
      "level": "atom",
      "since": "0.10",
      "aka": [
        "scrubber",
        "timeline scrubber",
        "seek bar"
      ],
      "category": "video",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "disabled",
          "note": "Set with the disabled prop.",
          "source": "prop"
        },
        {
          "name": "dragging",
          "note": "While a pointer drags, the preview bubble shows the time, frame and thumbnail.",
          "source": "documented"
        },
        {
          "name": "unknown length",
          "note": "duration 0: an empty, disabled track that says \"Length not known yet\".",
          "source": "documented"
        }
      ],
      "summary": "The seek slider for a video clip: time in words, frame steps and a preview while dragging.",
      "description": "FrameScrubber is the seek control VideoPlayer uses. A thin track with the played part in ink sits inside a 44px hit area. Its value is said in words, with the frame number when fps is set. Arrow keys move by step; comma and period, or Shift with an arrow, move one frame, and only while the scrubber itself has focus. While dragging or pointing, a bubble shows the time, and a thumbnail when you give one.",
      "purpose": "Lets people find an exact moment in a short clip, down to the frame, by pointer or keyboard.",
      "useFor": [
        "The seek bar inside a video player.",
        "Picking a single frame, such as a start frame or a thumbnail.",
        "Any short clip where tenths of a second matter."
      ],
      "notFor": [
        {
          "text": "Audio, where the loudness shape helps people find a moment",
          "use": "waveform"
        },
        {
          "text": "Picking a start and an end",
          "use": "clip-range-selector"
        },
        {
          "text": "Picking a value on a scale",
          "use": "slider"
        }
      ],
      "whenToUse": [
        "Pass fps for generated clips, so people can land on an exact frame.",
        "Keep the played part in ink and the track neutral.",
        "Pause playback while the person drags (onScrubbingChange)."
      ],
      "avoid": [
        "Don't colour the played part with the AI colour.",
        "Don't shrink the 44px hit area to match a thin design.",
        "Don't make comma and period work when the scrubber does not have focus. VideoPlayer adds them for the whole player, with an off switch."
      ],
      "accessibility": [
        "It is a slider with aria-valuetext in words, such as \"3.2 seconds of 8 seconds, frame 77 of 192\".",
        "Arrow keys move by step, Page Up and Page Down by ten steps, Home and End to the ends.",
        "Comma and period, or Shift with an arrow, move one frame (or a tenth of a second without fps). They only work while it has focus.",
        "When disabled it uses aria-disabled and says \"Length not known yet\" when there is no length.",
        "The preview bubble is for sight only and hidden from screen readers; the value text carries the same facts.",
        "In forced colours the track and played part use system colours."
      ],
      "accessibilityYouDo": [
        "Give a label that names the clip, such as \"Seek, take 3\".",
        "Update value from the real media time. Do not ease or animate it.",
        "Pass fps when frames matter, so the value text and keys use frames.",
        "Write the current time as text near it as well; the bubble only shows while dragging."
      ],
      "onPhone": [
        "The track is 4px but the hit area is 44px tall and fills the width.",
        "Tap anywhere to seek. While a finger drags, the bubble shows the time and frame above it.",
        "A touch that starts on it does not scroll the page."
      ],
      "importPath": "import { FrameScrubber } from \"./index\"",
      "sourcePath": "src/atoms/FrameScrubber.tsx",
      "props": [
        {
          "name": "duration",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Length in seconds. 0 shows a disabled, empty track."
        },
        {
          "name": "value",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Playhead in seconds. The parent owns it."
        },
        {
          "name": "onChange",
          "type": "(seconds: number) => void",
          "required": true,
          "default": null,
          "description": "Called with the new time after a drag, a tap or a key."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Name of the slider, such as \"Seek, take 3\"."
        },
        {
          "name": "fps",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Frames per second. Turns on frame steps and snaps to frames."
        },
        {
          "name": "step",
          "type": "number",
          "required": false,
          "default": "1",
          "description": "Seconds moved by an arrow key. Page Up and Page Down move ten steps."
        },
        {
          "name": "thumbnails",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Still images spread over the clip, shown in the preview."
        },
        {
          "name": "renderThumbnail",
          "type": "(seconds: number) => ReactNode",
          "required": false,
          "default": null,
          "description": "Draws the preview for a time when there are no image thumbnails."
        },
        {
          "name": "onScrubbingChange",
          "type": "(scrubbing: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when a drag starts and ends, so a player can pause."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Blocks seeking. It stays focusable and says it is unavailable."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg",
        "--border-strong",
        "--surface",
        "--surface-sunken",
        "--dur-fast",
        "--text-small"
      ],
      "related": [
        "video-player",
        "waveform",
        "slider",
        "clip-range-selector"
      ],
      "composedOf": [],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Frame-accurate, with a drawn preview",
          "description": "With fps the scrubber snaps to whole frames. Focus it, then use the arrows for 1 second and comma or period for one frame. Point or drag to see the frame preview.",
          "code": "const [t, setT] = useState(3.25);\n\n<FrameScrubber\n  label=\"Seek, take 3\"\n  duration={8}\n  fps={24}\n  value={t}\n  onChange={setT}\n  renderThumbnail={(s) => <VideoScene time={s} duration={8} />}\n/>\n<p>{formatMediaTime(t)} of 0:08, frame {frameAt(t, 24)} of 192</p>"
        },
        {
          "title": "Longer clip, and not ready yet",
          "description": "Without fps it moves in seconds; step sets the arrow-key jump. With no length yet it shows an empty track, says so, and stays focusable.",
          "code": "<FrameScrubber label=\"Seek, product walkthrough\" duration={95} step={5} value={t} onChange={setT} />\n<FrameScrubber label=\"Seek, take 5\" duration={0} value={0} onChange={() => {}} disabled />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-frame-scrubber/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/frame-scrubber.md"
    },
    {
      "id": "clip-range-selector",
      "name": "ClipRangeSelector",
      "level": "molecule",
      "since": "0.10",
      "aka": [
        "trim",
        "clip trimmer",
        "range trim"
      ],
      "category": "video",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "at the limit",
          "note": "When the range is as long as maxSpanSec, an \"At the limit\" tag shows beside the readout.",
          "source": "documented"
        },
        {
          "name": "too long",
          "note": "A range passed in longer than maxSpanSec keeps its start, moves its end and says so.",
          "source": "documented"
        }
      ],
      "summary": "Pick the start and end of the part of a clip to extend, edit or re-do, with the longest length built in.",
      "description": "ClipRangeSelector puts two named handles and a movable window over a filmstrip. Handles snap to frames, and the window cannot be stretched past maxSpanSec, so the person never meets a \"too long\" error later. The readout says \"6.0 of 10 s max\". Two number fields and Set start or end at the playhead give a way to set it without dragging. It shows processing (locked while the AI works) and applied, and says when a range passed in was too long and was shortened.",
      "purpose": "Lets people choose exactly which part of a clip the AI should extend, edit or re-do, inside the limits the model has.",
      "useFor": [
        "Choosing up to 10 seconds of an upload to edit.",
        "Picking the part of a take to re-do or extend from.",
        "Setting the start and end of a loop."
      ],
      "notFor": [
        {
          "text": "Seeking to one moment",
          "use": "frame-scrubber"
        },
        {
          "text": "Narrowing a chart over a long time range",
          "use": "chart-range-brush"
        },
        {
          "text": "Ordering shots in a sequence"
        }
      ],
      "whenToUse": [
        "Set the model's real limit as maxSpanSec, so the limit shows up front.",
        "Pair it with a player and pass playhead.",
        "Keep the original clip when the edit is applied, and say so."
      ],
      "avoid": [
        "Don't let people pick a range that will fail later and then show an error.",
        "Don't make dragging the only way to set the range.",
        "Don't colour the window with the AI colour. Only the processing dot uses it."
      ],
      "accessibility": [
        "The handles are two sliders named \"<label>, start\" and \"<label>, end\", with times in words such as \"Starts at 9 seconds\".",
        "The window is a third slider that moves the whole range and says both ends and the length.",
        "An arrow moves one frame (or a tenth of a second), Shift with an arrow or Page Up and Page Down one second, Home and End to the furthest allowed.",
        "The length is announced once, after a drag ends or the keys stop, not on every step.",
        "Each handle stops at the minimum and maximum length instead of pushing the other handle.",
        "The number fields are named by the label, such as \"Part to extend from, start in seconds\".",
        "While processing, the sliders use aria-disabled and the fields and buttons are disabled; the status line says why.",
        "In forced colours the window and handles use system colours."
      ],
      "accessibilityYouDo": [
        "Give a label that says what the part is for, such as \"Part to extend from\". It names both handles.",
        "Set maxSpanSec to the model's real limit, so the person cannot pick a range that will fail.",
        "Pass playhead from the player, so people can set the range without dragging.",
        "In processing and applied, pass a statusNote that says what is happening and that the original is kept."
      ],
      "onPhone": [
        "The handles are 44px wide on touch screens and reach outside the strip edges, so they never cover each other.",
        "The number fields and buttons are 44px tall on touch screens and wrap under each other on narrow screens.",
        "A touch that starts on the strip does not scroll the page."
      ],
      "importPath": "import { ClipRangeSelector } from \"./index\"",
      "sourcePath": "src/molecules/ClipRangeSelector.tsx",
      "props": [
        {
          "name": "durationSec",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Length of the clip in seconds."
        },
        {
          "name": "range",
          "type": "[number, number]",
          "required": true,
          "default": null,
          "description": "Start and end in seconds. The parent owns it."
        },
        {
          "name": "onRangeChange",
          "type": "(range: [number, number]) => void",
          "required": true,
          "default": null,
          "description": "Called with the new start and end."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Name of the control, such as \"Part to extend from\". Also names the handles."
        },
        {
          "name": "fps",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Frames per second. Handles snap to frames and an arrow moves one frame. Without it they move a tenth of a second."
        },
        {
          "name": "maxSpanSec",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Longest range allowed. The window cannot be stretched past it."
        },
        {
          "name": "minSpanSec",
          "type": "number",
          "required": false,
          "default": "1",
          "description": "Shortest range allowed."
        },
        {
          "name": "thumbnails",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Still images for the filmstrip."
        },
        {
          "name": "renderThumbnail",
          "type": "(seconds: number) => ReactNode",
          "required": false,
          "default": null,
          "description": "Draws one filmstrip tile for a time."
        },
        {
          "name": "playhead",
          "type": "number",
          "required": false,
          "default": null,
          "description": "The player's position. Shows a playhead line and Set start or end buttons."
        },
        {
          "name": "status",
          "type": "\"editing\" | \"processing\" | \"applied\"",
          "required": false,
          "default": "\"editing\"",
          "description": "Processing locks the range while the AI works."
        },
        {
          "name": "statusNote",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Words for processing or applied."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {
        "status": [
          "editing",
          "processing",
          "applied"
        ]
      },
      "tokens": [
        "--fg",
        "--fg-muted",
        "--surface",
        "--surface-sunken",
        "--border",
        "--lime",
        "--success"
      ],
      "related": [
        "video-player",
        "frame-scrubber",
        "chart-range-brush"
      ],
      "composedOf": [
        "number-field",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Extend from part of an upload",
          "description": "Up to 10 seconds of a 24-second clip. Drag a handle or the window, use the keys (an arrow is one frame, Shift with an arrow is one second), type the times, or play the clip and press Set start or Set end at the playhead.",
          "code": "const [range, setRange] = useState<[number, number]>([9, 15]);\nconst [time, setTime] = useState(11);\n\n<VideoPlayer label=\"Uploaded clip, kite on the hill\" scene=\"kite\" duration={24} fps={24}\n  current={time} onTimeChange={setTime} hasAudio={false} />\n<ClipRangeSelector\n  label=\"Part to extend from\"\n  durationSec={24}\n  fps={24}\n  maxSpanSec={10}\n  minSpanSec={1}\n  range={range}\n  onRangeChange={setRange}\n  playhead={time}\n  renderThumbnail={(s) => <VideoScene scene=\"kite\" time={s} duration={24} />}\n/>"
        },
        {
          "title": "Processing, applied and too long",
          "description": "While the AI works the range is locked and says why. Applied says where the result went. The last one was passed 19 seconds with a 10-second limit, so it keeps the start, moves the end and says so. Without thumbnails the strip is plain.",
          "code": "<ClipRangeSelector label=\"Part to re-do\" durationSec={8} fps={24} maxSpanSec={5}\n  range={[2, 6.5]} onRangeChange={setRange} status=\"processing\"\n  statusNote=\"Re-doing 2.0 to 6.5 seconds. The range is locked until it finishes.\" />\n<ClipRangeSelector label=\"Part to re-do\" durationSec={8} fps={24} maxSpanSec={5}\n  range={[0, 4.5]} onRangeChange={setRange} status=\"applied\"\n  statusNote=\"Applied. Saved as version 3; version 2 is still in the history.\" />\n<ClipRangeSelector label=\"Part to edit\" durationSec={24} maxSpanSec={10}\n  range={[1, 20]} onRangeChange={setRange} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-clip-range-selector/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/clip-range-selector.md"
    },
    {
      "id": "take-card",
      "name": "TakeCard",
      "level": "molecule",
      "since": "0.10",
      "aka": [
        "video take",
        "generation card"
      ],
      "category": "video",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "error",
          "note": "status failed: a red status word, the error and Try again.",
          "source": "documented"
        },
        {
          "name": "selected",
          "note": "selected with selectMode: ink border, check mark and the word selected.",
          "source": "documented"
        },
        {
          "name": "loading",
          "note": "status queued, generating or finalising: the lime working edge, aria-busy and the status word.",
          "source": "documented"
        },
        {
          "name": "blocked",
          "note": "status blocked: a calm note naming the input or the output, with the charge line.",
          "source": "documented"
        },
        {
          "name": "cancelled",
          "note": "status cancelled: says Cancelled and whether credits came back.",
          "source": "documented"
        },
        {
          "name": "rate limited",
          "note": "status rateLimited: says the take is paused and when it starts again.",
          "source": "documented"
        },
        {
          "name": "draft",
          "note": "quality draft: Draft tag on the frame and Draft quality in the words.",
          "source": "documented"
        }
      ],
      "summary": "One generated video take, from the queue to the finished clip, with a charge line that says whether credits were taken.",
      "description": "TakeCard is a single video take. It holds its aspect ratio from the moment the request is sent, so nothing moves when the result lands. While a model works, a calm lime edge goes round the card and the status is written out: queued with the place in line and why, generating, finalising. A finished take shows a poster, a Play button, the length, the model and the AI label. Every ending (failed, blocked by policy, cancelled, rate limited) says in words whether credits were taken.",
      "purpose": "Shows one video take honestly from the queue to the end: what is happening, what it cost, and what you can do next. It never leaves the person guessing whether a failed or blocked take was charged.",
      "useFor": [
        "A take inside a video studio grid, while it is queued, made and finished.",
        "A single take in a chat answer, where an agent made a clip.",
        "Showing a policy block calmly, with the refund and one way to rephrase."
      ],
      "notFor": [
        {
          "text": "A still image or a set of image variants",
          "use": "media-grid"
        },
        {
          "text": "Slow work that is not a video, such as a report",
          "use": "generation-progress"
        },
        {
          "text": "Many takes at once with one shared announcement",
          "use": "take-grid"
        },
        {
          "text": "Playing the video with a scrubber and frame steps"
        }
      ],
      "whenToUse": [
        "Put the card where the result will land as soon as the request is sent.",
        "Pass a charge on every card, and update it to refunded or notCharged when a job ends without a result.",
        "Say why a queued take waits (Busy period), and give an estimate only when you have a real one.",
        "Tag drafts as Draft and offer a way to make the final quality."
      ],
      "avoid": [
        "Do not show a red error for a policy block, or word it as the person's fault when the output was stopped.",
        "Do not show a percent the service does not report, and never hold it at 99%.",
        "Do not autoplay or loop takes in the grid.",
        "Do not use amber for waiting in a queue. Waiting is not something the person must do."
      ],
      "accessibility": [
        "The card is an article named by the take name and described by its status line and charge line.",
        "aria-busy is true while queued, generating or finalising.",
        "Standalone, a polite status region says only status changes, such as Take 1: ready. Never percentages.",
        "The poster is a real button named Play Take 1, 8 seconds.",
        "Length is shown as 0:08 and read as 8 seconds.",
        "Status is always a word with an icon. Selection adds a check mark and the word selected, not only a border.",
        "The select control is a checkbox in multi mode and a pressed button in single mode, each named after the take.",
        "Under reduced motion the working edge is still, and the poster and status swap with no fade."
      ],
      "accessibilityYouDo": [
        "Give each take a name people can tell apart, such as Take 3, not only an id.",
        "Write the charge from your billing records. Never let the model write an amount.",
        "Provide captions and a transcript in your player when the take has speech. The prompt is not alt text.",
        "When many cards update at once, use TakeGrid, or set announce={false} and speak a summary yourself."
      ],
      "onPhone": [
        "Fills the width of its column. The media box keeps its ratio, so the layout does not jump.",
        "The whole poster is the Play button, so a tap opens the take.",
        "Extra actions sit in an overflow menu, which opens as a bottom sheet on a phone.",
        "Buttons and the select control get a 44px tap area on touch screens."
      ],
      "importPath": "import { TakeCard } from \"./index\"",
      "sourcePath": "src/molecules/TakeCard.tsx",
      "props": [
        {
          "name": "id",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Take id. Set as data-take-id on the card."
        },
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Name of the take, such as Take 2. It names the card."
        },
        {
          "name": "status",
          "type": "\"queued\" | \"generating\" | \"finalising\" | \"done\" | \"failed\" | \"blocked\" | \"cancelled\" | \"rateLimited\"",
          "required": true,
          "default": null,
          "description": "Where the job is. Set it from the real job state."
        },
        {
          "name": "ratio",
          "type": "\"16:9\" | \"9:16\" | \"1:1\" | \"4:3\" | \"21:9\"",
          "required": false,
          "default": "\"16:9\"",
          "description": "The media box keeps this ratio in every state."
        },
        {
          "name": "poster",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Poster image URL, shown when done."
        },
        {
          "name": "scene",
          "type": "\"harbour\" | \"dusk\" | \"forest\" | \"city\" | \"studio\"",
          "required": false,
          "default": "\"harbour\"",
          "description": "A drawn stand-in poster from colour tokens, used when there is no image."
        },
        {
          "name": "durationSec",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Length in seconds. Shown as 0:08 and read as 8 seconds."
        },
        {
          "name": "model",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Model that made the take."
        },
        {
          "name": "prompt",
          "type": "string",
          "required": false,
          "default": null,
          "description": "What was asked for. It is not alt text for the result."
        },
        {
          "name": "quality",
          "type": "\"draft\" | \"final\"",
          "required": false,
          "default": null,
          "description": "draft adds a Draft tag on the frame and Draft quality in the words."
        },
        {
          "name": "queuePosition",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Place in the queue while queued."
        },
        {
          "name": "queueReason",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why the person waits, such as Busy period. Never an upgrade pitch."
        },
        {
          "name": "eta",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Plain time estimate, only when the service gives one."
        },
        {
          "name": "percent",
          "type": "number",
          "required": false,
          "default": null,
          "description": "0 to 100, only if the service reports it. Shows a neutral bar. Never spoken."
        },
        {
          "name": "charge",
          "type": "{ amount: number; state: \"estimated\" | \"charged\" | \"refunded\" | \"notCharged\"; unit?: string }",
          "required": false,
          "default": null,
          "description": "The charge line. Work the amount out in code."
        },
        {
          "name": "blockedReason",
          "type": "{ where: \"input\" | \"output\"; message: string; suggestion?: string }",
          "required": false,
          "default": null,
          "description": "Why a blocked take was stopped, and one way to rephrase."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "default": null,
          "description": "What went wrong, when failed."
        },
        {
          "name": "retryNote",
          "type": "string",
          "required": false,
          "default": null,
          "description": "When a rate-limited take starts again."
        },
        {
          "name": "cancelNote",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Says whether cancelling returns credits. Shown under Cancel while working."
        },
        {
          "name": "lineage",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Where the take came from, such as Extended from take 2."
        },
        {
          "name": "provenance",
          "type": "{ madeWith?: string; made?: string; carries?: string[]; caveat?: string }",
          "required": false,
          "default": null,
          "description": "Fills the AI label's explainer: what the file carries and what can strip it."
        },
        {
          "name": "onOpen",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Opens the take in your player. Makes the poster a Play button when done."
        },
        {
          "name": "onCancel",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Cancel while queued, generating or finalising."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Try again on failed, cancelled and rate-limited takes."
        },
        {
          "name": "onEditPrompt",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Edit prompt on blocked takes."
        },
        {
          "name": "menu",
          "type": "MenuEntry[]",
          "required": false,
          "default": null,
          "description": "Extra actions for a finished take, in an overflow menu."
        },
        {
          "name": "selectMode",
          "type": "\"single\" | \"multi\"",
          "required": false,
          "default": null,
          "description": "Shows the select control: a checkbox for multi, a pressed button for single."
        },
        {
          "name": "selected",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Selected. Shown with an ink border and a check mark, and said in words."
        },
        {
          "name": "onSelect",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Called by the select control."
        },
        {
          "name": "announce",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Speak status changes from this card. TakeGrid turns it off and speaks once for all cards."
        },
        {
          "name": "compact",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Hides the prompt and lineage for small tiles."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Heading level of the take name."
        },
        {
          "name": "tabIndex",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Makes the card a focus stop. TakeGrid sets it for its roving focus."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the card."
        }
      ],
      "variantsHint": {
        "status": [
          "queued",
          "generating",
          "finalising",
          "done",
          "failed",
          "blocked",
          "cancelled",
          "rateLimited"
        ],
        "ratio": [
          "16:9",
          "9:16",
          "1:1",
          "4:3",
          "21:9"
        ],
        "scene": [
          "harbour",
          "dusk",
          "forest",
          "city",
          "studio"
        ],
        "quality": [
          "draft",
          "final"
        ],
        "selectMode": [
          "single",
          "multi"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--border-strong",
        "--fg",
        "--fg-muted",
        "--lime",
        "--danger-fg",
        "--chart-1",
        "--chart-2",
        "--chart-3",
        "--chart-4",
        "--chart-5",
        "--chart-seq-1",
        "--chart-seq-5",
        "--dur-base",
        "--dur-fast",
        "--ease-out",
        "--focus-ring",
        "--touch-target"
      ],
      "related": [
        "take-grid",
        "take-versions",
        "generation-progress",
        "refusal-notice",
        "provenance-popover",
        "usage-meter"
      ],
      "composedOf": [
        "working-edge",
        "ai-label",
        "menu",
        "badge",
        "button",
        "meter"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "From the queue to a finished take",
          "description": "Step through the life of one take. The lime edge shows only while the model works. Cancel says first that the credits come back, and the card then says so.",
          "code": "<TakeCard\n  id=\"t1\"\n  label=\"Take 1\"\n  status={status}            // \"queued\" | \"generating\" | \"finalising\" | \"done\" | ...\n  ratio=\"16:9\"\n  durationSec={8}\n  model=\"Reel 2\"\n  prompt=\"Slow push in on a fishing harbour at sunrise, gulls overhead, calm water\"\n  queuePosition={3}\n  queueReason=\"Busy period\"\n  eta=\"About 2 minutes\"\n  charge={{ amount: 20, state: status === \"done\" ? \"charged\" : \"estimated\" }}\n  cancelNote=\"Cancel now and the 20 credits come back.\"\n  provenance={{ carries: [\"An invisible watermark\", \"C2PA Content Credentials\"], made: \"today at 10:42\" }}\n  onCancel={cancelTake}\n  onOpen={openInPlayer}\n/>"
        },
        {
          "title": "Endings: blocked, failed, rate limited, cancelled",
          "description": "A policy block is a calm note, not a red error. It says whether the request or the finished video was stopped, and that nothing was charged. A failure is the only red state. Every ending names the charge.",
          "code": "<TakeCard id=\"e1\" label=\"Take 2\" status=\"blocked\"\n  blockedReason={{ where: \"output\", message: \"The result showed a real brand logo on the shop sign.\",\n    suggestion: \"Ask for a plain wooden sign with no lettering.\" }}\n  charge={{ amount: 20, state: \"notCharged\" }} onEditPrompt={editPrompt} />\n\n<TakeCard id=\"e2\" label=\"Take 3\" status=\"blocked\"\n  blockedReason={{ where: \"input\", message: \"The prompt named a real public figure.\", suggestion: \"...\" }}\n  charge={{ amount: 20, state: \"notCharged\" }} onEditPrompt={editPrompt} />\n\n<TakeCard id=\"e3\" label=\"Take 4\" status=\"failed\"\n  error=\"The model stopped part way through. Your prompt and settings are saved.\"\n  charge={{ amount: 20, state: \"refunded\" }} onRetry={retry} />\n\n<TakeCard id=\"e4\" label=\"Take 5\" status=\"rateLimited\" retryNote=\"It starts again in about a minute.\"\n  charge={{ amount: 20, state: \"estimated\" }} onRetry={retry} />\n\n<TakeCard id=\"e5\" label=\"Take 6\" status=\"cancelled\" charge={{ amount: 20, state: \"refunded\" }} onRetry={retry} />"
        },
        {
          "title": "A draft take with lineage and more actions",
          "description": "A low-cost draft is tagged Draft on the frame and in the words, so it is never taken for the finished clip. The lineage line says where it came from, and the overflow menu holds the next steps.",
          "code": "<TakeCard\n  id=\"d1\" label=\"Take 7\" status=\"done\" ratio=\"9:16\" durationSec={6} model=\"Ray3\"\n  quality=\"draft\"\n  lineage=\"Extended from take 2\"\n  charge={{ amount: 4, state: \"charged\" }}\n  provenance={{ madeWith: \"Ray3 (draft mode)\", carries: [\"C2PA Content Credentials\"] }}\n  onOpen={openInPlayer}\n  menu={[\n    { id: \"final\", label: \"Make final quality\", onSelect: makeFinal },\n    { id: \"extend\", label: \"Extend\", onSelect: extend },\n    { id: \"remix\", label: \"Remix\", onSelect: remix },\n    { type: \"separator\" },\n    { id: \"dl\", label: \"Download\", onSelect: download },\n  ]}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-take-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/take-card.md"
    },
    {
      "id": "take-grid",
      "name": "TakeGrid",
      "level": "organism",
      "since": "0.10",
      "aka": [
        "takes grid",
        "video grid"
      ],
      "category": "video",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "No takes and no loadingCount: an EmptyState that says how to start.",
          "source": "documented"
        },
        {
          "name": "loading",
          "note": "loadingCount: still placeholders at the grid's ratio, with a hidden Loading line.",
          "source": "documented"
        },
        {
          "name": "mixed",
          "note": "Running and finished takes together; the summary counts each kind.",
          "source": "documented"
        },
        {
          "name": "selected",
          "note": "value: selected takes show a check mark and the selected count.",
          "source": "documented"
        }
      ],
      "summary": "A set of video takes where running and finished takes sit together, with one combined announcement, keyboard moves, and empty and loading views.",
      "description": "TakeGrid lays out TakeCards at one ratio, so rows stay even while some takes are still running. One polite live region speaks for the whole grid, such as \"2 of 4 takes ready, 1 take blocked, not charged\", so a screen reader is not interrupted by every card. Arrow keys move between takes, Enter opens a finished take and Space selects. It can pick one take or select several, group takes under their prompt, and switch between large and small tiles.",
      "purpose": "Lets people watch several takes arrive and choose between them, without the page jumping or a screen reader talking over itself.",
      "useFor": [
        "The results area of a video studio, with takes from one or more prompts.",
        "Choosing the one take to keep, in pick mode.",
        "Choosing several takes to download or delete, in select mode."
      ],
      "notFor": [
        {
          "text": "Generated images or text options",
          "use": "variant-grid"
        },
        {
          "text": "A gallery of uploaded or finished images",
          "use": "media-grid"
        },
        {
          "text": "The versions of one take",
          "use": "take-versions"
        }
      ],
      "whenToUse": [
        "Add each take to the grid as soon as it is requested, so its place never moves.",
        "Keep one ratio per grid.",
        "Group by prompt when one session runs several prompts.",
        "Use pick mode when the next step needs exactly one take."
      ],
      "avoid": [
        "Do not let every card speak. The grid already speaks one summary.",
        "Do not shimmer every pending tile. The working edge on each running take is enough.",
        "Do not autoplay takes in the grid.",
        "Do not paint the grid lime. Lime is only for the working edge and the AI label."
      ],
      "accessibility": [
        "The grid is a named group. One polite status region gives the summary, such as 2 of 4 takes ready, 1 take blocked, not charged. It changes only when a status changes.",
        "Each take is an article and one tab stop for the grid. Arrow keys move between takes, Up and Down keep the column, Home and End jump.",
        "Enter opens a finished take. Space selects or picks it. A hidden hint on each take says so.",
        "The = and - keys change tile size only while focus is in the grid and never inside a text field. Turn them off with shortcuts={false}.",
        "Buttons inside each take (Play, Cancel, Try again, More) stay in the Tab order.",
        "Group headings are real headings. New takes fade in with a short stagger, and appear at once under reduced motion."
      ],
      "accessibilityYouDo": [
        "Give the grid a label that names the work, such as Harbour opening takes.",
        "Keep each take's status and charge up to date from your job service; the summary follows.",
        "Offer a visible way to change tile size if people rely on it, with showDensityControl.",
        "Move focus to the player when onOpen opens one, and back to the take when it closes."
      ],
      "onPhone": [
        "Columns follow the grid's own width: one column for wide takes on a phone, two for tall 9:16 takes.",
        "Small tiles hide the prompt and lineage, so the name, status and charge still fit.",
        "Every button and select control has a 44px tap area on touch screens."
      ],
      "importPath": "import { TakeGrid } from \"./index\"",
      "sourcePath": "src/organisms/TakeGrid.tsx",
      "props": [
        {
          "name": "takes",
          "type": "TakeGridItem[]",
          "required": true,
          "default": null,
          "description": "TakeCard props for each take, plus an optional request for grouping."
        },
        {
          "name": "mode",
          "type": "\"pick\" | \"select\"",
          "required": false,
          "default": "\"select\"",
          "description": "pick chooses one finished take. select chooses several of any status."
        },
        {
          "name": "value",
          "type": "string[]",
          "required": true,
          "default": null,
          "description": "Selected take ids."
        },
        {
          "name": "onChange",
          "type": "(ids: string[]) => void",
          "required": true,
          "default": null,
          "description": "Called with the new selection."
        },
        {
          "name": "ratio",
          "type": "\"16:9\" | \"9:16\" | \"1:1\" | \"4:3\" | \"21:9\"",
          "required": false,
          "default": "\"16:9\"",
          "description": "One ratio for every tile. 9:16 gets more columns."
        },
        {
          "name": "groupBy",
          "type": "\"request\" | \"none\"",
          "required": false,
          "default": "\"none\"",
          "description": "A heading per prompt, from each take's request."
        },
        {
          "name": "density",
          "type": "\"comfortable\" | \"compact\"",
          "required": false,
          "default": null,
          "description": "Tile size. Controlled when onDensityChange is passed."
        },
        {
          "name": "onDensityChange",
          "type": "(d: TakeGridDensity) => void",
          "required": false,
          "default": null,
          "description": "Called by the tile size control and the = and - keys."
        },
        {
          "name": "showDensityControl",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Shows Large and Small above the grid."
        },
        {
          "name": "onOpen",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Opens a finished take, from Enter or its poster."
        },
        {
          "name": "loadingCount",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Draws this many still placeholders for takes on the way."
        },
        {
          "name": "empty",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Replaces the default empty state."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Takes\"",
          "description": "Accessible name of the grid."
        },
        {
          "name": "shortcuts",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "The = and - keys change tile size while focus is in the grid. Set false to turn them off."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of group headings. Take names sit one level below."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the outer box."
        }
      ],
      "variantsHint": {
        "mode": [
          "pick",
          "select"
        ],
        "ratio": [
          "16:9",
          "9:16",
          "1:1",
          "4:3",
          "21:9"
        ],
        "groupBy": [
          "request",
          "none"
        ],
        "density": [
          "comfortable",
          "compact"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--fg-muted",
        "--dur-base",
        "--ease-out",
        "--touch-target"
      ],
      "related": [
        "take-card",
        "variant-grid",
        "media-grid",
        "take-versions"
      ],
      "composedOf": [
        "take-card",
        "segmented-control",
        "empty-state",
        "reveal"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Four takes arriving",
          "description": "Press Next update to move the jobs along. The summary line changes only when a status changes, and it is the only thing spoken. Try the arrow keys, Enter, Space, and the = and - keys while a take has focus.",
          "code": "const [value, setValue] = useState<string[]>([]);\nconst [density, setDensity] = useState<TakeGridDensity>(\"comfortable\");\n\n<TakeGrid\n  label=\"Harbour opening takes\"\n  takes={takes}               // TakeCard props, kept up to date from your job service\n  mode=\"select\"\n  value={value}\n  onChange={setValue}\n  ratio=\"16:9\"\n  density={density}\n  onDensityChange={setDensity}\n  showDensityControl\n  onOpen={openInPlayer}\n/>"
        },
        {
          "title": "Pick one take, grouped by prompt",
          "description": "Tall 9:16 takes in small tiles, under the prompt that made them. Only finished takes can be picked; the failed take keeps its Try again.",
          "code": "<TakeGrid\n  label=\"Bakery story takes\"\n  takes={takes}               // each with request: \"Close-up of bread coming out of a wood oven...\"\n  mode=\"pick\"\n  value={value}\n  onChange={setValue}\n  ratio=\"9:16\"\n  groupBy=\"request\"\n  density=\"compact\"\n  onOpen={openInPlayer}\n/>"
        },
        {
          "title": "Empty and loading",
          "description": "With nothing yet, an empty state says how to start. While takes are on the way, still placeholders hold their space. They do not shimmer.",
          "code": "<TakeGrid label=\"Takes\" takes={[]} value={[]} onChange={setValue} />\n\n<TakeGrid label=\"Takes on the way\" takes={[]} value={[]} onChange={setValue} loadingCount={2} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-take-grid/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/take-grid.md"
    },
    {
      "id": "take-versions",
      "name": "TakeVersions",
      "level": "molecule",
      "since": "0.10",
      "aka": [
        "take history",
        "versions"
      ],
      "category": "video",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "previewing",
          "note": "A version other than the current one was previewed: sunken row and a Previewing tag.",
          "source": "documented"
        },
        {
          "name": "restored",
          "note": "After Make current: a notice says nothing was deleted, with Undo.",
          "source": "documented"
        },
        {
          "name": "working",
          "note": "A version still running, such as an upscale: its status tag shows and Make current is held back.",
          "source": "documented"
        }
      ],
      "summary": "The versions of one video take, each saying what it came from: extend, edit, remix, re-cut or upscale, with preview, make current and undo.",
      "description": "TakeVersions lists the history of one clip, newest at the top. Each version says how it was made and from which version, such as \"Extended +4 s from v1\" or \"Edited 0:02 to 0:06 from v2: warmer sunrise light\", with the prompt behind it and a tiny strip that marks the changed part. Preview shows a version without changing anything. Make current never deletes, and an Undo follows. On a phone it shows one version at a time with Previous and Next.",
      "purpose": "Keeps every extend and edit of a clip, says where each one came from, and makes going back safe.",
      "useFor": [
        "The history panel beside a take in a video editor.",
        "Going back to an earlier cut after an edit went wrong.",
        "Starting a new line of edits from an older version."
      ],
      "notFor": [
        {
          "text": "Versions of a document or a chat answer",
          "use": "version-history"
        },
        {
          "text": "Stepping between sibling replies in chat",
          "use": "version-pager"
        },
        {
          "text": "Different takes from one prompt",
          "use": "take-grid"
        }
      ],
      "whenToUse": [
        "Save every extend, edit, remix and upscale as a new version.",
        "Pass from on each version, so the lineage line can name its source.",
        "Pass segment for edits and re-cuts, so the changed part is shown and said."
      ],
      "avoid": [
        "Do not overwrite a version when the person edits.",
        "Do not offer Make current on a version that is still running.",
        "Do not show the operation as an icon alone. The words carry it."
      ],
      "accessibility": [
        "It is a section named by its heading. Versions are an ordered list, newest first, and the current one has aria-current=\"true\".",
        "Each version's main button previews it. Its name says the version and how it was made, such as Preview v3. Edited 0:02 to 0:06 from v2.",
        "Preview buttons are one tab stop. Up and Down move between versions; Home and End jump.",
        "After Make current or Undo, a polite status says what changed, and focus moves to the current version when the pressed button goes away.",
        "Length is read as seconds. The changed-part strip is hidden from screen readers because the lineage line says the same.",
        "The pager uses VersionPager, which says the version name and its place, such as v3, 3 of 5."
      ],
      "accessibilityYouDo": [
        "Show the previewed version in your player, and say in the player that it is a preview.",
        "Keep time text plain, such as Today 10:05, and the same across versions.",
        "If Branch opens a new editor, move focus there."
      ],
      "onPhone": [
        "Below 640px wide it shows one version at a time with Previous and Next buttons.",
        "Make current and Branch move under the version, in reach of a thumb.",
        "Buttons get a 44px tap area on touch screens."
      ],
      "importPath": "import { TakeVersions } from \"./index\"",
      "sourcePath": "src/molecules/TakeVersions.tsx",
      "props": [
        {
          "name": "versions",
          "type": "TakeVersion[]",
          "required": true,
          "default": null,
          "description": "Oldest first, so the first is v1. Each has op, from, prompt, segment, detail, durationSec, author, time and status."
        },
        {
          "name": "currentId",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The version in use now."
        },
        {
          "name": "onPreview",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Show a version without changing anything. Called with currentId to end the preview."
        },
        {
          "name": "onMakeCurrent",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Make a version current. Also called with the old id by Undo."
        },
        {
          "name": "onBranch",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Start a new line of edits from a version. Shows Branch."
        },
        {
          "name": "previewId",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Controlled preview. Omit to let the part keep it."
        },
        {
          "name": "layout",
          "type": "\"auto\" | \"list\" | \"pager\"",
          "required": false,
          "default": "\"auto\"",
          "description": "auto shows the pager below 640px wide and the list above."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Versions\"",
          "description": "Heading text."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Heading level of the title."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Classes for the panel."
        }
      ],
      "variantsHint": {
        "layout": [
          "auto",
          "list",
          "pager"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--chart-seq-1",
        "--chart-2"
      ],
      "related": [
        "take-card",
        "version-history",
        "version-pager",
        "variation-actions"
      ],
      "composedOf": [
        "take-card",
        "version-pager",
        "badge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "History of one clip",
          "description": "Five versions: generated, extended, edited, re-cut by a person, and an upscale still running. Make current says nothing was deleted and offers Undo. Up and Down move between versions. On a narrow screen it turns into the pager below.",
          "code": "<TakeVersions\n  versions={[\n    { id: \"v1\", op: \"generated\", prompt: \"A ferry leaves the harbour at dawn, wide shot\", durationSec: 8, author: \"ai\", authorName: \"Reel 2\", time: \"Today 9:40\", status: \"done\" },\n    { id: \"v2\", op: \"extended\", from: \"v1\", prompt: \"The ferry turns toward the open sea\", durationSec: 12, author: \"ai\", time: \"Today 9:52\", status: \"done\" },\n    { id: \"v3\", op: \"edited\", from: \"v2\", segment: [2, 6], detail: \"warmer sunrise light\", durationSec: 12, author: \"ai\", time: \"Today 10:05\", status: \"done\" },\n    { id: \"v4\", op: \"recut\", from: \"v3\", segment: [8, 12], durationSec: 12, author: \"person\", authorName: \"Mara\", time: \"Today 10:11\", status: \"done\" },\n    { id: \"v5\", op: \"upscaled\", from: \"v4\", detail: \"4K\", durationSec: 12, author: \"ai\", time: \"Just now\", status: \"generating\" },\n  ]}\n  currentId={current}\n  onPreview={showInPlayer}\n  onMakeCurrent={setCurrent}\n  onBranch={branchFrom}\n/>"
        },
        {
          "title": "One at a time, for a phone",
          "description": "The pager layout, which auto uses below 640px wide. Each step previews that version; Make current keeps the same Undo.",
          "code": "<TakeVersions versions={versions} currentId={current} layout=\"pager\"\n  onPreview={showInPlayer} onMakeCurrent={setCurrent} onBranch={branchFrom} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-take-versions/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/take-versions.md"
    },
    {
      "id": "shot-settings",
      "name": "ShotSettings",
      "level": "organism",
      "since": "0.10",
      "aka": [
        "video settings",
        "generation settings"
      ],
      "category": "video",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "loading",
          "note": "Set with the busy prop.",
          "source": "prop"
        }
      ],
      "summary": "Settings for one video generation, with the cost shown as takes × cost per take before Generate is pressed.",
      "description": "ShotSettings holds the model, length, aspect ratio, draft or final quality and the number of takes for a video generation. The cost line reads \"4 takes × 20 = 80 credits\" with what is left, and updates as settings change. Options the chosen model cannot use stay visible, marked and explained in words. The model is never switched to make an option work: if a chosen option stops fitting, Generate waits and the line under it says what to change. It is the video sibling of ParameterPanel, which stays as it is for images and text.",
      "purpose": "Lets people choose how a video is made and see exactly what it will cost, before they spend anything.",
      "useFor": [
        "The settings beside a video prompt box.",
        "A settings sheet on a phone, with Generate at the bottom.",
        "Any generation where each output is charged and models support different options."
      ],
      "notFor": [
        {
          "text": "Image or text settings with a single cost estimate",
          "use": "parameter-panel"
        },
        {
          "text": "Choosing only the model",
          "use": "model-picker"
        },
        {
          "text": "A spending limit for a whole project",
          "use": "budget-control"
        }
      ],
      "whenToUse": [
        "Show cost per take and the total before Generate, and say what is left.",
        "Keep unavailable options visible with the reason, so people learn the model's limits before they hit them.",
        "Point people to drafts first. A draft can be made final later.",
        "Name the button by what it makes: \"Generate 4 takes\"."
      ],
      "avoid": [
        "Do not switch the model or a setting silently to make a feature work.",
        "Do not hide a reason in a tooltip only.",
        "Do not colour the cost or the settings lime. Lime is only for Generate, which starts the AI.",
        "Do not use amber for low credits. Say it in words and offer fewer takes or a draft."
      ],
      "accessibility": [
        "The panel is a form named by its title. Each setting is a radio group with one tab stop; arrow keys, Home and End move and choose.",
        "Unavailable options keep aria-disabled, are skipped by the arrow keys and point to their written reason with aria-describedby. Screen readers also hear \"not available\".",
        "The chosen chip shows a check mark as well as a stronger border, so selection is not shown by colour alone.",
        "The cost line is a polite status region, so the new total is read after a change.",
        "When Generate cannot run, the reason is written next to it and next to the setting to change.",
        "Chips have a 44px touch area on touch screens."
      ],
      "accessibilityYouDo": [
        "Work out costPerOutput and the balance in your code. Never let a model write a price.",
        "Mark options as unavailable with a reason the person can act on, such as \"Reel Lite makes clips up to 6 seconds\".",
        "On a phone, put it in a bottom Sheet and keep Generate in reach.",
        "When a model changes, do not change the person's other choices for them. Pass the new unavailable reasons and let them choose."
      ],
      "onPhone": [
        "Fills the width. Chips wrap onto more lines.",
        "Generate takes the full width under the cost line.",
        "Every chip has a 44px touch area.",
        "Put it in a bottom Sheet on a phone."
      ],
      "importPath": "import { ShotSettings } from \"./index\"",
      "sourcePath": "src/organisms/ShotSettings.tsx",
      "props": [
        {
          "name": "value",
          "type": "{ model: string; length: string; aspect: string; quality: \"draft\" | \"final\"; outputs: number }",
          "required": true,
          "default": null,
          "description": "The current settings."
        },
        {
          "name": "onChange",
          "type": "(value: ShotSettingsValue) => void",
          "required": true,
          "default": null,
          "description": "Called with the new settings."
        },
        {
          "name": "onGenerate",
          "type": "(value: ShotSettingsValue) => void",
          "required": true,
          "default": null,
          "description": "Generate pressed. Never called while an option does not fit the model or credits are short."
        },
        {
          "name": "models",
          "type": "ShotOption[]",
          "required": true,
          "default": null,
          "description": "Models. A description shows next to the label."
        },
        {
          "name": "lengths",
          "type": "ShotOption[]",
          "required": true,
          "default": null,
          "description": "Lengths for the chosen model, such as \"4 s\". Set unavailable with a reason on the ones it cannot make."
        },
        {
          "name": "aspects",
          "type": "ShotOption[]",
          "required": true,
          "default": null,
          "description": "Aspect ratios labelled like \"16:9\", drawn with a shape glyph."
        },
        {
          "name": "qualities",
          "type": "ShotOption[]",
          "required": false,
          "default": "Draft and Final",
          "description": "Quality tiers. Values are \"draft\" and \"final\"."
        },
        {
          "name": "costPerOutput",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Cost of one take with the current settings, from your code. The total is takes × this."
        },
        {
          "name": "balance",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Credits left. Shows what is left after this, or that there is not enough."
        },
        {
          "name": "unit",
          "type": "string",
          "required": false,
          "default": "\"credits\"",
          "description": "Unit after numbers."
        },
        {
          "name": "maxOutputs",
          "type": "number",
          "required": false,
          "default": "4",
          "description": "Most takes per press."
        },
        {
          "name": "draftNote",
          "type": "string",
          "required": false,
          "default": "\"Drafts are faster and cheaper. …\"",
          "description": "Shown while Final is picked, to suggest a draft first."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Extra controls after the aspect ratio, such as CameraMovePicker."
        },
        {
          "name": "busy",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Generating: Generate shows a spinner and is off."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Shot settings\"",
          "description": "Heading and form name."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the panel."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--danger-fg",
        "--lime",
        "--radius-card",
        "--radius-control",
        "--dur-fast"
      ],
      "related": [
        "parameter-panel",
        "camera-move-picker",
        "usage-meter",
        "model-picker",
        "generation-progress"
      ],
      "composedOf": [
        "button",
        "spinner"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Settings with cost and a camera move",
          "description": "Switch to Reel Lite: 8 s, 10 s, 21:9 and some camera moves become unavailable with the reason written out, and the chosen 8 s is marked instead of being changed.",
          "code": "<ShotSettings value={value} onChange={setValue} onGenerate={generate}\n  models={models} lengths={lengthsFor(value.model)} aspects={aspectsFor(value.model)}\n  costPerOutput={costFor(value)} balance={1240} busy={busy}>\n  <CameraMovePicker value={camera} onChange={setCamera}\n    moves={[\"static\", \"pushIn\", \"pullOut\", \"panLeft\", \"panRight\", \"orbit\"]}\n    supported={value.model === \"lite\" ? [\"static\", \"pushIn\", \"pullOut\"] : undefined}\n    unsupportedReason=\"Reel Lite only pushes in or pulls out\" />\n</ShotSettings>\n\n// lengthsFor(\"lite\") marks 8 s and 10 s:\n// { value: \"8\", label: \"8 s\", unavailable: \"Reel Lite makes clips up to 6 seconds\" }"
        },
        {
          "title": "When Generate has to wait",
          "description": "A chosen length the model cannot make, not enough credits, and generating. Each says in words what to do.",
          "code": "<ShotSettings value={{ model: \"lite\", length: \"8\", aspect: \"9:16\", quality: \"final\", outputs: 2 }}\n  lengths={lengthsFor(\"lite\")} aspects={aspectsFor(\"lite\")} models={models}\n  costPerOutput={20} balance={1240} onChange={setValue} onGenerate={generate} />\n\n<ShotSettings ... costPerOutput={200} balance={300} />   // 4 takes × 200 = 800, 300 left\n<ShotSettings ... busy />"
        },
        {
          "title": "Without a balance",
          "description": "Leave out balance when credits are shown elsewhere. maxOutputs limits the takes per press.",
          "code": "<ShotSettings title=\"Final render\" value={value} onChange={setValue} onGenerate={generate}\n  models={models.slice(1)} lengths={lengthsFor(value.model)} aspects={aspectsFor(value.model)}\n  costPerOutput={costFor(value)} maxOutputs={2} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-shot-settings/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/shot-settings.md"
    },
    {
      "id": "camera-move-picker",
      "name": "CameraMovePicker",
      "level": "molecule",
      "since": "0.10",
      "aka": [
        "camera motion",
        "camera control"
      ],
      "category": "video",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "disabled",
          "note": "Set with the disabled prop.",
          "source": "prop"
        }
      ],
      "summary": "Pick a camera move such as push in, pan or orbit by name, with a small diagram for each.",
      "description": "CameraMovePicker shows camera moves as tiles, each with a written name and a small diagram of a subject in the frame and an arrow for the move. Pick one, or combine up to max. Static cannot be combined with other moves. Moves the model cannot make stay visible, cannot be picked and say why. On hover or keyboard focus the diagram moves once to preview the shot and holds; it never loops, and under reduced motion it stays still.",
      "purpose": "Lets people direct the camera without learning prompt jargon like \"dolly\" or \"truck\".",
      "useFor": [
        "Camera control inside ShotSettings.",
        "A camera edit on an existing clip, with only the moves that edit supports.",
        "Combining two moves, such as push in then pan right."
      ],
      "notFor": [
        {
          "text": "Choosing between a few plain words",
          "use": "segmented-control"
        },
        {
          "text": "Any single choice that has no picture",
          "use": "radio-group"
        }
      ],
      "whenToUse": [
        "Write the name of every move. The diagram helps but never stands alone.",
        "Show unsupported moves with a reason instead of hiding them.",
        "Keep combinations small. Two or three moves at most."
      ],
      "avoid": [
        "Do not loop the diagrams or play them while a take is generating.",
        "Do not colour chosen moves lime. They are the person's choice, not the AI's work.",
        "Do not make people type camera commands into the prompt."
      ],
      "accessibility": [
        "A group named by its label. Each move is a toggle button with aria-pressed, and the chosen ones also show a check mark.",
        "One tab stop for the group: arrow keys move between tiles, Home and End jump, Space or Enter picks.",
        "Moves that cannot be picked keep focus with aria-disabled, say \"not available\" and point to the written reason.",
        "When the limit is reached, a note says so in words and points to the group.",
        "Diagrams are hidden from screen readers; the name is the label. The preview moves once on hover or focus, within 400ms, and never under reduced motion."
      ],
      "accessibilityYouDo": [
        "Pass supported and a reason whenever a model cannot make some moves, so no option fails later.",
        "Turn the chosen moves into the model's own instructions in your code. Do not show bracket syntax to people."
      ],
      "onPhone": [
        "Three tiles per row on a phone, four from 640px wide.",
        "Each tile has a 44px touch area.",
        "There is no hover on touch, so diagrams stay still; the arrow shows the move."
      ],
      "importPath": "import { CameraMovePicker } from \"./index\"",
      "sourcePath": "src/molecules/CameraMovePicker.tsx",
      "props": [
        {
          "name": "value",
          "type": "CameraMove[]",
          "required": true,
          "default": null,
          "description": "Chosen moves, in the order picked."
        },
        {
          "name": "onChange",
          "type": "(value: CameraMove[]) => void",
          "required": true,
          "default": null,
          "description": "Called with the new moves. Clearing the last one gives [\"static\"]."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Camera move\"",
          "description": "Visible name of the group."
        },
        {
          "name": "moves",
          "type": "CameraMove[]",
          "required": false,
          "default": "all 13",
          "description": "Moves to offer: static, pushIn, pullOut, panLeft, panRight, tiltUp, tiltDown, truckLeft, truckRight, orbit, crane, tracking, handheld."
        },
        {
          "name": "max",
          "type": "number",
          "required": false,
          "default": "1",
          "description": "How many moves can be combined. 1 is a single choice."
        },
        {
          "name": "supported",
          "type": "CameraMove[]",
          "required": false,
          "default": null,
          "description": "Moves the model can make. Others are shown but cannot be picked."
        },
        {
          "name": "unsupportedReason",
          "type": "string",
          "required": false,
          "default": "\"Not available with this model\"",
          "description": "Why the other moves cannot be picked."
        },
        {
          "name": "strength",
          "type": "\"subtle\" | \"strong\"",
          "required": false,
          "default": null,
          "description": "How strong the move is."
        },
        {
          "name": "onStrengthChange",
          "type": "(s: \"subtle\" | \"strong\") => void",
          "required": false,
          "default": null,
          "description": "Shows the strength control while a moving choice is made."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Turns the whole picker off."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes."
        }
      ],
      "variantsHint": {
        "strength": [
          "subtle",
          "strong"
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--fg",
        "--fg-muted",
        "--radius-control",
        "--dur-slow",
        "--dur-fast",
        "--ease-out"
      ],
      "related": [
        "shot-settings",
        "segmented-control",
        "parameter-panel"
      ],
      "composedOf": [
        "segmented-control"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Pick one move",
          "description": "Every move with its name. Hover or Tab onto a tile to see the subject move once.",
          "code": "<CameraMovePicker value={value} onChange={setValue} />"
        },
        {
          "title": "Combine two, with strength and limits",
          "description": "Up to two moves at once. Orbit, crane and handheld are off for this model with the reason written below. Strength shows once a moving choice is made.",
          "code": "<CameraMovePicker label=\"Camera moves\" value={value} onChange={setValue} max={2}\n  strength={strength} onStrengthChange={setStrength}\n  supported={[\"static\", \"pushIn\", \"pullOut\", \"panLeft\", \"panRight\", \"tiltUp\", \"tiltDown\", \"tracking\"]}\n  unsupportedReason=\"Not available with Reel Fast\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-camera-move-picker/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/camera-move-picker.md"
    },
    {
      "id": "likeness-consent",
      "name": "LikenessConsent",
      "level": "organism",
      "since": "0.10",
      "aka": [
        "face consent",
        "likeness check"
      ],
      "category": "video",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        }
      ],
      "summary": "Ask for, track and control a real person's consent to use their face or voice in avatars.",
      "description": "LikenessConsent follows one person's consent from asking to using: not requested, pending (a link that lasts 24 hours), recording, checking, accepted, rejected, expired and revoked, plus not a real person for made-up avatars. The person records it themselves, on their own phone or here through VoiceConsentCheck with subject=\"likeness\"; there is no way to start from a photo. Once accepted, the person in it chooses who may use it, sees every use, and can revoke it. Amber shows only while that person still has to act.",
      "purpose": "Makes using a real person's likeness depend on that person's own recorded, scoped and revocable consent.",
      "useFor": [
        "Creating an avatar of a teammate or presenter.",
        "Cloning a person's voice for video narration (kind=\"voice\").",
        "A settings page where people see and control their own likeness."
      ],
      "notFor": [
        {
          "text": "The live recording check on its own",
          "use": "voice-consent-check"
        },
        {
          "text": "Approving one action an agent wants to take",
          "use": "approval-prompt"
        },
        {
          "text": "Asking for camera or mic permission",
          "use": "permission-prompt"
        }
      ],
      "whenToUse": [
        "Have the person record their own consent, and let it expire after 24 hours if they do not.",
        "Start who-can-use-it at the narrowest choice.",
        "List every use and tell the person each time.",
        "Say what happens to takes already made when consent is revoked."
      ],
      "avoid": [
        "Do not make a likeness from an uploaded photo of a real person.",
        "Do not use amber once the person has acted. Accepted, rejected and revoked are not waiting on anyone.",
        "Do not let anyone but the person in it widen who may use it.",
        "Do not reduce consent to a terms checkbox."
      ],
      "accessibility": [
        "A section named by its heading. The steps are an ordered list with aria-current on the current step and \"done\" or \"current\" written for screen readers.",
        "Every status is written in a badge and a heading, never colour alone. The amber badge carries words like \"Waiting for Asha\".",
        "Status changes are announced once through a polite status region.",
        "If a status change removes the button that had focus, focus moves to the heading. A change from elsewhere never takes focus.",
        "Revoke asks in place with InlineConfirm; Keep it gets focus first and Escape backs out.",
        "Who can use it is a native radio group with a legend."
      ],
      "accessibilityYouDo": [
        "Work out linkExpiresIn in code from the time the link was sent, and write it in words. A model must never make up the time.",
        "Only set canManage for the person in the likeness (or its owner). Everyone else sees who may use it but cannot change it.",
        "Tell the person each time their likeness is used, and fill usage from that log.",
        "Offer a check by a person through check.manualReviewHref for anyone who cannot read aloud or use a camera."
      ],
      "onPhone": [
        "Fills the width. Action buttons share the row on a phone.",
        "The step names sit under the step bar and stay readable at 320px.",
        "Recording happens on the subject's own phone by default; \"record now\" opens the same check here.",
        "Choices in Who can use it are 44px tall on touch."
      ],
      "importPath": "import { LikenessConsent } from \"./index\"",
      "sourcePath": "src/organisms/LikenessConsent.tsx",
      "props": [
        {
          "name": "subjectName",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The person, or the made-up avatar's name."
        },
        {
          "name": "kind",
          "type": "\"face\" | \"voice\" | \"faceAndVoice\"",
          "required": false,
          "default": "\"faceAndVoice\"",
          "description": "What is being used."
        },
        {
          "name": "status",
          "type": "\"notRequested\" | \"pending\" | \"expired\" | \"recording\" | \"reviewing\" | \"accepted\" | \"rejected\" | \"revoked\" | \"notRealPerson\"",
          "required": true,
          "default": null,
          "description": "Where consent is."
        },
        {
          "name": "linkExpiresIn",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Time left on the link, in words, from your code. Links last 24 hours."
        },
        {
          "name": "rejectionReason",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why the recording was not accepted."
        },
        {
          "name": "decidedOn",
          "type": "string",
          "required": false,
          "default": null,
          "description": "When consent was given or revoked, written by your code."
        },
        {
          "name": "audience",
          "type": "\"onlyMe\" | \"approved\" | \"workspace\" | \"anyone\"",
          "required": false,
          "default": "\"onlyMe\"",
          "description": "Who may use it."
        },
        {
          "name": "onAudienceChange",
          "type": "(a: LikenessAudience) => void",
          "required": false,
          "default": null,
          "description": "Change who may use it. Needs canManage."
        },
        {
          "name": "audienceOptions",
          "type": "LikenessAudience[]",
          "required": false,
          "default": "[\"onlyMe\", \"approved\", \"workspace\"]",
          "description": "Choices to offer. Add \"anyone\" only if your product allows it."
        },
        {
          "name": "canManage",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "The viewer is the person in it and may change who uses it or revoke it."
        },
        {
          "name": "usage",
          "type": "{ who: string; what: string; when: string }[]",
          "required": false,
          "default": "[]",
          "description": "Where it was used, newest first."
        },
        {
          "name": "onSendLink",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Send a link to record on their own device, or a new one."
        },
        {
          "name": "onRecordHere",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "The person is here: record on this device."
        },
        {
          "name": "onCancelRequest",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Withdraw a pending request."
        },
        {
          "name": "onRevoke",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Revoke consent, after a confirm in place."
        },
        {
          "name": "check",
          "type": "Omit<VoiceConsentCheckProps, \"subject\" | \"headingLevel\">",
          "required": false,
          "default": null,
          "description": "Props for the recording step, shown when status is \"recording\"."
        },
        {
          "name": "region",
          "type": "{ allowed: boolean; message?: string }",
          "required": false,
          "default": null,
          "description": "When not allowed, explains why nothing can be requested."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "2",
          "description": "Level of the heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the panel."
        }
      ],
      "variantsHint": {
        "kind": [
          "face",
          "voice",
          "faceAndVoice"
        ],
        "status": [
          "notRequested",
          "pending",
          "expired",
          "recording",
          "reviewing",
          "accepted",
          "rejected",
          "revoked",
          "notRealPerson"
        ],
        "audience": [
          "onlyMe",
          "approved",
          "workspace",
          "anyone"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--border",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--attention",
        "--warning-soft",
        "--warning-fg",
        "--success-soft",
        "--success-fg",
        "--danger-soft",
        "--danger-fg",
        "--radius-card"
      ],
      "related": [
        "voice-consent-check",
        "approval-prompt",
        "memory-list",
        "permission-prompt"
      ],
      "composedOf": [
        "voice-consent-check",
        "badge",
        "attention-dot",
        "button",
        "callout",
        "inline-confirm",
        "radio-group"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Every status",
          "description": "The view of the person who asked. Pending is amber because Asha still has to act; nothing else is. They can see who may use it but not change it.",
          "code": "<LikenessConsent subjectName=\"Asha Rao\" status=\"pending\" linkExpiresIn=\"23 hours\"\n  onSendLink={sendLink} onRecordHere={recordHere} onCancelRequest={cancel} />\n\n<LikenessConsent subjectName=\"Asha Rao\" status=\"rejected\"\n  rejectionReason=\"The person in the video did not read the lines, so we could not tell they agreed.\"\n  onSendLink={sendLink} />\n\n<LikenessConsent subjectName=\"Juno, the guide\" status=\"notRealPerson\" />"
        },
        {
          "title": "The person in it: who can use it, uses and revoke",
          "description": "With canManage, Asha picks who may use her likeness (narrowest first), sees each use, and can revoke with a confirm in place.",
          "code": "<LikenessConsent subjectName=\"Asha Rao\" status=\"accepted\" decidedOn=\"2 October\" canManage\n  audience={audience} onAudienceChange={setAudience}\n  usage={[{ who: \"Ravi Menon\", what: \"Spring launch, shot 3\", when: \"Today, 10:42\" }, ...]}\n  onRevoke={revoke} />"
        },
        {
          "title": "Record here",
          "description": "Tomas is in the room, so \"record now\" opens VoiceConsentCheck with subject=\"likeness\" inside the panel. Done moves to accepted.",
          "code": "<LikenessConsent subjectName=\"Tomas Okafor\" status={status}\n  onSendLink={sendLink} onRecordHere={() => setStatus(\"recording\")}\n  check={{ step, lines, currentLine: 0, recording, consentText, manualReviewHref: \"/help/manual-review\",\n    onAgree, onContinue, onStartLine, onStopLine, onCancel, onDone: () => setStatus(\"accepted\") }} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-likeness-consent/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/likeness-consent.md"
    },
    {
      "id": "scene-strip",
      "name": "SceneStrip",
      "level": "organism",
      "since": "0.10",
      "aka": [
        "storyboard",
        "scene timeline",
        "filmstrip"
      ],
      "category": "video",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "No shots yet: a short line and Add shot, so the first step is obvious.",
          "source": "documented"
        },
        {
          "name": "generating",
          "note": "A shot whose take is being made shows the lime working mark and the word Generating, because the AI is doing it.",
          "source": "documented"
        },
        {
          "name": "pick a take",
          "note": "A shot with finished takes but none chosen is marked in amber with Pick a take, because a person has to choose.",
          "source": "documented"
        },
        {
          "name": "did not work",
          "note": "A failed shot says so in words, with a note such as Not charged, and stays in its place.",
          "source": "documented"
        },
        {
          "name": "reordering",
          "note": "Drag, Move earlier and Move later, or Alt with an arrow key on a focused card. Each move is announced, and focus stays on the moved card.",
          "source": "documented"
        },
        {
          "name": "phone",
          "note": "Under 640px the strip scrolls sideways and snaps to each card; the move buttons grow to 44px.",
          "source": "documented"
        }
      ],
      "summary": "An ordered storyboard of shots with a status on each, a total length, and reorder by drag, buttons or keyboard.",
      "description": "SceneStrip lays out the shots of one scene in order as cards. Each card shows its number, name, prompt, length and status: no take yet, generating, pick a take, ready, or did not work. The header gives the total length and what is still open. Reorder by dragging a card, with the Move earlier and Move later buttons on every card, or with Alt and an arrow key on a focused card; each move is announced. Add shot puts a new shot at the end. It is order, status and length, not an editing timeline.",
      "purpose": "Puts chosen takes in order as a simple storyboard and shows which shots are done.",
      "useFor": [
        "A drawer under the take grid in a video studio.",
        "Planning a multi-shot ad or explainer before every shot exists.",
        "Following several shots while they generate."
      ],
      "notFor": [
        {
          "text": "Steps of a task an agent is doing",
          "use": "step-timeline"
        },
        {
          "text": "Choosing between takes of one shot",
          "use": "variant-grid"
        },
        {
          "text": "Events over time",
          "use": "timeline"
        }
      ],
      "whenToUse": [
        "Show the total length and what is still open.",
        "Keep move buttons on every card so drag is never the only way.",
        "Say on a failed shot whether it was charged."
      ],
      "avoid": [
        "Do not turn it into a multitrack timeline. Order, status and length are enough.",
        "Do not use lime for finished shots. Lime marks only shots the AI is making now.",
        "Do not use amber except for shots where a person must pick a take."
      ],
      "accessibility": [
        "Shots are an ordered list. Each card is focusable and named with its position, name, status and length, for example \"Shot 2 of 5: Ferry leaves, pick a take, 8 seconds\".",
        "Alt with an arrow key moves the focused card, and focus stays on it.",
        "Every card has Move earlier and Move later buttons named with the shot; at the ends they turn off and focus moves to the other button.",
        "Each move is announced once: \"Ferry leaves moved to position 1 of 5.\"",
        "Status is written in words with an icon or dot, never colour alone. The total length is also read in words."
      ],
      "accessibilityYouDo": [
        "Keep the order saved as it changes, so nothing is lost when the person leaves.",
        "Set note on a failed shot to say whether it was charged.",
        "Use onOpen to show the shot's takes or trim it, in a Sheet on a phone."
      ],
      "onPhone": [
        "A sideways strip that snaps to each card.",
        "Move buttons grow to 44px on touch, so reorder never needs a drag.",
        "The drag hint is hidden on small screens; the buttons are the way to move."
      ],
      "importPath": "import { SceneStrip } from \"./index\"",
      "sourcePath": "src/organisms/SceneStrip.tsx",
      "props": [
        {
          "name": "shots",
          "type": "SceneShot[]",
          "required": true,
          "default": null,
          "description": "Shots in order: id, label, prompt, durationSec, status, poster, note."
        },
        {
          "name": "onReorder",
          "type": "(shots: SceneShot[]) => void",
          "required": true,
          "default": null,
          "description": "Called with the new order after a move or drop."
        },
        {
          "name": "onAdd",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Adds a shot at the end. Leave out to hide Add shot."
        },
        {
          "name": "onOpen",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Opens a shot to pick a take or trim it."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Scene\"",
          "description": "Heading of the strip."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--fg",
        "--fg-muted",
        "--lime",
        "--attention",
        "--attention-fg",
        "--radius-card",
        "--radius-control",
        "--dur-fast"
      ],
      "related": [
        "timeline",
        "step-timeline",
        "variant-grid",
        "generation-progress"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A scene in progress",
          "description": "Five shots with every status. Drag a card, use its arrows, or focus it and press Alt with an arrow key. Add shot appends one.",
          "code": "<SceneStrip title=\"Ferry morning\" shots={shots} onReorder={setShots} onOpen={openShot} onAdd={addShot} />\n\nconst shots = [\n  { id: \"a\", label: \"Harbour at dawn\", prompt: \"Wide shot of a quiet harbour…\", durationSec: 6, status: \"ready\" },\n  { id: \"b\", label: \"Ferry leaves\", durationSec: 8, status: \"needsPick\", note: \"4 takes\" },\n  { id: \"c\", label: \"Deck close-up\", status: \"generating\" },\n  { id: \"d\", label: \"Island arrives\", status: \"failed\", note: \"Not charged\" },\n  { id: \"e\", label: \"Title card\", durationSec: 4, status: \"ready\" },\n];"
        },
        {
          "title": "Empty",
          "description": "Before any shots exist: a short line and Add shot.",
          "code": "<SceneStrip title=\"Product teaser\" shots={[]} onReorder={setShots} onAdd={addShot} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-scene-strip/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/scene-strip.md"
    },
    {
      "id": "outline-editor",
      "name": "OutlineEditor",
      "level": "organism",
      "since": "0.12",
      "aka": [
        "outline",
        "deck outline"
      ],
      "category": "slides",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "error",
          "note": "Set with the error prop.",
          "source": "prop"
        },
        {
          "name": "drafting",
          "note": "state=\"drafting\": slides stream in; the one in pendingId is read-only and says \"Still writing this slide\"; a working edge runs.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "No slides yet: while drafting it says the first slide is coming; otherwise it invites adding one.",
          "source": "documented"
        },
        {
          "name": "ready",
          "note": "state=\"ready\": Build slides and the estimate.",
          "source": "documented"
        },
        {
          "name": "edited",
          "note": "Any edit in ready, or state=\"edited\": Update outline joins Build slides.",
          "source": "documented"
        },
        {
          "name": "updating",
          "note": "state=\"updating\": read-only under a working edge; locked slides are marked.",
          "source": "documented"
        },
        {
          "name": "approved",
          "note": "state=\"approved\": read-only with a check and a note.",
          "source": "documented"
        },
        {
          "name": "building",
          "note": "state=\"building\": read-only, says slides are being built.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "state=\"failed\": what was written stays editable, with the error and Try again.",
          "source": "documented"
        },
        {
          "name": "missing title",
          "note": "Build with an empty title: field error, focus on it, a summary line, nothing built.",
          "source": "documented"
        },
        {
          "name": "at limit",
          "note": "items.length reaches maxItems: Add turns off and says why.",
          "source": "documented"
        },
        {
          "name": "removed",
          "note": "After a remove: Undo remove puts the slide back in its place.",
          "source": "documented"
        }
      ],
      "summary": "The plan of a deck before any slide is built: the AI drafts it, the person edits, reorders and approves it.",
      "description": "OutlineEditor shows the slides the AI plans to make, each with a title, a one-line description and its sources. Slides stream in while the AI drafts, and each one becomes editable once it is complete. The person can edit in place, add a slide after any other, remove one (Undo remove puts it back in its place), reorder with buttons or Alt and an arrow, and lock slides so Update outline leaves them alone. Any edit turns Ready into Edited, where Update outline (redraft) and Build slides (use as is) are both offered. Every slide needs a title before building.",
      "purpose": "Lets a person co-write the plan of a deck with the AI, and decide when it is good enough to build.",
      "useFor": [
        "The step between a brief and a built deck, where the person checks the slide list.",
        "Any generated document with sections the person may want to reorder, add or remove before the full text is written.",
        "Redrafting a plan around the person's edits while keeping the slides they locked."
      ],
      "notFor": [
        {
          "text": "A fixed plan of agent steps to approve or send back",
          "use": "plan-card"
        },
        {
          "text": "Progress while the slides are being built",
          "use": "generation-progress"
        },
        {
          "text": "Correcting one value the AI filled in",
          "use": "inline-edit"
        }
      ],
      "whenToUse": [
        "Offer Update outline and Build slides side by side once the person has edited, so they choose whether to redraft.",
        "Show where each slide's content comes from, and which slides the AI wrote.",
        "Keep the slides the person edited or locked when the outline is redrafted.",
        "Show the estimate next to Build, from your own code."
      ],
      "avoid": [
        "Don't build automatically when drafting finishes; the person approves first.",
        "Don't colour the person's own slides, the Build button or the whole outline with the AI colour. Only the AI marks and the working edge use it.",
        "Don't animate each word as it streams in. One calm working edge is enough.",
        "Don't let an empty title through. Screen-reader users move through a deck by its titles."
      ],
      "accessibility": [
        "The outline is a section named by its heading. The slides are an ordered list named with the slide count, and each slide is a group named \"Slide 3: title\".",
        "Every field and button names its slide, such as \"Slide 3 title\", \"Move slide 3 up\" and \"Add a slide after slide 3\".",
        "Alt with Up or Down moves the slide while focus is anywhere in its row, including the title field. Focus stays on the same field or button after the move.",
        "When a move button turns off because the slide reached the end, focus moves to the other move button.",
        "Remove takes one press and is never final: focus goes to Undo remove, which puts the slide back in its place and returns focus to its title.",
        "Lock is a toggle button with aria-pressed.",
        "An empty title gets aria-invalid and a linked error, \"Every slide needs a title\". Build moves focus to the first one.",
        "A polite live region says milestones only: \"Outline drafted, 6 slides\", \"Slide 4 moved to 3\", \"Outline updated, 6 slides\", \"Slide 2 removed. Undo is available.\" It never reads streamed words.",
        "The list is marked busy while the AI drafts or updates. Focus is not moved when the outline appears.",
        "New rows rise in and moved rows slide quickly; under reduced motion both happen at once with no animation."
      ],
      "accessibilityYouDo": [
        "Give the estimate from your own code (slides and credits), never from the model, and update it when the slide count changes.",
        "Pass pendingId while drafting so the half-written slide is read-only, and move state to ready when drafting ends; screen readers then hear \"Outline drafted, 6 slides\".",
        "Write the failed error in plain words and say whether anything was charged.",
        "Keep the outline in a column no wider than reading width, and do not take focus when it appears.",
        "If you set a heading label, keep it short; it also names the list."
      ],
      "onPhone": [
        "One column. The description folds under the title behind a Description button, and opens by itself when the row has an error.",
        "Move up, Move down, Add after, Lock and Remove are 44px on touch screens. Reorder works with these buttons; there is no drag.",
        "Build slides and Update outline sit in a bar that sticks to the bottom of the screen, above the safe area, and share the width."
      ],
      "importPath": "import { OutlineEditor } from \"./index\"",
      "sourcePath": "src/organisms/OutlineEditor.tsx",
      "props": [
        {
          "name": "items",
          "type": "OutlineItem[]",
          "required": true,
          "default": null,
          "description": "The slides, in order. Each has id, title, and optionally description, sources, locked, origin (\"ai\" or \"person\") and edited."
        },
        {
          "name": "state",
          "type": "\"drafting\" | \"ready\" | \"edited\" | \"updating\" | \"approved\" | \"building\" | \"failed\"",
          "required": true,
          "default": null,
          "description": "Where the outline is. Edits are allowed in drafting (finished slides), ready, edited and failed."
        },
        {
          "name": "onItemsChange",
          "type": "(items: OutlineItem[]) => void",
          "required": true,
          "default": null,
          "description": "Called with the new list after every edit, insert, remove, reorder, undo or lock."
        },
        {
          "name": "onBuild",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Approve and build. Only called when every slide has a title."
        },
        {
          "name": "onUpdate",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Redraft the unlocked slides around the person's edits. Adds Update outline while edited, Try again when failed, and the Lock buttons."
        },
        {
          "name": "onStateChange",
          "type": "(state: OutlineState) => void",
          "required": false,
          "default": null,
          "description": "Told when the first edit turns ready into edited."
        },
        {
          "name": "pendingId",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The slide the AI is still writing. It stays read-only and says so."
        },
        {
          "name": "overview",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The AI's short summary of the deck, marked as drafted by AI."
        },
        {
          "name": "estimate",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Slides and cost, worked out by your app, shown beside Build slides."
        },
        {
          "name": "error",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Why drafting failed, in plain words, shown in the failed state."
        },
        {
          "name": "maxItems",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Most slides allowed. Add turns off at the limit and says why."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the part's own heading."
        },
        {
          "name": "labels",
          "type": "Partial<OutlineEditorLabels>",
          "required": false,
          "default": null,
          "description": "heading, build, update, retry, addEnd, titleError and failedNote, for wording or translation."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the section."
        }
      ],
      "variantsHint": {
        "state": [
          "drafting",
          "ready",
          "edited",
          "updating",
          "approved",
          "building",
          "failed"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--border-strong",
        "--fg",
        "--fg-muted",
        "--danger-fg",
        "--lime",
        "--on-lime",
        "--dur-fast",
        "--dur-base",
        "--ease-out",
        "--safe-bottom",
        "--shape-card",
        "--shape-control"
      ],
      "related": [
        "plan-card",
        "parameter-panel",
        "inline-edit",
        "inline-confirm",
        "generation-progress"
      ],
      "composedOf": [
        "text-field",
        "button",
        "badge",
        "ai-mark",
        "reveal",
        "working-edge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Draft, edit and build",
          "description": "Slides stream in title first, and only the one being written is read-only. Edit any title and the badge changes to Edited by you, with Update outline beside Build slides. Lock a slide to keep it when updating. Build moves to Approved, then Building.",
          "code": "const [items, setItems] = useState<OutlineItem[]>([]);\nconst [state, setState] = useState<OutlineState>(\"drafting\");\n\n<OutlineEditor\n  items={items}\n  state={state}\n  pendingId={writingId}            // the slide the AI is still writing\n  overview={<p>A six-slide quarterly review of Larkspur Outdoor for the board…</p>}\n  onItemsChange={setItems}\n  onStateChange={setState}          // \"ready\" becomes \"edited\" on the first edit\n  onUpdate={() => redraftAround(items)}\n  onBuild={() => buildSlides(items)}\n  estimate={`${items.length} slides, about ${items.length * 5} credits`}\n  maxItems={12}\n/>"
        },
        {
          "title": "Failed, updating and building",
          "description": "When drafting stops, what was written stays editable and the error says why, with Try again beside Build. While the AI updates, the outline is read-only under one calm working edge and locked slides say so. While building, it reads as a plain list.",
          "code": "<OutlineEditor items={partial} state=\"failed\"\n  error=\"The connection dropped after slide 3. Nothing was charged for the unfinished part.\"\n  onItemsChange={setItems} onUpdate={retry} onBuild={build} estimate=\"3 slides, about 15 credits\" />\n\n<OutlineEditor items={items} state=\"updating\" onItemsChange={setItems} onUpdate={update} onBuild={build} />\n\n<OutlineEditor items={items} state=\"building\" onItemsChange={setItems} onBuild={build} />"
        },
        {
          "title": "Missing title and the slide limit",
          "description": "Press Build slides: the empty title gets the error \"Every slide needs a title\" and focus, and nothing is built. At the limit, Add is turned off and the reason is written beside it. Remove a slide to see Undo remove.",
          "code": "<OutlineEditor\n  items={items}                      // slide 2 has no title\n  state=\"edited\"\n  onItemsChange={setItems}\n  onUpdate={update}\n  onBuild={build}                    // not called while a title is empty\n  maxItems={5}\n  estimate=\"5 slides, about 25 credits\"\n  labels={{ heading: \"Returns policy briefing\" }}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-outline-editor/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/outline-editor.md"
    },
    {
      "id": "edit-scope-chip",
      "name": "EditScopeChip",
      "level": "atom",
      "since": "0.12",
      "aka": [
        "edit scope",
        "selection scope"
      ],
      "category": "slides",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "disabled",
          "note": "disabled: dashed border, no menu, disabledReason written beside it.",
          "source": "documented"
        },
        {
          "name": "closed",
          "note": "Shows the scope in words with a chevron.",
          "source": "documented"
        },
        {
          "name": "open",
          "note": "Menu of scopes, current one checked.",
          "source": "documented"
        },
        {
          "name": "whole deck",
          "note": "scope.kind=\"deck\" with deckCost: the cost shows beside the chip.",
          "source": "documented"
        },
        {
          "name": "single scope",
          "note": "options has one entry or none: a plain label, no menu.",
          "source": "documented"
        }
      ],
      "summary": "Says exactly what an AI edit will change, such as Slide 3 or Whole deck, and lets the person change it before sending.",
      "description": "EditScopeChip sits in the composer or a selection toolbar and names the reach of the next AI request: one element, one slide, a set of slides or the whole deck. Pressing it opens a menu of the scopes on offer, with the current one checked. Choosing Whole deck shows its cost beside the chip, before anything is sent. With only one scope, or while the AI is already editing, it is a plain label that says why it cannot change.",
      "purpose": "Makes the reach of an AI edit visible and changeable, so a request meant for one slide never rewrites the deck.",
      "useFor": [
        "The composer of a deck or document editor, beside the send button.",
        "A selection toolbar, where the selection sets the scope and the person can widen it.",
        "Any AI edit where a wider scope costs more or changes more."
      ],
      "notFor": [
        {
          "text": "Choosing which files or chats the AI reads",
          "use": "context-chip"
        },
        {
          "text": "A general list of actions",
          "use": "menu"
        },
        {
          "text": "Reviewing what the edit changed afterwards",
          "use": "diff-view"
        }
      ],
      "whenToUse": [
        "Default to the narrowest scope the selection gives.",
        "Show the cost of a whole-deck edit before the person sends.",
        "Use plain words: Slide 3, Slides 3 to 5, Whole deck."
      ],
      "avoid": [
        "Don't fill the chip with the AI colour; only the small mark uses it.",
        "Don't widen the scope by yourself after the person chose it.",
        "Don't hide a locked chip; say why it cannot change."
      ],
      "accessibility": [
        "The chip is a menu button named \"Edit scope: Slide 3\", with aria-expanded and aria-haspopup.",
        "Down or Up arrow on the chip opens the menu. Focus lands on the checked scope.",
        "Scopes are menuitemradio items with aria-checked. Arrows, Home and End move; Enter or Space chooses; Escape closes and returns focus to the chip.",
        "Without a menu the chip is plain text that reads \"Edit scope: This quote\", and a lock reason is written beside it.",
        "The AI mark and icons are hidden from screen readers; the scope is always in words."
      ],
      "accessibilityYouDo": [
        "Offer only scopes that make sense for the current selection, narrowest first.",
        "Work out the deck cost in your code and pass it as deckCost; update it when the deck changes.",
        "When you lock the chip, pass disabledReason so the reason is written, not only greyed out.",
        "Send the chosen scope with the request, and show the person's changes for review before keeping them."
      ],
      "onPhone": [
        "The chip keeps its drawn size and gains a 44px tap area on touch screens.",
        "The menu opens as a bottom sheet on phones, with 44px rows.",
        "The scope text truncates on very narrow screens; the full scope stays in the accessible name and in the menu."
      ],
      "importPath": "import { EditScopeChip } from \"./index\"",
      "sourcePath": "src/atoms/EditScopeChip.tsx",
      "props": [
        {
          "name": "scope",
          "type": "EditScope",
          "required": true,
          "default": null,
          "description": "The current scope: { kind: \"element\", label } | { kind: \"slides\", ids, label? } | { kind: \"deck\", label? }."
        },
        {
          "name": "options",
          "type": "EditScope[]",
          "required": true,
          "default": null,
          "description": "Scopes the person can switch to. One or none makes the chip a plain label."
        },
        {
          "name": "onChange",
          "type": "(scope: EditScope) => void",
          "required": true,
          "default": null,
          "description": "Called with the chosen scope."
        },
        {
          "name": "deckCost",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "Cost or reach of a whole-deck edit, from your code. Shown in the menu and beside the chip while the whole deck is chosen."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Locks the scope, for example while the AI is editing."
        },
        {
          "name": "disabledReason",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Written beside a locked chip."
        },
        {
          "name": "prefix",
          "type": "string",
          "required": false,
          "default": "\"Edit\"",
          "description": "Word before the scope, also used in the accessible name."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface",
        "--border",
        "--border-strong",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--lime",
        "--on-lime",
        "--dur-fast",
        "--shape-control",
        "--shape-overlay"
      ],
      "related": [
        "context-chip",
        "composer",
        "selection-toolbar",
        "menu"
      ],
      "composedOf": [
        "ai-mark",
        "popover"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "In a composer",
          "description": "The chip names what the request will change. Open it to pick this chart title, slide 3, slides 3 to 5 or the whole deck. The whole deck shows its cost beside the chip before you send.",
          "code": "const [scope, setScope] = useState<EditScope>({ kind: \"slides\", ids: [\"3\"] });\n\n<EditScopeChip\n  scope={scope}\n  options={[\n    { kind: \"element\", label: \"This chart title\" },\n    { kind: \"slides\", ids: [\"3\"] },\n    { kind: \"slides\", ids: [\"3\", \"4\", \"5\"] },\n    { kind: \"deck\" },\n  ]}\n  onChange={setScope}\n  deckCost=\"All 12 slides, about 40 credits\"\n/>"
        },
        {
          "title": "Scattered slides, whole deck, single scope and locked",
          "description": "Slides that are not side by side read as \"3 slides\". The whole deck carries its cost. With one scope there is no menu. While the AI edits, the chip is dashed and says why it cannot change.",
          "code": "<EditScopeChip scope={{ kind: \"slides\", ids: [\"2\", \"6\", \"9\"] }} options={options} onChange={setScope} />\n<EditScopeChip scope={{ kind: \"deck\" }} options={options} onChange={setScope} deckCost=\"All 12 slides, about 40 credits\" />\n<EditScopeChip scope={{ kind: \"element\", label: \"This quote\" }} options={[]} onChange={setScope} />\n<EditScopeChip scope={{ kind: \"slides\", ids: [\"3\", \"4\", \"5\"] }} options={options} onChange={setScope}\n  disabled disabledReason=\"Fixed while the AI edits these slides\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-edit-scope-chip/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/edit-scope-chip.md"
    },
    {
      "id": "slide-rail",
      "name": "SlideRail",
      "level": "organism",
      "since": "0.12",
      "aka": [
        "slide thumbnails",
        "slide sorter",
        "filmstrip"
      ],
      "category": "slides",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "waiting",
          "note": "status \"pending\": the title on a still placeholder, with \"Waiting\".",
          "source": "documented"
        },
        {
          "name": "writing",
          "note": "status \"generating\": a working edge in the AI colour and \"Writing…\".",
          "source": "documented"
        },
        {
          "name": "edited by you",
          "note": "status \"edited\": a person mark and \"Edited by you\"; the AI mark is dropped.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "status \"failed\": \"Failed\" in words, the error in the action bar and Try again.",
          "source": "documented"
        },
        {
          "name": "outline changed",
          "note": "status \"stale\": a neutral \"Outline changed\" note.",
          "source": "documented"
        },
        {
          "name": "needs review",
          "note": "status \"review\": amber \"Needs review\", only when the person must act.",
          "source": "documented"
        },
        {
          "name": "several chosen",
          "note": "Shift, Ctrl or Cmd with a click or arrow: chosen slides get a tint and a tick, and the bar says how many.",
          "source": "documented"
        },
        {
          "name": "dragging",
          "note": "An ink line shows where the slide will land.",
          "source": "documented"
        },
        {
          "name": "asking to delete",
          "note": "Delete opens a question with Keep focused; Undo shows after.",
          "source": "documented"
        }
      ],
      "summary": "The list of slide thumbnails: move around, choose slides, reorder, and see each slide's state in words.",
      "description": "SlideRail shows a deck as numbered thumbnails. Each slide says its state in words: waiting, writing, ready, edited by you, failed, outline changed or needs review. People pick the slide on the stage, choose several slides as the scope of an AI edit, reorder by drag or keyboard, and add, duplicate or delete slides with Undo. Delete always asks first. It is a column on wide screens and a sideways strip on phones.",
      "purpose": "Lets someone find their way around a deck the AI is building or editing, see which slides need them, and choose what the next AI edit may touch.",
      "useFor": [
        "The slide list beside the stage in a deck editor.",
        "Choosing several slides as the scope of an AI edit, such as \"shorten slides 3 to 5\".",
        "Following a deck build slide by slide, and retrying one failed slide without stopping the rest."
      ],
      "notFor": [
        {
          "text": "Planning the deck before slides exist",
          "use": "outline-editor"
        },
        {
          "text": "Overall progress of a long build",
          "use": "deck-build-progress"
        },
        {
          "text": "Picking one of several layouts for one slide",
          "use": "layout-alternatives"
        },
        {
          "text": "A gallery of finished images",
          "use": "variant-grid"
        }
      ],
      "whenToUse": [
        "Show every slide's state, and keep failed slides from blocking the others.",
        "Let people choose several slides before an AI edit, so the edit only touches those.",
        "Pass onUndo whenever you pass onDelete.",
        "Pass the same slide drawing you give the stage. The rail scales it down."
      ],
      "avoid": [
        "Don't use the AI colour for the selection, the current slide or the person's edits. It marks only slides being written and the AI mark.",
        "Don't make \"outline changed\" amber unless the person must act before export. Use review for that.",
        "Don't animate every thumbnail while a deck builds. One working edge per slide being written is enough.",
        "Don't delete on a single key press."
      ],
      "accessibility": [
        "The list is a listbox, multi-selectable when onSelectionChange is set. Each slide is an option named \"Slide 3 of 7, Orders by month, needs your review, made by AI\" plus its error.",
        "Only the current slide is in the Tab order. Arrows move, Home and End jump, Shift with an arrow or Home or End extends the selection, Space adds or removes a slide, Ctrl+A or Cmd+A selects all, Escape goes back to one slide.",
        "Alt with an arrow moves the current slide and is announced: \"Slide 4, What chefs told us, moved to position 3\".",
        "Delete or Backspace opens a question below the list with Keep focused; Escape closes it. Nothing is deleted on one key.",
        "A polite live region announces milestones only: a slide failing or needing review, how many slides are ready, moves, deletes and Undo.",
        "Drag has a keyboard and menu alternative: Alt with an arrow, and Move earlier or Move later in More actions.",
        "Thumbnails are hidden from screen readers; the option name carries the title and state. States are words with an icon, never colour alone.",
        "Thumbnail placeholders stop shimmering and the working edge stops turning when reduced motion is on."
      ],
      "accessibilityYouDo": [
        "Give every slide a real title. Screen readers move through the deck by these titles.",
        "Keep the history yourself when you pass onUndo, and restore the slides and the current slide in it.",
        "After a delete, set a new currentId so focus has a slide to land on.",
        "Pass error in plain words for failed and review slides, and say whether anything was lost.",
        "Never let a deck-wide AI change rewrite slides marked edited unless the person chose them."
      ],
      "onPhone": [
        "Below 600px wide, \"auto\" turns the column into a sideways strip that snaps slide by slide, with 64px-tall thumbnails.",
        "Every slide is at least 44px tall to tap. Add slide, Try again and More actions grow to 44px on touch screens.",
        "Drag does not work on most phones, so Move earlier and Move later are in the More actions menu, which opens as a bottom sheet.",
        "A long press on a slide opens the same menu where the browser supports it; the visible More button always does."
      ],
      "importPath": "import { SlideRail } from \"./index\"",
      "sourcePath": "src/organisms/SlideRail.tsx",
      "props": [
        {
          "name": "slides",
          "type": "SlideRef[]",
          "required": true,
          "default": null,
          "description": "The slides in order: id, title, status, and optional thumbnail, aiMade and error."
        },
        {
          "name": "currentId",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The slide on the stage. It holds keyboard focus in the list."
        },
        {
          "name": "onCurrentChange",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Called when the person moves to another slide."
        },
        {
          "name": "selectedIds",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Slides chosen as the scope of an edit. Defaults to the current slide."
        },
        {
          "name": "onSelectionChange",
          "type": "(ids: string[]) => void",
          "required": false,
          "default": null,
          "description": "Turns on choosing several slides."
        },
        {
          "name": "onReorder",
          "type": "(from: number, to: number) => void",
          "required": false,
          "default": null,
          "description": "Turns on drag, Alt with an arrow, and Move earlier or later. to is the new index."
        },
        {
          "name": "onRetry",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Shows Try again for a failed slide."
        },
        {
          "name": "onInsertAfter",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Shows Add slide, placed after this slide."
        },
        {
          "name": "onDuplicate",
          "type": "(ids: string[]) => void",
          "required": false,
          "default": null,
          "description": "Shows Duplicate for the chosen slides."
        },
        {
          "name": "onDelete",
          "type": "(ids: string[]) => void",
          "required": false,
          "default": null,
          "description": "Shows Delete. The rail always asks first, with Keep focused."
        },
        {
          "name": "onUndo",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Undo after an add, duplicate, delete or move, and on Ctrl+Z or Cmd+Z in the list."
        },
        {
          "name": "orientation",
          "type": "\"vertical\" | \"horizontal\" | \"auto\"",
          "required": false,
          "default": "\"auto\"",
          "description": "\"auto\" is a column, and a sideways strip below 600px wide."
        },
        {
          "name": "ratio",
          "type": "\"16:9\" | \"4:3\" | \"1:1\" | \"4:5\" | \"9:16\" | \"fluid\"",
          "required": false,
          "default": "\"16:9\"",
          "description": "Slide shape for the thumbnails. Fluid slides show as 16:9."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Slides\"",
          "description": "Name of the list, shown above it."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Give the vertical rail a height, such as h-160, so the list scrolls inside it."
        }
      ],
      "variantsHint": {
        "orientation": [
          "vertical",
          "horizontal",
          "auto"
        ],
        "ratio": [
          "16:9",
          "4:3",
          "1:1",
          "4:5",
          "9:16",
          "fluid"
        ]
      },
      "tokens": [
        "--lime",
        "--on-lime",
        "--attention",
        "--attention-fg",
        "--danger-fg",
        "--accent-soft",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--border",
        "--surface",
        "--surface-sunken",
        "--surface-hover",
        "--dur-fast",
        "--dur-base",
        "--ease-out",
        "--spacing"
      ],
      "related": [
        "slide-stage",
        "outline-editor",
        "deck-build-progress",
        "layout-alternatives"
      ],
      "composedOf": [
        "slide-stage",
        "working-edge",
        "skeleton",
        "ai-mark",
        "button",
        "menu"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A deck being built",
          "description": "Every state at once. Slide 5 is being written, slide 6 failed and can be tried again on its own, slide 3 needs review, and slide 4 was built before its outline changed. Try Shift with an arrow to choose several slides, Alt with an arrow to move one, and Delete then Undo.",
          "code": "const [current, setCurrent] = useState(\"s1\");\nconst [selected, setSelected] = useState([\"s1\"]);\n\n<SlideRail\n  slides={[\n    { id: \"s1\", title: \"Spring range review\", status: \"edited\", aiMade: true, thumbnail: <TitleSlide /> },\n    { id: \"s3\", title: \"Orders by month\", status: \"review\", aiMade: true, thumbnail: <ChartSlide />,\n      error: \"A number on this slide has no source.\" },\n    { id: \"s4\", title: \"What chefs told us\", status: \"stale\", aiMade: true, thumbnail: <QuoteSlide /> },\n    { id: \"s5\", title: \"Pricing\", status: \"generating\", aiMade: true, thumbnail: <PricingSlide /> },\n    { id: \"s6\", title: \"Customer photos\", status: \"failed\", error: \"The image could not be made. Your text is safe.\" },\n    { id: \"s7\", title: \"Next steps\", status: \"pending\" },\n  ]}\n  currentId={current} onCurrentChange={setCurrent}\n  selectedIds={selected} onSelectionChange={setSelected}\n  onReorder={(from, to) => moveSlide(from, to)}\n  onRetry={(id) => retrySlide(id)}\n  onInsertAfter={(id) => addSlideAfter(id)}\n  onDuplicate={(ids) => duplicateSlides(ids)}\n  onDelete={(ids) => deleteSlides(ids)}\n  onUndo={() => undoLast()}\n/>"
        },
        {
          "title": "Sideways strip for phones",
          "description": "orientation=\"auto\" does this by itself below 600px. Left and Right move between slides. Actions sit in the bar under the strip, so nothing depends on drag or hover.",
          "code": "<SlideRail orientation=\"horizontal\" slides={slides} currentId={current}\n  onCurrentChange={setCurrent} selectedIds={selected} onSelectionChange={setSelected}\n  onReorder={moveSlide} onRetry={retrySlide} onDuplicate={duplicateSlides}\n  onDelete={deleteSlides} onUndo={undoLast} />"
        },
        {
          "title": "Read only, for reviewers",
          "description": "Pass only onCurrentChange and the rail is a plain list to move around in: no selection, no reorder and no actions.",
          "code": "<SlideRail label=\"Shared deck\" orientation=\"vertical\" slides={slides}\n  currentId={current} onCurrentChange={setCurrent} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-slide-rail/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/slide-rail.md"
    },
    {
      "id": "slide-stage",
      "name": "SlideStage",
      "level": "molecule",
      "since": "0.12",
      "aka": [
        "slide editor",
        "slide canvas"
      ],
      "category": "slides",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "viewing",
          "note": "The default: the slide and its name.",
          "source": "documented"
        },
        {
          "name": "editing",
          "note": "state=\"editing\": an ink frame and \"Editing\".",
          "source": "documented"
        },
        {
          "name": "AI working",
          "note": "state=\"working\": a working edge and the workingLabel in words.",
          "source": "documented"
        },
        {
          "name": "previewing",
          "note": "state=\"previewing\": a neutral banner with Exit preview; Escape exits.",
          "source": "documented"
        },
        {
          "name": "loading",
          "note": "state=\"loading\": a still placeholder at the slide's shape.",
          "source": "documented"
        },
        {
          "name": "partly built",
          "note": "state=\"partial\": the slide with a \"Partly built\" note and Try the rest again.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "state=\"failed\": the reason, what was kept, and Try again.",
          "source": "documented"
        },
        {
          "name": "things to fix",
          "note": "A blocking issue turns the count amber: \"1 thing to fix before you present\".",
          "source": "documented"
        }
      ],
      "summary": "The large view of one slide at its true shape, scaled to fit, with its state and things to check.",
      "description": "SlideStage shows the current slide at a fixed shape (16:9, 4:3, 1:1, 4:5 or 9:16) and scales it to the space without reflowing, so what the person sees is what will export. It covers viewing, editing, AI working, previewing a change, loading, partly built and failed, each in words. Things to check, such as text that does not fit or a number with no source, sit behind a quiet count that turns amber only when they stop presenting. An optional caption says what the AI changed, with Undo.",
      "purpose": "Shows one slide exactly as it will export, says what is happening to it, and points at anything to fix before presenting.",
      "useFor": [
        "The main view of a deck editor, beside the slide rail.",
        "Previewing a layout or an AI change before it is applied.",
        "Showing a slide that failed or is only partly built, with Try again."
      ],
      "notFor": [
        {
          "text": "Comparing a slide before and after an AI change",
          "use": "deck-change-review"
        },
        {
          "text": "A single generated image",
          "use": "media-frame"
        },
        {
          "text": "The list of all slides",
          "use": "slide-rail"
        }
      ],
      "whenToUse": [
        "Lay out at the design size and let the stage scale it, so the view matches the export.",
        "Report text that does not fit as an issue instead of shrinking it.",
        "Say in the AI caption what was kept, such as \"Your numbers were not changed\".",
        "Use previewing for layouts and suggestions, and apply only from a separate choice."
      ],
      "avoid": [
        "Don't use the AI colour on the slide itself. Generated slides use the person's theme.",
        "Don't make the issue count amber for optional suggestions.",
        "Don't let hovering or previewing change the slide.",
        "Don't animate the scaling or fly slides in."
      ],
      "accessibility": [
        "The stage is a region named by label.",
        "The things-to-check count is a button with aria-expanded that opens a list; each issue is a button that moves focus to its target.",
        "The preview banner is a polite status. Escape in the stage exits the preview.",
        "Failed is an alert with the reason and Try again. Partly built is a polite status.",
        "The working edge announces its label once when the AI starts.",
        "States and issue severity are words with an icon, never colour alone.",
        "Scaling never animates; the preview cross-fade stops under reduced motion."
      ],
      "accessibilityYouDo": [
        "Give a label that names the slide, such as \"Slide 3, Orders by month\". It names the region.",
        "Give each issue's target element an id and pass it as targetId, so choosing the issue moves focus there.",
        "Mark an issue blocking only when it really stops presenting or export.",
        "Lay slide content out at the design size (960 wide for landscape, 540 wide for 9:16). Do not shrink text to make it fit; report overflow instead.",
        "Write the AI change caption in plain words and say what was kept."
      ],
      "onPhone": [
        "Fills the width and keeps its shape. It never causes sideways page scroll, and pinch-zoom still works.",
        "Portrait slides are capped by maxHeight (70dvh by default) so the whole slide stays on screen.",
        "The things-to-check count, Exit preview, Try again and Undo grow to 44px on touch screens. Rows in the issue list are 44px."
      ],
      "importPath": "import { SlideStage } from \"./index\"",
      "sourcePath": "src/molecules/SlideStage.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Names the region and shows at the top, such as \"Slide 3, Orders by month\"."
        },
        {
          "name": "ratio",
          "type": "\"16:9\" | \"4:3\" | \"1:1\" | \"4:5\" | \"9:16\" | \"fluid\"",
          "required": false,
          "default": "\"16:9\"",
          "description": "Slide shape. Fluid grows with its content and is not scaled."
        },
        {
          "name": "state",
          "type": "\"viewing\" | \"editing\" | \"working\" | \"previewing\" | \"loading\" | \"partial\" | \"failed\"",
          "required": false,
          "default": "\"viewing\"",
          "description": "What is happening to the slide, shown in words."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The slide, laid out at 960 wide (540 for 9:16)."
        },
        {
          "name": "issues",
          "type": "StageIssue[]",
          "required": false,
          "default": null,
          "description": "Things to check: kind, message, optional targetId and blocking."
        },
        {
          "name": "onIssueSelect",
          "type": "(issue: StageIssue) => void",
          "required": false,
          "default": null,
          "description": "Called when an issue is chosen. Without it focus moves to targetId."
        },
        {
          "name": "previewLabel",
          "type": "string",
          "required": false,
          "default": "\"Previewing a change. Nothing has changed yet.\"",
          "description": "Words in the preview banner."
        },
        {
          "name": "onExitPreview",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Exit preview; Escape in the stage does the same."
        },
        {
          "name": "workingLabel",
          "type": "string",
          "required": false,
          "default": null,
          "description": "What the AI is doing while working."
        },
        {
          "name": "partialNote",
          "type": "string",
          "required": false,
          "default": null,
          "description": "What is missing while partly built."
        },
        {
          "name": "errorMessage",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why it failed and what was kept."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Try again when failed or partly built."
        },
        {
          "name": "aiChange",
          "type": "{ summary: string; onUndo?: () => void }",
          "required": false,
          "default": null,
          "description": "A caption saying what the AI changed, with an AI label and optional Undo."
        },
        {
          "name": "maxHeight",
          "type": "string",
          "required": false,
          "default": "\"70dvh\"",
          "description": "Tallest the frame may be, as a CSS length."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the region."
        }
      ],
      "variantsHint": {
        "ratio": [
          "16:9",
          "4:3",
          "1:1",
          "4:5",
          "9:16",
          "fluid"
        ],
        "state": [
          "viewing",
          "editing",
          "working",
          "previewing",
          "loading",
          "partial",
          "failed"
        ]
      },
      "tokens": [
        "--lime",
        "--attention-soft",
        "--attention-fg",
        "--danger-fg",
        "--unsure",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--border",
        "--surface",
        "--surface-sunken",
        "--dur-fast",
        "--shape-card"
      ],
      "related": [
        "slide-rail",
        "layout-alternatives",
        "deck-change-review",
        "media-frame"
      ],
      "composedOf": [
        "working-edge",
        "skeleton",
        "badge",
        "button",
        "ai-label"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Things to check and what the AI changed",
          "description": "A blocking issue (a number with no source) turns the count amber. Open it and choose the issue to move focus to that text on the slide. The caption says what the AI changed, with Undo.",
          "code": "<SlideStage\n  label=\"Slide 3, Orders by month\"\n  issues={[\n    { id: \"i1\", kind: \"noSource\", blocking: true, targetId: \"figure\",\n      message: \"“Up 15% on August” was written by AI and has no source. Add one or remove it.\" },\n    { id: \"i2\", kind: \"contrast\", message: \"The grey month labels are faint on a projector.\" },\n  ]}\n  aiChange={{\n    summary: \"Turned your table into a bar chart and highlighted September. Your numbers were not changed.\",\n    onUndo: undoChange,\n  }}\n>\n  <ChartSlide />\n</SlideStage>"
        },
        {
          "title": "Every state",
          "description": "AI working wraps the frame in one calm working edge. Previewing shows a neutral banner, and nothing changes until a choice is made elsewhere; Escape exits. Partly built keeps what is ready. Failed says what was kept.",
          "code": "<SlideStage label=\"Slide 5, Pricing\" state=\"working\" workingLabel=\"AI is shortening the three plans\">…</SlideStage>\n<SlideStage label=\"Slide 5, Pricing\" state=\"previewing\"\n  previewLabel=\"Previewing layout 2 of 4. Nothing has changed yet.\" onExitPreview={exitPreview}>…</SlideStage>\n<SlideStage label=\"Slide 5, Pricing\" state=\"partial\"\n  partialNote=\"The text is ready. The plan icons could not be made.\" onRetry={retry}>…</SlideStage>\n<SlideStage label=\"Slide 5, Pricing\" state=\"loading\" />\n<SlideStage label=\"Slide 5, Pricing\" state=\"failed\"\n  errorMessage=\"This slide timed out. Your outline and the other slides were not changed.\" onRetry={retry} />\n<SlideStage label=\"Slide 5, Pricing\" state=\"editing\"\n  issues={[{ id: \"o1\", kind: \"overflow\", targetId: \"group-plan\",\n    message: \"The Group plan text runs past the bottom. Shorten it or split the slide.\" }]}>…</SlideStage>"
        },
        {
          "title": "Shapes: 4:5 and 9:16",
          "description": "Portrait slides are laid out at their own design size and capped in height, so they stay on screen.",
          "code": "<SlideStage label=\"Slide 1, Spring range review, 4 by 5\" ratio=\"4:5\" maxHeight=\"28rem\">…</SlideStage>\n<SlideStage label=\"Slide 1, Spring range review, 9 by 16\" ratio=\"9:16\" maxHeight=\"28rem\">…</SlideStage>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-slide-stage/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/slide-stage.md"
    },
    {
      "id": "layout-alternatives",
      "name": "LayoutAlternatives",
      "level": "organism",
      "since": "0.12",
      "aka": [
        "layout suggestions",
        "design ideas"
      ],
      "category": "slides",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the current prop.",
          "source": "prop"
        },
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "error",
          "note": "Set with the error prop.",
          "source": "prop"
        }
      ],
      "summary": "Other layouts for one slide, suggested by the AI and compared with the slide as it is. Preview without changing the deck, then apply one or keep the current.",
      "description": "LayoutAlternatives shows the slide's current layout first and the AI's suggested layouts beside it. Focus or hover previews a layout on the slide without changing the deck; Enter, a mouse click or Apply layout commits it, and Undo takes it back. On touch a tap only previews, so nothing is applied by accident. A visible text mode promises what happens to the person's words: kept exactly, or shortened to fit with each option saying what it shortens. Layouts arrive one by one, a failed request says so with Try again, and when no layout is good enough it says so plainly and offers Edit text instead of weak options.",
      "purpose": "Lets people try other layouts for one slide safely: they see each one on the slide before anything changes, and their words are protected unless they say otherwise.",
      "useFor": [
        "A Layouts panel beside the slide stage in a deck editor.",
        "A bottom Sheet on a phone, opened from the slide.",
        "Any single item where the AI offers other arrangements of the same content."
      ],
      "notFor": [
        {
          "text": "Picking one of several fresh generations, such as images",
          "use": "variant-grid"
        },
        {
          "text": "Reviewing an AI edit that changed several slides",
          "use": "deck-change-review"
        },
        {
          "text": "Choosing between video takes",
          "use": "take-grid"
        }
      ],
      "whenToUse": [
        "Keep the current layout first, so going back is always one step.",
        "Say on the slide stage, in words, that a preview is not applied.",
        "Make the text mode visible and keep it until the person changes it.",
        "Show no options rather than weak ones, and offer Edit text.",
        "Say on each option what it changes about the words when the mode is Shorten to fit."
      ],
      "avoid": [
        "Do not apply a layout on hover or focus. Previews never change the deck.",
        "Do not shuffle layouts in a way that drops the person's edits.",
        "Do not colour the slide drawings or the preview lime. Lime is only the AI mark and the placeholders' mark.",
        "Do not use amber for \"layouts available\". Suggestions are optional."
      ],
      "accessibility": [
        "The layouts are a radio group with one tab stop. Arrow keys, Home and End move between layouts, and focus previews the layout.",
        "Enter applies the focused layout. Space previews it. Escape goes back to the current layout and moves focus to it.",
        "Each layout's name says whether it is the current one or applied, and includes what it changes.",
        "Previewing and Applied are written on the tile, not shown by border alone.",
        "A polite live region says only state changes: layouts ready, previewing a layout, back to original, applied, undone.",
        "Placeholders are hidden from screen readers; the live region says how many layouts are ready.",
        "A touch tap previews instead of applying, and Apply layout is a separate button.",
        "Tiles enter with Reveal, which does not move under reduced motion."
      ],
      "accessibilityYouDo": [
        "Show the preview on your slide stage when onPreview is called, and mark it as a preview in words. Never save a preview to the deck.",
        "Return null from your preview when onPreview(null) arrives, so Escape and Back to current really restore the slide.",
        "Only offer layouts that fit the chosen text mode. In Keep my words, leave out layouts that would cut text.",
        "Work out the cost of More layouts in your code and pass it in cost.",
        "On a phone, put the part in a bottom Sheet and keep the slide visible above it."
      ],
      "onPhone": [
        "Two layouts per row; three from a wider panel and four on a wide page.",
        "A tap previews a layout; the Apply layout button commits it, because touch has no hover.",
        "Buttons and layout tiles have a 44px touch area.",
        "Put it in a bottom Sheet on a phone."
      ],
      "importPath": "import { LayoutAlternatives } from \"./index\"",
      "sourcePath": "src/organisms/LayoutAlternatives.tsx",
      "props": [
        {
          "name": "current",
          "type": "{ thumbnail: ReactNode; label?: string }",
          "required": true,
          "default": null,
          "description": "The slide as it is now. Always the first option, labelled Current."
        },
        {
          "name": "options",
          "type": "{ id: string; label: string; thumbnail: ReactNode; note?: string }[]",
          "required": true,
          "default": null,
          "description": "Suggested layouts. label is the accessible name; note says what changes, such as \"Shortens two points\"."
        },
        {
          "name": "state",
          "type": "\"loading\" | \"partial\" | \"ready\" | \"failed\" | \"none\"",
          "required": true,
          "default": null,
          "description": "Where the request is. none means the AI found no layout good enough."
        },
        {
          "name": "loadingCount",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Placeholders to draw for layouts still on the way, while loading or partial."
        },
        {
          "name": "previewId",
          "type": "string | null",
          "required": false,
          "default": null,
          "description": "The option previewed on the slide. null shows the current layout."
        },
        {
          "name": "onPreview",
          "type": "(id: string | null) => void",
          "required": true,
          "default": null,
          "description": "Preview an option on your slide stage, or go back to the current layout with null. Never change the deck here."
        },
        {
          "name": "onApply",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Apply an option to the slide: Enter, a mouse click or Apply layout."
        },
        {
          "name": "appliedId",
          "type": "string | null",
          "required": false,
          "default": null,
          "description": "The option just applied. Shows Applied and Undo."
        },
        {
          "name": "onUndo",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Undo after applying. Restore the previous layout and clear appliedId."
        },
        {
          "name": "textMode",
          "type": "\"keep\" | \"fit\"",
          "required": true,
          "default": null,
          "description": "keep: the person's words stay exactly. fit: the AI may shorten text to fit a layout."
        },
        {
          "name": "onTextModeChange",
          "type": "(mode: \"keep\" | \"fit\") => void",
          "required": false,
          "default": null,
          "description": "Shows the Keep my words and Shorten to fit control. Ask for new layouts when it changes."
        },
        {
          "name": "onMore",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows More layouts when ready, and Try again in the none state."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Try again in the failed state."
        },
        {
          "name": "onEditText",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Edit text in the none state."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Plain reason in the failed state."
        },
        {
          "name": "noneReason",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Plain reason in the none state."
        },
        {
          "name": "cost",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "What More layouts costs, shown beside it."
        },
        {
          "name": "slideName",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The slide, such as \"slide 3\". Used in the title and the group name."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Heading level of the title."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the panel."
        }
      ],
      "variantsHint": {
        "state": [
          "loading",
          "partial",
          "ready",
          "failed",
          "none"
        ],
        "textMode": [
          "keep",
          "fit"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--bg",
        "--sunken",
        "--border",
        "--border-strong",
        "--fg",
        "--fg-muted",
        "--lime",
        "--on-lime",
        "--success-fg",
        "--danger-fg",
        "--radius-card",
        "--radius-control",
        "--dur-fast",
        "--ease-out"
      ],
      "related": [
        "variant-grid",
        "deck-change-review",
        "segmented-control",
        "sheet",
        "toast"
      ],
      "composedOf": [
        "ai-mark",
        "badge",
        "button",
        "reveal",
        "segmented-control"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Preview, apply and undo",
          "description": "Hover or Tab through the layouts: the slide on the left shows each one as a preview and the deck does not change. Enter or a click applies; Undo takes it back. Switch to Shorten to fit to ask again with one more option.",
          "code": "<LayoutAlternatives slideName=\"slide 3\"\n  current={{ thumbnail: <SlideDrawing slide={slide} /> }}\n  options={options} state={state} loadingCount={waiting}\n  previewId={previewId} onPreview={setPreviewId}\n  onApply={(id) => { applyLayout(id); setAppliedId(id); setPreviewId(null); }}\n  appliedId={appliedId} onUndo={undoLayout}\n  textMode={textMode} onTextModeChange={(m) => { setTextMode(m); askAgain(m); }}\n  onMore={() => askAgain(textMode)} cost=\"About 2 credits\" />\n\n// options: [{ id: \"chart-full\", label: \"Full-width chart\",\n//   note: \"Points move to speaker notes\", thumbnail: <SlideDrawing … /> }, …]"
        },
        {
          "title": "Loading, some ready, failed and no good layouts",
          "description": "Placeholders hold the space while layouts are made. A failed request keeps the slide as it is and offers Try again. When nothing is good enough the part says so and offers Edit text.",
          "code": "<LayoutAlternatives state=\"partial\" options={firstOne} loadingCount={2} … />\n\n<LayoutAlternatives state=\"failed\" options={[]} onRetry={retry} … />\n\n<LayoutAlternatives state=\"none\" options={[]} onEditText={openEditor}\n  noneReason=\"This slide has a 9-row table. Every other layout would cut rows\n    or shrink the text below a readable size.\" … />"
        },
        {
          "title": "In a bottom sheet on a phone",
          "description": "Tap a layout to preview it on the slide, then press Apply layout. Closing the sheet drops the preview.",
          "code": "<Sheet open={open} onClose={() => { setPreviewId(null); setOpen(false); }} title=\"Layouts for slide 3\">\n  <LayoutAlternatives current={{ thumbnail: <SlideDrawing slide={slide} /> }}\n    options={options} state=\"ready\" textMode=\"keep\"\n    previewId={previewId} onPreview={setPreviewId}\n    onApply={applyLayout} appliedId={appliedId} onUndo={undoLayout} />\n</Sheet>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-layout-alternatives/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/layout-alternatives.md"
    },
    {
      "id": "deck-change-review",
      "name": "DeckChangeReview",
      "level": "organism",
      "since": "0.12",
      "aka": [
        "review changes",
        "deck diff"
      ],
      "category": "slides",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        }
      ],
      "summary": "Review an AI edit that changed several slides: before and after for each slide, Keep or Undo each one or all at once, then save the kept changes as one version.",
      "description": "DeckChangeReview lists every slide an AI request changed, each with what changed in one line, the slide before and after, and Keep or Undo. Undecided slides are the amber \"needs you\" state, and the header counts in words: \"1 of 4 slides kept, 3 left to review\". Keep all and Undo all handle the whole set. Nothing reaches the deck until Done, which saves the kept changes as one version that can be restored as a whole. On a narrow screen it shows one slide at a time with Previous and Next, and a Before and After switch instead of two pictures.",
      "purpose": "Lets people take the parts of a deck-wide AI edit they like and leave the rest, instead of accepting or losing it all.",
      "useFor": [
        "After a deck-wide request such as \"Make it more visual\" or \"Tighten the wording\".",
        "Reviewing AI speaker notes or rewrites across several slides before they are saved.",
        "Any staged edit to an ordered set of visual items that each need their own decision."
      ],
      "notFor": [
        {
          "text": "Reviewing code changes by file and hunk",
          "use": "diff-view"
        },
        {
          "text": "Choosing another layout for one slide",
          "use": "layout-alternatives"
        },
        {
          "text": "Switching one text field between the AI version and your edit",
          "use": "revert-toggle"
        },
        {
          "text": "Restoring an older version of the whole deck",
          "use": "version-history"
        }
      ],
      "whenToUse": [
        "Stage deck-wide AI edits and review them here before they touch the deck.",
        "Write each change's summary in plain words: what moved, what was cut, what was added.",
        "Save the kept changes as one version named after the request, so it can be undone as a whole.",
        "Keep amber for slides that still need a decision."
      ],
      "avoid": [
        "Do not apply a deck-wide edit first and offer only a global undo.",
        "Do not force all-or-nothing: always allow keeping some slides.",
        "Do not colour the After picture or kept slides lime. Lime only marks that the After picture was made by the AI.",
        "Do not pick a decision for the person when they press Done; ask them to decide the slides left."
      ],
      "accessibility": [
        "The panel is a section named by its title, with a heading per slide one level below.",
        "Keep and Undo are toggle buttons with aria-pressed, grouped and named by the slide's number and title.",
        "Each slide's state is a written badge (Needs review, Kept, Undone) with an icon, not colour alone.",
        "A polite live region says each decision and what is left, such as \"Slide 5 kept. 2 left to review.\", and when the deck is saved.",
        "Done stays focusable while slides are left; pressing it explains in words what to do instead of failing silently.",
        "Before and After pictures are hidden from screen readers; the one-line summary carries the change.",
        "Keyboard order is slide by slide, then Keep all, Undo all and Done.",
        "The only motion is a fast colour change on the slide border."
      ],
      "accessibilityYouDo": [
        "Stage the AI edit; do not write it to the deck until onDone. Save the kept changes as one version named after the request.",
        "Give each change a one-line summary of what really changed, written by your code from the edit, not a guess.",
        "Draw before and after from the real slides. The pictures are hidden from screen readers, so the summary must carry the meaning.",
        "Set status to applying while saving and applied when saved, and pass onOpenHistory so the whole edit can be restored."
      ],
      "onPhone": [
        "Below 480px wide (layout auto) it shows one slide at a time with Previous and Next.",
        "On a narrow screen Before and After become a switch, so each picture fills the width.",
        "Buttons have a 44px touch area.",
        "Keep all, Undo all and Done stay together under the slide."
      ],
      "importPath": "import { DeckChangeReview } from \"./index\"",
      "sourcePath": "src/organisms/DeckChangeReview.tsx",
      "props": [
        {
          "name": "request",
          "type": "string",
          "required": true,
          "default": null,
          "description": "What the person asked the AI, in their words. Shown in the header and in the saved version's name."
        },
        {
          "name": "changes",
          "type": "{ slideId: string; slideNumber: number; title: string; before: ReactNode; after: ReactNode; summary: string; state: \"proposed\" | \"kept\" | \"rejected\" }[]",
          "required": true,
          "default": null,
          "description": "Each changed slide. rejected shows as Undone."
        },
        {
          "name": "onChange",
          "type": "(slideId: string, state: \"proposed\" | \"kept\" | \"rejected\") => void",
          "required": true,
          "default": null,
          "description": "Keep or Undo on one slide. Pressing the chosen button again returns the slide to proposed."
        },
        {
          "name": "onKeepAll",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Keep every change."
        },
        {
          "name": "onRejectAll",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Undo every change."
        },
        {
          "name": "onDone",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Save the kept changes as one version. Only called once no slide is left to review."
        },
        {
          "name": "status",
          "type": "\"reviewing\" | \"applying\" | \"applied\"",
          "required": false,
          "default": "\"reviewing\"",
          "description": "applying locks the decisions and shows a busy Done. applied shows the saved notice."
        },
        {
          "name": "view",
          "type": "\"side-by-side\" | \"toggle\"",
          "required": false,
          "default": "\"side-by-side\"",
          "description": "Before and After next to each other, or one picture with a switch. Narrow screens always use the switch."
        },
        {
          "name": "layout",
          "type": "\"auto\" | \"list\" | \"pager\"",
          "required": false,
          "default": "\"auto\"",
          "description": "All slides in a column, or one at a time. auto uses the pager when the part is under 480px wide."
        },
        {
          "name": "onOpenHistory",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Version history in the saved notice."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Heading level of the title. Each slide is one level below."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the panel."
        }
      ],
      "variantsHint": {
        "status": [
          "reviewing",
          "applying",
          "applied"
        ],
        "view": [
          "side-by-side",
          "toggle"
        ],
        "layout": [
          "auto",
          "list",
          "pager"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--bg",
        "--border",
        "--fg",
        "--fg-muted",
        "--lime",
        "--on-lime",
        "--attention",
        "--attention-soft",
        "--attention-fg",
        "--success-fg",
        "--radius-card",
        "--radius-control",
        "--dur-fast"
      ],
      "related": [
        "diff-view",
        "revert-toggle",
        "version-history",
        "suggestion-bar",
        "layout-alternatives"
      ],
      "composedOf": [
        "ai-mark",
        "badge",
        "button",
        "segmented-control"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Keep or undo each slide",
          "description": "Four slides changed by \"Make it more visual\". Keep or Undo each one; the header counts what is left. Done only applies once every slide has a decision, then saves one version.",
          "code": "<DeckChangeReview request=\"Make it more visual\" changes={changes} status={status}\n  onChange={(slideId, state) => setState(slideId, state)}\n  onKeepAll={() => setAll(\"kept\")} onRejectAll={() => setAll(\"rejected\")}\n  onDone={saveAsOneVersion} onOpenHistory={openHistory} />\n\n// changes: [{ slideId: \"s3\", slideNumber: 3, title: \"Pricing\",\n//   summary: \"Made the chart full width and moved the points to speaker notes\",\n//   before: <SlideDrawing … />, after: <SlideDrawing … />, state: \"proposed\" }, …]"
        },
        {
          "title": "One slide at a time on a phone",
          "description": "layout=\"pager\" (or auto on a narrow screen) shows one slide with Previous and Next and a Before and After switch.",
          "code": "<DeckChangeReview request=\"Make it more visual\" changes={changes} status={status}\n  layout=\"pager\" onChange={setState} onKeepAll={keepAll} onRejectAll={undoAll} onDone={save} />"
        },
        {
          "title": "Needs review, all reviewed, saving and saved",
          "description": "Amber only while a slide still needs a decision. The toggle view shows one picture with a Before and After switch.",
          "code": "<DeckChangeReview request=\"Tighten the wording on the results slides\" changes={changes}\n  view=\"toggle\" status=\"applying\" … />\n\n<DeckChangeReview … status=\"applied\" onOpenHistory={openHistory} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-deck-change-review/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/deck-change-review.md"
    },
    {
      "id": "deck-build-progress",
      "name": "DeckBuildProgress",
      "level": "organism",
      "since": "0.12",
      "aka": [
        "deck progress",
        "build progress"
      ],
      "category": "slides",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "error",
          "note": "Set with the error prop.",
          "source": "prop"
        },
        {
          "name": "queued",
          "note": "\"Waiting to start\", with the queue position when given. The bar is empty.",
          "source": "documented"
        },
        {
          "name": "building",
          "note": "Count, bar and the current step. The slide being made has a lime segment and a lime-edged thumbnail.",
          "source": "documented"
        },
        {
          "name": "partial",
          "note": "Build ended with failed slides: amber \"Needs you\" line, Retry the failed slides, Open deck.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "Nothing usable was made: error text and Try the build again.",
          "source": "documented"
        },
        {
          "name": "cancelled",
          "note": "\"Stopped. 3 slides kept, 5 not built\", with Build the other slides and Open deck.",
          "source": "documented"
        },
        {
          "name": "done",
          "note": "\"All 8 slides are ready\" with Open deck.",
          "source": "documented"
        },
        {
          "name": "compact",
          "note": "One-row phone layout with the slide list folded. Set compact.",
          "source": "documented"
        }
      ],
      "summary": "Progress for a deck the AI builds slide by slide: slides ready out of the total, the real step, Stop that keeps what is done, and Retry per failed slide.",
      "description": "DeckBuildProgress counts slides ready out of the total with one bar segment per slide, and shows the real current step your app reports. Each slide is listed with its status in words; ready slides can be opened at once and failed slides have their own Retry. Stop says how many slides it keeps. When the job runs on the server it tells the person they can leave the page. If some slides fail the result is \"Partly ready\", not \"Failed\".",
      "purpose": "Shows honestly how far a deck build has got, lets people use and keep what is done, and makes failed slides easy to fix without starting again.",
      "useFor": [
        "Building a deck from an approved outline, where each slide takes a few seconds.",
        "Redoing many slides at once, such as a new theme across the deck.",
        "A phone screen, as a compact bar above the slide strip."
      ],
      "notFor": [
        {
          "text": "One image or one file being made",
          "use": "generation-progress"
        },
        {
          "text": "A long agent run with many kinds of steps",
          "use": "activity-trace"
        },
        {
          "text": "A job that is out of sight in the background",
          "use": "background-run-chip"
        }
      ],
      "whenToUse": [
        "Count real slides: \"3 of 12 slides ready\". People can check that.",
        "Let people open ready slides while the rest build.",
        "Say what Stop keeps, and offer to build the rest later.",
        "Treat one failed slide as that slide's problem, with its own Retry."
      ],
      "avoid": [
        "Do not show a made-up percentage or a timer that is not tied to the job.",
        "Do not ask people to keep the tab open. Run the job on the server.",
        "Do not animate slides flying in or play a celebration when the deck is ready.",
        "Do not colour the whole card lime. Lime marks only the slide the AI is making right now."
      ],
      "accessibility": [
        "The bar is a progressbar named \"Slides ready\" with a value text such as \"3 of 12 slides ready, 1 failed\".",
        "A polite live region speaks only when the job changes state or a slide fails, never for each step or each ready slide. It stays quiet on first render.",
        "Every slide status is a word (Waiting, Building, Ready, Failed, Not built), with an icon for Ready and Failed.",
        "Open and Retry buttons name their slide, such as \"Retry slide 5, Price change in July\".",
        "The slide list sits behind a button with aria-expanded.",
        "The pulsing dot on the slide being built holds still under reduced motion, and segments change colour without movement."
      ],
      "accessibilityYouDo": [
        "Pass the real step from your build job. Never invent steps or a percentage to look busy.",
        "Keep the job running on the server if the person leaves, and only set background when that is true.",
        "When the person comes back to a finished job, show what happened with CatchUpSummary instead of replaying the progress.",
        "Move focus to the deck or the first ready slide when the person presses Open."
      ],
      "onPhone": [
        "Set compact for a phone: one status line, the bar and the main action, with the slide list behind a Show slides button.",
        "Slide rows are at least 48px tall, and Open, Retry and Show slides get a 44px touch area.",
        "The bar keeps one segment per slide and shrinks the gaps, so it fits at 320px wide."
      ],
      "importPath": "import { DeckBuildProgress } from \"./index\"",
      "sourcePath": "src/organisms/DeckBuildProgress.tsx",
      "props": [
        {
          "name": "state",
          "type": "\"queued\" | \"building\" | \"partial\" | \"failed\" | \"cancelled\" | \"done\"",
          "required": true,
          "default": null,
          "description": "Where the whole job is. Use partial when the job ended with some slides made and some failed; failed only when nothing usable exists."
        },
        {
          "name": "slides",
          "type": "DeckBuildSlide[]",
          "required": true,
          "default": null,
          "description": "Every slide in outline order: id, title, status (\"waiting\" | \"building\" | \"ready\" | \"failed\") and an optional thumbnail kind. All counts come from here."
        },
        {
          "name": "currentStep",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The real step the job is on, such as \"Making the chart for slide 4\". Shown while building."
        },
        {
          "name": "queuePosition",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Place in the queue while queued."
        },
        {
          "name": "background",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Says the person can leave the page and the deck keeps building. Set it only when that is true."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why the whole build failed, in plain words."
        },
        {
          "name": "onStop",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Stops the build and keeps the ready slides. The button reads \"Stop and keep 3 slides\"."
        },
        {
          "name": "onRetrySlide",
          "type": "(slideId: string) => void",
          "required": false,
          "default": null,
          "description": "Retries one failed slide. Adds Retry to its row."
        },
        {
          "name": "onRetryFailed",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Retries every failed slide, or the whole build when state is failed."
        },
        {
          "name": "onResume",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "After a stop, builds the slides that were not made."
        },
        {
          "name": "onOpen",
          "type": "(slideId?: string) => void",
          "required": false,
          "default": null,
          "description": "Opens the deck, or one ready slide. Adds Open to ready rows and Open deck when the job ends."
        },
        {
          "name": "compact",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "One-row layout for a phone, with the list folded away."
        },
        {
          "name": "deckTitle",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Name of the deck, shown in the heading."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the heading in the full layout."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the card."
        }
      ],
      "variantsHint": {
        "state": [
          "queued",
          "building",
          "partial",
          "failed",
          "cancelled",
          "done"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--sunken",
        "--border",
        "--border-strong",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--lime",
        "--danger",
        "--danger-soft",
        "--danger-fg",
        "--attention-fg",
        "--success-fg",
        "--radius-card",
        "--radius-control",
        "--dur-base",
        "--dur-fast",
        "--ease-out"
      ],
      "related": [
        "generation-progress",
        "background-run-chip",
        "status-ticker",
        "catch-up-summary"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A live build",
          "description": "Eight slides build one by one. Slide 5 fails and the rest carry on, so the job ends Partly ready. Try Stop part way: it keeps the ready slides and offers to build the rest.",
          "code": "<DeckBuildProgress\n  deckTitle=\"Q3 churn review\"\n  state={state}               // \"queued\" | \"building\" | \"partial\" | \"failed\" | \"cancelled\" | \"done\"\n  slides={slides}             // [{ id, title, status: \"waiting\" | \"building\" | \"ready\" | \"failed\", kind }]\n  currentStep=\"Making the chart for slide 5\"\n  background                  // the job keeps running if the person leaves\n  onStop={stopAndKeep}\n  onRetrySlide={(id) => retry(id)}\n  onRetryFailed={retryFailed}\n  onResume={buildTheRest}\n  onOpen={(slideId) => open(slideId)}\n/>"
        },
        {
          "title": "Every state",
          "description": "Queued, building, partly ready, failed, stopped and ready. Each one is written in words; the colour only repeats it.",
          "code": "<DeckBuildProgress state=\"partial\" slides={slides} onRetryFailed={retryFailed} onOpen={open} />\n<DeckBuildProgress state=\"failed\" slides={slides} error=\"The AI service did not answer. Your outline is saved.\" onRetryFailed={retry} />\n<DeckBuildProgress state=\"cancelled\" slides={slides} onResume={buildTheRest} onOpen={open} />"
        },
        {
          "title": "Compact, on a phone",
          "description": "One line, the bar and Stop. The slide list opens on request.",
          "code": "<DeckBuildProgress compact deckTitle=\"Q3 churn review\" state=\"building\" slides={slides}\n  currentStep=\"Laying out three columns for slide 6\" onStop={stopAndKeep} onRetrySlide={retry} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-deck-build-progress/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/deck-build-progress.md"
    },
    {
      "id": "speaker-notes",
      "name": "SpeakerNotes",
      "level": "molecule",
      "since": "0.12",
      "aka": [
        "presenter notes",
        "notes"
      ],
      "category": "slides",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "error",
          "note": "Set with the error prop.",
          "source": "prop"
        },
        {
          "name": "open or closed",
          "note": "Folded behind the Speaker notes button. Set collapsible.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "No notes: placeholder text and Draft notes with AI.",
          "source": "documented"
        },
        {
          "name": "generating",
          "note": "Draft streams into a separate box with a lime working edge and Stop drafting. The person's notes are untouched.",
          "source": "documented"
        },
        {
          "name": "proposed",
          "note": "Draft waits with Keep (or \"Keep, replacing my notes\") and Discard.",
          "source": "documented"
        },
        {
          "name": "kept",
          "note": "AI draft kept: lime \"AI draft\" label.",
          "source": "documented"
        },
        {
          "name": "edited",
          "note": "\"Edited by you\", a revert button when onRevert is set, and drafting asks first.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "Red line with the reason and Try drafting again. The notes are unchanged.",
          "source": "documented"
        }
      ],
      "summary": "Notes for one slide: an AI draft held apart until you keep it, \"AI draft\" or \"Edited by you\", and the word count and speaking time worked out in code.",
      "description": "SpeakerNotes is a labelled notes box for the current slide. An AI draft streams into a separate box and only replaces the notes when the person presses Keep. Kept notes carry an AI label; once the person types, it changes to \"Edited by you\" with a button to put the AI draft back. Drafting again over the person's own words asks first. The word count and speaking time are counted from the text, never asked of the model.",
      "purpose": "Helps a presenter get notes for a slide quickly while keeping what they will say in their own hands.",
      "useFor": [
        "The notes panel under the slide in a deck editor.",
        "Drafting notes for one slide on request, with Keep or Discard.",
        "Checking how long a slide will take to present."
      ],
      "notFor": [
        {
          "text": "Drafting text that goes into the slide or a document",
          "use": "suggestion-bar"
        },
        {
          "text": "Switching between your text and the AI's for any field",
          "use": "revert-toggle"
        },
        {
          "text": "A plain form text box",
          "use": "field"
        }
      ],
      "whenToUse": [
        "Hold every AI draft apart until the person presses Keep.",
        "Show who wrote the notes: \"AI draft\" or \"Edited by you\".",
        "Count words and speaking time in code from the text the person sees.",
        "List AI images on the slide, so the presenter can say where they came from."
      ],
      "avoid": [
        "Do not write AI notes straight into the box, even when it is empty.",
        "Do not ask the model how long the notes take to say.",
        "Do not keep the lime AI label after the person has edited the notes.",
        "Do not redraft notes for every slide without asking."
      ],
      "accessibility": [
        "The notes box is a native textarea labelled \"Speaker notes\" and described by its slide.",
        "A polite live region says Drafting notes, Draft ready, Notes kept, Draft discarded or Notes could not be drafted. The streaming words are not read out.",
        "Origin is written in words: \"AI draft\" on a lime pill, or \"Edited by you\" with a pencil icon.",
        "Drafting over edited notes asks \"Draft over your edits?\" in place, with Keep mine focused first, so one key press never replaces the person's words.",
        "The fold button has aria-expanded and aria-controls.",
        "The working edge around a draft being written holds still under reduced motion."
      ],
      "accessibilityYouDo": [
        "Set state to edited as soon as the person changes AI-written notes, and back to kept if they revert.",
        "Pass the slide's real title in slideLabel, so the notes box is named after its slide.",
        "Set wordsPerMinute to the presenter's pace if you know it. The default is 130.",
        "Draft notes for many slides only on a clear request, and still stage each one for Keep or Discard."
      ],
      "onPhone": [
        "Set collapsible to fold the notes under the slide behind a Speaker notes button.",
        "The notes box text is 16px on touch screens, so phones do not zoom in.",
        "Buttons get a 44px touch area; the words and time line wraps under the label when space is short."
      ],
      "importPath": "import { SpeakerNotes } from \"./index\"",
      "sourcePath": "src/molecules/SpeakerNotes.tsx",
      "props": [
        {
          "name": "slideLabel",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Which slide the notes are for, such as \"Slide 4: Pricing plans\". Names the section and the notes box."
        },
        {
          "name": "value",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The person's notes. AI text lands here only through onKeep."
        },
        {
          "name": "onChange",
          "type": "(value: string) => void",
          "required": true,
          "default": null,
          "description": "Called on every keystroke."
        },
        {
          "name": "state",
          "type": "\"empty\" | \"generating\" | \"proposed\" | \"kept\" | \"edited\" | \"failed\"",
          "required": true,
          "default": null,
          "description": "Where the notes are. kept means the AI draft as kept; edited means the person wrote or changed them."
        },
        {
          "name": "proposed",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The AI draft, shown apart while generating or proposed."
        },
        {
          "name": "onGenerate",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Drafts notes for this slide. Asks first when the notes are edited by the person."
        },
        {
          "name": "onKeep",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Replaces the notes with the draft."
        },
        {
          "name": "onDiscard",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Drops the draft. The notes stay as they were."
        },
        {
          "name": "onStop",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Stops a draft that is still being written."
        },
        {
          "name": "onRevert",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Puts the AI draft back after an edit. Shows a revert button by \"Edited by you\"."
        },
        {
          "name": "wordsPerMinute",
          "type": "number",
          "required": false,
          "default": "130",
          "description": "Speaking pace used to turn the word count into time."
        },
        {
          "name": "imageCredits",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Lines about images on the slide, such as \"Photo on this slide: made by AI\"."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why the draft failed, in plain words."
        },
        {
          "name": "collapsible",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Folds the notes behind a Speaker notes button."
        },
        {
          "name": "defaultOpen",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Whether folded notes start open."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the section."
        }
      ],
      "variantsHint": {
        "state": [
          "empty",
          "generating",
          "proposed",
          "kept",
          "edited",
          "failed"
        ]
      },
      "tokens": [
        "--surface",
        "--sunken",
        "--border",
        "--border-strong",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--lime",
        "--danger-fg",
        "--radius-field",
        "--radius-card",
        "--radius-control",
        "--dur-fast"
      ],
      "related": [
        "suggestion-bar",
        "ai-label",
        "revert-toggle",
        "inline-confirm"
      ],
      "composedOf": [
        "ai-label",
        "inline-confirm",
        "button",
        "working-edge"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Draft, keep, then edit",
          "description": "Draft notes with AI, then Keep. Type in the box: the label becomes \"Edited by you\" with a button to put the AI draft back. Drafting again now asks first.",
          "code": "<SpeakerNotes\n  slideLabel=\"Slide 2: Churn went from 3.1% to 4.4%\"\n  value={notes}\n  state={state}               // \"empty\" | \"generating\" | \"proposed\" | \"kept\" | \"edited\" | \"failed\"\n  proposed={draft}\n  onChange={(v) => { setNotes(v); if (state === \"kept\") setState(\"edited\"); }}\n  onGenerate={draftNotes}\n  onKeep={() => { setNotes(draft); setState(\"kept\"); }}\n  onDiscard={() => setState(previousState)}\n  onRevert={() => { setNotes(aiVersion); setState(\"kept\"); }}\n  imageCredits={[\"Chart on this slide: made from your file churn-q3.csv\"]}\n/>"
        },
        {
          "title": "Every state",
          "description": "Empty, drafting, a draft to review next to the person's own notes, kept, edited and failed.",
          "code": "<SpeakerNotes state=\"proposed\" value={myNotes} proposed={draft} onKeep={keep} onDiscard={discard} ... />\n<SpeakerNotes state=\"failed\" value={myNotes} onGenerate={draftNotes} ... />"
        },
        {
          "title": "Folded, on a phone",
          "description": "Collapsible notes under the slide, with image credits at the bottom.",
          "code": "<SpeakerNotes collapsible defaultOpen={false} slideLabel=\"Slide 4: What they told us\" value={notes} onChange={setNotes} state=\"edited\"\n  onGenerate={draftNotes} imageCredits={[\"Photo on this slide: made by AI, content credentials attached\"]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-speaker-notes/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/speaker-notes.md"
    },
    {
      "id": "export-sheet",
      "name": "ExportSheet",
      "level": "organism",
      "since": "0.12",
      "aka": [
        "export dialog",
        "download options",
        "export"
      ],
      "category": "slides",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "open or closed",
          "note": "Set with the open prop.",
          "source": "prop"
        },
        {
          "name": "status",
          "note": "Set with the state prop.",
          "source": "prop"
        },
        {
          "name": "error",
          "note": "Set with the error prop.",
          "source": "prop"
        },
        {
          "name": "checking",
          "note": "Spinner and \"Checking the deck for PowerPoint…\". Format and notes are disabled for the moment.",
          "source": "documented"
        },
        {
          "name": "choosing",
          "note": "Format, what the file holds and the check results. Export anyway when fix items remain.",
          "source": "documented"
        },
        {
          "name": "nothing to fix",
          "note": "\"Nothing to fix. It should look the same as PDF.\" with a check icon.",
          "source": "documented"
        },
        {
          "name": "exporting",
          "note": "\"Making PowerPoint: slide 5 of 8\" with a meter and Stop export.",
          "source": "documented"
        },
        {
          "name": "done",
          "note": "\"Export done\" with the file name and size, and Download or Copy link.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "Red panel with the reason and Try again. The format choice stays, so the person can pick another.",
          "source": "documented"
        }
      ],
      "summary": "Export a deck as PDF, PowerPoint, images or a link: what the file holds, what needs fixing first (amber) and what will change, then progress, the file name, or Retry.",
      "description": "ExportSheet lets the person choose a format and shows what the file will hold. Before anything is made it lists the results of your app's checks for that format: things a person must act on, such as AI changes nobody has reviewed or an image with no description, in amber with a link to the slide; and things worth knowing, such as fonts that will be swapped, in neutral. Export is never blocked; with open items the button says Export anyway. Then it shows real progress, the file name when done, and Try again when it fails. It is a dialog on wide screens and a bottom sheet on phones.",
      "purpose": "Stops people finding out after they sent a deck that fonts changed, descriptions went missing or AI changes were never checked.",
      "useFor": [
        "The Export or Share action in a deck editor.",
        "Making a view-only link to a deck.",
        "An export panel inside a side bar or settings page, with inline."
      ],
      "notFor": [
        {
          "text": "Reviewing AI changes slide by slide",
          "use": "deck-change-review"
        },
        {
          "text": "A plain confirm before one action",
          "use": "dialog"
        },
        {
          "text": "Progress for a single generated file",
          "use": "generation-progress"
        }
      ],
      "whenToUse": [
        "Run the checks per format: a font swap matters in PowerPoint, not in PDF.",
        "Put things a person must act on first, in amber, each with a link to its slide.",
        "Let people export anyway, and say so on the button.",
        "Name the file after the deck and show it when done."
      ],
      "avoid": [
        "Do not use amber for notes that need no action, such as \"Credentials are kept in PDF\".",
        "Do not block the export until every check is fixed.",
        "Do not let font swaps, missing image descriptions or removed content credentials go unmentioned.",
        "Do not share or send the file without a press from the person."
      ],
      "accessibility": [
        "In dialog mode it is a native modal dialog: the page behind is inert, Tab stays inside, Escape closes it and focus returns to the opener.",
        "Formats are a native radio group, so arrow keys move between them.",
        "Each check starts with the words \"Needs you\" or \"Note\" and an icon, so the meaning does not rest on colour.",
        "A polite live region speaks when checking ends (\"Checks done. 2 things to fix before you send it.\"), when the export starts, ends or fails. Progress is not read out slide by slide.",
        "Export progress is a meter with value text such as \"Slide 5 of 8\".",
        "The check list fades in at --dur-fast, and not at all under reduced motion."
      ],
      "accessibilityYouDo": [
        "Run the checks in your app for the chosen format and pass them in plain words. The model must not write them.",
        "Return focus to the right slide when onJumpTo is used, and close the sheet if the slide is hidden behind it.",
        "Make the file name readable: the deck title and the format, such as \"q3-churn-review.pdf\".",
        "Never send or share the file from code. Download and Copy link happen only when the person presses them."
      ],
      "onPhone": [
        "Opens as a bottom sheet under the sm breakpoint, with the actions stacked at the bottom where a thumb reaches.",
        "Format options and the notes switch grow to 44px rows on touch screens.",
        "\"Go to slide\" links drop under their message on narrow screens and keep a 44px touch area."
      ],
      "importPath": "import { ExportSheet } from \"./index\"",
      "sourcePath": "src/organisms/ExportSheet.tsx",
      "props": [
        {
          "name": "open",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Whether the dialog shows. Ignored when inline."
        },
        {
          "name": "onClose",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Closes it. Also used by Cancel and Close."
        },
        {
          "name": "inline",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Draws the same content as a card in the page."
        },
        {
          "name": "deckTitle",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Shown in the title: \"Export Q3 churn review\"."
        },
        {
          "name": "formats",
          "type": "(\"pdf\" | \"pptx\" | \"png\" | \"link\")[]",
          "required": false,
          "default": "[\"pdf\", \"pptx\", \"png\", \"link\"]",
          "description": "Formats on offer, in order."
        },
        {
          "name": "format",
          "type": "\"pdf\" | \"pptx\" | \"png\" | \"link\"",
          "required": true,
          "default": null,
          "description": "The chosen format."
        },
        {
          "name": "onFormatChange",
          "type": "(format: ExportFormat) => void",
          "required": true,
          "default": null,
          "description": "Called when the person picks a format. Run the checks for it."
        },
        {
          "name": "state",
          "type": "\"choosing\" | \"checking\" | \"exporting\" | \"done\" | \"failed\"",
          "required": true,
          "default": null,
          "description": "Where the export is."
        },
        {
          "name": "includes",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "What the file will hold, worked out by your app."
        },
        {
          "name": "includeNotes",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Whether speaker notes go in."
        },
        {
          "name": "onIncludeNotesChange",
          "type": "(value: boolean) => void",
          "required": false,
          "default": null,
          "description": "Shows the speaker notes switch. Leave out for formats that cannot carry notes."
        },
        {
          "name": "checks",
          "type": "ExportCheck[]",
          "required": false,
          "default": null,
          "description": "{ id, severity: \"fix\" | \"info\", message, slideId?, slideLabel? }. Fix items are amber and come first."
        },
        {
          "name": "onJumpTo",
          "type": "(slideId: string) => void",
          "required": false,
          "default": null,
          "description": "Adds a Go to slide link to checks that name a slide."
        },
        {
          "name": "progress",
          "type": "{ done: number; total: number }",
          "required": false,
          "default": null,
          "description": "Real progress in slides while exporting."
        },
        {
          "name": "fileName",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The file made, or the link."
        },
        {
          "name": "fileSize",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Size of the file, such as \"3.8 MB\"."
        },
        {
          "name": "error",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why the export failed, in plain words."
        },
        {
          "name": "onExport",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Starts the export. Never blocked."
        },
        {
          "name": "onStopExport",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Stops a running export."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Try again after a failure. Defaults to onExport."
        },
        {
          "name": "onDownload",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Download button when a file is done."
        },
        {
          "name": "onCopyLink",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Copy link button when a link is done. The sheet then says \"Link copied\"."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Heading level when inline."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes for the panel."
        }
      ],
      "variantsHint": {
        "format": [
          "pdf",
          "pptx",
          "png",
          "link"
        ],
        "state": [
          "choosing",
          "checking",
          "exporting",
          "done",
          "failed"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--raised",
        "--surface",
        "--sunken",
        "--border",
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--attention-soft",
        "--attention-fg",
        "--danger-soft",
        "--danger-fg",
        "--success-fg",
        "--radius-card",
        "--radius-overlay",
        "--radius-control",
        "--dur-fast"
      ],
      "related": [
        "dialog",
        "sheet",
        "deck-build-progress",
        "generation-progress"
      ],
      "composedOf": [
        "dialog",
        "radio-group",
        "switch",
        "meter",
        "spinner",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Export with checks",
          "description": "Open it, switch format and watch the checks change. PowerPoint fails the first time at slide 6, to show Try again. Go to slide 4 closes the sheet and jumps there.",
          "code": "<ExportSheet\n  open={open}\n  onClose={() => setOpen(false)}\n  deckTitle=\"Q3 churn review\"\n  format={format}\n  onFormatChange={(f) => { setFormat(f); runChecks(f); }}\n  state={state}               // \"choosing\" | \"checking\" | \"exporting\" | \"done\" | \"failed\"\n  includes={[\"8 slides, 16:9\", \"Speaker notes on 6 slides\"]}\n  includeNotes={notes}\n  onIncludeNotesChange={setNotes}\n  checks={[\n    { id: \"ai\", severity: \"fix\", message: \"3 AI changes on slides 4, 6 and 7 have not been reviewed.\", slideId: \"s4\", slideLabel: \"slide 4\" },\n    { id: \"font\", severity: \"info\", message: \"2 fonts will be replaced: Fieldwork becomes Arial, Lumen Serif becomes Georgia.\" },\n  ]}\n  onJumpTo={goToSlide}\n  progress={{ done, total: 8 }}\n  fileName=\"q3-churn-review.pptx\"\n  fileSize=\"3.8 MB\"\n  onExport={startExport}\n  onStopExport={stopExport}\n  onDownload={download}\n/>"
        },
        {
          "title": "Every state",
          "description": "Drawn inline so each state is easy to compare: checking, things to fix, nothing to fix, exporting, done and failed.",
          "code": "<ExportSheet inline state=\"exporting\" format=\"pptx\" progress={{ done: 5, total: 8 }} ... />\n<ExportSheet inline state=\"failed\" format=\"pptx\" error=\"The export service did not answer. Nothing was sent.\" onExport={retry} ... />"
        },
        {
          "title": "A view-only link",
          "description": "The link format ends with the link and Copy link instead of a file.",
          "code": "<ExportSheet inline deckTitle=\"Q3 churn review\" format=\"link\" state=\"done\"\n  fileName=\"decks.northwind.app/v/q3-churn-review\" onFormatChange={setFormat} onCopyLink={copy} onExport={createLink} onClose={close} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-export-sheet/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/export-sheet.md"
    },
    {
      "id": "take-picker",
      "name": "TakePicker",
      "level": "organism",
      "since": "0.13",
      "aka": [
        "take selector",
        "generation picker"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "making",
          "note": "Every take is pending: each slot keeps its height and reads \"Making take 2\" next to a small AI dot.",
          "source": "documented"
        },
        {
          "name": "partly ready",
          "note": "Some takes are ready or still making their end (streaming) while others are pending. Ready ones can be played and kept.",
          "source": "documented"
        },
        {
          "name": "pick a take",
          "note": "Every take is done and none is kept: an amber line with a dot and words asks the person to pick one. Turn off with requirePick={false}.",
          "source": "documented"
        },
        {
          "name": "kept",
          "note": "The kept row has a dark border, a check mark and the word Kept, and its Keep button is pressed.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "A take that could not be made shows the reason and, with onRetryTake, Try again. When every take failed, one message replaces the list.",
          "source": "documented"
        },
        {
          "name": "playing",
          "note": "One take at a time. Moving to another take plays from the same moment.",
          "source": "documented"
        }
      ],
      "summary": "Compare a few AI-made takes of the same request, hear them at the same moment, and keep one.",
      "description": "TakePicker lists two to four (or more) audio takes made from one request. Each row has Play, a waveform you can seek, the length, a few words on what is different, and Keep. One playhead is shared by every take, so moving from one take to the next plays from the same moment, which makes the difference easy to hear. Takes still being made keep their slot and say so, a failed take explains itself and can be tried again, and once every take is done and none is kept, an amber line asks the person to pick one. Takes not kept stay in history.",
      "purpose": "Helps a person choose the best of several generated takes by ear, without losing the ones they did not choose.",
      "useFor": [
        "Choosing between two to four song takes from one prompt.",
        "Choosing a new version of one section, heard with a few seconds of the song around it.",
        "Picking several short sound effects at once, with multiple."
      ],
      "notFor": [
        {
          "text": "Video takes",
          "use": "take-grid"
        },
        {
          "text": "Image variations",
          "use": "variant-grid"
        },
        {
          "text": "Playing one finished clip",
          "use": "audio-player"
        },
        {
          "text": "Earlier versions of the whole song",
          "use": "version-history"
        }
      ],
      "whenToUse": [
        "Keep the takes that were not chosen, and say where they went.",
        "Say what is different about each take in a few plain words.",
        "Show the cost of making more beside the button.",
        "For a section, play a little of the song around it so the joins can be heard."
      ],
      "avoid": [
        "Don't autoplay a take when it arrives, or play a preview on hover.",
        "Don't colour the waveform or the playhead with the AI colour. Only the AI marks and Make more use it.",
        "Don't use amber for \"making\" or \"new take ready\". Amber appears only when the person must pick.",
        "Don't describe takes as sounding like a real singer or band."
      ],
      "accessibility": [
        "The picker is a section named by its heading, and the takes are a list named by the same words.",
        "Play on each row is a toggle button named \"Play Take 2\" with aria-pressed. Starting a take stops any other player on the page.",
        "Each waveform is a slider named \"Seek, Take 2\" with the time in words; see Waveform for its keys.",
        "Keep is a toggle button named \"Keep Take 2\" with aria-pressed. The kept row also shows a check mark and the word Kept.",
        "Keep is disabled until a take has finished, with a tooltip saying why.",
        "Keys 1 to 9 play that take while focus is in the list, never while typing or with a modifier key. A key hint shows this on screens with a mouse.",
        "One status region speaks changes only, such as \"Making 3 takes.\", \"1 of 3 takes ready, 2 still being made.\" and \"3 takes ready. Pick one to keep.\" It never reads the clock.",
        "What is different is read as one line starting \"Different:\".",
        "Under reduced motion, rows do not rise in, the making dot does not pulse, and the playhead moves in steps."
      ],
      "accessibilityYouDo": [
        "Give every take a unique label, such as \"Take 2\". It names the Play, Seek and Keep buttons.",
        "Write differs in plain words about sound (tempo, voice, instruments), never as \"sounds like\" a real artist.",
        "Never start a take by itself when it arrives. The picker only plays after a press.",
        "Say in error why a take failed and whether the person was charged.",
        "Keep the takes that were not chosen in history, and say so in note.",
        "Offer lyrics or a written description of each take where you can, since audio alone is not enough for everyone."
      ],
      "onPhone": [
        "Takes are stacked rows. Play is 44px on the left and Keep sits on the right, 44px tall on touch screens.",
        "Each waveform is a 44px tall seek area. On very narrow rows it becomes a plain bar.",
        "Number keys and their hints are hidden on touch screens.",
        "Make more and its cost wrap under the history note when space is short."
      ],
      "importPath": "import { TakePicker } from \"./index\"",
      "sourcePath": "src/organisms/TakePicker.tsx",
      "props": [
        {
          "name": "takes",
          "type": "AudioTake[]",
          "required": true,
          "default": null,
          "description": "The takes: id, label, status (pending, streaming, ready, failed), and optional src, peaks, duration, madeUpTo, differs and error."
        },
        {
          "name": "kept",
          "type": "string[]",
          "required": true,
          "default": null,
          "description": "Ids of the kept takes."
        },
        {
          "name": "onKeptChange",
          "type": "(ids: string[]) => void",
          "required": true,
          "default": null,
          "description": "Called with the new kept ids when Keep is pressed."
        },
        {
          "name": "multiple",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Lets the person keep more than one take. Off, Keep works like a radio choice."
        },
        {
          "name": "onMore",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows the make-more button. It shows a busy state while any take is still being made."
        },
        {
          "name": "moreLabel",
          "type": "string",
          "required": false,
          "default": "\"Make 2 more\"",
          "description": "Text of the make-more button."
        },
        {
          "name": "moreCost",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Cost shown beside the make-more button, such as \"about 10 credits\"."
        },
        {
          "name": "section",
          "type": "{ start: number; end: number; label: string }",
          "required": false,
          "default": null,
          "description": "The takes replace one section of a longer song. Play runs around the section and the section is marked on each waveform."
        },
        {
          "name": "context",
          "type": "number",
          "required": false,
          "default": "3",
          "description": "Seconds of the song played before and after the section."
        },
        {
          "name": "onRetryTake",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Shows Try again on a failed take."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Takes\"",
          "description": "Heading of the picker. Also names the list."
        },
        {
          "name": "prompt",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The request the takes were made from, shown under the heading."
        },
        {
          "name": "note",
          "type": "string",
          "required": false,
          "default": "\"Takes you don't keep stay in history.\"",
          "description": "Line beside the make-more button."
        },
        {
          "name": "requirePick",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Once every take is done and none is kept, an amber line asks the person to pick one."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the picker."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--lime",
        "--attention",
        "--attention-soft",
        "--attention-fg",
        "--accent",
        "--fg",
        "--fg-muted",
        "--border",
        "--border-strong",
        "--surface",
        "--surface-sunken",
        "--danger-fg",
        "--dur-base",
        "--dur-fast",
        "--dur-instant"
      ],
      "related": [
        "audio-player",
        "waveform",
        "take-grid",
        "variant-grid",
        "generation-progress",
        "version-history"
      ],
      "composedOf": [
        "waveform",
        "button",
        "ai-badge",
        "ai-mark",
        "kbd",
        "skeleton"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Three song takes, pick one",
          "description": "Every take is done and none is kept yet, so the amber line asks for a pick. Press Play on Take 1, then Play on Take 2: it carries on from the same moment. Make 2 more shows the making, still-making-the-end and ready states, and the cost sits beside the button.",
          "code": "const [kept, setKept] = useState<string[]>([]);\n\n<TakePicker\n  title=\"Takes for Rainy Platform\"\n  prompt=\"Gentle lo-fi song about waiting for a late train, about 2 and a half minutes\"\n  takes={[\n    { id: \"t1\", label: \"Take 1\", status: \"ready\", duration: 148, peaks, differs: [\"Slower, 84 BPM\", \"Soft female vocal\", \"Piano lead\"] },\n    { id: \"t2\", label: \"Take 2\", status: \"ready\", duration: 136, peaks, differs: [\"92 BPM\", \"Low male vocal\", \"Brushed drums\"] },\n    { id: \"t3\", label: \"Take 3\", status: \"ready\", duration: 141, peaks, differs: [\"92 BPM\", \"Two voices\", \"Guitar lead, no drums\"] },\n  ]}\n  kept={kept}\n  onKeptChange={setKept}\n  onMore={makeTwoMore}\n  moreCost=\"about 10 credits\"\n/>"
        },
        {
          "title": "Takes for one section, with a failed take",
          "description": "With section set, Play runs from a few seconds before the chorus to a few seconds after it, so the joins can be heard, and the chorus is marked as AI-made on each waveform. Take 3 failed and says why; Try again keeps its slot while it is made again.",
          "code": "<TakePicker\n  title=\"New chorus for Harbour Lights\"\n  prompt=\"Chorus, lines 2 and 4 rewritten\"\n  takes={[\n    { id: \"c1\", label: \"Chorus take 1\", status: \"ready\", duration: 184, peaks, differs: [\"Same melody\", \"Fuller drums\"] },\n    { id: \"c2\", label: \"Chorus take 2\", status: \"ready\", duration: 184, peaks, differs: [\"Higher melody\", \"Harmony on the last line\"] },\n    { id: \"c3\", label: \"Chorus take 3\", status: \"failed\", error: \"The model stopped before the end of the chorus. You were not charged.\" },\n  ]}\n  kept={kept}\n  onKeptChange={setKept}\n  onRetryTake={retry}\n  section={{ start: 62, end: 88, label: \"the chorus (1:02 to 1:28)\" }}\n  context={4}\n  note=\"Keeping a take makes a new version of the whole song. The old chorus stays in history.\"\n/>"
        },
        {
          "title": "Making, partly ready, and keeping several",
          "description": "While every take is still being made, each slot keeps its height and says which take it is making. Partly ready: a finished take can be played while the next is still making its end; nothing is amber yet because the person cannot act on all of them. With multiple, Keep works like a check box.",
          "code": "<TakePicker title=\"Takes for Morning Ferry\" kept={[]} onKeptChange={setKept}\n  takes={[{ id: \"g1\", label: \"Take 1\", status: \"pending\" }, { id: \"g2\", label: \"Take 2\", status: \"pending\" }]}\n  onMore={makeMore} moreCost=\"about 4 credits\" />\n\n<TakePicker title=\"Takes for Field Notes\" kept={[]} onKeptChange={setKept}\n  takes={[\n    { id: \"p1\", label: \"Take 1\", status: \"ready\", duration: 15, peaks },\n    { id: \"p2\", label: \"Take 2\", status: \"streaming\", duration: 15, madeUpTo: 9, peaks },\n    { id: \"p3\", label: \"Take 3\", status: \"pending\" },\n  ]} />\n\n<TakePicker title=\"Sound effects for the shop door\" multiple kept={kept} onKeptChange={setKept}\n  note=\"Keep as many as you want. The rest stay in history.\" takes={bells} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-take-picker/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/take-picker.md"
    },
    {
      "id": "enhance-compare",
      "name": "EnhanceCompare",
      "level": "molecule",
      "since": "0.13",
      "aka": [
        "before after",
        "audio compare",
        "a b compare"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "idle",
          "note": "Not cleaned up yet. Only the original plays, and Clean up starts the work.",
          "source": "documented"
        },
        {
          "name": "processing",
          "note": "The AI is cleaning up: a small AI dot and words. The original stays playable; the slider and check boxes wait.",
          "source": "documented"
        },
        {
          "name": "ready",
          "note": "Before and After can be switched at the same moment. The After version has the AI label.",
          "source": "documented"
        },
        {
          "name": "settings changed",
          "note": "Ready, but the strength or sounds to keep changed since the After was made: the line says so and Apply appears.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "The clean-up did not work: the reason, \"Nothing was changed\", and Try again.",
          "source": "documented"
        }
      ],
      "summary": "Clean up a recording with one strength slider, and switch Before and After at the same moment.",
      "description": "EnhanceCompare sets how strongly a recording is cleaned up, from Natural to Clean, and which sounds to keep, such as laughter or room sound. Once the clean-up is ready, a Before and After switch changes version without moving the playhead, so the difference is heard straight away. It covers idle, processing, ready and failed, and says when the settings have changed since the After version was made.",
      "purpose": "Lets a person hear exactly what a clean-up changes and choose how far it goes, without losing the original.",
      "useFor": [
        "Removing background noise from a podcast interview.",
        "Making a phone voice memo clearer while keeping some room sound.",
        "Any AI clean-up where people say they cannot hear a difference."
      ],
      "notFor": [
        {
          "text": "Choosing between several generated takes",
          "use": "take-picker"
        },
        {
          "text": "Undoing one AI change in text",
          "use": "revert-toggle"
        },
        {
          "text": "Just playing a recording",
          "use": "audio-player"
        }
      ],
      "whenToUse": [
        "Keep the original and let people go back to it at any time.",
        "Start from a middle strength. Full strength often sounds robotic.",
        "Offer to keep the sounds that carry meaning, such as laughter.",
        "Show the cost next to Clean up when there is one."
      ],
      "avoid": [
        "Don't render again every time the person wants to compare. The switch must be instant.",
        "Don't colour the waveform with the AI colour. Only the AI label on After and Clean up use it.",
        "Don't use amber for processing. Nothing needs the person yet."
      ],
      "accessibility": [
        "The panel is a section named by its heading.",
        "Before and After is a radio group named \"Listen to\": arrow keys move and select, and only the chosen option is a tab stop.",
        "B switches Before and After while focus is inside the panel, but not on the slider, a check box or any text field, and never with a modifier key.",
        "The strength slider is a native range named \"Cleanup strength\", read as \"60 percent, between Natural and Clean\".",
        "The sounds to keep are native check boxes in a fieldset named \"Sounds to keep\". They are disabled while processing.",
        "One status region announces processing, ready and failed. It never reads the clock.",
        "The player follows AudioPlayer: Play is a toggle, the waveform is a slider with the time in words, and the After version has an AI label.",
        "Under reduced motion the processing dot does not pulse."
      ],
      "accessibilityYouDo": [
        "Keep the original. Clean-up must never replace it without a way back.",
        "Name the sounds to keep in plain words, starting with Keep.",
        "Say in errorText what happened to the recording and whether the person was charged.",
        "Set status to processing as soon as the work starts, and to ready or failed when it ends.",
        "When onProcess costs anything, pass costNote so the cost sits beside the button."
      ],
      "onPhone": [
        "Fills the width. The Before and After switch wraps under the heading on narrow screens and its buttons are 44px tall on touch.",
        "The strength slider and check boxes are 44px tall on touch screens.",
        "The B key hint is hidden on touch screens; the switch does the same job.",
        "The player follows AudioPlayer: a 44px Play button and a 44px seek area."
      ],
      "importPath": "import { EnhanceCompare } from \"./index\"",
      "sourcePath": "src/molecules/EnhanceCompare.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Name of the recording. Also names the player."
        },
        {
          "name": "duration",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Length in seconds."
        },
        {
          "name": "strength",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Cleanup strength, 0 (Natural) to 100 (Clean), in steps of 5."
        },
        {
          "name": "onStrengthChange",
          "type": "(n: number) => void",
          "required": true,
          "default": null,
          "description": "Called as the slider moves."
        },
        {
          "name": "listening",
          "type": "\"before\" | \"after\"",
          "required": true,
          "default": null,
          "description": "Which version plays once the clean-up is ready. Switching keeps the position."
        },
        {
          "name": "onListeningChange",
          "type": "(v: \"before\" | \"after\") => void",
          "required": true,
          "default": null,
          "description": "Called by the switch and the B key."
        },
        {
          "name": "status",
          "type": "\"idle\" | \"processing\" | \"ready\" | \"failed\"",
          "required": true,
          "default": null,
          "description": "Where the clean-up is. Before ready, only the original plays."
        },
        {
          "name": "onProcess",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Starts or repeats the clean-up. Shows Clean up, Apply (after a change) or Try again."
        },
        {
          "name": "costNote",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Cost shown beside the button, such as \"Free on your plan\"."
        },
        {
          "name": "keepParts",
          "type": "{ id: string; label: string; on: boolean }[]",
          "required": false,
          "default": null,
          "description": "Sounds the person can choose to keep, such as \"Keep laughter\"."
        },
        {
          "name": "onKeepPartChange",
          "type": "(id: string, on: boolean) => void",
          "required": false,
          "default": null,
          "description": "Called when a sound to keep is ticked or cleared."
        },
        {
          "name": "beforePeaks",
          "type": "number[]",
          "required": false,
          "default": null,
          "description": "Loudness per slice of the original."
        },
        {
          "name": "afterPeaks",
          "type": "number[]",
          "required": false,
          "default": null,
          "description": "Loudness per slice of the cleaned version."
        },
        {
          "name": "beforeSrc",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The original file. Leave both files out to simulate playback."
        },
        {
          "name": "afterSrc",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The cleaned file."
        },
        {
          "name": "errorText",
          "type": "string",
          "required": false,
          "default": "\"The clean-up did not finish. Your recording is unchanged.\"",
          "description": "Message in the failed state."
        },
        {
          "name": "heading",
          "type": "string",
          "required": false,
          "default": "\"Clean up sound\"",
          "description": "Heading of the panel."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the panel."
        }
      ],
      "variantsHint": {
        "listening": [
          "before",
          "after"
        ],
        "status": [
          "idle",
          "processing",
          "ready",
          "failed"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--lime",
        "--accent",
        "--fg",
        "--fg-muted",
        "--border",
        "--surface",
        "--surface-sunken",
        "--danger-fg",
        "--dur-fast"
      ],
      "related": [
        "audio-player",
        "revert-toggle",
        "transcript-sync"
      ],
      "composedOf": [
        "audio-player",
        "segmented-control",
        "slider",
        "checkbox",
        "button",
        "kbd"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Clean up an interview",
          "description": "Press Clean up. While it works the original stays playable. When it is ready, play and press B or the switch: the playhead stays where it was. Move the strength or a check box and Apply appears, because the After you hear no longer matches the settings.",
          "code": "const [strength, setStrength] = useState(60);\nconst [listening, setListening] = useState<\"before\" | \"after\">(\"after\");\nconst [status, setStatus] = useState<EnhanceStatus>(\"idle\");\n\n<EnhanceCompare\n  title=\"Episode 14, interview with Noor Haddad\"\n  duration={74}\n  strength={strength}\n  onStrengthChange={setStrength}\n  listening={listening}\n  onListeningChange={setListening}\n  status={status}\n  onProcess={cleanUp}\n  costNote=\"Free on your plan\"\n  keepParts={[\n    { id: \"laugh\", label: \"Keep laughter\", on: true },\n    { id: \"room\", label: \"Keep room sound\", on: false },\n    { id: \"music\", label: \"Keep background music\", on: false },\n  ]}\n  onKeepPartChange={setPart}\n  beforePeaks={original}\n  afterPeaks={cleaned}\n  beforeSrc=\"/audio/ep14.mp3\"\n  afterSrc=\"/audio/ep14-clean.mp3\"\n/>"
        },
        {
          "title": "Ready, comparing",
          "description": "A short voice memo already cleaned up, listening to Before. The After version carries the AI label; the original does not. The heading can be changed to match the job.",
          "code": "<EnhanceCompare\n  title=\"Voice memo, kitchen\"\n  heading=\"Reduce noise\"\n  duration={41}\n  strength={strength}\n  onStrengthChange={setStrength}\n  listening={listening}\n  onListeningChange={setListening}\n  status=\"ready\"\n  onProcess={apply}\n  beforePeaks={original}\n  afterPeaks={cleaned}\n/>"
        },
        {
          "title": "Failed",
          "description": "The clean-up did not finish. The panel says the recording is unchanged and whether you were charged, and Try again starts it once more (in this demo it then works).",
          "code": "<EnhanceCompare\n  title=\"Episode 14, interview with Noor Haddad\"\n  duration={74}\n  status=\"failed\"\n  errorText=\"The clean-up stopped part way. Your recording is unchanged, and you were not charged.\"\n  onProcess={cleanUp}\n  {...rest}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-enhance-compare/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/enhance-compare.md"
    },
    {
      "id": "section-lane",
      "name": "SectionLane",
      "level": "organism",
      "since": "0.13",
      "aka": [
        "song sections",
        "arrangement lane",
        "track lane"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "up to date",
          "note": "A section as it was made or recorded, labelled AI, Recorded or Uploaded.",
          "source": "documented"
        },
        {
          "name": "edited, not generated",
          "note": "Changed by the person but not made again yet: amber, in words, because a person has to press Generate.",
          "source": "documented"
        },
        {
          "name": "out of date",
          "note": "A section an edit nearby has made stale: amber with Out of date and a dashed border.",
          "source": "documented"
        },
        {
          "name": "generating",
          "note": "The lime working ring and the word Generating, because the AI is making it. Still under reduced motion.",
          "source": "documented"
        },
        {
          "name": "takes to compare",
          "note": "New takes are ready for this section: amber, N takes to compare.",
          "source": "documented"
        },
        {
          "name": "did not work",
          "note": "An alert icon, Try again and whether it was charged.",
          "source": "documented"
        },
        {
          "name": "changes staged",
          "note": "One amber bar counts the changes waiting, with the cost and a Generate button.",
          "source": "documented"
        }
      ],
      "summary": "A song's structure as labelled sections on a timeline: select, regenerate one, extend after it or move it, with staged edits and out-of-date sections in amber words.",
      "description": "SectionLane shows a generated song as blocks in play order, each sized to its length: Intro, Verse, Chorus and so on. Each block says its time, who made it (a small AI mark, or Recorded or Uploaded) and its state in words. Select a section to see its lyrics and style and to Regenerate it, Extend after it, Edit it or move it. Edits are staged: one amber bar counts them and holds a lime Generate with its cost, so several changes cost one generation. Sections that no longer fit after an edit are marked Out of date, sections being made get a working ring, and failed ones say so with Try again.",
      "purpose": "Lets a person fix one part of a generated song without regenerating the whole thing, and see which parts the AI made and which need them.",
      "useFor": [
        "Under an AudioPlayer for a kept song in a music studio.",
        "Replacing or extending one section and generating several edits at once.",
        "Showing mixed authorship: which sections the AI made and which were recorded or uploaded."
      ],
      "notFor": [
        {
          "text": "Ordering video shots",
          "use": "scene-strip"
        },
        {
          "text": "Picking between whole takes",
          "use": "take-picker"
        },
        {
          "text": "Playing one clip",
          "use": "audio-player"
        }
      ],
      "whenToUse": [
        "Stage edits and generate them together, with the cost beside Generate.",
        "Say why a section is out of date, in its note.",
        "Mark which sections the AI made and which a person recorded or uploaded."
      ],
      "avoid": [
        "Do not regenerate the whole song for a local fix without saying so.",
        "Do not use amber for generating sections. Amber is only for edits, out-of-date sections and takes a person must pick.",
        "Do not use lime for the playhead or the played part. Lime marks AI-made sections and Generate only.",
        "Do not autoplay new takes when they arrive."
      ],
      "accessibility": [
        "The sections are a horizontal listbox. Left and Right (or Up and Down) move between them, Home and End go to the ends, Enter or Space selects, and Escape clears the selection. Only one section is in the Tab order.",
        "Each section is named with its position, time, origin and state, for example \"Chorus, 3 of 6, 0:40 to 1:02, Made by AI, Out of date\".",
        "Alt with an arrow key moves the focused section and keeps focus on it. The selected section also has Move earlier and Move later buttons; at the ends they turn off and focus moves to the other one.",
        "State is always written in words with a mark: amber diamonds for edited, out of date and takes to compare; an alert icon for did not work; the working ring and the word Generating while it is made. The selected block has a check mark and a thicker ink border, and is read as selected.",
        "One polite status line says moves, the staged count when it changes, and when sections finish, fail or get new takes. It does not repeat progress.",
        "Under reduced motion the working ring stops and stays as a still outline next to the word Generating.",
        "The waveform is a seek slider with the time in words when onSeek is set."
      ],
      "accessibilityYouDo": [
        "Keep the order, edits and staged count saved, so nothing is lost if the person leaves before Generate.",
        "Pass generateCost so the price sits next to the button, and generateBlocked when the person cannot afford it.",
        "Make each new generation a new version of the whole song (for example with VersionHistory), and keep takes that were not chosen.",
        "Offer the lyrics as text next to the song for people who cannot hear it.",
        "Set note on a failed section to say whether it was charged, and on an out-of-date one to say why."
      ],
      "onPhone": [
        "The lane scrolls sideways inside its card; the page does not. Each block is at least 7rem wide.",
        "Blocks and buttons grow to 44px tall on touch screens, and moving uses buttons, never a drag.",
        "The keyboard hint is hidden on small screens."
      ],
      "importPath": "import { SectionLane } from \"./index\"",
      "sourcePath": "src/organisms/SectionLane.tsx",
      "props": [
        {
          "name": "sections",
          "type": "SongSection[]",
          "required": true,
          "default": null,
          "description": "Sections in play order: id, name, start, end, origin (ai | person | upload), state, lyrics, styles, alternates, note."
        },
        {
          "name": "duration",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Length of the whole song in seconds."
        },
        {
          "name": "peaks",
          "type": "number[] | null",
          "required": false,
          "default": null,
          "description": "Loudness for the whole-song waveform above the lane. Leave out to hide it; null shows it drawing."
        },
        {
          "name": "position",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Playhead in seconds."
        },
        {
          "name": "onSeek",
          "type": "(seconds: number) => void",
          "required": false,
          "default": null,
          "description": "Makes the waveform a seek slider."
        },
        {
          "name": "selectedId",
          "type": "string | null",
          "required": false,
          "default": "null",
          "description": "The selected section."
        },
        {
          "name": "onSelect",
          "type": "(id: string | null) => void",
          "required": false,
          "default": null,
          "description": "Called on click, Enter or Space; Escape clears it."
        },
        {
          "name": "onReorder",
          "type": "(ids: string[]) => void",
          "required": false,
          "default": null,
          "description": "New order after Move earlier, Move later or Alt with an arrow key. Leave out to hide moving."
        },
        {
          "name": "onRegenerate",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Stages a new take of one section."
        },
        {
          "name": "onExtend",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Adds a new section after this one."
        },
        {
          "name": "onEdit",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Opens the section's lyrics and style for editing."
        },
        {
          "name": "onOpenAlternates",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Opens the new takes of a section in the has-alternates state."
        },
        {
          "name": "onRetry",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Tries a failed section again."
        },
        {
          "name": "pendingChanges",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Staged edits. Above 0 the amber bar shows the count and Generate."
        },
        {
          "name": "onGenerate",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Generates every staged change at once."
        },
        {
          "name": "generateCost",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Cost next to Generate, such as \"about 20 credits\"."
        },
        {
          "name": "generateBlocked",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why Generate cannot run. Turns it off and shows the reason."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Song structure\"",
          "description": "Heading, usually the song name."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--fg",
        "--fg-muted",
        "--lime",
        "--attention",
        "--attention-soft",
        "--attention-fg",
        "--radius-card",
        "--radius-control",
        "--dur-fast",
        "--ease-out"
      ],
      "related": [
        "audio-player",
        "waveform",
        "scene-strip",
        "version-history",
        "generation-progress"
      ],
      "composedOf": [
        "waveform",
        "working-edge",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Edit a song",
          "description": "Select a section, then Regenerate, Extend after or move it. Each edit adds to the amber bar and the section after a changed one goes out of date. Generate makes them all at once; the sections then show new takes to compare.",
          "code": "<SectionLane title=\"Harbour Light\" sections={sections} duration={124} peaks={peaks}\n  position={position} onSeek={setPosition}\n  selectedId={selected} onSelect={setSelected}\n  onReorder={reorder} onRegenerate={stageRegenerate} onExtend={addAfter}\n  onOpenAlternates={openTakes}\n  pendingChanges={pending} generateCost=\"about 20 credits\" onGenerate={generate} />\n\nconst sections = [\n  { id: \"intro\", name: \"Intro\", start: 0, end: 12, origin: \"ai\", state: \"original\" },\n  { id: \"v1\", name: \"Verse 1\", start: 12, end: 40, origin: \"ai\", state: \"original\", lyrics: \"Lanterns on the water line…\" },\n  { id: \"ch1\", name: \"Chorus\", start: 40, end: 62, origin: \"ai\", state: \"original\" },\n  { id: \"v2\", name: \"Verse 2\", start: 62, end: 88, origin: \"person\", state: \"original\" },\n  …\n];"
        },
        {
          "title": "Every section state",
          "description": "Up to date, edited and not generated, out of date, generating, new takes to compare, and did not work. The failed Outro is selected, so it offers Try again.",
          "code": "<SectionLane title=\"Paper Boats\" sections={[\n  { id: \"a\", name: \"Intro\", start: 0, end: 10, origin: \"upload\", state: \"original\" },\n  { id: \"b\", name: \"Verse 1\", start: 10, end: 34, origin: \"ai\", state: \"edited\" },\n  { id: \"c\", name: \"Pre-chorus\", start: 34, end: 46, origin: \"ai\", state: \"stale\", note: \"Out of date: Verse 1 changed key.\" },\n  { id: \"d\", name: \"Chorus\", start: 46, end: 68, origin: \"ai\", state: \"generating\" },\n  { id: \"e\", name: \"Bridge\", start: 68, end: 84, origin: \"ai\", state: \"has-alternates\", alternates: 3 },\n  { id: \"f\", name: \"Outro\", start: 84, end: 98, origin: \"ai\", state: \"failed\", note: \"The model timed out. You were not charged.\" },\n]} duration={98} selectedId=\"f\" onSelect={setSelected} onRetry={retry}\n  pendingChanges={2} generateCost=\"about 12 credits\" onGenerate={generate} />"
        },
        {
          "title": "Narrow, cannot afford Generate",
          "description": "At phone width the lane scrolls sideways inside the card. generateBlocked turns Generate off and says why.",
          "code": "<SectionLane title=\"Harbour Light\" sections={sections} duration={124} peaks={peaks}\n  selectedId={selected} onSelect={setSelected} onRegenerate={stage} onExtend={addAfter}\n  pendingChanges={1} onGenerate={generate} generateBlocked=\"Not enough credits\" />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-section-lane/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/section-lane.md"
    },
    {
      "id": "stem-mixer",
      "name": "StemMixer",
      "level": "organism",
      "since": "0.13",
      "aka": [
        "mixer",
        "stems",
        "audio mixer"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        }
      ],
      "summary": "One lane per stem of a song with Mute, Solo, a named level slider and a box to pick it for export, plus the separation state in words.",
      "description": "StemMixer lists the parts the AI split a song into, such as vocals, drums, bass and other. Each row has a box to pick it for export, Mute and Solo toggle buttons, its waveform and a level slider named after the stem. Each row says in words whether it is playing, muted, soloed or silent because another stem is soloed. The whole mixer shows the separation state: separating (with a plain time, no percentage), ready, failed (with whether it was charged and Try again) or not on the person's plan. Export says how many stems it will export.",
      "purpose": "Lets a person hear and export the separate parts of a generated song.",
      "useFor": [
        "The stems step of an export Sheet in a music studio.",
        "Checking a vocal or drum part on its own before export.",
        "Telling a person plainly that stems are still being made, failed, or are not on their plan."
      ],
      "notFor": [
        {
          "text": "The structure of a song in time",
          "use": "section-lane"
        },
        {
          "text": "Playing one finished clip",
          "use": "audio-player"
        },
        {
          "text": "Choosing a file format and licence",
          "use": "export-sheet"
        }
      ],
      "whenToUse": [
        "Name every stem plainly: Vocals, Drums, Bass, Other.",
        "Say how many stems Export will take, and any download limits beside it.",
        "Say whether a failed split was charged."
      ],
      "avoid": [
        "Do not show a percentage for a split that is really queued.",
        "Do not colour muted or soloed rows only; the words say it.",
        "Do not use amber for separating or plan-locked. Neither needs the person to act."
      ],
      "accessibility": [
        "Mute and Solo are toggle buttons with aria-pressed, named with the stem: \"Mute Drums\", \"Solo Vocals\".",
        "Each level is a native range slider named \"Drums level\", read as a percentage.",
        "Each export box is named \"Export Drums\". Export says how many stems it will take and turns off when none are picked.",
        "Each row says in words whether the stem is playing, muted, soloed or silent while another is soloed.",
        "Waveforms are pictures only and hidden from screen readers.",
        "One polite status line says mute, solo and separation changes once each; the mixer is aria-busy while separating.",
        "The separating dot only pulses with motion allowed; otherwise it is still next to the word Separating."
      ],
      "accessibilityYouDo": [
        "Apply mute, solo and level to the audio you play; the mixer only reports them.",
        "Pass a separatingNote with a real time or queue position, never a made-up percentage.",
        "Say in failedNote whether the person was charged.",
        "Keep the plan-locked state honest: say which plan has stems and that the full song can still be exported."
      ],
      "onPhone": [
        "Rows stack: the export box and name, then Mute and Solo, then the level slider.",
        "Waveforms are hidden when the mixer is narrower than 24rem (about 400px).",
        "Mute, Solo, sliders and buttons grow to 44px on touch screens."
      ],
      "importPath": "import { StemMixer } from \"./index\"",
      "sourcePath": "src/organisms/StemMixer.tsx",
      "props": [
        {
          "name": "stems",
          "type": "Stem[]",
          "required": true,
          "default": null,
          "description": "Stems: id, name, peaks, level (0 to 100), muted, soloed, exported."
        },
        {
          "name": "status",
          "type": "\"separating\" | \"ready\" | \"failed\" | \"plan-locked\"",
          "required": true,
          "default": null,
          "description": "Where the split is."
        },
        {
          "name": "duration",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Song length in seconds, for the waveforms."
        },
        {
          "name": "position",
          "type": "number",
          "required": false,
          "default": "0",
          "description": "Playhead in seconds, drawn on each waveform."
        },
        {
          "name": "onLevelChange",
          "type": "(id: string, level: number) => void",
          "required": false,
          "default": null,
          "description": "Level slider moved."
        },
        {
          "name": "onMuteChange",
          "type": "(id: string, muted: boolean) => void",
          "required": false,
          "default": null,
          "description": "Mute pressed."
        },
        {
          "name": "onSoloChange",
          "type": "(id: string, soloed: boolean) => void",
          "required": false,
          "default": null,
          "description": "Solo pressed. Several stems can be soloed."
        },
        {
          "name": "onExportChange",
          "type": "(id: string, exported: boolean) => void",
          "required": false,
          "default": null,
          "description": "Export box ticked or cleared."
        },
        {
          "name": "onExport",
          "type": "(ids: string[]) => void",
          "required": false,
          "default": null,
          "description": "Exports the picked stems. Leave out to hide Export."
        },
        {
          "name": "exportNote",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Format and limits beside Export."
        },
        {
          "name": "separatingNote",
          "type": "string",
          "required": false,
          "default": "\"Splitting the song into stems. About a minute.\"",
          "description": "What is happening while separating. No percentages."
        },
        {
          "name": "failedNote",
          "type": "string",
          "required": false,
          "default": "\"The song could not be split. You were not charged.\"",
          "description": "What happened and whether it was charged."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Try again when failed."
        },
        {
          "name": "planNote",
          "type": "string",
          "required": false,
          "default": "\"Stems come with the Pro plan.\"",
          "description": "Which plan has stems."
        },
        {
          "name": "onSeePlans",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows See plans when plan-locked."
        },
        {
          "name": "placeholderCount",
          "type": "number",
          "required": false,
          "default": "4",
          "description": "Placeholder rows while separating."
        },
        {
          "name": "title",
          "type": "string",
          "required": false,
          "default": "\"Stems\"",
          "description": "Heading."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes."
        }
      ],
      "variantsHint": {
        "status": [
          "separating",
          "ready",
          "failed",
          "plan-locked"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--surface-sunken",
        "--border",
        "--border-strong",
        "--fg",
        "--fg-muted",
        "--lime",
        "--accent",
        "--radius-card",
        "--radius-control",
        "--dur-fast"
      ],
      "related": [
        "section-lane",
        "audio-player",
        "waveform",
        "export-sheet",
        "slider"
      ],
      "composedOf": [
        "checkbox",
        "slider",
        "waveform",
        "skeleton",
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Mix and pick stems to export",
          "description": "Four stems, Other muted. Solo one to hear it alone; the rest say they are silent. Tick stems to change what Export takes.",
          "code": "<StemMixer title=\"Harbour Light stems\" status=\"ready\" duration={124} position={34}\n  stems={stems} onLevelChange={setLevel} onMuteChange={setMuted}\n  onSoloChange={setSoloed} onExportChange={setExported}\n  exportNote=\"WAV · 3 downloads left this month\" onExport={exportStems} />\n\nconst stems = [\n  { id: \"vox\", name: \"Vocals\", peaks, level: 80, muted: false, soloed: false, exported: true },\n  { id: \"drums\", name: \"Drums\", peaks, level: 70, muted: false, soloed: false, exported: true },\n  { id: \"bass\", name: \"Bass\", peaks, level: 65, muted: false, soloed: false, exported: false },\n  { id: \"other\", name: \"Other\", peaks, level: 55, muted: true, soloed: false, exported: false },\n];"
        },
        {
          "title": "Separation states",
          "description": "Separating shows placeholder rows and a plain time. Failed says it was not charged and offers Try again. Plan-locked says which plan has stems and that the full song still exports.",
          "code": "<StemMixer status=\"separating\" stems={[]} separatingNote=\"Splitting into 4 stems. About a minute. You can leave this page.\" />\n<StemMixer status=\"failed\" stems={[]} onRetry={retry} />\n<StemMixer status=\"plan-locked\" stems={[]} onSeePlans={openPlans} />"
        },
        {
          "title": "Phone width",
          "description": "Rows stack and the waveforms are hidden. Vocals is soloed, so the other rows say they are silent.",
          "code": "<StemMixer title=\"Stems\" status=\"ready\" duration={124} stems={stems} onExport={exportStems} … />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-stem-mixer/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/stem-mixer.md"
    },
    {
      "id": "transcript-audio-editor",
      "name": "TranscriptAudioEditor",
      "level": "organism",
      "since": "0.13",
      "aka": [
        "text based audio editor",
        "transcript editor"
      ],
      "category": "voice",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "removed",
          "note": "Struck-through words that playback skips; the waveform hatches the same stretch. They stay visible so they can be restored.",
          "source": "documented"
        },
        {
          "name": "regenerating",
          "note": "A phrase being spoken again in a consented voice: a moving lime underline and Making new audio. Text only under reduced motion.",
          "source": "documented"
        },
        {
          "name": "regenerated",
          "note": "New AI audio, marked with a lime underline and an AI label, with Play, Retry, Keep and Revert.",
          "source": "documented"
        },
        {
          "name": "kept",
          "note": "New audio the person accepted; the AI mark stays so it is always clear which words were made.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "A dashed danger underline and the reason in words, with Retry and Revert.",
          "source": "documented"
        },
        {
          "name": "filler words",
          "note": "Words such as um and like get a neutral dotted underline and one button removes them all.",
          "source": "documented"
        },
        {
          "name": "change not allowed",
          "note": "When a phrase cannot be changed (no consented voice, crosses a cut, two speakers, too long) the reason is written in one line.",
          "source": "documented"
        }
      ],
      "summary": "Edit recorded speech by editing its words: remove words to cut the audio, or change words to make new audio in a consented voice.",
      "description": "TranscriptAudioEditor shows a recording's words, each tied to a time, with a waveform and a docked player above. Removing words strikes them through, hatches the same stretch on the waveform and makes playback skip it. Changing words sends the phrase to a voice model; while it works the phrase has a moving AI-coloured underline and the words Making new audio, and when the new audio is ready it is marked AI and waits for Play, Retry, Keep or Revert. Filler words are marked and can be removed in one go, every edit point gets a dashed line, and Undo takes back the last edit.",
      "purpose": "Lets someone fix a voice-over, podcast or interview by editing text instead of cutting a waveform, and see exactly which words the AI re-spoke.",
      "useFor": [
        "Cutting filler words, false starts and long pauses from a recording.",
        "Fixing a wrong word in a voice-over without recording it again, using a voice the speaker agreed to.",
        "Reviewing which parts of a finished recording were made by AI before it is published."
      ],
      "notFor": [
        {
          "text": "Reading along with a call recording, without editing",
          "use": "transcript-sync"
        },
        {
          "text": "Choosing between several takes of a whole clip",
          "use": "take-picker"
        },
        {
          "text": "Checking and recording a speaker's consent",
          "use": "voice-consent-check"
        }
      ],
      "whenToUse": [
        "Keep removed words visible, struck through, so people can restore them.",
        "Wait for Keep before treating new audio as final, and keep the recording until then.",
        "Say why Change words is off, such as no consent or too many characters.",
        "Make every edit undoable."
      ],
      "avoid": [
        "Don't use the AI colour for the playing word or the person's own speech. Only new audio made by AI gets it.",
        "Don't use amber for new audio waiting to be kept. Nothing is wrong, and Keep is not urgent.",
        "Don't make new audio in a voice without the speaker's recorded consent.",
        "Don't announce each word as it plays. It would talk over the audio."
      ],
      "accessibility": [
        "Each paragraph is a multi-select listbox named by speaker and time, and every word is an option. The whole transcript is one tab stop.",
        "Arrow keys move by word, Up and Down by paragraph, Home and End to the paragraph's ends, Ctrl+Home and Ctrl+End to the whole transcript's. Shift extends the selection and Space selects one word.",
        "Delete removes the selection, D opens Change words, Enter keeps new audio (or moves the playhead to the word), Escape clears the selection, Ctrl+Z undoes. Shortcuts are off while typing in the new words field.",
        "Removed, filler, making new audio, new audio by AI, kept and failed are all said in words after each word, never by colour or strike-through alone.",
        "Change words stays focusable when it is off, and its reason is linked to it as a description.",
        "A status region announces edits and finished or failed audio. The word being played is never announced.",
        "The waveform is a seek slider with the time in words, and lists cuts and new audio in its description.",
        "While new audio is made, its underline moves only when motion is allowed; the words Making new audio are always shown."
      ],
      "accessibilityYouDo": [
        "Only set voiceAvailable when the speaker has consented to their voice being used, and say why in voiceUnavailableReason when they have not.",
        "Keep an undo history and pass onUndo with an undoLabel that says what it will undo.",
        "Pass the voice model's real limits in limits, so people learn them before they type.",
        "Label exported audio as partly AI-made when any span is kept."
      ],
      "onPhone": [
        "It fills the width. The player, waveform and words stack in one column.",
        "A tap selects a word; the − and + buttons in the selection bar make the selection shorter or longer, so no dragging is needed.",
        "Lines are 44px tall on a touch screen, and every button in the bars is 44px tall.",
        "The selection bar sticks to the bottom of the editor, so it stays in reach and sits just above the keyboard while typing new words.",
        "The keyboard shortcuts hint is hidden on narrow screens."
      ],
      "importPath": "import { TranscriptAudioEditor } from \"./index\"",
      "sourcePath": "src/organisms/TranscriptAudioEditor.tsx",
      "props": [
        {
          "name": "words",
          "type": "TranscriptWord[]",
          "required": true,
          "default": null,
          "description": "Each word: id, text, start and end in seconds, and optional speaker and filler."
        },
        {
          "name": "spans",
          "type": "TranscriptEditSpan[]",
          "required": false,
          "default": "[]",
          "description": "Edits so far: id, wordIds, state (\"original\" | \"removed\" | \"regenerating\" | \"regenerated\" | \"kept\" | \"reverted\" | \"failed\"), and optional newText and error."
        },
        {
          "name": "speakers",
          "type": "Record<string, { label: string }>",
          "required": false,
          "default": null,
          "description": "Names for the speaker keys. Each run of words by one speaker is a paragraph."
        },
        {
          "name": "audio",
          "type": "Omit<AudioPlayerProps, \"current\" | \"onTimeChange\" | \"compact\" | ...>",
          "required": false,
          "default": null,
          "description": "Wires in a docked player. The part keeps the time, skips removed words and plays new phrases."
        },
        {
          "name": "peaks",
          "type": "number[] | null",
          "required": false,
          "default": null,
          "description": "Loudness for the waveform when you keep your own player."
        },
        {
          "name": "duration",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Length in seconds. Defaults to the audio's duration or the last word's end."
        },
        {
          "name": "position",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Playback position in seconds, when your own player keeps it."
        },
        {
          "name": "onSeek",
          "type": "(seconds: number) => void",
          "required": false,
          "default": null,
          "description": "Called when a word is clicked, the waveform is moved or Play is pressed on new audio."
        },
        {
          "name": "onRemove",
          "type": "(wordIds: string[]) => void",
          "required": false,
          "default": null,
          "description": "Shows Remove and Remove filler words. Make a removed span."
        },
        {
          "name": "onRegenerate",
          "type": "(wordIds: string[], newText?: string) => void",
          "required": false,
          "default": null,
          "description": "Shows Change words. Make a regenerating span, then set it to regenerated or failed."
        },
        {
          "name": "onSmooth",
          "type": "(wordIds: string[]) => void",
          "required": false,
          "default": null,
          "description": "Shows Smooth edit when the selection includes a cut."
        },
        {
          "name": "onKeep",
          "type": "(spanId: string) => void",
          "required": false,
          "default": null,
          "description": "Keep new audio. Also on Enter while the phrase has focus."
        },
        {
          "name": "onRetry",
          "type": "(spanId: string) => void",
          "required": false,
          "default": null,
          "description": "Make the new audio again."
        },
        {
          "name": "onRevert",
          "type": "(spanId: string) => void",
          "required": false,
          "default": null,
          "description": "Back to the recording. Also restores removed words."
        },
        {
          "name": "onUndo",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Shows Undo, and runs on Ctrl+Z or Cmd+Z in the editor."
        },
        {
          "name": "undoLabel",
          "type": "string",
          "required": false,
          "default": "\"Undo\"",
          "description": "What Undo will undo. Used as the button's name."
        },
        {
          "name": "voiceAvailable",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "False when there is no consented voice. Change words then says why."
        },
        {
          "name": "voiceName",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Whose voice new audio uses, shown while typing new words and in the AI label."
        },
        {
          "name": "voiceUnavailableReason",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Why the voice cannot be used, in one line."
        },
        {
          "name": "limits",
          "type": "{ maxChars?: number; languages?: string[] }",
          "required": false,
          "default": null,
          "description": "The voice model's limits, shown before typing and checked against the selection."
        },
        {
          "name": "label",
          "type": "string",
          "required": false,
          "default": "\"Transcript\"",
          "description": "Name of the transcript region."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the wrapper."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--fg",
        "--fg-muted",
        "--fg-subtle",
        "--surface",
        "--surface-raised",
        "--surface-sunken",
        "--border",
        "--lime",
        "--danger-fg",
        "--radius-card",
        "--radius-field"
      ],
      "related": [
        "transcript-sync",
        "audio-player",
        "waveform",
        "selection-toolbar",
        "voice-consent-check"
      ],
      "composedOf": [
        "audio-player",
        "waveform",
        "ai-label",
        "button",
        "text-field",
        "kbd"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Edit a podcast intro",
          "description": "Press Play to hear it; the word being played gets a tint. Click a word, Shift-click another, then Remove or Change words. New audio takes a moment, then waits for Keep or Revert. Remove filler words cuts every marked filler at once, and Undo takes back each step.",
          "code": "const [spans, setSpans] = useState<TranscriptEditSpan[]>([]);\n\n<TranscriptAudioEditor\n  words={words}\n  spans={spans}\n  speakers={{ nadia: { label: \"Nadia Brooks\" }, tomas: { label: \"Tomás Ferreira\" } }}\n  audio={{ title: \"The Allotment Hour, episode 12\", duration: 24, peaks }}\n  voiceName=\"each speaker's own, consent on file\"\n  limits={{ maxChars: 250, languages: [\"English\"] }}\n  onRemove={(ids) => setSpans((s) => [...s, { id: newId(), wordIds: ids, state: \"removed\" }])}\n  onRegenerate={(ids, text) => startVoiceJob(ids, text)}   // adds a \"regenerating\" span\n  onKeep={(id) => setState(id, \"kept\")}\n  onRetry={(id) => startVoiceJob(id)}\n  onRevert={(id) => setSpans((s) => s.filter((x) => x.id !== id))}\n  onUndo={undo}\n  undoLabel=\"Undo: removed 2 words\"\n/>"
        },
        {
          "title": "Every state",
          "description": "Removed words are struck through and hatched on the waveform. One phrase is making new audio, one is ready and waiting, one was kept and one failed with a reason. Each state is also said in words for screen readers.",
          "code": "<TranscriptAudioEditor words={words} speakers={speakers} peaks={peaks} duration={24}\n  position={t} onSeek={setT}\n  spans={[\n    { id: \"s1\", wordIds: [\"w6\"], state: \"removed\" },\n    { id: \"s2\", wordIds: [\"w13\", \"w14\", \"w15\"], state: \"regenerating\", newText: \"keeping seeds from\" },\n    { id: \"s3\", wordIds: [\"w19\", \"w20\", \"w21\", \"w22\"], state: \"kept\", newText: \"Thanks for having me on.\" },\n    { id: \"s5\", wordIds: [\"w32\", \"w33\", \"w34\"], state: \"regenerated\", newText: \"the softest, ripest fruit\" },\n    { id: \"s6\", wordIds: [\"w62\", \"w63\", \"w64\", \"w65\"], state: \"failed\", newText: \"A clean plate works too.\",\n      error: \"The voice service is busy. Try again in a minute.\" },\n  ]}\n  onRemove={...} onRegenerate={...} onKeep={...} onRetry={...} onRevert={...} onSmooth={...} />"
        },
        {
          "title": "No consented voice",
          "description": "Removing words still works, but Change words says in one line why it is off. Select a few words to see it. The limits are said before typing.",
          "code": "<TranscriptAudioEditor words={words} spans={spans} speakers={speakers} peaks={peaks} duration={9}\n  voiceAvailable={false}\n  voiceUnavailableReason=\"Nadia Brooks has not recorded a consent statement, so new words cannot be made in her voice.\"\n  limits={{ maxChars: 40, languages: [\"English\"] }}\n  onRemove={remove} onRevert={revert} onRegenerate={regenerate} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-transcript-audio-editor/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/transcript-audio-editor.md"
    },
    {
      "id": "response-compare",
      "name": "ResponseCompare",
      "level": "organism",
      "since": "0.20.0",
      "aka": [
        "compare responses",
        "side by side",
        "a b compare"
      ],
      "category": "conversation",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "not chosen",
          "note": "Both replies have a Choose this reply button, with About the same below.",
          "source": "documented"
        },
        {
          "name": "chosen",
          "note": "The kept reply gets the selected fill and says You chose this; the other steps back. A thank-you line is read out.",
          "source": "documented"
        },
        {
          "name": "about the same",
          "note": "Both replies stay as they were and the thank-you line says they are about the same.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Each Choose button shows a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "Two replies side by side, and the person picks the better one.",
      "description": "ResponseCompare shows two replies to the same question next to each other, each with a Choose this reply button, and an About the same choice. After a choice, the kept reply says You chose this in words and the other steps back. A short thank-you line is read out. On a phone the two replies stack.",
      "purpose": "Asks people which of two replies is better, in a way that is quick to answer and easy to skip, so the product learns what they prefer.",
      "useFor": [
        "Asking which of two replies helped more, to learn preferences.",
        "Letting someone pick between two drafts before continuing."
      ],
      "notFor": [
        {
          "text": "More than two options to pick from",
          "use": "variant-grid"
        },
        {
          "text": "A thumbs up or down on one reply",
          "use": "feedback-bar"
        },
        {
          "text": "Moving between versions of the same reply",
          "use": "version-pager"
        }
      ],
      "whenToUse": [
        "Ask now and then, not after every reply.",
        "Show both replies in full, so people compare the real thing.",
        "Say why you are asking in one short line.",
        "Let people carry on without choosing."
      ],
      "avoid": [
        "Do not label one reply as recommended. It sways the choice.",
        "Do not use it for more than two replies.",
        "Do not hide the choice after it is made. Say which one was kept."
      ],
      "accessibility": [
        "The part is a region named by its heading, and each reply is named by its label.",
        "The choice is an ordinary button under each reply, described by that reply's label.",
        "After a choice, the kept reply says You chose this in words, and a status line reads the thank-you.",
        "The kept reply's border change stops animating when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Give each reply a short label that says which is which, such as Reply A and Reply B.",
        "Set headingLevel so the heading fits your page outline. It is 3 unless you change it.",
        "Do not ask on every reply. Ask now and then, and never block the conversation on it."
      ],
      "onPhone": [
        "Below 640px the two replies stack, Reply A first, each with its own Choose button under it.",
        "Long replies keep their full text; the page scrolls, not the card."
      ],
      "importPath": "import { ResponseCompare } from \"./index\"",
      "sourcePath": "src/organisms/ResponseCompare.tsx",
      "props": [
        {
          "name": "replies",
          "type": "[CompareReply, CompareReply]",
          "required": true,
          "default": null,
          "description": "The two replies: { id, label, content }."
        },
        {
          "name": "onChoose",
          "type": "(id: string | \"same\") => void",
          "required": true,
          "default": null,
          "description": "Called with the chosen reply's id, or same."
        },
        {
          "name": "chosen",
          "type": "string | \"same\" | null",
          "required": false,
          "default": null,
          "description": "The current choice. Leave it out to let the part keep its own."
        },
        {
          "name": "allowSame",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Offer an About the same choice."
        },
        {
          "name": "labels",
          "type": "ResponseCompareLabels",
          "required": false,
          "default": null,
          "description": "Overrides heading, hint, choose, same, chosen and thanks(label)."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the part's heading, so it fits the page outline."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--bg",
        "--line",
        "--selected",
        "--selected-border",
        "--fg-muted",
        "--radius-card",
        "--radius-control"
      ],
      "related": [
        "feedback-bar",
        "version-pager",
        "variant-grid"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Pick the better reply",
          "description": "Press Choose this reply under one, or About the same.",
          "code": "<ResponseCompare\n  replies={[\n    { id: \"a\", label: \"Reply A\", content: <p>Your refund of ₹3,499 was sent on 12 October…</p> },\n    { id: \"b\", label: \"Reply B\", content: <p>Refunds usually take a while…</p> },\n  ]}\n  onChoose={(id) => saveChoice(id)}\n/>"
        },
        {
          "title": "Without About the same",
          "description": "Turn it off when a choice is required.",
          "code": "<ResponseCompare allowSame={false} replies={replies} onChoose={choose} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-response-compare/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/response-compare.md"
    },
    {
      "id": "claim-check",
      "name": "ClaimCheck",
      "level": "molecule",
      "since": "0.20.0",
      "aka": [
        "fact check",
        "grounding check",
        "hallucination check"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "nothing flagged",
          "note": "Just the reply, with no summary line.",
          "source": "documented"
        },
        {
          "name": "unsupported",
          "note": "A dotted amber underline and a warning mark: no source backs the claim up.",
          "source": "documented"
        },
        {
          "name": "disputed",
          "note": "A dotted red underline: a source says otherwise.",
          "source": "documented"
        },
        {
          "name": "note open",
          "note": "The pressed claim gets an amber fill and its note opens under the reply with Ask for a source and Take it out.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Each warning mark shows a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "A reply with the sentences no source backs up marked in place.",
      "description": "ClaimCheck shows a reply with each unsupported or disputed claim marked by a dotted underline and a small warning mark. A line above says how many were flagged. Pressing the warning mark opens a short note under the reply: why it was flagged, and buttons to ask the assistant for a source or take the claim out.",
      "purpose": "Helps people see which parts of an AI reply they should check before relying on them, without making them read every source.",
      "useFor": [
        "Replies that make factual claims a check could not match to a source.",
        "Claims that a source contradicts.",
        "Reports and summaries people will act on or share."
      ],
      "notFor": [
        {
          "text": "Showing the sources that do back a reply up",
          "use": "source-list"
        },
        {
          "text": "How sure the AI is about the whole reply",
          "use": "confidence-indicator"
        },
        {
          "text": "A numbered link to one source",
          "use": "citation-marker"
        }
      ],
      "whenToUse": [
        "Flag only claims a check actually failed to match.",
        "Say in the note why it was flagged.",
        "Offer a way to fix it: ask for a source, or take it out."
      ],
      "avoid": [
        "Do not mark every sentence. Then nothing stands out.",
        "Do not use red for unsupported claims. Save it for claims a source contradicts.",
        "Do not hide the summary line. People skim."
      ],
      "accessibility": [
        "The warning mark after each claim is a button named by whether the claim is unsupported or disputed, and the claim itself.",
        "Marks use a dotted underline and an icon, not color alone.",
        "The note is announced when it opens, and each button says whether its note is open."
      ],
      "accessibilityYouDo": [
        "Keep each flagged claim to one sentence or less, so the button's name stays short.",
        "Write the note as a plain reason, such as No source in this chat measures that."
      ],
      "onPhone": [
        "Marked claims wrap with the text like ordinary words.",
        "The note opens under the whole reply, full width, so it never covers the text."
      ],
      "importPath": "import { ClaimCheck } from \"./index\"",
      "sourcePath": "src/molecules/ClaimCheck.tsx",
      "props": [
        {
          "name": "segments",
          "type": "ClaimSegment[]",
          "required": true,
          "default": null,
          "description": "The reply as plain text and flagged claims, in reading order. A claim is { id, text, status: \"unsupported\" | \"disputed\", note? }."
        },
        {
          "name": "onAskSource",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Asks the assistant to back the claim up. The button is hidden when omitted."
        },
        {
          "name": "onRemove",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Takes the claim out of the reply. The button is hidden when omitted."
        },
        {
          "name": "labels",
          "type": "ClaimCheckLabels",
          "required": false,
          "default": null,
          "description": "Overrides summary(n), unsupported, disputed, askSource, remove and close."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--attention",
        "--attention-soft",
        "--attention-fg",
        "--attention-border",
        "--danger",
        "--danger-fg",
        "--radius-control"
      ],
      "related": [
        "source-list",
        "citation-marker",
        "confidence-indicator"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Two claims flagged",
          "description": "Press a marked claim to see why it was flagged. Take it out or ask for a source.",
          "code": "<ClaimCheck\n  segments={[\n    \"Orders slowed after Tuesday's update. \",\n    { id: \"c1\", text: \"The tax check adds about 0.5 s to every order\", status: \"unsupported\", note: \"No source measures it.\" },\n    \", and \",\n    { id: \"c2\", text: \"most shoppers left before paying\", status: \"disputed\", note: \"Order times shows checkouts went up.\" },\n    \".\",\n  ]}\n  onAskSource={askForSource}\n  onRemove={removeClaim}\n/>"
        },
        {
          "title": "Nothing flagged",
          "description": "With no claims flagged, it is just the reply. No summary line shows.",
          "code": "<ClaimCheck segments={[\"Your order shipped on Monday and arrives Friday.\"]} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-claim-check/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/claim-check.md"
    },
    {
      "id": "sensitive-data-guard",
      "name": "SensitiveDataGuard",
      "level": "molecule",
      "since": "0.20.0",
      "aka": [
        "pii warning",
        "data leak warning",
        "privacy check"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "hidden",
          "note": "With nothing found, it shows nothing.",
          "source": "documented"
        },
        {
          "name": "one detail",
          "note": "One detail with its safe preview, and a single Take out button.",
          "source": "documented"
        },
        {
          "name": "several details",
          "note": "A row per detail with its own Take out button, and Take them out for all.",
          "source": "documented"
        }
      ],
      "summary": "Warns before a message with a card number, password or other private detail is sent.",
      "description": "SensitiveDataGuard sits just above the message box when a message holds a private detail: a card number, an API key, a password, an email address or a phone number. It names each one with a safe preview such as •••• 4242, and offers to take them out. Send anyway is still there. The findSensitive helper finds the details on the person's device; nothing is sent anywhere to check.",
      "purpose": "Stops people sharing private details with an AI by accident, without blocking them when they meant to.",
      "useFor": [
        "Checking a message just before it is sent.",
        "Pasted text that may hold card numbers, keys or passwords."
      ],
      "notFor": [
        {
          "text": "Telling people the chat is private or not saved",
          "use": "private-chat-banner"
        },
        {
          "text": "A message the AI will not answer",
          "use": "refusal-notice"
        },
        {
          "text": "A general warning not tied to the message",
          "use": "callout"
        }
      ],
      "whenToUse": [
        "Check when the person presses Send, so they are not interrupted while typing.",
        "Show a safe preview, never the whole detail.",
        "Make taking it out the main action, and keep Send anyway."
      ],
      "avoid": [
        "Do not send the message to a server to check it.",
        "Do not block sending. People may mean to share it.",
        "Do not show the full card number or password in the warning."
      ],
      "accessibility": [
        "The warning is a status region, so it is read out when it appears without moving focus.",
        "It is named by its heading, which says how many details were found.",
        "Each Take out button names the detail it removes.",
        "Amber and a lock icon, plus words, so it does not rely on color."
      ],
      "accessibilityYouDo": [
        "Show it before sending, not after. Run findSensitive when the person presses Send.",
        "Keep focus in the message box; the warning is read out on its own."
      ],
      "onPhone": [
        "It sits above the message box at full width, so the box and keyboard stay where they are.",
        "Each detail's row wraps so its button drops under the text on a narrow screen."
      ],
      "importPath": "import { SensitiveDataGuard } from \"./index\"",
      "sourcePath": "src/molecules/SensitiveDataGuard.tsx",
      "props": [
        {
          "name": "findings",
          "type": "SensitiveFinding[]",
          "required": true,
          "default": null,
          "description": "What was found, from findSensitive(text). Nothing shows when it is empty."
        },
        {
          "name": "onRemove",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Takes one detail out of the message."
        },
        {
          "name": "onRemoveAll",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Takes every detail out."
        },
        {
          "name": "onSendAnyway",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Sends the message as it is."
        },
        {
          "name": "labels",
          "type": "SensitiveDataGuardLabels",
          "required": false,
          "default": null,
          "description": "Overrides heading(n), body, kinds, remove, removeAll and sendAnyway."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the part's heading, so it fits the page outline."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--attention-soft",
        "--attention-border",
        "--attention-fg",
        "--fg-muted",
        "--radius-card"
      ],
      "related": [
        "composer",
        "private-chat-banner",
        "callout"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A card number and a phone number",
          "description": "Take one out, take both out, or send anyway. Edit the message and the warning follows.",
          "code": "const findings = findSensitive(text);\n\n<SensitiveDataGuard\n  findings={findings}\n  onRemove={(id) => setText(removeSensitive(text, findings.filter((f) => f.id === id)))}\n  onRemoveAll={() => setText(removeSensitive(text, findings))}\n  onSendAnyway={send}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-sensitive-data-guard/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/sensitive-data-guard.md"
    },
    {
      "id": "connector-permission",
      "name": "ConnectorPermission",
      "level": "molecule",
      "since": "0.20.0",
      "aka": [
        "oauth consent",
        "connect account",
        "integration permission"
      ],
      "category": "trust",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "connected",
          "note": "A green Connected badge, the list of access, Disconnect and when it was last used.",
          "source": "documented"
        },
        {
          "name": "asking to disconnect",
          "note": "Disconnect becomes a question in place, with Disconnect and Keep it; focus moves to Keep it.",
          "source": "documented"
        },
        {
          "name": "not connected",
          "note": "A neutral badge and a Connect button; the list of access is hidden.",
          "source": "documented"
        },
        {
          "name": "expired",
          "note": "An amber Needs you to sign in again badge and a Reconnect button.",
          "source": "documented"
        },
        {
          "name": "delete access",
          "note": "A line with delete access uses the danger color and the word Delete.",
          "source": "documented"
        }
      ],
      "summary": "One connected app, and exactly what the AI can do with it.",
      "description": "ConnectorPermission shows an app the assistant is connected to, such as Gmail, with its account and status. It lists what the assistant can do in plain words, each marked See, Change, Send or Delete; Delete stands out. Disconnect asks once, in place. An expired connection says so and offers Reconnect.",
      "purpose": "Makes clear what an AI can reach in each connected app, so people connect with confidence and can cut access in one step.",
      "useFor": [
        "A settings page that lists connected apps.",
        "Asking someone to connect an app, showing what it allows first.",
        "Telling people a connection has expired."
      ],
      "notFor": [
        {
          "text": "Asking before one action the AI wants to take",
          "use": "permission-prompt"
        },
        {
          "text": "Everything the AI can see in this chat",
          "use": "context-panel"
        },
        {
          "text": "Setting how much the AI may do without asking",
          "use": "permission-mode-switcher"
        }
      ],
      "whenToUse": [
        "List every kind of access, including the scary ones.",
        "Use the app's real name and logo.",
        "Say when it was last used, so people notice access they forgot."
      ],
      "avoid": [
        "Do not hide delete or send access in small print.",
        "Do not disconnect without one question.",
        "Do not show the list for an app that is not connected."
      ],
      "accessibility": [
        "The card is a region named by the app's name, with a heading for the list.",
        "Each kind of access is said in words (See, Change, Send, Delete), not only by its icon.",
        "Disconnect asks in place and moves focus to the safe choice; the question is read out."
      ],
      "accessibilityYouDo": [
        "Write each line as an action in plain words, such as Send email as you.",
        "Set headingLevel so the app name fits your page outline. It is 3 unless you change it."
      ],
      "onPhone": [
        "The status badge drops under the app name when the row is narrow.",
        "The Disconnect question replaces the button in place, so nothing jumps."
      ],
      "importPath": "import { ConnectorPermission } from \"./index\"",
      "sourcePath": "src/molecules/ConnectorPermission.tsx",
      "props": [
        {
          "name": "app",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The app's name, such as Gmail."
        },
        {
          "name": "icon",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "The app's logo, about 24px."
        },
        {
          "name": "account",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Which account it is connected to."
        },
        {
          "name": "status",
          "type": "\"connected\" | \"off\" | \"expired\"",
          "required": true,
          "default": null,
          "description": "Whether it is connected, not connected, or needs signing in again."
        },
        {
          "name": "access",
          "type": "ConnectorAccess[]",
          "required": true,
          "default": null,
          "description": "What the assistant can do: { id, label, level: \"read\" | \"write\" | \"send\" | \"delete\" }."
        },
        {
          "name": "lastUsed",
          "type": "string",
          "required": false,
          "default": null,
          "description": "When the assistant last used it, such as 2 hours ago."
        },
        {
          "name": "onConnect",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Connects or reconnects. Shown when not connected or expired."
        },
        {
          "name": "onDisconnect",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Disconnects, after one question in place."
        },
        {
          "name": "labels",
          "type": "ConnectorPermissionLabels",
          "required": false,
          "default": null,
          "description": "Overrides status, canHeading, levels, connect, reconnect, disconnect, disconnectQuestion(app), keep and lastUsed(when)."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the app name's heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "status": [
          "connected",
          "off",
          "expired"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--bg",
        "--line",
        "--danger-fg",
        "--success",
        "--warning",
        "--radius-card"
      ],
      "related": [
        "permission-prompt",
        "permission-mode-switcher",
        "context-panel"
      ],
      "composedOf": [
        "badge",
        "button",
        "inline-confirm"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Gmail, connected",
          "description": "Press Disconnect: it asks once, in place. Then Connect again.",
          "code": "<ConnectorPermission\n  app=\"Gmail\"\n  icon={<GmailLogo />}\n  account=\"mira@example.com\"\n  status=\"connected\"\n  lastUsed=\"2 hours ago\"\n  access={[\n    { id: \"r\", label: \"Read your email and attachments\", level: \"read\" },\n    { id: \"w\", label: \"Write drafts for you to check\", level: \"write\" },\n    { id: \"s\", label: \"Send email as you\", level: \"send\" },\n    { id: \"d\", label: \"Delete email\", level: \"delete\" },\n  ]}\n  onDisconnect={disconnect}\n  onConnect={connect}\n/>"
        },
        {
          "title": "Expired",
          "description": "When access runs out, say so and offer to reconnect.",
          "code": "<ConnectorPermission app=\"Google Drive\" status=\"expired\" access={[{ id: \"r\", label: \"See your files\", level: \"read\" }]} onConnect={reconnect} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-connector-permission/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/connector-permission.md"
    },
    {
      "id": "context-panel",
      "name": "ContextPanel",
      "level": "organism",
      "since": "0.20.0",
      "aka": [
        "context window",
        "attached context",
        "context sources"
      ],
      "category": "trust",
      "status": "experimental",
      "lifecycle": {
        "stage": "experimental",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "Says the assistant only sees this chat.",
          "source": "documented"
        },
        {
          "name": "fixed item",
          "note": "An item the person cannot remove shows without a take-out button.",
          "source": "documented"
        },
        {
          "name": "nearly full",
          "note": "The working memory bar turns amber past 90%.",
          "source": "documented"
        },
        {
          "name": "item taken out",
          "note": "Focus moves to the next item's button, or to the heading when none are left, and a status line says what was taken out.",
          "source": "documented"
        }
      ],
      "summary": "Everything the AI can see right now, each item removable.",
      "description": "ContextPanel lists what the assistant can see in this chat, grouped by kind: instructions, the open page, files, connected apps and memories. Each item can be taken out with one press, and focus moves to the next item. Items the person cannot remove, such as a workspace's own instructions, show without a button. An optional bar shows how full the assistant's working memory is.",
      "purpose": "Shows people exactly what the AI is working from, so its answers make sense and they can take out what it should not use.",
      "useFor": [
        "A side panel or popover next to the message box.",
        "Explaining why the AI said something, by showing what it read.",
        "Freeing up room when the working memory is nearly full."
      ],
      "notFor": [
        {
          "text": "One file or app chip inside the message box",
          "use": "attachment-chip"
        },
        {
          "text": "Only how full the working memory is",
          "use": "context-meter"
        },
        {
          "text": "Saved memories across chats",
          "use": "memory-list"
        }
      ],
      "whenToUse": [
        "Show everything the assistant can use, including instructions and memories.",
        "Let people take items out in one press.",
        "Say when nothing is shared."
      ],
      "avoid": [
        "Do not hide what a connected app gives the assistant.",
        "Do not ask before taking an item out. It can be added back.",
        "Do not show token counts without the bar and plain words."
      ],
      "accessibility": [
        "The panel is a region named by its heading, with a heading for each group.",
        "Each take-out button names the item it removes.",
        "After taking an item out, focus moves to the next item's button, or to the heading when none are left, and a status line says what was taken out.",
        "The working memory bar is a meter with its value in words."
      ],
      "accessibilityYouDo": [
        "Give each item a name people recognise, such as the file name.",
        "Set headingLevel so the heading fits your page outline. It is 3 unless you change it."
      ],
      "onPhone": [
        "Each item is at least 40px tall, and its take-out button grows to 44px on touch screens.",
        "Long names wrap to two lines, then end with an ellipsis; the full name is in the button's label."
      ],
      "importPath": "import { ContextPanel } from \"./index\"",
      "sourcePath": "src/organisms/ContextPanel.tsx",
      "props": [
        {
          "name": "items",
          "type": "ContextItem[]",
          "required": true,
          "default": null,
          "description": "What the assistant can see: { id, kind: \"file\" | \"app\" | \"memory\" | \"page\" | \"instruction\", label, detail?, fixed? }."
        },
        {
          "name": "onRemove",
          "type": "(id: string) => void",
          "required": true,
          "default": null,
          "description": "Takes an item out of what the assistant can see."
        },
        {
          "name": "onAdd",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Opens a way to add files or apps. The button is hidden when omitted."
        },
        {
          "name": "room",
          "type": "{ used: number; total: number }",
          "required": false,
          "default": null,
          "description": "How full the working memory is, in tokens. The bar turns amber past 90%."
        },
        {
          "name": "labels",
          "type": "ContextPanelLabels",
          "required": false,
          "default": null,
          "description": "Overrides heading, count(n), groups, remove(label), removed(label), add, empty, room(used, total) and roomLabel."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the part's heading, so it fits the page outline."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--line",
        "--hover",
        "--fg-muted",
        "--attention",
        "--radius-card",
        "--radius-control"
      ],
      "related": [
        "context-meter",
        "attachment-chip",
        "memory-list",
        "connector-permission"
      ],
      "composedOf": [
        "button",
        "icon-button",
        "meter"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A support chat",
          "description": "Take items out with the X. The instructions are set by the workspace, so they stay.",
          "code": "<ContextPanel\n  items={[\n    { id: \"i\", kind: \"instruction\", label: \"Answer as the store's support team\", detail: \"Set by your workspace\", fixed: true },\n    { id: \"p\", kind: \"page\", label: \"Orders dashboard\", detail: \"The page you have open\" },\n    { id: \"f1\", kind: \"file\", label: \"Q3 refunds.csv\", detail: \"1,204 rows\" },\n    { id: \"a\", kind: \"app\", label: \"Shopify\", detail: \"Read only\" },\n    { id: \"m\", kind: \"memory\", label: \"You prefer replies under 100 words\" },\n  ]}\n  onRemove={remove}\n  onAdd={openPicker}\n  room={{ used: 18400, total: 200000 }}\n/>"
        },
        {
          "title": "Nothing shared",
          "description": "Say plainly that the assistant only sees the chat.",
          "code": "<ContextPanel items={[]} onRemove={remove} onAdd={openPicker} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-context-panel/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/context-panel.md"
    },
    {
      "id": "editable-turn",
      "name": "EditableTurn",
      "level": "molecule",
      "since": "0.21.0",
      "aka": [
        "edit message",
        "edit prompt",
        "resend"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "selected",
          "note": "Set with the current prop.",
          "source": "prop"
        },
        {
          "name": "one version",
          "note": "The message with Copy and Edit under it. No pager.",
          "source": "documented"
        },
        {
          "name": "editing",
          "note": "The bubble becomes an edit box with a note, Cancel and Send. Escape cancels, Ctrl or Cmd + Enter sends, and focus goes back to Edit.",
          "source": "documented"
        },
        {
          "name": "several versions",
          "note": "A pager (◀ 2 of 2 ▶) moves between the versions the person sent.",
          "source": "documented"
        },
        {
          "name": "empty edit",
          "note": "Send is turned off while the box is empty.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Copy, Edit and the pager arrows show a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "A message the person sent, which they can edit and send again, with its earlier versions kept.",
      "description": "EditableTurn shows the person's own message with Edit and Copy buttons under it, always there rather than only on hover. Edit opens the text in place with Cancel and Send. Sending an edit adds a new version; the earlier ones stay, and a small pager (2 of 2) moves between them and the replies that followed each.",
      "purpose": "Lets people fix or rephrase what they asked without starting over, and go back to what they had before.",
      "useFor": [
        "Fixing a typo or adding detail to a question already sent.",
        "Trying a question two ways and comparing the replies."
      ],
      "notFor": [
        {
          "text": "Moving between regenerated versions of a reply",
          "use": "version-pager"
        },
        {
          "text": "A person's message that cannot be changed",
          "use": "message"
        },
        {
          "text": "Editing text the AI wrote",
          "use": "inline-edit"
        }
      ],
      "whenToUse": [
        "Keep every version and its replies.",
        "Say that sending an edit writes the replies after it again.",
        "Put focus back on Edit when editing ends."
      ],
      "avoid": [
        "Do not show Edit only on hover.",
        "Do not throw the old version away.",
        "Do not let people send an empty edit."
      ],
      "accessibility": [
        "Edit and Copy are labelled buttons under the bubble, reachable by Tab.",
        "Editing moves focus into the box with the cursor at the end; Escape cancels and Ctrl or Cmd with Enter sends.",
        "When editing ends, focus goes back to Edit.",
        "The pager says which version shows, for example Version 2 of 2."
      ],
      "accessibilityYouDo": [
        "Keep the replies of each version, so going back shows what the person saw then.",
        "Tell people that sending an edit writes the replies after it again; the note under the box does this."
      ],
      "onPhone": [
        "Edit and Copy sit under the bubble at all times, because a phone has no hover.",
        "The edit box takes the full width on a narrow screen."
      ],
      "importPath": "import { EditableTurn } from \"./index\"",
      "sourcePath": "src/molecules/EditableTurn.tsx",
      "props": [
        {
          "name": "versions",
          "type": "string[]",
          "required": true,
          "default": null,
          "description": "Every version of this message, oldest first."
        },
        {
          "name": "current",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Which version shows, 1-based. Defaults to the newest."
        },
        {
          "name": "onChangeVersion",
          "type": "(version: number) => void",
          "required": false,
          "default": null,
          "description": "Shows another version and the replies that followed it."
        },
        {
          "name": "onResend",
          "type": "(text: string) => void",
          "required": true,
          "default": null,
          "description": "Sends the edited text as a new version."
        },
        {
          "name": "onCopy",
          "type": "(text: string) => void",
          "required": false,
          "default": null,
          "description": "Copies the message. The button is hidden when omitted."
        },
        {
          "name": "labels",
          "type": "EditableTurnLabels",
          "required": false,
          "default": null,
          "description": "Overrides edit, copy, copied, send, cancel, field and note."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--surface-sunken",
        "--surface",
        "--line-strong",
        "--fg-muted",
        "--radius-card"
      ],
      "related": [
        "version-pager",
        "message",
        "message-actions"
      ],
      "composedOf": [
        "button",
        "icon-button",
        "version-pager"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Edit and send again",
          "description": "Press Edit, change the question, and Send. Then use the pager to go back.",
          "code": "<EditableTurn\n  versions={versions}\n  current={current}\n  onChangeVersion={setCurrent}\n  onResend={(text) => sendEdit(text)}\n  onCopy={copy}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-editable-turn/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/editable-turn.md"
    },
    {
      "id": "file-result-card",
      "name": "FileResultCard",
      "level": "molecule",
      "since": "0.21.0",
      "aka": [
        "file card",
        "download card",
        "artifact card"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "ready",
          "note": "The kind of file in words, its size, a preview, Open and Download.",
          "source": "documented"
        },
        {
          "name": "making",
          "note": "A spinner in place of the preview and Making followed by the name; the buttons wait.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "A red line says the file could not be made, with Try again.",
          "source": "documented"
        },
        {
          "name": "no preview",
          "note": "An icon for the kind of file stands in for the preview.",
          "source": "documented"
        }
      ],
      "summary": "A file the AI made, with what kind it is, a preview, Open and Download.",
      "description": "FileResultCard shows a file the AI created, such as a report, a spreadsheet, slides or an image. It says the kind of file in words (worked out from its name), its size or length, and shows a small preview when there is one. Open and Download sit on the right. While the file is being made it says so, and if making it failed it says so and offers Try again.",
      "purpose": "Hands people the files the AI made in a form they recognise, with the two things they want: look at it, or keep it.",
      "useFor": [
        "Documents, spreadsheets, slides and images the AI created.",
        "A file that is still being made, or that failed."
      ],
      "notFor": [
        {
          "text": "A file the person attached",
          "use": "attachment-chip"
        },
        {
          "text": "An image the AI made, shown large",
          "use": "media-frame"
        },
        {
          "text": "Editing the file next to the chat",
          "use": "canvas-panel"
        }
      ],
      "whenToUse": [
        "Show the real file name and its kind in words.",
        "Say when a file is still being made.",
        "Offer Try again when it fails."
      ],
      "avoid": [
        "Do not show a download link before the file exists.",
        "Do not show only an icon for the kind of file.",
        "Do not hide a failure; say it in words."
      ],
      "accessibility": [
        "Open and Download include the file name in their labels.",
        "Making and failed are said in words and read out as a status.",
        "The preview is decoration; the name and kind carry the meaning."
      ],
      "accessibilityYouDo": [
        "Use the real file name, with its extension, so the kind of file is clear.",
        "Give a size or length, such as 8 slides, when you know it."
      ],
      "onPhone": [
        "The buttons wrap under the name when the card is narrow.",
        "Long names end with an ellipsis; the full name is in the buttons' labels."
      ],
      "importPath": "import { FileResultCard } from \"./index\"",
      "sourcePath": "src/molecules/FileResultCard.tsx",
      "props": [
        {
          "name": "name",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The file's name with its extension."
        },
        {
          "name": "kind",
          "type": "\"document\" | \"sheet\" | \"slides\" | \"pdf\" | \"image\" | \"code\" | \"file\"",
          "required": false,
          "default": null,
          "description": "What kind of file it is. Worked out from the extension when omitted."
        },
        {
          "name": "detail",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Size or length, such as 24 KB or 8 slides."
        },
        {
          "name": "preview",
          "type": "ReactNode",
          "required": false,
          "default": null,
          "description": "A small picture of the first page or the image itself."
        },
        {
          "name": "status",
          "type": "\"ready\" | \"making\" | \"failed\"",
          "required": false,
          "default": "\"ready\"",
          "description": "Whether the file is ready, still being made, or failed."
        },
        {
          "name": "onOpen",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Opens the file. The button is hidden when omitted."
        },
        {
          "name": "onDownload",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Downloads the file. The button is hidden when omitted."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Tries again after a failure."
        },
        {
          "name": "labels",
          "type": "FileResultCardLabels",
          "required": false,
          "default": null,
          "description": "Overrides kinds, open, download, retry, making(name) and failed."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "kind": [
          "document",
          "sheet",
          "slides",
          "pdf",
          "image",
          "code",
          "file"
        ],
        "status": [
          "ready",
          "making",
          "failed"
        ]
      },
      "tokens": [
        "--surface",
        "--bg",
        "--line",
        "--fg-muted",
        "--danger-fg",
        "--radius-card",
        "--radius-control"
      ],
      "related": [
        "attachment-chip",
        "canvas-panel",
        "media-frame"
      ],
      "composedOf": [
        "button",
        "spinner"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Files the AI made",
          "description": "A document, a spreadsheet still being made, and one that failed.",
          "code": "<FileResultCard name=\"Q3 support report.docx\" detail=\"6 pages\" onOpen={open} onDownload={download} />\n<FileResultCard name=\"Refunds by region.xlsx\" status=\"making\" />\n<FileResultCard name=\"Board slides.pptx\" status=\"failed\" onRetry={retry} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-file-result-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/file-result-card.md"
    },
    {
      "id": "share-panel",
      "name": "SharePanel",
      "level": "organism",
      "since": "0.21.0",
      "aka": [
        "share dialog",
        "share link",
        "share settings"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "only you",
          "note": "No link is shown.",
          "source": "documented"
        },
        {
          "name": "workspace",
          "note": "The link, Copy link, and a line saying what the link shows.",
          "source": "documented"
        },
        {
          "name": "anyone with the link",
          "note": "The same, and the choice itself warns about private details.",
          "source": "documented"
        },
        {
          "name": "copied",
          "note": "Copy link says Copied with a check for a moment, and the same word is read out.",
          "source": "documented"
        },
        {
          "name": "asking to stop",
          "note": "Stop sharing becomes a question in place, with Stop sharing and Keep sharing.",
          "source": "documented"
        }
      ],
      "summary": "Who can open a chat or a result, with its link to copy and a way to stop sharing.",
      "description": "SharePanel goes in a Dialog or a Sheet. It offers who can open the item as plain choices: Only you, People in your workspace, or Anyone with the link, each with one line on what it means. When shared, it shows the link with Copy link (which says Copied), a line on exactly what the link shows, and Stop sharing, which asks once.",
      "purpose": "Makes sharing a chat or result a clear choice about who can see it, so nothing is shared more widely than meant.",
      "useFor": [
        "Sharing a chat, an artifact or a report as a link.",
        "Changing or stopping sharing later."
      ],
      "notFor": [
        {
          "text": "Inviting people to work on something together",
          "use": "dialog"
        },
        {
          "text": "Exporting a file",
          "use": "export-sheet"
        },
        {
          "text": "Asking before an action the AI takes",
          "use": "permission-prompt"
        }
      ],
      "whenToUse": [
        "Start from Only you.",
        "Say what the link shows and what it does not.",
        "Make stopping as easy as sharing."
      ],
      "avoid": [
        "Do not make Anyone with the link the default.",
        "Do not hide that later messages are not shared, or are.",
        "Do not stop sharing without one question."
      ],
      "accessibility": [
        "The choices are a radio group with a legend; arrow keys move between them.",
        "The link field selects itself on focus, and Copy link says Copied, also read out.",
        "Stop sharing asks in place and moves focus to the safe choice."
      ],
      "accessibilityYouDo": [
        "Put it in a Dialog or a Sheet with a clear title, and set headingLevel to fit (2 by default).",
        "Say exactly what the link shows in the scope line."
      ],
      "onPhone": [
        "The link and Copy link stack on a narrow screen, with the button full width.",
        "Each choice has its own line of explanation, so nothing is cut off."
      ],
      "importPath": "import { SharePanel } from \"./index\"",
      "sourcePath": "src/organisms/SharePanel.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "What is being shared, such as the chat's title."
        },
        {
          "name": "access",
          "type": "\"private\" | \"workspace\" | \"link\"",
          "required": true,
          "default": null,
          "description": "Who can open it now."
        },
        {
          "name": "onAccessChange",
          "type": "(access) => void",
          "required": true,
          "default": null,
          "description": "Called when the person picks who can open it."
        },
        {
          "name": "link",
          "type": "string",
          "required": false,
          "default": null,
          "description": "The link, once there is one."
        },
        {
          "name": "onCopy",
          "type": "(link: string) => void",
          "required": false,
          "default": null,
          "description": "Copies the link. Defaults to the clipboard."
        },
        {
          "name": "onStopSharing",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Turns the link off. Asks once first."
        },
        {
          "name": "showWorkspace",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Offer the workspace choice."
        },
        {
          "name": "labels",
          "type": "SharePanelLabels",
          "required": false,
          "default": null,
          "description": "Overrides heading, access, options, link, copy, copied, scope, stop, stopQuestion and keep."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "2",
          "description": "Level of the part's heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "access": [
          "private",
          "workspace",
          "link"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface-sunken",
        "--line",
        "--fg-muted",
        "--danger",
        "--radius-field"
      ],
      "related": [
        "dialog",
        "sheet",
        "export-sheet"
      ],
      "composedOf": [
        "radio-group",
        "button",
        "inline-confirm"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Share a chat",
          "description": "Change who can open it, copy the link, or stop sharing.",
          "code": "<SharePanel\n  title=\"Lisbon trip plan\"\n  access={access}\n  onAccessChange={setAccess}\n  link=\"https://example.com/share/7KQ4M9XP\"\n  onStopSharing={stopSharing}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-share-panel/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/share-panel.md"
    },
    {
      "id": "continue-notice",
      "name": "ContinueNotice",
      "level": "molecule",
      "since": "0.21.0",
      "aka": [
        "continue generating",
        "truncated response"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "loading",
          "note": "The button shows a spinner, keeps its place and ignores presses.",
          "source": "documented"
        },
        {
          "name": "length limit",
          "note": "Says the reply stopped at the length limit, with Continue.",
          "source": "documented"
        },
        {
          "name": "stopped",
          "note": "Says the person stopped the reply, with Continue.",
          "source": "documented"
        },
        {
          "name": "connection lost",
          "note": "A warning mark, and Try again in place of Continue.",
          "source": "documented"
        }
      ],
      "summary": "Says why a reply ended early and offers to carry on.",
      "description": "ContinueNotice is one quiet line under a reply that ended early. It says why in plain words (it hit the length limit, you stopped it, or the connection dropped) and offers Continue, or Try again after a lost connection. Without it, a reply cut off by the length limit just stops mid-sentence.",
      "purpose": "Makes an unfinished reply look unfinished, and gives a one-press way to finish it.",
      "useFor": [
        "A reply that hit the length limit.",
        "A reply the person stopped.",
        "A reply cut off by a lost connection."
      ],
      "notFor": [
        {
          "text": "Running out of messages for the day",
          "use": "rate-limit-notice"
        },
        {
          "text": "A request the AI will not answer",
          "use": "refusal-notice"
        },
        {
          "text": "Any other error",
          "use": "error-state"
        }
      ],
      "whenToUse": [
        "Say why it stopped, in plain words.",
        "Continue from where it stopped, not from the start."
      ],
      "avoid": [
        "Do not show it on a reply that finished.",
        "Do not use red. Stopping early is not an error.",
        "Do not blame the person."
      ],
      "accessibility": [
        "The reason is text next to an icon, not the icon alone.",
        "While busy, the button says it is working and ignores presses but keeps focus."
      ],
      "accessibilityYouDo": [
        "Put it straight after the reply it belongs to.",
        "Turn on busy while the continuation starts, so a second press does nothing."
      ],
      "onPhone": [
        "The button drops under the line on a narrow screen."
      ],
      "importPath": "import { ContinueNotice } from \"./index\"",
      "sourcePath": "src/molecules/ContinueNotice.tsx",
      "props": [
        {
          "name": "reason",
          "type": "\"length\" | \"stopped\" | \"lost\"",
          "required": true,
          "default": null,
          "description": "Why the reply ended early."
        },
        {
          "name": "onContinue",
          "type": "() => void",
          "required": true,
          "default": null,
          "description": "Carries on from where it ended, or tries again after a lost connection."
        },
        {
          "name": "busy",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "True while the continuation starts. The button keeps its place and ignores presses."
        },
        {
          "name": "labels",
          "type": "ContinueNoticeLabels",
          "required": false,
          "default": null,
          "description": "Overrides length, stopped, lost, continue and retry."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "reason": [
          "length",
          "stopped",
          "lost"
        ]
      },
      "tokens": [
        "--fg-muted",
        "--radius-control"
      ],
      "related": [
        "message",
        "message-actions",
        "rate-limit-notice"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Hit the length limit",
          "description": "Press Continue to carry on from where it stopped.",
          "code": "<ContinueNotice reason=\"length\" onContinue={continueReply} />"
        },
        {
          "title": "Stopped, and lost",
          "description": "The person pressed Stop; the connection dropped.",
          "code": "<ContinueNotice reason=\"stopped\" onContinue={continueReply} />\n<ContinueNotice reason=\"lost\" onContinue={retry} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-continue-notice/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/continue-notice.md"
    },
    {
      "id": "project-panel",
      "name": "ProjectPanel",
      "level": "organism",
      "since": "0.21.0",
      "aka": [
        "project sidebar",
        "workspace panel",
        "knowledge"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "empty",
          "note": "No instructions and no files: each says in one line what it is for.",
          "source": "documented"
        },
        {
          "name": "long instructions",
          "note": "Folded after three lines, with Show all.",
          "source": "documented"
        },
        {
          "name": "nearly full",
          "note": "The knowledge bar turns amber past 90%.",
          "source": "documented"
        },
        {
          "name": "file removed",
          "note": "Focus moves to the next file's remove button, or to the Files heading when none are left, and a status line says what was removed.",
          "source": "documented"
        }
      ],
      "summary": "A project's instructions and files, shared by every chat in it.",
      "description": "ProjectPanel shows a project: chats that share the same instructions and files. It has the project's name and how many chats use it, its instructions (folded after three lines, with Show all), the files every chat can read with a way to add or remove them, and how full the project's knowledge is.",
      "purpose": "Gives people one place to set how the AI works on a body of work, and what it knows, for every chat at once.",
      "useFor": [
        "A side panel on a project page.",
        "Showing what every chat in a project starts with."
      ],
      "notFor": [
        {
          "text": "What the AI can see in one chat",
          "use": "context-panel"
        },
        {
          "text": "The list of chats",
          "use": "conversation-list"
        },
        {
          "text": "Saved memories about the person",
          "use": "memory-list"
        }
      ],
      "whenToUse": [
        "Show the instructions people wrote, word for word.",
        "Say what an empty project needs.",
        "Show how full the knowledge is before it is full."
      ],
      "avoid": [
        "Do not hide that every chat reads these files.",
        "Do not cut instructions off without Show all."
      ],
      "accessibility": [
        "The panel is a region named by the project, with headings for Instructions and Files.",
        "Show all says whether the instructions are open.",
        "Removing a file moves focus to the next file, or to the Files heading, and a status line says what was removed."
      ],
      "accessibilityYouDo": [
        "Set headingLevel so the project name fits your page (2 by default).",
        "Give files their real names."
      ],
      "onPhone": [
        "Each file row is at least 40px tall and its remove button grows to 44px on touch screens.",
        "Long instructions fold after three lines."
      ],
      "importPath": "import { ProjectPanel } from \"./index\"",
      "sourcePath": "src/organisms/ProjectPanel.tsx",
      "props": [
        {
          "name": "name",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The project's name."
        },
        {
          "name": "description",
          "type": "string",
          "required": false,
          "default": null,
          "description": "One line about what it is for."
        },
        {
          "name": "instructions",
          "type": "string",
          "required": false,
          "default": null,
          "description": "How the assistant should work in every chat of this project."
        },
        {
          "name": "onEditInstructions",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Opens the instructions to edit."
        },
        {
          "name": "files",
          "type": "ProjectFile[]",
          "required": true,
          "default": null,
          "description": "Files every chat can read: { id, name, detail? }."
        },
        {
          "name": "onAddFiles",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Opens a way to add files."
        },
        {
          "name": "onRemoveFile",
          "type": "(id: string) => void",
          "required": false,
          "default": null,
          "description": "Removes a file. Focus moves to the next one."
        },
        {
          "name": "capacity",
          "type": "{ used: number; total: number }",
          "required": false,
          "default": null,
          "description": "How full the project's knowledge is, in tokens. The bar turns amber past 90%."
        },
        {
          "name": "chatCount",
          "type": "number",
          "required": false,
          "default": null,
          "description": "How many chats belong to the project."
        },
        {
          "name": "labels",
          "type": "ProjectPanelLabels",
          "required": false,
          "default": null,
          "description": "Overrides every line of text."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "2",
          "description": "Level of the project name's heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--line",
        "--hover",
        "--fg-muted",
        "--attention",
        "--radius-card"
      ],
      "related": [
        "context-panel",
        "conversation-list",
        "memory-list"
      ],
      "composedOf": [
        "button",
        "icon-button",
        "meter"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A support project",
          "description": "Show all the instructions, or remove a file: focus moves to the next.",
          "code": "<ProjectPanel\n  name=\"Customer support\"\n  description=\"Replies to customers of the Acme store.\"\n  chatCount={24}\n  instructions={instructions}\n  files={files}\n  onEditInstructions={edit}\n  onAddFiles={add}\n  onRemoveFile={remove}\n  capacity={{ used: 41800, total: 200000 }}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-project-panel/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/project-panel.md"
    },
    {
      "id": "research-run",
      "name": "ResearchRun",
      "level": "organism",
      "since": "0.21.0",
      "aka": [
        "deep research",
        "research progress",
        "research report"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "status",
          "note": "Set with the status prop.",
          "source": "prop"
        },
        {
          "name": "running",
          "note": "Done phases have a check, the current one a lime ring, the rest wait in grey. Sources read, time so far, what it is reading now, and Stop.",
          "source": "documented"
        },
        {
          "name": "done",
          "note": "One line says how many sources were read and how long it took, with Open report.",
          "source": "documented"
        },
        {
          "name": "stopped",
          "note": "Says the report covers what was read so far, with Open report.",
          "source": "documented"
        },
        {
          "name": "failed",
          "note": "A red line and Try again.",
          "source": "documented"
        }
      ],
      "summary": "A long research run: its phases, how many sources it has read, Stop, then the report.",
      "description": "ResearchRun shows a deep research run that can take many minutes. It shows the question, the phases (done, now, next), how many sources it has read and for how long, and what it is reading this moment. Stop is always there. When it finishes it says what it did in one line, such as Read 87 sources in 9 min, and offers Open report. Screen readers hear each new phase, not every source.",
      "purpose": "Keeps people informed and in control during a long research run, so the wait feels like progress, not silence.",
      "useFor": [
        "Deep research that searches and reads many sources.",
        "Any long run with a few clear phases and a count."
      ],
      "notFor": [
        {
          "text": "An agent's steps with tools and approvals",
          "use": "step-timeline"
        },
        {
          "text": "A short wait",
          "use": "shimmer-text"
        },
        {
          "text": "Generating images or video",
          "use": "generation-progress"
        }
      ],
      "whenToUse": [
        "Show a count that moves, so the wait feels like progress.",
        "Keep Stop in reach the whole time.",
        "When stopped, still offer the report of what was read."
      ],
      "avoid": [
        "Do not announce every source to screen readers.",
        "Do not show a percentage you cannot know.",
        "Do not hide which phase it is in."
      ],
      "accessibility": [
        "The phases are an ordered list; the current one is marked as the current step and said as now.",
        "Screen readers hear each new phase once, and the summary when it ends, through a status line.",
        "The pulse on the current phase stops when people turn off motion in their system settings."
      ],
      "accessibilityYouDo": [
        "Keep phase names short and in plain words.",
        "Pass the time already in words, such as 4 min."
      ],
      "onPhone": [
        "The activity line ends with an ellipsis on one line.",
        "Stop and Open report wrap under the summary."
      ],
      "importPath": "import { ResearchRun } from \"./index\"",
      "sourcePath": "src/organisms/ResearchRun.tsx",
      "props": [
        {
          "name": "question",
          "type": "string",
          "required": true,
          "default": null,
          "description": "What is being researched, in the person's words."
        },
        {
          "name": "status",
          "type": "\"running\" | \"done\" | \"stopped\" | \"failed\"",
          "required": true,
          "default": null,
          "description": "Where the run is."
        },
        {
          "name": "phases",
          "type": "ResearchPhase[]",
          "required": true,
          "default": null,
          "description": "The phases: { id, label, state: \"done\" | \"active\" | \"waiting\" }."
        },
        {
          "name": "sourcesRead",
          "type": "number",
          "required": true,
          "default": null,
          "description": "Sources read so far."
        },
        {
          "name": "elapsed",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Time so far, in words."
        },
        {
          "name": "activity",
          "type": "string",
          "required": false,
          "default": null,
          "description": "What it is doing this moment. Shown while running."
        },
        {
          "name": "onStop",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Stops the run. Shown while running."
        },
        {
          "name": "onOpenReport",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Opens the report. Shown when done or stopped."
        },
        {
          "name": "onRetry",
          "type": "() => void",
          "required": false,
          "default": null,
          "description": "Tries again after a failure."
        },
        {
          "name": "labels",
          "type": "ResearchRunLabels",
          "required": false,
          "default": null,
          "description": "Overrides every line of text."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the part's heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "status": [
          "running",
          "done",
          "stopped",
          "failed"
        ],
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--line",
        "--lime",
        "--success-soft",
        "--success-fg",
        "--danger-fg",
        "--radius-card"
      ],
      "related": [
        "step-timeline",
        "source-list",
        "activity-trace"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Researching a question",
          "description": "Watch the phases and count move, press Stop, or wait for the report.",
          "code": "<ResearchRun\n  question=\"Which European cities cut traffic deaths the most since 2015, and how?\"\n  status=\"running\"\n  phases={phases}\n  sourcesRead={34}\n  elapsed=\"4 min\"\n  activity=\"Reading itf-oecd.org\"\n  onStop={stop}\n  onOpenReport={openReport}\n/>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-research-run/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/research-run.md"
    },
    {
      "id": "file-drop-zone",
      "name": "FileDropZone",
      "level": "molecule",
      "since": "0.21.0",
      "aka": [
        "file upload",
        "dropzone",
        "drag and drop upload",
        "uploader"
      ],
      "category": "conversation",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "disabled",
          "note": "The area fades and Browse files is turned off.",
          "source": "documented"
        },
        {
          "name": "resting",
          "note": "A dashed area with Add files, which kinds and sizes work, and Browse files.",
          "source": "documented"
        },
        {
          "name": "dragging over",
          "note": "The area takes the selected fill and border and says Drop to add.",
          "source": "documented"
        },
        {
          "name": "file refused",
          "note": "A red line names each file that was refused and why; the files that passed are still added.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Browse files shows a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "Where people drop or browse for files, with the kinds and sizes that work said up front.",
      "description": "FileDropZone is a dashed area to drop files on, with a Browse files button for everyone who cannot drag. It says which kinds of file and what size work before anyone tries, changes its words when a file is over it, and when a file does not work it names the file and says why.",
      "purpose": "Makes adding files easy and predictable, so people know what will work before they try.",
      "useFor": [
        "Adding files to a chat, a project or a research run.",
        "Uploads with limits on kind or size."
      ],
      "notFor": [
        {
          "text": "A file already added",
          "use": "attachment-chip"
        },
        {
          "text": "A file the AI made",
          "use": "file-result-card"
        }
      ],
      "whenToUse": [
        "Say the kinds and size limit before anyone tries.",
        "Name the file that did not work and say why.",
        "Keep Browse files for people who cannot drag."
      ],
      "avoid": [
        "Do not make dragging the only way.",
        "Do not reject a file without saying which one."
      ],
      "accessibility": [
        "Browse files is a real button, described by the kinds and size line.",
        "Errors are read out as soon as they appear.",
        "The words change to Drop to add when a file is over the area, not just the color."
      ],
      "accessibilityYouDo": [
        "List the kinds people actually use, not every extension.",
        "Check the files again on your server; this checks only names and sizes."
      ],
      "onPhone": [
        "On a phone, Browse files opens the phone's own picker; dragging is rare there.",
        "The area fills the width and its button is a full tap target."
      ],
      "importPath": "import { FileDropZone } from \"./index\"",
      "sourcePath": "src/molecules/FileDropZone.tsx",
      "props": [
        {
          "name": "onFiles",
          "type": "(files: File[]) => void",
          "required": true,
          "default": null,
          "description": "Called with the files that passed the checks."
        },
        {
          "name": "accept",
          "type": "string[]",
          "required": false,
          "default": null,
          "description": "Accepted extensions, such as [\"pdf\", \"docx\"]. Any file when omitted."
        },
        {
          "name": "maxSizeMb",
          "type": "number",
          "required": false,
          "default": null,
          "description": "Largest file in megabytes."
        },
        {
          "name": "multiple",
          "type": "boolean",
          "required": false,
          "default": "true",
          "description": "Allow more than one file at a time."
        },
        {
          "name": "disabled",
          "type": "boolean",
          "required": false,
          "default": null,
          "description": "Turns the area and its button off."
        },
        {
          "name": "labels",
          "type": "FileDropZoneLabels",
          "required": false,
          "default": null,
          "description": "Overrides title, browse, drop, hint, wrongKind and tooBig."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {},
      "tokens": [
        "--bg",
        "--line-strong",
        "--selected",
        "--selected-border",
        "--danger-fg",
        "--radius-card"
      ],
      "related": [
        "attachment-chip",
        "composer",
        "file-result-card"
      ],
      "composedOf": [
        "button"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Add files",
          "description": "Press Browse files, or drag files onto the area. A file of the wrong kind or size says why.",
          "code": "<FileDropZone accept={[\"pdf\", \"docx\", \"csv\", \"png\"]} maxSizeMb={25} onFiles={add} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-file-drop-zone/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/file-drop-zone.md"
    },
    {
      "id": "fact-list",
      "name": "FactList",
      "level": "molecule",
      "since": "0.28.0",
      "aka": [
        "description list",
        "key value list",
        "definition list",
        "properties",
        "details list",
        "dl"
      ],
      "category": "generated",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "loading",
          "note": "Placeholder rows hold the space while the facts arrive, marked busy.",
          "source": "documented"
        },
        {
          "name": "facts",
          "note": "Label and value pairs, side by side or stacked.",
          "source": "documented"
        },
        {
          "name": "empty",
          "note": "Says the empty label, or draws nothing when there is none.",
          "source": "documented"
        },
        {
          "name": "long value",
          "note": "A long value wraps under itself instead of pushing the label out.",
          "source": "documented"
        }
      ],
      "summary": "Short label and value pairs, such as Check-in: 3pm.",
      "description": "FactList shows a few facts about one thing as label and value pairs. It is a description list, so a screen reader reads each value with its label. Labels sit on the left and values on the right, or stacked for narrow places, and two columns are possible on wide screens. It can show placeholder rows while the facts arrive.",
      "purpose": "Gives the answer a light, scannable shape for the details people compare: times, plans, sizes, prices.",
      "useFor": [
        "Three to eight short facts about one thing.",
        "Details inside an answer, a card or a panel.",
        "Facts that are still arriving, with placeholders."
      ],
      "notFor": [
        {
          "text": "Many rows people sort or filter",
          "use": "data-table"
        },
        {
          "text": "Several things compared side by side",
          "use": "comparison-table"
        },
        {
          "text": "A single number that matters most",
          "use": "stat-tile"
        }
      ],
      "whenToUse": [
        "Keep each value short.",
        "Use the same labels in the same order across similar results.",
        "Show placeholders while facts stream in."
      ],
      "avoid": [
        "Do not use it for long paragraphs.",
        "Do not put actions inside values.",
        "Do not use it for a table people need to sort."
      ],
      "accessibility": [
        "A description list (dl, dt, dd), so each value is read with its label.",
        "Placeholder rows are marked busy while the facts arrive."
      ],
      "accessibilityYouDo": [
        "Keep labels short and the same across similar lists.",
        "Put units in the value (3pm, 4 min), not only in the label."
      ],
      "onPhone": [
        "Label and value stay on one line and the value wraps under itself when long.",
        "Two columns become one on a phone."
      ],
      "importPath": "import { FactList } from \"./index\"",
      "sourcePath": "src/molecules/FactList.tsx",
      "props": [
        {
          "name": "facts",
          "type": "{ label: string; value: ReactNode }[]",
          "required": true,
          "default": null,
          "description": "The pairs to show, in order."
        },
        {
          "name": "layout",
          "type": "\"split\" | \"stacked\"",
          "required": false,
          "default": "\"split\"",
          "description": "Side by side, or label above value."
        },
        {
          "name": "columns",
          "type": "1 | 2",
          "required": false,
          "default": "1",
          "description": "Two columns on wide screens."
        },
        {
          "name": "loading",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Shows placeholder rows while the facts arrive."
        },
        {
          "name": "loadingRows",
          "type": "number",
          "required": false,
          "default": "3",
          "description": "How many placeholder rows to show."
        },
        {
          "name": "emptyLabel",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Said when there are no facts. Nothing is drawn when omitted."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "layout": [
          "split",
          "stacked"
        ],
        "columns": [
          1,
          2
        ]
      },
      "tokens": [
        "--fg",
        "--fg-muted",
        "--text-body"
      ],
      "related": [
        "result-card",
        "data-table",
        "comparison-table"
      ],
      "composedOf": [
        "skeleton"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Facts about a booking",
          "description": "Label on the left, value on the right.",
          "code": "<FactList facts={[\n  { label: \"Check-in\", value: \"From 3pm\" },\n  { label: \"Check-out\", value: \"By 11am\" },\n  { label: \"Plan\", value: <Badge tone=\"success\">Free cancellation</Badge> },\n]} />"
        },
        {
          "title": "Two columns, and arriving",
          "description": "Two columns on wide screens; placeholders while the facts stream in.",
          "code": "<FactList columns={2} facts={specs} />\n<FactList loading loadingRows={3} facts={[]} />"
        },
        {
          "title": "Stacked",
          "description": "Label above value, for a narrow side panel.",
          "code": "<FactList layout=\"stacked\" facts={facts} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-fact-list/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/fact-list.md"
    },
    {
      "id": "result-card",
      "name": "ResultCard",
      "level": "molecule",
      "since": "0.28.0",
      "aka": [
        "product card",
        "listing card",
        "item card",
        "search result card",
        "card"
      ],
      "category": "generated",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "streaming",
          "note": "The card shows but its action is turned off and the card is marked busy until the answer is complete.",
          "source": "documented"
        },
        {
          "name": "loading",
          "note": "Placeholders for the picture, the name and the facts.",
          "source": "documented"
        },
        {
          "name": "ready",
          "note": "Picture, name, short line, facts, price, tag and the action.",
          "source": "documented"
        },
        {
          "name": "no picture",
          "note": "The card starts with the name; nothing else moves.",
          "source": "documented"
        },
        {
          "name": "picture failed",
          "note": "The picture frame shows its own fallback tile with Retry.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The action shows a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "One thing in an answer: a picture, its name, a few facts, a price and one action.",
      "description": "ResultCard shows one thing the AI found, such as a hotel, a product, a place or a person. It has a picture, the name as a heading, one short line, two to four facts, an optional price and tag, and one action. While the answer is still streaming the card shows, but its action waits until the answer is complete. It can also draw placeholders while it loads.",
      "purpose": "Turns the things an answer recommends into something people can compare and act on, instead of a paragraph to read.",
      "useFor": [
        "Hotels, products, places, people or plans the AI suggests.",
        "Results laid out side by side with CardRow.",
        "A result that is still arriving."
      ],
      "notFor": [
        {
          "text": "A source behind a claim",
          "use": "source-card"
        },
        {
          "text": "A file the AI made",
          "use": "file-result-card"
        },
        {
          "text": "A single headline number",
          "use": "stat-tile"
        },
        {
          "text": "A prompt the person can pick",
          "use": "suggestion-card"
        }
      ],
      "whenToUse": [
        "Give every card in a row the same fields.",
        "Keep facts to the ones people compare.",
        "Hold the action until the answer is complete."
      ],
      "avoid": [
        "Do not put more than one main action on a card.",
        "Do not make the whole card a link and also give it a button.",
        "Do not invent a price or rating the data does not have."
      ],
      "accessibility": [
        "An article with the name as a heading, so people can jump between results.",
        "The action's label includes the result's name.",
        "While streaming the action is turned off and the card is marked busy."
      ],
      "accessibilityYouDo": [
        "Write alt text that says what the picture shows.",
        "Give every card in a row the same fields, so people can compare them.",
        "Name the action for what happens next, such as View rooms, not Click here."
      ],
      "onPhone": [
        "The card fills the width; in a CardRow it swipes sideways with the next card peeking in.",
        "The price and the action wrap onto two lines when there is no room."
      ],
      "importPath": "import { ResultCard } from \"./index\"",
      "sourcePath": "src/molecules/ResultCard.tsx",
      "props": [
        {
          "name": "title",
          "type": "string",
          "required": true,
          "default": null,
          "description": "The thing's name, shown as a heading."
        },
        {
          "name": "subtitle",
          "type": "string",
          "required": false,
          "default": null,
          "description": "One short line under the name."
        },
        {
          "name": "image",
          "type": "{ src: string; alt: string }",
          "required": false,
          "default": null,
          "description": "A picture of the thing; alt says what it shows."
        },
        {
          "name": "facts",
          "type": "{ label: string; value: ReactNode }[]",
          "required": false,
          "default": null,
          "description": "Two to four facts that help choose."
        },
        {
          "name": "price",
          "type": "{ value: number; currency?: string; locale?: string; decimals?: number; note?: string }",
          "required": false,
          "default": null,
          "description": "The price, formatted for the locale, with what it is for."
        },
        {
          "name": "tag",
          "type": "{ label: string; tone?: Tone }",
          "required": false,
          "default": null,
          "description": "One short tag, such as Free cancellation."
        },
        {
          "name": "action",
          "type": "{ label: string; onClick: () => void }",
          "required": false,
          "default": null,
          "description": "The one thing to do with this result."
        },
        {
          "name": "streaming",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "The answer is still arriving; the action waits until it is complete."
        },
        {
          "name": "loading",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "Draws placeholders."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of the name's heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--line",
        "--fg",
        "--fg-muted",
        "--radius-card"
      ],
      "related": [
        "card-row",
        "fact-list",
        "media-frame",
        "source-card"
      ],
      "composedOf": [
        "media-frame",
        "fact-list",
        "badge",
        "button",
        "skeleton"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "Hotels in a row",
          "description": "Three results with the same fields, laid out with CardRow.",
          "code": "<CardRow label=\"Hotels in Paris\">\n  {hotels.map((h) => (\n    <ResultCard key={h.id} title={h.name} subtitle={h.area} image={h.image}\n      facts={[{ label: \"Rating\", value: h.rating }, { label: \"Check-in\", value: h.checkIn }]}\n      price={{ value: h.price, currency: \"EUR\", note: \"a night\" }}\n      action={{ label: \"View rooms\", onClick: () => open(h) }} />\n  ))}\n</CardRow>"
        },
        {
          "title": "While the answer streams",
          "description": "Cards arrive one by one. Placeholders hold the space and the actions wait until the answer is complete.",
          "code": "<ResultCard {...hotel} streaming={isStreaming} action={{ label: \"View rooms\", onClick }} />\n<ResultCard title=\"\" loading />"
        },
        {
          "title": "Without a picture",
          "description": "A plan, with a tag and a price.",
          "code": "<ResultCard title=\"Team plan\" subtitle=\"For up to 10 people\" tag={{ label: \"Most picked\", tone: \"accent\" }}\n  facts={[{ label: \"Storage\", value: \"1 TB\" }, { label: \"Support\", value: \"Same day\" }]}\n  price={{ value: 24, currency: \"USD\", note: \"a person a month\" }} action={{ label: \"Choose\", onClick }} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-result-card/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/result-card.md"
    },
    {
      "id": "card-row",
      "name": "CardRow",
      "level": "molecule",
      "since": "0.28.0",
      "aka": [
        "carousel",
        "card grid",
        "card list",
        "horizontal scroll",
        "slider of cards"
      ],
      "category": "generated",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "wide",
          "note": "A grid that wraps; cards in a row share one height.",
          "source": "documented"
        },
        {
          "name": "phone",
          "note": "A row that swipes sideways and snaps, with the next card peeking in.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "The row shows a focus ring and scrolls with the arrow keys on a phone.",
          "source": "documented"
        }
      ],
      "summary": "Results side by side: a grid on wide screens, a swipe row on a phone.",
      "description": "CardRow lays out two or more ResultCards of the same kind. On wide screens they form a grid that wraps; on a phone they sit in a row that swipes sideways, with the next card peeking in so people can tell there is more. It is a list, so its length is announced.",
      "purpose": "Lets people compare a handful of results at a glance, on any screen.",
      "useFor": [
        "Two to eight results of the same kind.",
        "Results in a chat answer that must fit a phone."
      ],
      "notFor": [
        {
          "text": "Many results people filter and sort",
          "use": "data-table"
        },
        {
          "text": "Pictures the AI made, to pick from",
          "use": "variant-grid"
        }
      ],
      "whenToUse": [
        "Use it for results of the same kind.",
        "Keep to a handful; link to a full list for more."
      ],
      "avoid": [
        "Do not mix kinds of results in one row.",
        "Do not hide the scroll on a phone; the peeking card shows there is more."
      ],
      "accessibility": [
        "A list with a label, so its length and name are announced.",
        "On a phone the row takes keyboard focus and scrolls with the arrow keys."
      ],
      "accessibilityYouDo": [
        "Give the row a label that says what is in it, such as Hotels in Paris.",
        "Keep the cards in the order the answer ranks them."
      ],
      "onPhone": [
        "Cards swipe sideways and snap into place, each at most 85% of the width.",
        "The row can be scrolled with the keyboard once it has focus."
      ],
      "importPath": "import { CardRow } from \"./index\"",
      "sourcePath": "src/molecules/CardRow.tsx",
      "props": [
        {
          "name": "label",
          "type": "string",
          "required": true,
          "default": null,
          "description": "Names the group for screen readers."
        },
        {
          "name": "children",
          "type": "ReactNode",
          "required": true,
          "default": null,
          "description": "Two or more ResultCards with the same fields."
        },
        {
          "name": "minCardWidth",
          "type": "52 | 60 | 72",
          "required": false,
          "default": "60",
          "description": "Narrowest a card may get on wide screens, in the spacing scale (60 is 240px)."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the list."
        }
      ],
      "variantsHint": {
        "minCardWidth": [
          52,
          60,
          72
        ]
      },
      "tokens": [
        "--spacing",
        "--focus-ring"
      ],
      "related": [
        "result-card",
        "variant-grid"
      ],
      "composedOf": [
        "result-card"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "A row of results",
          "description": "Wraps on wide screens, swipes on a phone.",
          "code": "<CardRow label=\"Hotels in Paris\">\n  <ResultCard ... />\n  <ResultCard ... />\n  <ResultCard ... />\n</CardRow>"
        },
        {
          "title": "Wider cards",
          "description": "minCardWidth sets how narrow a card may get before the row wraps.",
          "code": "<CardRow label=\"Plans\" minCardWidth={72}>...</CardRow>"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-card-row/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/card-row.md"
    },
    {
      "id": "sections",
      "name": "Sections",
      "level": "organism",
      "since": "0.28.0",
      "aka": [
        "accordion",
        "collapsible sections",
        "expandable sections",
        "faq"
      ],
      "category": "generated",
      "status": "stable",
      "lifecycle": {
        "stage": "stable",
        "since": null,
        "replacedBy": null,
        "removeIn": null,
        "note": null
      },
      "states": [
        {
          "name": "streaming",
          "note": "The newest section is open; earlier ones fold as each new one arrives.",
          "source": "documented"
        },
        {
          "name": "finished",
          "note": "The first section is open and the rest are folded.",
          "source": "documented"
        },
        {
          "name": "person chose",
          "note": "After the person opens or closes a section, nothing moves on its own.",
          "source": "documented"
        },
        {
          "name": "focus",
          "note": "Each section heading button shows a focus ring on keyboard focus.",
          "source": "documented"
        }
      ],
      "summary": "A long answer in foldable sections that open as they stream in.",
      "description": "Sections shows a long answer as a group of foldable sections, each with a title and an optional short line such as 3 options. While the answer streams, each new section opens as it arrives so people can follow along. When it ends, the sections fold back to the first one. Once the person opens or closes a section, the answer stops moving things and leaves their choice alone.",
      "purpose": "Keeps a long, structured answer short to scan, without hiding where the answer is while it is still being written.",
      "useFor": [
        "Trip plans, reports and comparisons with clear parts.",
        "Answers that stream in part by part."
      ],
      "notFor": [
        {
          "text": "One section that people open and close",
          "use": "collapsible"
        },
        {
          "text": "The assistant's reasoning, shown on request",
          "use": "thinking-block"
        },
        {
          "text": "Steps the assistant is working through",
          "use": "plan-card"
        }
      ],
      "whenToUse": [
        "Use a few sections with clear titles.",
        "Keep ids stable while streaming.",
        "Let the person's choice win once they open or close one."
      ],
      "avoid": [
        "Do not fold an answer of one or two short paragraphs.",
        "Do not move sections after the person has opened one.",
        "Do not hide the only important part in a folded section."
      ],
      "accessibility": [
        "Each section title is a heading wrapping a button with aria-expanded.",
        "Folded content is hidden from everyone, not just from sight.",
        "Opening and closing follow reduced motion."
      ],
      "accessibilityYouDo": [
        "Write section titles that say what is inside.",
        "Keep the id of a section the same while the answer streams, so it keeps its open state."
      ],
      "onPhone": [
        "Titles wrap; the short line moves under the title when there is no room.",
        "Each heading is a full-width button, easy to tap."
      ],
      "importPath": "import { Sections } from \"./index\"",
      "sourcePath": "src/organisms/Sections.tsx",
      "props": [
        {
          "name": "sections",
          "type": "{ id: string; title: string; meta?: string; content: ReactNode }[]",
          "required": true,
          "default": null,
          "description": "The sections, in order. Ids stay the same while the answer streams."
        },
        {
          "name": "streaming",
          "type": "boolean",
          "required": false,
          "default": "false",
          "description": "The answer is still arriving: each new section opens; when it ends, the first stays open."
        },
        {
          "name": "headingLevel",
          "type": "2 | 3 | 4 | 5 | 6",
          "required": false,
          "default": "3",
          "description": "Level of each section's heading."
        },
        {
          "name": "className",
          "type": "string",
          "required": false,
          "default": null,
          "description": "Extra classes on the root."
        }
      ],
      "variantsHint": {
        "headingLevel": [
          2,
          3,
          4,
          5,
          6
        ]
      },
      "tokens": [
        "--surface",
        "--line",
        "--fg",
        "--fg-muted",
        "--radius-card"
      ],
      "related": [
        "collapsible",
        "thinking-block",
        "fact-list"
      ],
      "composedOf": [
        "collapsible"
      ],
      "plan": "free",
      "codeAvailable": true,
      "examples": [
        {
          "title": "While the answer streams",
          "description": "Each new section opens as it arrives, then the answer settles on the first.",
          "code": "<Sections streaming={isStreaming} sections={[\n  { id: \"flights\", title: \"Flights\", meta: \"2 options\", content: <FactList facts={flightFacts} /> },\n  { id: \"stay\", title: \"Where to stay\", meta: \"3 hotels\", content: stayText },\n  { id: \"plan\", title: \"Day by day\", meta: \"2 days\", content: planText },\n]} />"
        },
        {
          "title": "A finished answer",
          "description": "The first section open, the rest folded.",
          "code": "<Sections sections={sections} />"
        }
      ],
      "pageUrl": "https://lkb00.github.io/tattva/component-sections/",
      "markdownUrl": "https://lkb00.github.io/tattva/docs/sections.md"
    }
  ],
  "renamed": [
    {
      "from": "AltitudeToggle",
      "fromId": "altitude-toggle",
      "to": "AutonomyLevel",
      "toId": "autonomy-level",
      "since": "0.29.0",
      "removeIn": "0.31.0"
    },
    {
      "from": "ContextPill",
      "fromId": "context-pill",
      "to": "ContextChip",
      "toId": "context-chip",
      "since": "0.29.0",
      "removeIn": "0.31.0"
    },
    {
      "from": "MeterBar",
      "fromId": "meter-bar",
      "to": "Meter",
      "toId": "meter",
      "since": "0.29.0",
      "removeIn": "0.31.0"
    },
    {
      "from": "KeyHint",
      "fromId": "key-hint",
      "to": "Kbd",
      "toId": "kbd",
      "since": "0.29.0",
      "removeIn": "0.31.0"
    },
    {
      "from": "ReEntryRecap",
      "fromId": "re-entry-recap",
      "to": "CatchUpSummary",
      "toId": "catch-up-summary",
      "since": "0.29.0",
      "removeIn": "0.31.0"
    },
    {
      "from": "NeedsYouInbox",
      "fromId": "needs-you-inbox",
      "to": "ApprovalInbox",
      "toId": "approval-inbox",
      "since": "0.29.0",
      "removeIn": "0.31.0"
    }
  ],
  "exports": [
    "AIEditIcon",
    "AIEnhanceIcon",
    "AISuggestIcon",
    "AISummarizeIcon",
    "AlertIcon",
    "AltitudeToggle",
    "ApproveIcon",
    "ArrowRightIcon",
    "BookmarkIcon",
    "BranchIcon",
    "CameraIcon",
    "CheckIcon",
    "CheckpointIcon",
    "ChevronIcon",
    "CitationIcon",
    "ContextIcon",
    "ContextPill",
    "CopyIcon",
    "DEFAULT_VARIATION_ACTIONS",
    "DownloadIcon",
    "EditIcon",
    "EyeIcon",
    "FileIcon",
    "GlanceSheet",
    "GlobeIcon",
    "HistoryIcon",
    "ImageIcon",
    "InfoIcon",
    "KeyHint",
    "LayersIcon",
    "LockIcon",
    "LoopIcon",
    "MemoryIcon",
    "MeterBar",
    "MicIcon",
    "MicOffIcon",
    "Mono",
    "MoonIcon",
    "MoreIcon",
    "NEUTRAL_STYLE",
    "NeedsReviewIcon",
    "NeedsYouInbox",
    "NotebookIcon",
    "PageBar",
    "PaletteIcon",
    "PaperclipIcon",
    "PinIcon",
    "PlanIcon",
    "PlusIcon",
    "QS_AGENT_DECIDES",
    "QS_SKIPPED",
    "REVEAL_CAP",
    "REVEAL_STEP",
    "ReEntryRecap",
    "RedoIcon",
    "RefreshIcon",
    "RegenerateIcon",
    "RetryIcon",
    "RevertAIIcon",
    "SANDBOX_MESSAGES",
    "SERIES_STYLES",
    "SearchIcon",
    "SendIcon",
    "SeriesMark",
    "SettingsIcon",
    "ShareIcon",
    "ShieldIcon",
    "SlideCanvas",
    "SourceIcon",
    "SparkleIcon",
    "StopIcon",
    "SunIcon",
    "SyncIcon",
    "TAKE_STATUS_WORD",
    "TagIcon",
    "TakePoster",
    "ThoughtIcon",
    "ThumbDownIcon",
    "ThumbUpIcon",
    "ToastProvider",
    "ToolIcon",
    "UndoIcon",
    "UsersIcon",
    "VideoScene",
    "VoiceSession",
    "XIcon",
    "allCameraMoves",
    "answerValues",
    "barChartTable",
    "cameraMoveInfo",
    "candlestickTable",
    "chargeText",
    "cn",
    "deckReviewSummary",
    "defaultPermissionModes",
    "dotPlotTable",
    "fileKindOf",
    "findSensitive",
    "formatClock",
    "formatCredits",
    "formatMediaTime",
    "formatNumber",
    "formatPrice",
    "formatSeconds",
    "formatSpokenDuration",
    "frameAt",
    "heatmapTable",
    "isTakeWorking",
    "layoutAltSummary",
    "lineChartTable",
    "markerPath",
    "niceTicks",
    "payoffAtExpiry",
    "payoffTable",
    "pictogramNames",
    "pressable",
    "removeSensitive",
    "sceneNames",
    "sceneTitles",
    "scopeLabel",
    "seriesStyle",
    "spaceValue",
    "speakClipTime",
    "speakMediaTime",
    "spotIllustrations",
    "takeAnnouncement",
    "takeGridSummary",
    "takeOpLabel",
    "takePickerSummary",
    "takeRatioClass",
    "toastDuration",
    "useElementWidth",
    "useIndicator",
    "usePrefersReducedMotion",
    "usePresence",
    "useReducedMotion",
    "useScrollEdges",
    "useSimulatedSamples",
    "useStickToBottom",
    "useStreamingText",
    "useToast"
  ]
}
