{
  "name": "OtpInput",
  "type": "component",
  "description": "One-time-code field: a row of single-character cells, wrapping Base UI's OTPField.",
  "export": "@gtmi/strix-react/atoms/otp-input",
  "path": "@gtmi/strix-react/atoms/otp-input",
  "figma": {
    "file": "zT3vGm4QlFEI2Jp9nxyFKI",
    "fileName": "Demo components library",
    "nodes": [
      {
        "node": "22:3953",
        "name": ".input-otp",
        "section": "Atoms"
      }
    ]
  },
  "props": [
    {
      "name": "length",
      "type": "number",
      "default": "6",
      "description": "How many characters the code has, i.e. how many cells to draw."
    },
    {
      "name": "value",
      "type": "string",
      "description": "Controlled value."
    },
    {
      "name": "defaultValue",
      "type": "string",
      "description": "Initial value when uncontrolled."
    },
    {
      "name": "onValueChange",
      "type": "(value: string) => void",
      "description": "Called with the value on every change."
    },
    {
      "name": "onComplete",
      "type": "(value: string) => void",
      "description": "Called once the value is complete."
    },
    {
      "name": "invalid",
      "type": "boolean",
      "default": "false",
      "description": "Draws every cell with the error boundary and marks each input aria-invalid."
    },
    {
      "name": "disabled",
      "type": "boolean",
      "default": "false",
      "description": "Every cell leaves the tab order."
    },
    {
      "name": "readOnly",
      "type": "boolean",
      "default": "false",
      "description": "Readable but uneditable."
    },
    {
      "name": "required",
      "type": "boolean",
      "default": "false",
      "description": "Require a complete value before the owning form submits."
    },
    {
      "name": "autoSubmit",
      "type": "boolean",
      "default": "false",
      "description": "Submit the owning form as soon as the code completes."
    },
    {
      "name": "mask",
      "type": "boolean",
      "default": "false",
      "description": "Mask the characters. A masked cell is a password input, so it is no longer a textbox."
    },
    {
      "name": "name",
      "type": "string",
      "description": "Form field name, for the hidden input Base UI submits."
    },
    {
      "name": "id",
      "type": "string",
      "description": "Id of the FIRST cell. Later cells derive theirs ({id}-2, {id}-3, ...), so a Label htmlFor={id} names the field."
    },
    {
      "name": "cellLabel",
      "type": "(index: number) => string",
      "default": "(index) => `Digit ${index}`",
      "description": "Accessible name for cells after the first. The first gets none: Base UI ignores aria-label there and warns in development, because a real label is what names the field."
    }
  ],
  "ai_tags": ["otp", "one-time-code", "verification", "2fa", "mfa", "code", "input", "primitive"],
  "since": "0.2.0",
  "tokens_used": [
    "--color-border-otp-cell",
    "--color-border-otp-cell-filled",
    "--color-border-field-error",
    "--color-border-disabled",
    "--color-border-focus",
    "--color-surface-field",
    "--color-text-disabled",
    "--color-text-primary",
    "--leading-height-figma-20",
    "--radius-large",
    "--spacing-borders-sm",
    "--spacing-gap-100",
    "--spacing-gap-200",
    "--text-size-figma-20"
  ],
  "notes": "Wraps Base UI's `OTPField` (`@base-ui/react/otp-field`), which owns the behaviour: distributing a paste across the slots, platform SMS autofill, backspace navigation, a roving tabindex so the group is one tab stop, and a hidden input carrying the value for form submission and validation. Ported from the Figma atom `.input-otp` (scripts/figma/bindings/input-otp.json), which specifies ONE cell -- 40x40, radius 10, a 20/32 centred digit, three states -- and nothing about the group. BUILT BY HAND FIRST, AND THAT WAS WRONG. The first version rendered a single transparent input over presentational aria-hidden cells, reasoning that N inputs break autofill, paste and screen reader announcement. The reasoning was sound and the conclusion was not: Base UI renders N real inputs plus a hidden validation input, which is the tested solution to those exact problems, and sixteen other components here already wrap Base UI. Check for a primitive before reasoning from first principles about one. Two things the rebuild learned by measuring rather than reading. Base UI IGNORES `aria-label` on the first cell and warns in development -- the field must be named by a real `<label htmlFor>` pointing at `id`, or by a Base UI Field -- so `cellLabel` names only cells 2..n. And its hidden input is `type=\"text\"` with `aria-hidden` and `tabindex=\"-1\"`, not `type=\"hidden\"`, which is what the first test looked for and failed to find. Arrow keys do not move between cells; that is recorded as a characterised gap rather than patched with a keydown handler that would fight the next upgrade -- the same conclusion this project reached about Base UI's Accordion having no arrow-key navigation between headers. jsdom does not model caret position and Base UI's navigation is caret-based, so the test records jsdom's behaviour and is a prompt to check a real browser, not proof of absence. Figma's focus state is still declined, and the rebuild improved the fix. Its `Active-focus` cell REMOVES the border and substitutes a gray-800 fill measuring 1.39:1 against the page, so the focused cell is less distinguishable than the resting one at 7.43:1 and the indicator does not meet WCAG 1.4.11. The cell keeps its boundary and takes the fill as well -- and because each cell is a real focused input, the ring is native `:focus-visible` on the cell rather than a `focus-within` ring on the group, with no computed \"which cell is next\" state to get wrong. One place Figma's own value would have passed and was still not adopted: its resting border is gray-300 at 7.43:1, the only atom whose resting boundary clears 1.4.11. The cell uses --color-border-control anyway, because that role is shared with every other control and a second value that happens to work would fragment it. A filled cell takes --color-border-accent, which is what Input already uses for its `completed` state, and `filled` is read from Base UI's own slot state so it cannot drift from what is rendered. Mutation-tested four ways. Two survived the first pass and both were real: nothing verified that a filled cell renders differently from an empty one, and the test written for it used a substring match that also caught `focus-visible:border-border-accent`, so it could never fail. Both are fixed and both mutations now fail.\n\nPORTED FROM FIGMA .input-otp (22:3953), and BOTH of the binding's declines are reverted. Its two boundaries are now Figma's own, via minted roles: --color-border-otp-cell (gray-300 #9aa0b4, 7.43:1 on the page) resting and --color-border-otp-cell-filled (color/brand/white, 19.37:1) when a cell holds a character. The resting decline read \"gray-300 passes at 7.43:1, but --color-border-control is the shared role and a second working value would fragment it\" -- a tidiness argument with no clause behind it, which rule 1 settles in Figma's favour, and backwards besides: --color-border-control is the role for the controls Figma draws NO atom for, so using it on an atom Figma DOES draw is what blurred the meaning. The filled decline preferred --color-border-accent #9ec1fd for \"having a measured light value\", a consideration rule 3 removed.\n\nFIGMA'S FOCUS STATE SHIPS AS A LOGGED CONFLICT rather than being declined. Active-focus removes the border and substitutes a gray-800 fill at 1.39:1 against the page, so a focused cell is LESS distinguishable than a resting one at 7.43:1 -- against 1.4.11's 3:1 for a focus indicator. Under rule 1 Figma's drawing ships: the border goes transparent and the fill appears. What keeps it conformant is the outline ring, which Figma never drew and which is therefore a rule-4 addition breaking no tie -- 6.63:1 on the page and 4.79:1 on the focused cell's own fill, so it reads against both surfaces it can sit against. Recorded in docs/FIGMA-EXCEPTIONS.md.\n\nA CASCADE DEFECT FOUND WHILE PORTING, and a different one from Input's. focus-visible:border-transparent and focus-visible:border-border-field-error are the SAME specificity, so specificity cannot break the tie and source order decides -- and Tailwind emits the transparent rule LAST, which silently erased the error boundary on a focused invalid cell. Fixed structurally rather than with !important: the transparent rule lives on `invalid: false`, so the two are mutually exclusive and never race. Asserted against the compiled stylesheet in build.storybook.test.ts, because jsdom has no cascade.\n\nThe invalid cell is a rule-2 GAP -- Figma's three states are Default, Active-focus and Filled -- derived from .input-text, the field atom that does draw one: #ef223a via --color-border-field-error, 3.30:1 on this atom's focused fill and 4.57:1 on the page.\n\nThe digit ramp is Figma's Paragraph/extra-large: font/size/large 20px over font/line-height/large 32px at Book (CSS 400). Only Dev Mode names the size variable -- the REST extraction could not resolve it and the binding had recorded it as `$unnamed-font-size-20`, a measured number with no name behind it."
}
