{
  "$schema": "https://www.digitaltableteur.com/schemas/contract-spec/1.0/contract.schema.json",
  "specVersion": "1.0",
  "name": "Button",
  "status": "stable",
  "description": "Primary action control with an orthogonal variant (visual weight) and tone (semantic color) model, loading state, and polymorphic button/link rendering.",
  "package": {
    "name": "@digitaltableteur/react",
    "import": "import { Button } from \"@digitaltableteur/react\";"
  },
  "props": {
    "submits": {
      "type": "boolean",
      "optional": true,
      "description": "When true, sets `type=\"submit\"`."
    },
    "clickAction": {
      "type": "(event: React.MouseEvent<HTMLButtonElement>) => void | Promise<void>",
      "optional": true,
      "description": "Async click handler. `onClick` still fires first if both are provided.\nWhile the returned promise is pending, the button shows its `loading`\nstate, sets `aria-busy`, and disables itself (deduping repeat clicks).\nRejections are swallowed after clearing the pending state; callers own\nerror UX (e.g. surface a toast from within `clickAction` itself)."
    },
    "href": {
      "type": "string",
      "optional": true,
      "description": "Destination URL. When provided, the button renders as an anchor."
    },
    "target": {
      "type": "union",
      "values": [
        "_self",
        "_blank",
        "_parent",
        "_top"
      ],
      "optional": true
    },
    "rel": {
      "type": "string | undefined",
      "optional": true
    },
    "variant": {
      "type": "union",
      "values": [
        "primary",
        "secondary",
        "tertiary"
      ],
      "optional": true,
      "description": "Visual weight."
    },
    "tone": {
      "type": "union",
      "values": [
        "info",
        "success",
        "warning",
        "error",
        "neutral"
      ],
      "optional": true,
      "description": "Semantic color, orthogonal to `variant`."
    },
    "size": {
      "type": "union",
      "values": [
        "sm",
        "md",
        "lg"
      ],
      "optional": true,
      "description": "Size."
    },
    "surface": {
      "type": "union",
      "values": [
        "default",
        "onDark",
        "onBrand"
      ],
      "optional": true,
      "description": "Surface the button renders on; prefer this over absolute colors on tinted bands."
    },
    "disabled": {
      "type": "boolean",
      "optional": true,
      "description": "Disables interaction and dims the control."
    },
    "loading": {
      "type": "boolean",
      "optional": true,
      "description": "Shows a loading state and blocks interaction; sets `aria-busy`."
    },
    "rounded": {
      "type": "boolean",
      "optional": true,
      "description": "Fully rounded (pill) corners."
    },
    "icon": {
      "type": "React.ReactNode | string",
      "optional": true,
      "description": "Leading icon: a React node, a component, or a Phosphor icon name (e.g. `\"arrow-left\"`)."
    },
    "endIcon": {
      "type": "React.ReactNode | string",
      "optional": true,
      "description": "Trailing icon: a React node, a component, or a Phosphor icon name."
    },
    "children": {
      "type": "React.ReactNode",
      "optional": true,
      "description": "Button label."
    },
    "accessibleName": {
      "type": "string",
      "optional": true,
      "description": "Accessible name; required for icon-only buttons. Maps to `aria-label`."
    },
    "accessibleNameRef": {
      "type": "string",
      "optional": true,
      "description": "id of an element that labels this button. Maps to `aria-labelledby`."
    },
    "accessibleDescription": {
      "type": "string",
      "optional": true,
      "description": "Extra context for assistive tech. Maps to `aria-describedby`."
    },
    "tooltip": {
      "type": "string",
      "optional": true,
      "description": "Native tooltip text (`title`). Becomes the `aria-label` only on icon-only buttons; with visible children the label stays the accessible name."
    },
    "consequence": {
      "type": "union",
      "values": [
        "reversible",
        "irreversible",
        "external",
        "financial",
        "privacy",
        "identity"
      ],
      "optional": true,
      "description": "Class of action this button performs. `irreversible` requires\n`tone=\"error\"` and an explicit confirmation step; `external` (sends\nsomething to someone) requires a review step; `reversible` requires an\nundo. Rendered as `data-consequence` for tests and audits."
    }
  },
  "rules": [
    {
      "id": "button-icon-only-needs-name",
      "when": {
        "icon": {
          "present": true
        },
        "children": {
          "absent": true
        }
      },
      "requireAnyOf": [
        "accessibleName",
        "accessibleNameRef",
        "tooltip",
        "aria-label"
      ],
      "reason": "An icon-only button has no accessible name. Add accessibleName, accessibleNameRef, or tooltip, e.g. <Button icon=\"search\" accessibleName=\"Search\" />.",
      "severity": "error",
      "evidence": "Button.tsx development warning (iconOnly && !hasAccessibleName)"
    },
    {
      "id": "button-status-icon-only-needs-name",
      "when": {
        "tone": {
          "oneOf": [
            "error",
            "warning",
            "success",
            "info"
          ]
        },
        "icon": {
          "absent": true
        },
        "children": {
          "absent": true
        }
      },
      "requireAnyOf": [
        "accessibleName",
        "accessibleNameRef",
        "tooltip",
        "aria-label"
      ],
      "reason": "A tone other than neutral renders its status icon by default, so a Button with a tone and no children is icon-only and needs an accessible name, e.g. <Button tone=\"error\" accessibleName=\"Delete\" />.",
      "severity": "error",
      "evidence": "Button.tsx development warning (iconOnly && !hasAccessibleName, checked against the resolved icon)"
    },
    {
      "id": "button-irreversible-needs-error-tone",
      "when": {
        "consequence": {
          "equals": "irreversible"
        },
        "tone": {
          "oneOf": [
            "neutral",
            "info",
            "success",
            "warning"
          ]
        }
      },
      "forbid": [
        "tone"
      ],
      "reason": "An irreversible action must read as destructive before it is activated. Use tone=\"error\", e.g. <Button consequence=\"irreversible\" tone=\"error\">Delete</Button>.",
      "severity": "error",
      "evidence": "Button.tsx development warning (consequence irreversible without tone error)"
    },
    {
      "id": "button-irreversible-tone-missing",
      "when": {
        "consequence": {
          "equals": "irreversible"
        },
        "tone": {
          "absent": true
        }
      },
      "requireAllOf": [
        "tone"
      ],
      "reason": "An irreversible action defaults to the neutral tone, which hides its consequence. Set tone=\"error\".",
      "severity": "error",
      "evidence": "Button.tsx development warning (consequence irreversible without tone error)"
    }
  ],
  "accessibility": {
    "criteria": [
      {
        "id": "accessibility-tree",
        "statement": "The accessibility tree (role, name, state) matches the committed snapshot for every required story.",
        "verification": "automated",
        "wcag": [
          "1.3.1",
          "4.1.2"
        ],
        "check": "npm run test:stories:matrix:ci",
        "evidence": [
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-example.dark.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:43:39.657Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-example.forced-colors.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:51:45.726Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-example.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:24:38.527Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-example.light.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:34:46.898Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-forced-colors.dark.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:43:39.693Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-forced-colors.forced-colors.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:51:45.762Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-forced-colors.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:24:38.553Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-forced-colors.light.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:34:46.948Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-playground.dark.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:43:39.628Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-playground.forced-colors.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:51:45.698Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-playground.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:24:38.452Z",
            "passed": true,
            "runner": "local-recapture"
          },
          {
            "artifact": "../../../nextjs-app/shared/components/Button/__a11y-evidence__/actions-button-playground.light.json",
            "sourceCommit": "930c562b591b6d9967e1e8c214899052624d9a2f",
            "capturedAt": "2026-11-03T22:34:46.865Z",
            "passed": true,
            "runner": "local-recapture"
          }
        ]
      },
      {
        "id": "forced-colors-real-browser",
        "statement": "The component remains legible and operable under a real browser forced-colors: active pass.",
        "verification": "manual",
        "note": "Storybook forced-colors addon (simulated, not real browser HC).",
        "reviewedAt": "2026-11-04"
      },
      {
        "id": "keyboard-contract",
        "statement": "Every documented keyboard interaction works as specified.",
        "verification": "unverified",
        "wcag": [
          "2.1.1",
          "2.1.2"
        ],
        "note": "No exportable evidence record for this check; re-run the check to capture one."
      },
      {
        "id": "aria-requirements",
        "statement": "Documented ARIA roles, states, and properties are present on the rendered output.",
        "verification": "unverified",
        "wcag": [
          "4.1.2"
        ],
        "note": "No exportable evidence record for this check; re-run the check to capture one."
      },
      {
        "id": "human-a11y-review",
        "statement": "A human completed an end-to-end accessibility review of this component.",
        "verification": "manual",
        "note": "2026-06-18 — platinum pass: variant/tone API, scale(0.97) press feedback, tokenized motion, forced-colors + reduced-motion handled. AT snapshots refreshed.",
        "reviewedAt": "2026-11-04"
      }
    ]
  },
  "governance": {
    "owner": "design-system",
    "lastReviewed": "2026-11-04",
    "note": "Reviewed by Petri Lahdelma; prepared by Claude from a code review on 2026-11-03"
  },
  "x-temporal": {
    "transitions": [
      {
        "id": "pending-locks-immediately",
        "statement": "When clickAction returns a promise, the button is disabled and sets aria-busy on the same render, and repeat activations while pending do not call clickAction again.",
        "from": "pending",
        "to": "locked",
        "afterMs": 0,
        "guarantees": [
          "no-duplicate-action",
          "announced"
        ],
        "verification": "automated",
        "test": "nextjs-app/shared/components/Button/Button.temporal.test.tsx"
      },
      {
        "id": "pending-settles",
        "statement": "When the pending promise resolves or rejects, the button returns to idle; a rejection never leaves it disabled.",
        "from": "pending",
        "to": "idle",
        "on": "settled",
        "verification": "automated",
        "test": "nextjs-app/shared/components/Button/Button.temporal.test.tsx"
      }
    ]
  },
  "x-consequence": {
    "prop": "consequence",
    "policies": {
      "reversible": {
        "confirmation": "none",
        "recovery": "undo",
        "rationale": "Offer undo instead of asking first. Confirming routine actions trains people to click through the confirmations that matter."
      },
      "irreversible": {
        "confirmation": "explicit",
        "recovery": "none",
        "tone": "error",
        "rule": "button-irreversible-needs-error-tone",
        "rationale": "A separate confirming action names what will be destroyed; the label says Delete, never Remove (see content.terminology)."
      },
      "external": {
        "confirmation": "review",
        "recovery": "none",
        "rationale": "Sending cannot be recalled. Show exactly what will be sent, to whom, next to the button that sends it."
      },
      "financial": {
        "confirmation": "explicit",
        "recovery": "none",
        "authentication": "step-up",
        "rationale": "Restate the amount and the payee in the confirming action."
      },
      "privacy": {
        "confirmation": "review",
        "recovery": "none",
        "rationale": "Show which personal data leaves the user's own view, and to whom, before it does."
      },
      "identity": {
        "confirmation": "explicit",
        "recovery": "none",
        "authentication": "recent",
        "rationale": "Credential and ownership changes are the usual target of account takeover; require a recent sign-in."
      }
    }
  },
  "source": [
    "../../../nextjs-app/shared/components/Button/Button.module.css",
    "../../../nextjs-app/shared/components/Button/Button.tsx"
  ]
}
