{
  "$schema": "https://ui.shadcn.com/schema/registry.json",
  "name": "txbrendan",
  "homepage": "https://txbrendan.dev",
  "items": [
    {
      "name": "theme",
      "type": "registry:theme",
      "title": "txbrendan theme",
      "description": "Color, tone, density, and motion tokens that every txbrendan component reads. Install it first: it replaces your color tokens. A component install only adds missing tokens.",
      "cssVars": {
        "theme": {
          "shadow-card": "var(--elevation-card)",
          "shadow-float": "var(--elevation-float)",
          "color-background": "var(--background)",
          "color-foreground": "var(--foreground)",
          "color-card": "var(--card)",
          "color-card-foreground": "var(--card-foreground)",
          "color-popover": "var(--popover)",
          "color-popover-foreground": "var(--popover-foreground)",
          "color-muted": "var(--muted)",
          "color-muted-foreground": "var(--muted-foreground)",
          "color-accent": "var(--accent)",
          "color-accent-foreground": "var(--accent-foreground)",
          "color-secondary": "var(--secondary)",
          "color-secondary-foreground": "var(--secondary-foreground)",
          "color-neutral": "var(--neutral)",
          "color-neutral-foreground": "var(--neutral-foreground)",
          "color-neutral-hover": "var(--neutral-hover)",
          "color-primary": "var(--primary)",
          "color-primary-foreground": "var(--primary-foreground)",
          "color-primary-hover": "var(--primary-hover)",
          "color-primary-soft": "var(--primary-soft)",
          "color-primary-soft-foreground": "var(--primary-soft-foreground)",
          "color-destructive": "var(--destructive)",
          "color-destructive-foreground": "var(--destructive-foreground)",
          "color-destructive-hover": "var(--destructive-hover)",
          "color-destructive-soft": "var(--destructive-soft)",
          "color-destructive-soft-foreground": "var(--destructive-soft-foreground)",
          "color-success": "var(--success)",
          "color-success-foreground": "var(--success-foreground)",
          "color-success-hover": "var(--success-hover)",
          "color-success-soft": "var(--success-soft)",
          "color-success-soft-foreground": "var(--success-soft-foreground)",
          "color-warning": "var(--warning)",
          "color-warning-foreground": "var(--warning-foreground)",
          "color-warning-hover": "var(--warning-hover)",
          "color-warning-soft": "var(--warning-soft)",
          "color-warning-soft-foreground": "var(--warning-soft-foreground)",
          "color-border": "var(--border)",
          "color-input": "var(--input)",
          "color-ring": "var(--ring)",
          "color-chart-1": "var(--chart-1)",
          "color-chart-2": "var(--chart-2)",
          "color-chart-3": "var(--chart-3)",
          "color-chart-4": "var(--chart-4)",
          "color-chart-5": "var(--chart-5)",
          "color-sidebar": "var(--sidebar)",
          "color-sidebar-foreground": "var(--sidebar-foreground)",
          "color-sidebar-primary": "var(--sidebar-primary)",
          "color-sidebar-primary-foreground": "var(--sidebar-primary-foreground)",
          "color-sidebar-accent": "var(--sidebar-accent)",
          "color-sidebar-accent-foreground": "var(--sidebar-accent-foreground)",
          "color-sidebar-border": "var(--sidebar-border)",
          "color-sidebar-ring": "var(--sidebar-ring)",
          "color-tone": "var(--tone)",
          "color-tone-foreground": "var(--tone-foreground)",
          "color-tone-hover": "var(--tone-hover)",
          "color-tone-soft": "var(--tone-soft)",
          "color-tone-soft-foreground": "var(--tone-soft-foreground)",
          "color-tone-soft-hover": "color-mix(in oklab, var(--tone-soft), var(--tone) 12%)",
          "color-tone-line": "color-mix(in oklab, var(--tone-soft-foreground), var(--background) 35%)",
          "color-skeleton": "color-mix(in oklab, var(--foreground) 10%, transparent)",
          "color-track": "color-mix(in oklab, var(--card), var(--foreground) 14%)",
          "radius-sm": "calc(var(--radius) * 0.6)",
          "radius-md": "calc(var(--radius) * 0.8)",
          "radius-lg": "var(--radius)",
          "radius-xl": "calc(var(--radius) * 1.4)",
          "radius-2xl": "calc(var(--radius) * 1.8)",
          "radius-3xl": "calc(var(--radius) * 2.2)",
          "radius-4xl": "calc(var(--radius) * 2.6)",
          "font-headline": "var(--font-geist-serif, ui-serif), ui-serif, Georgia, \"Times New Roman\", serif"
        },
        "light": {
          "radius": "0.875rem",
          "background": "oklch(0.982 0 0)",
          "foreground": "oklch(0.163 0 0)",
          "card": "oklch(1 0 0)",
          "card-foreground": "oklch(0.208 0 0)",
          "popover": "oklch(1 0 0)",
          "popover-foreground": "oklch(0.208 0 0)",
          "muted": "oklch(0.952 0 0)",
          "muted-foreground": "oklch(0.502 0 0)",
          "accent": "oklch(0.955 0 0)",
          "accent-foreground": "oklch(0.208 0 0)",
          "secondary": "oklch(0.952 0 0)",
          "secondary-foreground": "oklch(0.208 0 0)",
          "neutral": "oklch(0.208 0 0)",
          "neutral-foreground": "oklch(0.99 0 0)",
          "neutral-hover": "oklch(0.3 0 0)",
          "primary": "oklch(0.542 0.212 266)",
          "primary-foreground": "oklch(0.99 0 0)",
          "primary-hover": "oklch(0.517 0.199 266)",
          "primary-soft": "oklch(0.957 0.02 266)",
          "primary-soft-foreground": "oklch(0.469 0.182 266)",
          "destructive": "oklch(0.57 0.2 24)",
          "destructive-foreground": "oklch(0.99 0 0)",
          "destructive-hover": "oklch(0.545 0.19 24)",
          "destructive-soft": "oklch(0.949 0.021 15)",
          "destructive-soft-foreground": "oklch(0.483 0.138 18)",
          "success": "oklch(0.511 0.12 157)",
          "success-foreground": "oklch(0.99 0 0)",
          "success-hover": "oklch(0.485 0.113 157)",
          "success-soft": "oklch(0.953 0.023 168)",
          "success-soft-foreground": "oklch(0.452 0.099 160)",
          "warning": "oklch(0.531 0.113 73)",
          "warning-foreground": "oklch(0.99 0 0)",
          "warning-hover": "oklch(0.505 0.106 73)",
          "warning-soft": "oklch(0.959 0.023 83)",
          "warning-soft-foreground": "oklch(0.468 0.095 76)",
          "border": "oklch(0.907 0 0)",
          "input": "oklch(0.64 0 0)",
          "ring": "oklch(0.542 0.212 266)",
          "chart-1": "oklch(0.542 0.212 266)",
          "chart-2": "oklch(0.58 0.099 193)",
          "chart-3": "oklch(0.62 0.135 68)",
          "chart-4": "oklch(0.55 0.18 308)",
          "chart-5": "oklch(0.58 0.19 23)",
          "sidebar": "oklch(0.973 0 0)",
          "sidebar-foreground": "oklch(0.163 0 0)",
          "sidebar-primary": "oklch(0.542 0.212 266)",
          "sidebar-primary-foreground": "oklch(0.99 0 0)",
          "sidebar-accent": "oklch(0.955 0 0)",
          "sidebar-accent-foreground": "oklch(0.208 0 0)",
          "sidebar-border": "oklch(0.907 0 0)",
          "sidebar-ring": "oklch(0.542 0.212 266)",
          "control-h-sm": "1.75rem",
          "control-h-md": "2.25rem",
          "control-h-lg": "2.75rem",
          "control-px-sm": "0.625rem",
          "control-px-md": "0.875rem",
          "control-px-lg": "1.125rem",
          "control-gap": "0.5rem",
          "space-inset": "0.75rem",
          "space-stack": "0.5rem",
          "motion-duration-fast": "120ms",
          "motion-duration-base": "240ms",
          "motion-duration-slow": "320ms",
          "motion-duration-layout": "280ms",
          "motion-duration-close": "100ms",
          "motion-ease-out": "cubic-bezier(0.2, 0, 0, 1)",
          "motion-ease-in-out": "cubic-bezier(0.45, 0, 0.2, 1)",
          "motion-ease-spring": "linear(0, 0.018, 0.065, 0.131, 0.207, 0.289, 0.372, 0.452, 0.528, 0.598, 0.661, 0.718, 0.768, 0.812, 0.849, 0.881, 0.907, 0.929, 0.947, 0.962, 0.974, 0.983, 0.99, 0.995, 0.999, 1.002, 1.004, 1.005, 1.006, 1.006, 1.006, 1.006, 1.006, 1.005, 1.005, 1.004, 1.004, 1.003, 1.003, 1.002, 1)",
          "motion-ease-close": "cubic-bezier(0.25, 0.1, 0.25, 1)",
          "motion-press-scale": "0.96",
          "motion-close-scale": "0.95",
          "motion-distance": "6px",
          "motion-pulse-duration": "2s",
          "motion-crossfade-delay": "50%",
          "elevation-card": "0 1px 2px oklch(0.208 0 0 / 0.05), 0 2px 10px oklch(0.208 0 0 / 0.05)",
          "elevation-float": "0 2px 6px oklch(0.208 0 0 / 0.06), 0 14px 36px oklch(0.208 0 0 / 0.14)",
          "elevation-glow": "0px",
          "type-meta": "0.75rem",
          "type-meta-leading": "1rem",
          "type-body": "0.8125rem",
          "type-body-leading": "1.125rem",
          "type-prose-leading": "1.25rem",
          "type-section": "1rem",
          "type-section-leading": "1.25rem",
          "type-display": "1.25rem",
          "type-display-leading": "1.5rem",
          "type-display-tracking": "-0.02em",
          "type-headline": "clamp(1.75rem, 1.25rem + 2cqi, 2.25rem)",
          "type-hero": "clamp(2.25rem, 1.5rem + 3.5cqi, 3rem)",
          "type-headline-leading": "1.12",
          "type-headline-tracking": "-0.02em",
          "type-weight-regular": "450",
          "type-weight-strong": "550"
        },
        "dark": {
          "background": "oklch(0.17 0 0)",
          "foreground": "oklch(0.97 0 0)",
          "card": "oklch(0.205 0 0)",
          "card-foreground": "oklch(0.97 0 0)",
          "popover": "oklch(0.225 0 0)",
          "popover-foreground": "oklch(0.97 0 0)",
          "muted": "oklch(0.25 0 0)",
          "muted-foreground": "oklch(0.72 0 0)",
          "accent": "oklch(0.28 0 0)",
          "accent-foreground": "oklch(0.97 0 0)",
          "secondary": "oklch(0.26 0 0)",
          "secondary-foreground": "oklch(0.96 0 0)",
          "neutral": "oklch(0.93 0 0)",
          "neutral-foreground": "oklch(0.208 0 0)",
          "neutral-hover": "oklch(0.86 0 0)",
          "primary": "oklch(0.55 0.191 266)",
          "primary-foreground": "oklch(0.99 0 0)",
          "primary-hover": "oklch(0.525 0.191 266)",
          "primary-soft": "oklch(0.29 0.075 266)",
          "primary-soft-foreground": "oklch(0.84 0.078 266)",
          "destructive": "oklch(0.57 0.19 24)",
          "destructive-foreground": "oklch(0.99 0 0)",
          "destructive-hover": "oklch(0.545 0.18 24)",
          "destructive-soft": "oklch(0.3 0.08 25)",
          "destructive-soft-foreground": "oklch(0.85 0.08 25)",
          "success": "oklch(0.53 0.12 157)",
          "success-foreground": "oklch(0.99 0 0)",
          "success-hover": "oklch(0.505 0.113 157)",
          "success-soft": "oklch(0.29 0.06 155)",
          "success-soft-foreground": "oklch(0.86 0.09 155)",
          "warning": "oklch(0.55 0.113 73)",
          "warning-foreground": "oklch(0.99 0 0)",
          "warning-hover": "oklch(0.525 0.106 73)",
          "warning-soft": "oklch(0.31 0.06 75)",
          "warning-soft-foreground": "oklch(0.88 0.09 85)",
          "border": "oklch(1 0 0 / 10%)",
          "input": "oklch(0.53 0 0)",
          "ring": "oklch(0.55 0.191 266)",
          "chart-1": "oklch(0.72 0.14 266)",
          "chart-2": "oklch(0.74 0.11 193)",
          "chart-3": "oklch(0.78 0.14 78)",
          "chart-4": "oklch(0.72 0.15 308)",
          "chart-5": "oklch(0.72 0.16 23)",
          "sidebar": "oklch(0.15 0 0)",
          "sidebar-foreground": "oklch(0.97 0 0)",
          "sidebar-primary": "oklch(0.55 0.191 266)",
          "sidebar-primary-foreground": "oklch(0.99 0 0)",
          "sidebar-accent": "oklch(0.28 0 0)",
          "sidebar-accent-foreground": "oklch(0.97 0 0)",
          "sidebar-border": "oklch(1 0 0 / 10%)",
          "sidebar-ring": "oklch(0.55 0.191 266)",
          "elevation-card": "0 1px 2px oklch(0 0 0 / 0.3), 0 2px 10px oklch(0 0 0 / 0.22)",
          "elevation-float": "0 2px 6px oklch(0 0 0 / 0.32), 0 16px 40px oklch(0 0 0 / 0.5)",
          "elevation-glow": "6px"
        }
      },
      "css": {
        "@utility tone-neutral": {
          "--tone": "var(--neutral)",
          "--tone-foreground": "var(--neutral-foreground)",
          "--tone-hover": "var(--neutral-hover)",
          "--tone-soft": "var(--secondary)",
          "--tone-soft-foreground": "var(--secondary-foreground)"
        },
        "@utility tone-brand": {
          "--tone": "var(--primary)",
          "--tone-foreground": "var(--primary-foreground)",
          "--tone-hover": "var(--primary-hover)",
          "--tone-soft": "var(--primary-soft)",
          "--tone-soft-foreground": "var(--primary-soft-foreground)"
        },
        "@utility tone-danger": {
          "--tone": "var(--destructive)",
          "--tone-foreground": "var(--destructive-foreground)",
          "--tone-hover": "var(--destructive-hover)",
          "--tone-soft": "var(--destructive-soft)",
          "--tone-soft-foreground": "var(--destructive-soft-foreground)"
        },
        "@utility tone-success": {
          "--tone": "var(--success)",
          "--tone-foreground": "var(--success-foreground)",
          "--tone-hover": "var(--success-hover)",
          "--tone-soft": "var(--success-soft)",
          "--tone-soft-foreground": "var(--success-soft-foreground)"
        },
        "@utility tone-warning": {
          "--tone": "var(--warning)",
          "--tone-foreground": "var(--warning-foreground)",
          "--tone-hover": "var(--warning-hover)",
          "--tone-soft": "var(--warning-soft)",
          "--tone-soft-foreground": "var(--warning-soft-foreground)"
        },
        "@property --dither": {
          "syntax": "\"<number>\"",
          "inherits": "true",
          "initial-value": "0"
        },
        "@property --dither-swap": {
          "syntax": "\"<number>\"",
          "inherits": "true",
          "initial-value": "0"
        },
        "@property --dither-third": {
          "syntax": "\"<number>\"",
          "inherits": "true",
          "initial-value": "0"
        },
        "@property --texture-gradient": {
          "syntax": "\"<number>\"",
          "inherits": "true",
          "initial-value": "0"
        },
        "@property --texture-grain": {
          "syntax": "\"<number>\"",
          "inherits": "true",
          "initial-value": "0"
        },
        "@property --texture-hover": {
          "syntax": "\"<number>\"",
          "inherits": "true",
          "initial-value": "0"
        },
        "html": {
          "--texture-tile-64": "image-set(url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABAAQMAAACQp+OdAAAABlBMVEUAAAAAAAClZ7nPAAAAAnRSTlMA/1uRIrUAAAJLSURBVHjaAUACv/0AQzHuVYcI/A8ApD8Y1NW0BKsAeSOFhjrTwngArwiUxzF/xbsAe9hvQJ8OrxsAeHP09z2VeHMAP9+Y7baHr4AASza3lRK7xI0AknYTJong3BYAtn/9OUmrojIAWn7rj1ekBkQAJaUtpjG/YXsAxzetyMnw/4YAYeUGh2JcaQcAxCBwQPigZk0AuhOMNYdT0PEAe5+MXvMmDpcAkNQpDnE6w1gAMvZThEe0hS8Au0iwCr6w9PQAinj+SLzY66cAENt6xOkySk0A292BXIcemfkA/3CwA5CpNQUATtCILUitP7YAWt0opMn5klIAJcLsqAgyxn8A7nAWWpTeucsAg0q9E3BD3uIA/j7Z0KSSkzIAk+b5fjQKUxQALeDj8E9iELQAmX1JZaGkIPIAyY7VXpifgOkAjxyKdWPvfzoApl3/l53OitwAR3NBHDtnVCoAKB8S2bpfQ88AHO5c12IN8KsA+XFfgu81H4AArDILwjPt78oAUSEe4kOuoIkADu7HpA+mteUAHcM/OD+A2NUA6WXkBtdKmTQAUtZkOEmZIHkACmzEPxGgkmoAmHi7jbT3jJUA8p/qYQlLHYIAqPDMElI7uf0AHyPwqHTQhvoA7KQVZlA58EcAAFuOtvz+r7UAVe9Aq2kq+mUAB3WcHtJav9oAUSiO1sndhakAZ7K2/cZba+IAJG/SxZiB2EUAB7B5IgFTcpwAgloCN0Jyw5MAng6mengAMIIAoWgVbta/SgwAndmOff91rL4A8kKwYsswgTOPdgYh4zKVvgAAAABJRU5ErkJggg==\") 1x, url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACAAQMAAAD58POIAAAABlBMVEUAAAAAAAClZ7nPAAAAAnRSTlMA/1uRIrUAAAK5SURBVHjabdVNqkMxCAXgQKaCWwlkGnDrglPBrQScBuy5Yzt5ff1Kmx/13LGY53t7qwytO2o08MU1NVfuWGN5dBAj1q2HPfmSaAevobau8JS6O6KDROqpNazG85r/QIVrlXDa/v7vwJVl+jKOipeODiv4hNieFHGXZgcjOZPpqL2Rd54OcaQq2ZZ5ODF12C7vhdYWX+Os1YG2b0o/PKPOlOiAqxHPqxfnr9LT4cy3Bw52aN9jQzrcRfi7RqmPc1Z2CJ+sl1HszTlqdpCw0rtf4QrGM+lgIxfZeDLZv292YKqzWdeSWLqpOgReYwyPFwMlXR3UResBb+oLlw5zZIjf9YxpOQ7XICNT574q85mVdaiSgWXZhhvvsTusl0OVcil6u+J02J5J6guFMqNNHVAfetd1KNNF83R4D+c5223lC7vRQRmTl5NRTs73qEM9fmmJcTUy5j9g/E6ZPF7DN8/VgfKNxwiFOoTm+ANmksuws+lorqIO95vAvZ8pGgcd00FrXpRzH34lxd7Bz87Czizvwz3fDkuE15yXMY17kXcgnTUpLXzX4lsd5kXw3cQAjUTuRQesOHcpofv3/AKlAToeiXCJcdevrndA1kwkLC32h6NaB1T73S8qyk9sDHWDmZe5WBlLpuYfeCjBQ9ikLDdDQRsgb5ABmEJDVyKyO+CW7/3Cfjpi8HgHU9EIzUAMI5l2hyKr52cOWzFTqYNrjXsnBoMD2ZIdZhFazlUww3gmeAcMz5r7nI1JRXNKhzF2ICdOXaxesTugI5EzHsfIcc9/YIhsu/Ml0joqvQNKqWjucw3BgmntcCQIi+Y9O05gTBvQwju621RxP2t3QAZ/j1gac7OQ3Q6KDQ1iWSR02bgDnmaogiBEkW74uIPPg2w82HzU8nE74EwYZcEgyvYbr0PRohiHbuAnJ3ODHwSZCuMs7pOPAAAAAElFTkSuQmCC\") 2x, url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMAAAADAAQMAAABoEv5EAAAABlBMVEUAAAAAAAClZ7nPAAAAAnRSTlMA/1uRIrUAAAQ7SURBVHjanZjBrYNADESNOOyNNLASbeyNlqABCA2ElrjRxko0ALc9IPyf+RXgy/9RJgrr9XhmHInS1UfQpcRhy5VKI6qrfFTeA/u81qoyyjWs17Dt5yrVuv86B9Deof52ueIBpb7T1XeXpPYWB7AvdsTMAyrl3K3qVW373TmA9u6uUdpFo0ieVD7FPjr6AGmPTs9VT611y8P2/44D4AavSfMounCDhZbwjCziAGLT1WfZT+XbpU/UfFVr/mwOIPepPYv0XT2X3AT9yjWtMhQHwBHpgepW3yE2AX7s31QfyQHEMbVa9Nflj8ZB4Z9UJVarA6jnbZ+3mhucCyzZVdtvoN8OwHh20oPtauSi8oOJUnjsAPhGnTeOSFPbb4rT2v6CMKXvARpZf2EYAy96y87ruURa+x7Y70Rf82etT5OMyHD2okdwANwXgwQQp6KHcYWhyoM6gNyb7tRU+yntEUw1pIujOAC4pWfhlLlaI+N0rihRzai/Bxih2Mh+iDRp18KLZ+ZXB5AbU0DVYrCuKBE83md1AELlY9dScLXqL3D62CRr7XuAd69pyxKgmvnHVPIY9A4OANLDNqtcOnqz85gTKm8OIH4KDMuNMJ9W/LKhkuiIBxgTx61/Zm82nHdA2mKfHACSgY3pginSXSPNVZm0OQAMm8rRUx5m3jaV/Q5X0zmADOmNaih+90w77Sn6TQ4AqtVarjFwlRRP2RkDODwA/3Qu3B16XcN+2NZ3MqwOgI5iP/rtENb4MVmEzZZO3gMwtVX4EVrU/xss8fA5O+5rAGZkODds0QbA8gSezWg5AMxMmCWm/dxaDr0oFEHOHABmFqfN7GfQPG0X2tokc+73ADpIoIkSeFJ9d+2M46K2yQEwS0Le6hNEwUvipMY5angP8NUWdicS6tPXz0aPLaS+B4xqBypGGxhRNel5WuIA9mU10jcWc2tyIfxb9GqSA4g90ZkbtIGk5n0pODcJzAHgi1ROOrGc9OEv0XCzeOEAYAabhpHMAis1X7YneAAYzyzpE91oCUrEjNnAvwcQ5WsoLTN5k32Dvfu13cMBIDftslpmQp37YDdIAoa77wFmG9smB5C892dDIO5YIn8PIDTIhP4SuVAa0nMnZr3JAeBA8P76rPKYGfOJfxDKHQCkrx9t5RPtuZrvVgh3cgBmjfSV9QkZ6oUNwSS7UgdgG9PIDULZwg6MM7FHQV8HwC6tz5aITJC/kTDOCgUdANkdjc7o6URASZg3m8Y1Jgdgc/6Tx0IKXmIhjE34FxwA0rwf6Sl7I5FwC+2vswTwHqjnlY2ag7KWm6tJYM4jGe49QOVkwee3A64hREsnyS7iPQDjo91lYjGLknj3sojZeYDJVhSkkPRsP2HYfmGfdgAEiBZjg7uLtYS+omXCgL0HzKpHyxOkE4vR54ajsG84ACSDgu0ev4lA+ZyVDaF7D/wBe+EmpPPxYRQAAAAASUVORK5CYII=\") 3x)",
          "--texture-tile-59": "image-set(url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADsAAAA7AQMAAAD/62DwAAAABlBMVEUAAAAAAAClZ7nPAAAAAnRSTlMA/1uRIrUAAAIeSURBVHjaARMC7P0AIyGgSzC8n6AAioYyVZFE1sAAfHK5bc5ul6AAnXQlt4gYgIAADLcL2oXu1uAApuuzj9efEoAA7t5KIUS6WEAAnNSOusrW+yAASXNQWx8EFcAAm6QWHPXRd4AAMr9fDjOsXuAAAGj3HWq+JGAAyRQBiA2xIeAAxgVjczLczgAAdjoQnfF+buAA+Vyrsf0NZ8AAp023XoAyCqAA6TSD8eYvr0AAKIaw16ojuyAAHuiFND53tqAAdk8egIFnoeAAgBQWAakMXWAAEVP6v0+y3UAA+AZCcySegiAAchY98AJC32AAdoPxEU0EbyAAgXYFLFyyKYAA6VtxhRVdYMAARnIXpjA9pOAASKRAbbvSbMAAnWlfe1kvSqAA/nhhAj65CMAAfi4d6tmtVcAAi3mbbqifJqAA136SKSNnC4AARRgNxnTfjmAAD54eAKlVN8AAyHPc09qSnoAA3JX/pd0/JOAAiKB+IIMJN4AAelysSTA7tAAA4WLehvjgWGAAlA1CodaV2gAAkgOXhw2AfAAAHcp6MMaWxMAA1mQYU8qQBYAAXf/h0na/4oAAI++gLi6JxyAA1HKqkTWQSKAAyXB0pPe3LMAAXnM4/TPjDWAAivYNIxgMEmAA6TjNWEku46AAf6sVD2Skf6AAAaMcBtAqWCAAxJIMkc7wqwAApyXULoKRrEAAstu0g8KF+4AADji4WWZYKIDw69/Xu6tF2wAAAABJRU5ErkJggg==\") 1x, url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHYAAAB2AQMAAAAnafVSAAAABlBMVEUAAAAAAAClZ7nPAAAAAnRSTlMA/1uRIrUAAAJWSURBVHjaZdQxCoMxCAZQwVXwKoGsglcXXIVcJeAqpP5r3fpKaRL9FIgJA9ZhONdfwL8tTJj2dlwr5Q7rVToueUvKNYY9ddE+aoZmYMNwj8LJsF2VUsMhdQ7bS/WHZMP9q1pBfZsT29aw31xWJ26kvEPDy5U37IMPFu477CcWCt63E1VtmOm8/aCY4/aVhwHEnmJKnKIlw7cLCV8p8iBhTQtsYWWmvPcrz59VOLCr+lC74jX8fN84/ZyEFL3DoSuP7vpeAhHD5byMH5bQi7eGyUwOpEYQn67nv7HKbPPiUj0Swyqr/6yTgKKBNWyAXW/AcLg7ZRhx84vzVjczcw0/A1mkTMvLiIaVUDgfAC3KJ9PyvRZxJSx5NGyo3RG6+x4it+HyDjvaxv3d9g4v6RM1hIEzVk1brAWS5yTJvcOe4vvp2U5vRQy/UhPssa7jYHdYiwqzIj1y72k76n6kwvxR9+vfqVreLyWWnlsbXhv7yysdq2clwx3I6jZD+N7cefz3NeW82cVw6o4N5+3nvtiZ/Kjr9W+z+N4ExuDc8/pvjZ6Hu7zrfc6C4UKhLJO+LGyTYV+QiwK/smfANAG7mkL2WXca84YG91y73HWHU2R1XPh24PvA4Z3vdcNI5byi6W70t9W7q+a9modzKUU48vYvW8PXFXTF6kgpdd7+vat3Ua9j5uK+3LBFD1ESd9cv0nTvE7vZm9qpimNYe5hxw5M+sz8P9yZgvD0QQL3vabhbSd9SvfUgDgyH0u6G9S5w7L4P9xbpHBhfsv06z//uTW/Htov0aTb8A8skYgI9U5KkAAAAAElFTkSuQmCC\") 2x, url(\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAALEAAACxAQMAAACx586GAAAABlBMVEUAAAAAAAClZ7nPAAAAAnRSTlMA/1uRIrUAAAO6SURBVHjanZfBjawwEEQ98sE3SADJaXBzSkwCwCQAKXEjDUtOYLj5gOj/2vsT6FmtVrtvR2C3q6vLzq/Jr6GsbuiS/7oiR16EP608d2N+Vf8dh/eZpzC8jvtdb2fmUY74HcsT4n7efY17zW99vpXn5YzX4T9nuSR3zs0uu/Zt5K4/IK5L98yCT9kr65TVzMunypbKN+Ve7rdQNDeNPN/K9YFLHbqRU6Bo5RmH2Q12nhcKfuS+Qu5u1EU+nKyZD12I3zRMbpiTY5Gvw71PztHK85zKh9+rWw65znsKkYOwcxRVRAae3KOuVPZjWLT+Vs5X3JxwlAhj4zPVf45o53cX3PtwLqio+rN8AwchP/BXda8zronqseab4+grL7XyeFX/jG5Swcs3RNGW4flWLk8YlqOoRIPIyVLjRzhHKy8fGXptOsr+d3yuG8tq5rIFfx3ZJRYpn+p3Kbuo/o3cbw5fKl9H05WNrkml6dzKHSrSf528xVO0S8pVi51T/6FHVLXZUaBopenBytXK3q1fXChbwKYGhGrnDq+ekjyta3opKq2Tulm5PM696uBG1eeHIcKCqZ6ZY/4s0gvi5GUjH7gXiT/wq/6JQRfcn/hS3MXbudb80tbz6iQH+/Vb+H8WFo4+ccj4DUgCh/wr/m3nw6u2LQsDhXnNxrFN9GDmHa13cHDMWRR+T2Pcj+bnNq5zlqVS8ycNc6Dvhta/Vi70yOPiGjhE+oUA4LpWByPH0PzOEDllG+85lP0c2jyy8tyl+ASmEvZYaORF/Ke2XGTj2AivyJPKADOhf8kS6MHKB2Qw6wRhEBBsEDmzO65m7nqyB/vV2YEPsFl/qW9b+d3ReknH0NSiEWGmeZSZL0d+nyLo88RJvFA31aeVM15beqytZ5PrGActVxh5JFDRtjQvhH55UrkOZ+eyhrjiRZVpgtdpx83I1cwzpt1jsPRIIKSxd6rH88184mciS+cX0ejMLZn/wOkU4iK7ZrNIKxPyX+oDVs7aInmP7IoeuDtMjIMz2znGiBionjoSE1Z/H3/g9Jrs7UK0qyHkLtwv9X8rJ1Tr/WVjhSiKq1Bzzh+45mqm0qF2reFBfPNbKydW6YRFUXKSh2VNGtVWM+d+J8y1/mTjzVgO7i8/cM1pj7uR6Kx3B6/hMxU7jzjAlpiMGBTCYNdK7FzjECtcDtLIPRH/9D7l7RxVa8v0/Awkc9KIvs6ZOXmYSyuqUFE5VYV6yw+cYDarF2Fut2tX10fniJXrNepx5cGOCJ9VN7s5O/8H9gDGY50nj04AAAAASUVORK5CYII=\") 3x)"
        },
        "@utility texture-dither": {
          "--dither": "1",
          "--texture-gradient": "0",
          "--texture-grain": "0"
        },
        "@utility texture-gradient": {
          "--dither": "0",
          "--texture-gradient": "1",
          "--texture-grain": "0"
        },
        "@utility texture-grain": {
          "--dither": "0",
          "--texture-gradient": "0",
          "--texture-grain": "1"
        },
        "@utility texture-flat": {
          "--dither": "0",
          "--texture-gradient": "0",
          "--texture-grain": "0"
        },
        "@utility texture-fade": {
          "transition-property": "--dither, --texture-gradient, --texture-grain, --texture-hover",
          "transition-duration": "var(--dither-fade, var(--motion-duration-slow)), var(--motion-duration-slow), var(--motion-duration-slow),\n    var(--motion-duration-base)",
          "transition-timing-function": "var(--motion-ease-out)"
        },
        "@property --dither-in": {
          "syntax": "\"<number>\"",
          "inherits": "true",
          "initial-value": "1"
        },
        "@utility dither-intro": {
          "animation": "dither-in calc(var(--motion-pulse-duration, 0s) * 0.4) linear both",
          "@media print": {
            "--dither-in": "1 !important"
          }
        },
        "@keyframes dither-in": {
          "from": {
            "--dither-in": "0"
          },
          "to": {
            "--dither-in": "1"
          }
        },
        ".dither-intro[data-dither-held]": {
          "animation": "none",
          "--dither-in": "0"
        },
        "@property --dither-view": {
          "syntax": "\"<number>\"",
          "inherits": "true",
          "initial-value": "1"
        },
        "@utility dither-intro-view": {
          "animation": "dither-in calc(var(--motion-pulse-duration, 0s) * 0.4) linear both",
          "@supports (animation-timeline: view())": {
            "animation": "dither-in calc(var(--motion-pulse-duration, 0s) * 0.4) linear both,\n      dither-seen auto linear both",
            "animation-timeline": "auto, view()",
            "animation-range": "normal, entry 0% entry 100%"
          },
          "@media print": {
            "--dither-in": "1 !important",
            "--dither-view": "1 !important"
          }
        },
        "@layer utilities": {
          "@supports (animation-timeline: view())": {
            ".dither-intro-view > *, .dither-intro-view::before": {
              "animation": "dither-view calc(var(--motion-pulse-duration, 0s) * 0.4) linear both"
            },
            "@container style(--dither-seen: 0)": {
              ".dither-intro-view > *, .dither-intro-view::before": {
                "animation-play-state": "paused"
              }
            }
          },
          "@media print": {
            ".dither-intro-view > *, .dither-intro-view::before": {
              "--dither-in": "1 !important",
              "--dither-view": "1 !important"
            }
          }
        },
        "@keyframes dither-seen": {
          "from": {
            "--dither-seen": "0"
          },
          "1%, to": {
            "--dither-seen": "1"
          }
        },
        "@keyframes dither-view": {
          "from": {
            "--dither-view": "0"
          },
          "to": {
            "--dither-view": "1"
          }
        },
        "@utility dither-mask": {
          "--dither-cell": "calc(var(--dither-size, 4px) / 2)",
          "--texture-sum": "max(var(--dither, 0) + var(--texture-gradient, 0) + var(--texture-grain, 0), 0.001)",
          "--dither-gate": "min(1, 2 * var(--dither, 0) / var(--texture-sum))",
          "--gradient-gate": "min(1, 2 * var(--texture-gradient, 0) / var(--texture-sum))",
          "--grain-gate": "min(1, 2 * var(--texture-grain, 0) / var(--texture-sum))",
          "mask-image": "linear-gradient(\n        color-mix(\n          in srgb,\n          var(--foreground)\n            calc(\n              (1 - var(--dither, 0) * 0.8 - var(--texture-gradient, 0) * 0.8 * (1 - var(--texture-hover, 0) * (1 - var(--texture-move, 1))) - var(--texture-grain, 0) * var(--grain-depth, 0.6) * (1 - var(--texture-hover, 0))) *\n                (1 - var(--dither, 0) * (1 - min(var(--dither-in, 1), var(--dither-view, 1)))) * 100%\n            ),\n          transparent\n        )\n        0 0\n      ),\n    var(\n      --texture-ramp,\n      linear-gradient(\n        var(--texture-angle, 180deg),\n        color-mix(in srgb, var(--foreground) calc(var(--gradient-gate) * 100%), transparent) calc(var(--texture-hover, 0) * var(--texture-move, 1) * 100%),\n        transparent\n      )\n    ),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 0, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 1, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 2, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 3, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 4, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 5, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 6, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 7, 1) * 100%), transparent) 0),\n    linear-gradient(color-mix(in srgb, var(--foreground) calc(var(--grain-gate) * 100%), transparent) 0 0),\n    var(--texture-tile-64),\n    var(--texture-tile-59)",
          "mask-position": "0 0,\n    0 0,\n    calc(var(--dither-cell) * 0) calc(var(--dither-cell) * 0),\n    calc(var(--dither-cell) * 2) calc(var(--dither-cell) * 2),\n    calc(var(--dither-cell) * 2) calc(var(--dither-cell) * 0),\n    calc(var(--dither-cell) * 0) calc(var(--dither-cell) * 2),\n    calc(var(--dither-cell) * 1) calc(var(--dither-cell) * 1),\n    calc(var(--dither-cell) * 3) calc(var(--dither-cell) * 3),\n    calc(var(--dither-cell) * 3) calc(var(--dither-cell) * 1),\n    calc(var(--dither-cell) * 1) calc(var(--dither-cell) * 3),\n    0 0,\n    0 0,\n    0 0",
          "mask-size": "100% 100%,\n    100% 100%,\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    100% 100%,\n    64px 64px,\n    59px 59px",
          "mask-repeat": "no-repeat, no-repeat, repeat, repeat, repeat, repeat, repeat, repeat, repeat, repeat, no-repeat, repeat, repeat",
          "mask-composite": "add, add, add, add, add, add, add, add, add, add, intersect, exclude, add"
        },
        "@utility dither-sheen": {
          "--dither-cell": "calc(var(--dither-size, 4px) / 2)",
          "--texture-sum": "max(var(--dither, 0) + var(--texture-gradient, 0) + var(--texture-grain, 0), 0.001)",
          "--dither-gate": "min(1, 2 * var(--dither, 0) / var(--texture-sum))",
          "--gradient-gate": "min(1, 2 * var(--texture-gradient, 0) / var(--texture-sum))",
          "--grain-gate": "min(1, 2 * var(--texture-grain, 0) / var(--texture-sum))",
          "background-color": "currentColor",
          "opacity": "calc(\n    (var(--dither, 0) * 0.3 + var(--texture-gradient, 0) * 0.28 + var(--texture-grain, 0) * 0.2) *\n      (1 - var(--texture-hover, 0) * (var(--texture-gradient, 0) + var(--texture-grain, 0)))\n  )",
          "mask-image": "linear-gradient(transparent 30%, var(--foreground)),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 0, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 1, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 2, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 3, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 4, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 5, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 6, 1) * 100%), transparent) 0),\n    conic-gradient(at var(--dither-cell) var(--dither-cell), transparent 75%, color-mix(in srgb, var(--foreground) calc(var(--dither-gate) * clamp(0, min(var(--dither-in, 1), var(--dither-view, 1)) * 8 - 7, 1) * 100%), transparent) 0),\n    linear-gradient(color-mix(in srgb, var(--foreground) calc(var(--gradient-gate) * 100%), transparent) 0 0),\n    linear-gradient(color-mix(in srgb, var(--foreground) calc(var(--grain-gate) * 100%), transparent) 0 0),\n    var(--texture-tile-64),\n    var(--texture-tile-59)",
          "mask-position": "0 0,\n    calc(var(--dither-cell) * 0) calc(var(--dither-cell) * 0),\n    calc(var(--dither-cell) * 2) calc(var(--dither-cell) * 2),\n    calc(var(--dither-cell) * 2) calc(var(--dither-cell) * 0),\n    calc(var(--dither-cell) * 0) calc(var(--dither-cell) * 2),\n    calc(var(--dither-cell) * 1) calc(var(--dither-cell) * 1),\n    calc(var(--dither-cell) * 3) calc(var(--dither-cell) * 3),\n    calc(var(--dither-cell) * 3) calc(var(--dither-cell) * 1),\n    calc(var(--dither-cell) * 1) calc(var(--dither-cell) * 3),\n    0 0,\n    0 0,\n    0 0,\n    0 0",
          "mask-size": "100% 100%,\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    calc(var(--dither-cell) * 4) calc(var(--dither-cell) * 4),\n    100% 100%,\n    100% 100%,\n    64px 64px,\n    59px 59px",
          "mask-repeat": "no-repeat, repeat, repeat, repeat, repeat, repeat, repeat, repeat, repeat, no-repeat, no-repeat, repeat, repeat",
          "mask-composite": "intersect, add, add, add, add, add, add, add, add, add, intersect, exclude, add"
        },
        "@utility dither-glow": {
          "filter": "drop-shadow(\n    0 0 calc(min((var(--dither, 0) + var(--texture-gradient, 0) + var(--texture-grain, 0)) * 1000, 1) * min(var(--dither-in, 1), var(--dither-view, 1)) * var(--elevation-glow, 0px))\n      color-mix(in srgb, var(--dither-glow-color, var(--tone, currentColor)) calc((var(--dither, 0) + var(--texture-gradient, 0) + var(--texture-grain, 0)) * 70%), transparent)\n  )"
        },
        "@utility dither-halo": {
          "box-shadow": "0 0 calc(min((var(--dither, 0) + var(--texture-gradient, 0) + var(--texture-grain, 0)) * 1000, 1) * min(var(--dither-in, 1), var(--dither-view, 1)) * var(--elevation-glow, 0px) * 1.5)\n    color-mix(in srgb, var(--dither-glow-color, var(--tone, currentColor)) calc((var(--dither, 0) + var(--texture-gradient, 0) + var(--texture-grain, 0)) * 55%), transparent)"
        },
        "@utility focus-ring": {
          "&:focus-visible": {
            "outline": "2px solid var(--ring)",
            "outline-offset": "2px"
          }
        },
        "[data-density=\"compact\"]": {
          "--control-h-sm": "1.5rem",
          "--control-h-md": "1.75rem",
          "--control-h-lg": "2.25rem",
          "--control-px-sm": "0.5rem",
          "--control-px-md": "0.625rem",
          "--control-px-lg": "0.875rem",
          "--control-gap": "0.375rem",
          "--space-inset": "0.5rem",
          "--space-stack": "0.375rem"
        },
        "[data-density=\"comfortable\"]": {
          "--control-h-sm": "1.75rem",
          "--control-h-md": "2.25rem",
          "--control-h-lg": "2.75rem",
          "--control-px-sm": "0.625rem",
          "--control-px-md": "0.875rem",
          "--control-px-lg": "1.125rem",
          "--control-gap": "0.5rem",
          "--space-inset": "0.75rem",
          "--space-stack": "0.5rem"
        },
        "[data-density=\"spacious\"]": {
          "--control-h-sm": "2.25rem",
          "--control-h-md": "2.75rem",
          "--control-h-lg": "3.25rem",
          "--control-px-sm": "0.875rem",
          "--control-px-md": "1.125rem",
          "--control-px-lg": "1.375rem",
          "--control-gap": "0.625rem",
          "--space-inset": "1rem",
          "--space-stack": "0.75rem"
        },
        "[data-motion=\"calm\"]": {
          "--motion-duration-fast": "160ms",
          "--motion-duration-base": "240ms",
          "--motion-duration-slow": "360ms",
          "--motion-duration-layout": "320ms",
          "--motion-duration-close": "140ms",
          "--motion-ease-out": "cubic-bezier(0.25, 0.1, 0.25, 1)",
          "--motion-ease-in-out": "cubic-bezier(0.42, 0, 0.58, 1)",
          "--motion-ease-spring": "cubic-bezier(0.25, 0.1, 0.25, 1)",
          "--motion-ease-close": "cubic-bezier(0.25, 0.1, 0.25, 1)",
          "--motion-press-scale": "0.96",
          "--motion-close-scale": "0.97",
          "--motion-distance": "4px",
          "--motion-pulse-duration": "2.4s",
          "--motion-crossfade-delay": "50%"
        },
        "[data-motion=\"crisp\"]": {
          "--motion-duration-fast": "120ms",
          "--motion-duration-base": "240ms",
          "--motion-duration-slow": "320ms",
          "--motion-duration-layout": "280ms",
          "--motion-duration-close": "100ms",
          "--motion-ease-out": "cubic-bezier(0.2, 0, 0, 1)",
          "--motion-ease-in-out": "cubic-bezier(0.45, 0, 0.2, 1)",
          "--motion-ease-spring": "linear(0, 0.018, 0.065, 0.131, 0.207, 0.289, 0.372, 0.452, 0.528, 0.598, 0.661, 0.718, 0.768, 0.812, 0.849, 0.881, 0.907, 0.929, 0.947, 0.962, 0.974, 0.983, 0.99, 0.995, 0.999, 1.002, 1.004, 1.005, 1.006, 1.006, 1.006, 1.006, 1.006, 1.005, 1.005, 1.004, 1.004, 1.003, 1.003, 1.002, 1)",
          "--motion-ease-close": "cubic-bezier(0.25, 0.1, 0.25, 1)",
          "--motion-press-scale": "0.96",
          "--motion-close-scale": "0.95",
          "--motion-distance": "6px",
          "--motion-pulse-duration": "2s",
          "--motion-crossfade-delay": "50%"
        },
        "[data-motion=\"playful\"]": {
          "--motion-duration-fast": "160ms",
          "--motion-duration-base": "260ms",
          "--motion-duration-slow": "400ms",
          "--motion-duration-layout": "360ms",
          "--motion-duration-close": "140ms",
          "--motion-ease-out": "cubic-bezier(0.2, 0, 0, 1)",
          "--motion-ease-in-out": "cubic-bezier(0.45, 0, 0.2, 1)",
          "--motion-ease-spring": "linear(0, 0.027, 0.097, 0.195, 0.308, 0.425, 0.54, 0.647, 0.744, 0.827, 0.896, 0.951, 0.994, 1.026, 1.047, 1.06, 1.067, 1.068, 1.065, 1.06, 1.053, 1.045, 1.037, 1.03, 1.022, 1.016, 1.011, 1.006, 1.003, 1, 0.998, 0.997, 0.996, 0.995, 0.995, 0.996, 0.996, 0.997, 0.997, 0.998, 1)",
          "--motion-ease-close": "cubic-bezier(0.25, 0.1, 0.25, 1)",
          "--motion-press-scale": "0.96",
          "--motion-close-scale": "0.95",
          "--motion-distance": "10px",
          "--motion-pulse-duration": "1.6s",
          "--motion-crossfade-delay": "50%"
        },
        "[data-motion=\"off\"]": {
          "--motion-duration-fast": "0ms",
          "--motion-duration-base": "0ms",
          "--motion-duration-slow": "0ms",
          "--motion-duration-layout": "0ms",
          "--motion-duration-close": "0ms",
          "--motion-ease-out": "linear",
          "--motion-ease-in-out": "linear",
          "--motion-ease-spring": "linear",
          "--motion-ease-close": "linear",
          "--motion-press-scale": "1",
          "--motion-close-scale": "1",
          "--motion-distance": "0px",
          "--motion-pulse-duration": "0s",
          "--motion-crossfade-delay": "0%"
        },
        "@media (prefers-reduced-motion: reduce)": {
          ":root, [data-motion]": {
            "--motion-duration-layout": "0ms",
            "--motion-ease-spring": "var(--motion-ease-out)",
            "--motion-press-scale": "1",
            "--motion-close-scale": "1",
            "--motion-distance": "0px",
            "--motion-pulse-duration": "0s",
            "--motion-crossfade-delay": "0%",
            "--texture-move": "0"
          }
        },
        "@utility motion-press": {
          "transition-property": "background-color, border-color, color, opacity, box-shadow, scale",
          "transition-duration": "var(--motion-duration-fast)",
          "transition-timing-function": "var(--motion-ease-out)",
          "&:is(:active, [data-motion-pressed]):not(:disabled, [disabled], [data-disabled], [aria-disabled=\"true\"], [aria-busy=\"true\"], [data-readonly])": {
            "scale": "var(--motion-press-scale)"
          }
        },
        "@utility motion-feedback": {
          "transition-property": "background-color, border-color, color, opacity, box-shadow",
          "transition-duration": "var(--motion-duration-fast)",
          "transition-timing-function": "var(--motion-ease-out)",
          "&:active:not(:disabled, [disabled], [data-disabled], [aria-disabled=\"true\"], [aria-busy=\"true\"], [data-readonly])": {
            "opacity": "0.8"
          },
          "&:empty:active:not(:disabled, [disabled], [data-disabled], [aria-disabled=\"true\"], [aria-busy=\"true\"], [data-readonly])": {
            "background-color": "color-mix(in oklab, var(--foreground) 8%, transparent)"
          }
        },
        "@utility effect-rise": {
          "--popup-open-duration": "initial",
          "--popup-open-ease": "initial",
          "--popup-open-scale": "initial",
          "--popup-open-distance": "initial",
          "--popup-close-duration": "initial",
          "--popup-close-ease": "initial",
          "--popup-submenu-scale": "initial",
          "--dialog-open-duration": "initial",
          "--dialog-open-ease": "initial",
          "--dialog-open-scale": "initial",
          "--dialog-open-distance": "initial",
          "--dialog-close-duration": "initial",
          "--dialog-close-ease": "initial",
          "--tooltip-open-duration": "initial",
          "--tooltip-open-ease": "initial",
          "--tooltip-open-scale": "initial",
          "--tooltip-open-distance": "initial",
          "--tooltip-close-duration": "initial",
          "--tooltip-close-ease": "initial"
        },
        "@utility effect-grow": {
          "--popup-open-duration": "calc(var(--motion-duration-base) * 10 / 9)",
          "--popup-open-ease": "cubic-bezier(0.23, 1, 0.32, 1)",
          "--popup-open-scale": "var(--motion-close-scale)",
          "--popup-open-distance": "0px",
          "--popup-close-duration": "calc(var(--motion-duration-base) * 10 / 9)",
          "--popup-close-ease": "cubic-bezier(0.23, 1, 0.32, 1)",
          "--popup-submenu-scale": "1",
          "--dialog-open-duration": "calc(var(--motion-duration-base) * 10 / 9)",
          "--dialog-open-ease": "ease",
          "--dialog-open-scale": "var(--motion-close-scale)",
          "--dialog-open-distance": "0px",
          "--dialog-close-duration": "calc(var(--motion-duration-base) * 10 / 9)",
          "--dialog-close-ease": "ease",
          "--tooltip-open-duration": "calc(var(--motion-duration-fast) * 5 / 4)",
          "--tooltip-open-ease": "ease",
          "--tooltip-open-scale": "var(--motion-close-scale)",
          "--tooltip-open-distance": "calc(var(--motion-distance) * 4 / 3)",
          "--tooltip-close-duration": "calc(var(--motion-duration-fast) * 5 / 4)",
          "--tooltip-close-ease": "ease"
        },
        "@utility effect-instant": {
          "--tooltip-open-duration": "0s",
          "--tooltip-close-duration": "0s"
        }
      }
    },
    {
      "name": "pill",
      "type": "registry:theme",
      "title": "Pill buttons and fields",
      "description": "Makes buttons, toolbars, tabs, and fields fully round. Install it after the theme. Cards, menus, and text areas keep their corners.",
      "cssVars": {
        "light": {
          "button-radius": "999px",
          "tool-button-radius": "999px",
          "toolbar-radius": "999px",
          "tabs-radius": "999px",
          "input-radius": "999px",
          "select-trigger-radius": "999px"
        }
      }
    },
    {
      "name": "access-list",
      "type": "registry:ui",
      "title": "Access List",
      "description": "Manage who has access and what they can do.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/button",
        "@txbrendan/menu",
        "@txbrendan/person",
        "@txbrendan/tool-button",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/access-list.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "people",
        "forms",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The people part of a share sheet: who can open a page, a plan, or a file, and whether they view, comment, or edit.",
            "Each entry is a person, an email with no account, a phone number, or the link. Give each an id.",
            "Each row's level is a menu: Can view, Can comment, Can edit, then Remove access. A change applies at once, and ⌘Z takes it back for 8 seconds.",
            "Remove access shows Removed with Undo on the row. onRemove runs once the window ends.",
            "Set asked on an entry to show a request. Requests come first, with Approve and Decline in the row.",
            "Guests view or comment. Set guestsCanEdit when the owner lets them edit. The link and a phone number only view.",
            "Put it in a Card with className=\"gap-0 py-0\". Its rows bring their own padding and lines."
          ],
          "avoid": [
            "Picking people to share with. Use a token field or a search above the list.",
            "Roles in a team, like admin or member. This is about access to one thing.",
            "Setting manage on someone who cannot change access. Pass manage={false} and the levels are plain words."
          ]
        },
        "props": [
          {
            "name": "entries",
            "type": "AccessEntry[]",
            "description": "Required. { id, person?, photo?, email?, phone?, link?, meta?, guest?, owner?, level?, asked? }. level and asked are \"view\" | \"comment\" | \"edit\"."
          },
          {
            "name": "guestsCanEdit",
            "type": "boolean",
            "default": "false",
            "description": "The owner lets guests edit. Off, guests view or comment."
          },
          {
            "name": "manage",
            "type": "boolean",
            "default": "true",
            "description": "You can change access. Off, levels are plain words and requests have no buttons."
          },
          {
            "name": "onLevel",
            "type": "(id: string, level: \"view\" | \"comment\" | \"edit\") => void",
            "description": "A level changed, at once. It runs again with the old level if the change is undone."
          },
          {
            "name": "onRemove",
            "type": "(id: string) => void",
            "description": "Removed, once the 8-second undo window ends."
          },
          {
            "name": "onApprove",
            "type": "(id: string, level: \"view\" | \"comment\" | \"edit\") => void",
            "description": "A request approved, once the undo window ends, with the level given."
          },
          {
            "name": "onDecline",
            "type": "(id: string) => void",
            "description": "A request declined, once the undo window ends."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the list. Later classes win."
          }
        ],
        "a11y": [
          "A list of rows. Each level is a menu button named by the person and the level, like \"Kate Clarke can edit\".",
          "In the menu, the arrow keys move without wrapping, Enter chooses, and Esc closes it and puts focus back on the button. The current level is a checked menuitemradio.",
          "After Remove access, the same button says \"Kate Clarke removed\" and is the Undo, armed 1.2 seconds after a key press. It no longer says it opens a menu.",
          "Decline is an icon named \"Decline\" plus the name, and its tooltip says Decline. Approve's name carries the name too.",
          "Each result is said once in the page's polite live region. ⌘Z or Ctrl+Z takes back the newest change, outside a text field."
        ],
        "examples": [
          {
            "title": "In a share sheet",
            "code": "\"use client\";\nimport { useState } from \"react\";\nimport { AccessList, type AccessEntry } from \"@/components/ui/access-list\";\nimport { Card } from \"@/components/ui/card\";\n\nexport function Access() {\n  const [entries, setEntries] = useState<AccessEntry[]>([\n    { id: \"sam\", person: \"Sam Carter\", meta: \"Design lead\", owner: true, level: \"edit\" },\n    { id: \"rob\", person: \"Rob Kerr\", meta: \"Client, guest\", guest: true, asked: \"comment\" },\n    { id: \"kate\", person: \"Kate Clarke\", level: \"edit\" },\n    { id: \"link\", link: true, meta: \"View only · 3 visitors\", level: \"view\" },\n  ]);\n  const set = (id: string, change: Partial<AccessEntry>) =>\n    setEntries((all) => all.map((entry) => (entry.id === id ? { ...entry, ...change } : entry)));\n  const drop = (id: string) => setEntries((all) => all.filter((entry) => entry.id !== id));\n  return (\n    <Card className=\"gap-0 py-0\">\n      <AccessList\n        entries={entries}\n        onLevel={(id, level) => set(id, { level })}\n        onRemove={drop}\n        onApprove={(id, level) => set(id, { asked: undefined, level })}\n        onDecline={drop}\n      />\n    </Card>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "accordion",
      "type": "registry:ui",
      "title": "Accordion",
      "description": "A stack of headings that each open and close a section below them, one at a time or several at once.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/accordion.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Questions and answers, like an FAQ, where people read only the ones they need.",
            "Long settings or details split into sections, so the page starts short.",
            "Build each section from AccordionItem, AccordionTrigger, and AccordionPanel. Give each item a value when you control which ones are open.",
            "Add multiple when people may want to compare two sections side by side.",
            "Add hiddenUntilFound so the browser's find-in-page can search closed sections and open the match.",
            "Panels can contain images, links, lists, and controls. Use keepMounted on a panel with local form state so closing it preserves edits."
          ],
          "avoid": [
            "Content everyone needs to read. Show it on the page.",
            "Switching between views of the same thing. Use tabs.",
            "A single section that shows more. Use a details element instead.",
            "Steps that must happen in order. Use a page per step, or step dots."
          ]
        },
        "props": [
          {
            "name": "Accordion",
            "type": "{ value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[], details) => void; multiple?: boolean; disabled?: boolean; hiddenUntilFound?: boolean }",
            "description": "Root. Holds which sections are open. Takes value, defaultValue, onValueChange, multiple, disabled, and hiddenUntilFound."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Accordion. Row height and text size: 36, 44, or 56px rows at the default density."
          },
          {
            "name": "multiple",
            "type": "boolean",
            "default": "false",
            "description": "Accordion. Lets more than one section be open at once."
          },
          {
            "name": "value",
            "type": "string[]",
            "description": "Accordion. The open items' values, controlled. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string[]",
            "description": "Accordion. The items open at the start when you do not control it."
          },
          {
            "name": "onValueChange",
            "type": "(value: string[], details) => void",
            "description": "Accordion. Called with the open items' values when one opens or closes."
          },
          {
            "name": "hiddenUntilFound",
            "type": "boolean",
            "default": "false",
            "description": "Accordion or AccordionPanel. Keeps closed panels in the page with hidden='until-found', so find-in-page can open them."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Accordion or AccordionItem. Blocks opening and closing. It dims."
          },
          {
            "name": "AccordionItem",
            "type": "ComponentProps<\"div\"> & { value?: string }",
            "description": "Part. One section. A line separates it from the next. value names it for value and defaultValue."
          },
          {
            "name": "AccordionTrigger",
            "type": "ComponentProps<\"button\">",
            "description": "Part. The heading's button. It renders inside an <h3> and adds a chevron that turns when the section opens."
          },
          {
            "name": "AccordionPanel",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The section's content. It grows open and shrinks closed."
          },
          {
            "name": "keepMounted",
            "type": "boolean",
            "default": "false",
            "description": "AccordionPanel. Keeps the contents mounted while closed, preserving local field values. Hidden controls stay out of the tab order."
          },
          {
            "name": "className",
            "type": "string",
            "description": "On any part. Later classes win. On AccordionPanel it goes on the inner box, where the padding is, so className='pb-6' works."
          }
        ],
        "a11y": [
          "Each trigger is a button inside an <h3>, so screen reader users can jump between sections by heading.",
          "The button has aria-expanded and aria-controls. An open panel has role=\"region\", named by its trigger.",
          "Each trigger is its own tab stop. Enter and Space open and close it. Arrow keys do not move between triggers, as in the current ARIA pattern.",
          "The chevron is hidden from screen readers. aria-expanded says whether a section is open.",
          "The heading level is fixed at h3. If the page needs another level, pass render to the header in your copy of accordion.tsx.",
          "The focus ring uses --ring, which the theme tests hold to 3:1 against the page and cards.",
          "Opening grows the panel over --motion-duration-base while its contents slide into place and fade in over 1.5 times --motion-duration-slow. Closing fades the contents over --motion-duration-close. Reduced motion removes height, content, and chevron movement; Motion Off removes all transitions."
        ],
        "examples": [
          {
            "title": "FAQ",
            "code": "<Accordion hiddenUntilFound>\n  <AccordionItem value=\"refunds\">\n    <AccordionTrigger>Can I get a refund?</AccordionTrigger>\n    <AccordionPanel>Yes, within 30 days of your order. We send the money back to the card you paid with.</AccordionPanel>\n  </AccordionItem>\n  <AccordionItem value=\"shipping\">\n    <AccordionTrigger>How long does shipping take?</AccordionTrigger>\n    <AccordionPanel>Three to five working days in the US, and up to ten days anywhere else.</AccordionPanel>\n  </AccordionItem>\n</Accordion>"
          },
          {
            "title": "Editable values that survive closing a section",
            "code": "<Accordion multiple defaultValue={[\"account\"]} size=\"lg\">\n  <AccordionItem value=\"account\">\n    <AccordionTrigger>Account</AccordionTrigger>\n    <AccordionPanel keepMounted>\n      <label>Display name <input defaultValue=\"Workspace owner\" /></label>\n    </AccordionPanel>\n  </AccordionItem>\n  <AccordionItem value=\"billing\">\n    <AccordionTrigger>Billing</AccordionTrigger>\n    <AccordionPanel>Your plan, card, and past invoices.</AccordionPanel>\n  </AccordionItem>\n</Accordion>"
          },
          {
            "title": "Controlled",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nfunction Sections() {\n  const [open, setOpen] = useState<string[]>([]);\n  return (\n    <Accordion value={open} onValueChange={(value) => setOpen(value as string[])}>\n      <AccordionItem value=\"one\">\n        <AccordionTrigger>First section</AccordionTrigger>\n        <AccordionPanel>Shown when open includes \"one\".</AccordionPanel>\n      </AccordionItem>\n    </Accordion>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "action-keys",
      "type": "registry:ui",
      "title": "Action Keys",
      "description": "Gives a list of actions their single-key shortcuts. It shows nothing, and it ignores keys while someone is typing.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/action-keys.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Single-key shortcuts for a focused or hovered row's actions, like R for Reply.",
            "Pass the same action objects you pass to HoverActions or BulkBar, so the key, the tooltip, and the button never drift apart.",
            "Pass the toolbar's ref as within. A key then presses its button, so it shows the same spinner, result, and Undo a click does.",
            "Turn it on for one row at a time with enabled. RowActions does this for you.",
            "When two are on at once, like a row under the pointer and another with focus, a key goes to the one turned on last that has it. It never acts on both.",
            "Put KeepTabInside last, right inside a popup of your own that keeps focus in, so a fast Tab cannot leave it."
          ],
          "avoid": [
            "Shortcuts with Cmd or Ctrl. It ignores keys with a modifier, so it never takes the browser's own.",
            "Keys inside a field. It ignores keys while someone types in an input, a textarea, or editable text.",
            "Turning it on for every row in a list at once. Only the row turned on last would answer, and no one could see which row that is."
          ]
        },
        "props": [
          {
            "name": "actions",
            "type": "ActionKey[]",
            "description": "Required. The actions. Each with a shortcut gets its key."
          },
          {
            "name": "ActionKey",
            "type": "{ label: string; shortcut?: string; onClick?: () => void | Promise<unknown>; disabled?: boolean }",
            "description": "Type. shortcut is one key, matched in either case. A disabled action ignores its key. HoverAction and BulkBarAction fit it."
          },
          {
            "name": "enabled",
            "type": "boolean",
            "default": "true",
            "description": "Listens for keys. Turn it on for the focused or hovered row only. When two are on, the one turned on last answers a key."
          },
          {
            "name": "within",
            "type": "RefObject<HTMLElement | null>",
            "description": "The toolbar that holds the actions' buttons, like HoverActions' ref. A key presses the button whose data-shortcut matches. Without it, a key calls onClick."
          },
          {
            "name": "isPageKey",
            "type": "(event: KeyboardEvent, within?: HTMLElement | null) => boolean",
            "description": "Helper. Whether a key is free for page shortcuts: no one is typing, no modifier is held, and focus is not in an open dialog or menu."
          },
          {
            "name": "KeepTabInside",
            "type": "{}",
            "description": "Part, with no props. Put it last, right inside a popup that keeps focus in, not in a wrapper. Tab and Shift+Tab then wrap at the ends at once, so a fast Tab never lands on the page behind. Dialog, Alert Dialog, Sheet, Lightbox, Command Menu, and Notifications use it."
          }
        ],
        "a11y": [
          "Every key it listens for should be taught where people see the action, like HoverActions' tooltips, and set as aria-keyshortcuts on the button. ToolButton does both.",
          "It ignores keys while focus is in a text field, a textarea, a select, or editable text, so typing never runs an action.",
          "It ignores keys with Cmd, Ctrl, or Alt held, keys held down, and keys another handler already took.",
          "It ignores keys while focus is in an open dialog or menu that its toolbar is not part of.",
          "Single-key shortcuts can clash with speech input. Keep them on only while their row is hovered or focused, as WCAG 2.1.4 asks.",
          "KeepTabInside wraps Tab only past the last stop and Shift+Tab only before the first. In between, the browser moves focus as it always does. A field that takes Tab itself, and calls preventDefault, keeps it.",
          "Safari's Tab skips plain buttons and links, so it can pass the last stop KeepTabInside knows of. KeepTabInside then sends focus back in at once.",
          "A native radio group with a checked choice is one Tab stop, at that choice, as in the browser. Shift+Tab wraps back to it."
        ],
        "examples": [
          {
            "title": "Keys for a focused row (needs @txbrendan/hover-actions)",
            "code": "import { Pencil, Reply } from \"lucide-react\";\nimport { useRef } from \"react\";\n\nimport { HoverActions, type HoverAction } from \"@/components/ui/hover-actions\";\n\nfunction MessageActions({ focused, actions }: { focused: boolean; actions: HoverAction[] }) {\n  const bar = useRef<HTMLDivElement>(null);\n  return (\n    <>\n      <HoverActions ref={bar} visible={focused} actions={actions} />\n      <ActionKeys actions={actions} enabled={focused} within={bar} />\n    </>\n  );\n}\n\n<MessageActions focused actions={[{ icon: <Reply />, label: \"Reply\", shortcut: \"R\" }, { icon: <Pencil />, label: \"Edit\", shortcut: \"E\" }]} />"
          }
        ]
      }
    },
    {
      "name": "agreement",
      "type": "registry:ui",
      "title": "Agreement",
      "description": "Consent and codes of conduct: an \"I agree\" button that records who and when, never a checkbox. Once agreed, the record shows in its place with the person's photo.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/button",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/agreement.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Codes of conduct, consents, and terms that someone must agree to, by name.",
            "Store the record in onAgree and pass it back as agreed: { person, photo, at }. The record showing in place is the confirmation, so no toast repeats it.",
            "A guardian agreeing for a junior passes forPerson. The record then says who it was for.",
            "A consent that can be taken back, like photo consent, passes onWithdraw.",
            "A required one left unagreed takes error, set when the form is sent. Its title and button edge turn red, its button glows once, and nothing changes size. Say what is missing on the button that was pressed, with shake() and new words, so it never moves.",
            "In a stack, each sits on the sheet with no box, divided by a faint line. Pass divider={false} on the first one. The sheet's own Continue is the main action, so the buttons stay outlines.",
            "When it is the surface's only decision, pass main, and the button is solid brand."
          ],
          "avoid": [
            "Settings that are just on or off. Use a switch.",
            "Picking among options. Use choice chips or a radio group.",
            "A checkbox for consent. A button that records who and when says more, and cannot be ticked by accident."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "What is agreed to, like \"Code of conduct\". It names the agreement."
          },
          {
            "name": "text",
            "type": "ReactNode",
            "description": "The short version, in plain words."
          },
          {
            "name": "link",
            "type": "{ label: ReactNode; href?: string; onClick?: () => void }",
            "description": "The full text: a link with href, or a button with onClick."
          },
          {
            "name": "forPerson",
            "type": "{ person: string; photo?: string }",
            "description": "Who it is for, when someone agrees on their behalf."
          },
          {
            "name": "agreed",
            "type": "{ person: string; photo?: string; at?: string | Date } | null",
            "description": "The record. Once set, it shows in place of the button: \"Agreed by Anna Price · Tue 29 Sept, 18:04\"."
          },
          {
            "name": "onAgree",
            "type": "() => void",
            "description": "Store the record here, then pass it back as agreed."
          },
          {
            "name": "onWithdraw",
            "type": "() => void",
            "description": "Shows Withdraw on the record. Clear agreed here."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "For a required one left unagreed. The title and button edge turn red and the button glows once, with no change in size. The words describe the button for screen readers."
          },
          {
            "name": "divider",
            "type": "boolean",
            "default": "true",
            "description": "A faint line above it that fades out at both ends. Pass false on the first of a stack."
          },
          {
            "name": "agreeLabel",
            "type": "ReactNode",
            "default": "\"I agree\"",
            "description": "The button's words."
          },
          {
            "name": "main",
            "type": "boolean",
            "default": "false",
            "description": "The surface's only decision: the button is solid brand. Off, it is an outline."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Text, button, and photo size."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "The button shows a spinner while the record is saved."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks the button and Withdraw."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-GB\"",
            "description": "Formats the record's date and time."
          },
          {
            "name": "timeZone",
            "type": "string",
            "description": "The zone the record's time shows in, like \"Europe/London\". By default, wherever it renders. Pass one when a server renders the page."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the root."
          }
        ],
        "a11y": [
          "The root is a group named by the title, so each \"I agree\" in a stack is heard with what it agrees to.",
          "An error is described on the button. It is not announced here: the press that found it says it out loud, like a role=\"alert\" beside the button that was pressed.",
          "The glow follows the --motion-* tokens, so reduced motion skips it. The red title and edge stay.",
          "Once agreed, focus moves from the button to the record, so screen readers read who agreed and when. After Withdraw it moves back to the button.",
          "The record's date is a <time> element with a machine-readable dateTime.",
          "The faint divider is hidden from screen readers. In forced-colors mode it draws as a plain border."
        ],
        "examples": [
          {
            "title": "In a stack",
            "code": "<div className=\"flex flex-col gap-4\">\n  <Agreement\n    divider={false}\n    title=\"Code of conduct\"\n    text=\"Treat everyone with respect, keep shared spaces tidy, and report problems early.\"\n    link={{ label: \"Read the code of conduct\", href: \"/conduct\" }}\n    agreed={{ person: \"Anna Price\", at: \"2026-09-29T18:04\" }}\n  />\n  <Agreement\n    title=\"Photo consent\"\n    forPerson={{ person: \"Mia Price\" }}\n    error=\"The club needs photo consent to share team photos.\"\n  />\n</div>"
          },
          {
            "title": "The only decision",
            "code": "<Agreement main title=\"Terms of service\" text=\"Invoices are due on the first day of each month.\" />"
          }
        ]
      }
    },
    {
      "name": "alert-dialog",
      "type": "registry:ui",
      "title": "Alert Dialog",
      "description": "A window over the page that asks people to confirm or cancel something serious, like a delete, before anything happens.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/action-keys"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/alert-dialog.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Confirming an action that cannot be undone, like deleting a project or leaving with unsaved changes.",
            "Ask a question in AlertDialogTitle, and say what will happen in AlertDialogDescription.",
            "Put Cancel first in AlertDialogFooter. It gets focus when the dialog opens, so Enter never confirms by accident.",
            "Keep the footer to one row: quiet actions lead, and the confirm trails. Use short labels, so two or three buttons fit a 320px screen.",
            "Name the main button with the action, like 'Delete project', not 'OK' or 'Yes'. Give it tone='danger' when it destroys data.",
            "For an action that takes time, control open, keep the dialog open while it runs, and close it when it is done.",
            "The alert dialog opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on AlertDialogPopup so the alert dialog and its backdrop keep the box's colors."
          ],
          "avoid": [
            "A form or a task with several fields. Use a dialog.",
            "A message that needs no answer. Use Alert on the page, or Toast.",
            "Actions people can undo. Do it at once, and offer Undo for 8 seconds with toast.undoable().",
            "Opening one when the page loads. People did not ask for it."
          ]
        },
        "props": [
          {
            "name": "AlertDialog",
            "type": "{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void }",
            "description": "Root. Holds the open state and renders no element. Takes open, defaultOpen, and onOpenChange."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "AlertDialog. Whether it is open, controlled. Use it with onOpenChange."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "AlertDialog. Whether it is open at the start, when you do not control it."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean, details) => void",
            "description": "AlertDialog. Called with the new open state when it opens or closes."
          },
          {
            "name": "AlertDialogTrigger",
            "type": "ComponentProps<\"button\"> & { render?: ReactElement }",
            "description": "Part. The button that opens it. Pass render={<Button />} to use your own button. It keeps data-slot=\"alert-dialog-trigger\", even from a server component."
          },
          {
            "name": "AlertDialogPopup",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The alert dialog, centered over a backdrop. It renders in a portal while open. It has no Close button in the corner."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "AlertDialogPopup. Most width: 24rem, 28rem, or 32rem. It never gets wider than the screen."
          },
          {
            "name": "AlertDialogTitle",
            "type": "ComponentProps<\"h2\">",
            "description": "Part. The question it asks, like 'Delete this project?'. An <h2> by default."
          },
          {
            "name": "AlertDialogDescription",
            "type": "ComponentProps<\"p\">",
            "description": "Part. What will happen. Screen readers read it when it opens."
          },
          {
            "name": "AlertDialogFooter",
            "type": "Omit<ComponentProps<\"div\">, \"color\">",
            "description": "Part. One row of actions at the bottom, lined up at the end. It never wraps, so the confirm always sits last."
          },
          {
            "name": "AlertDialogClose",
            "type": "ComponentProps<\"button\"> & { render?: ReactElement }",
            "description": "Part. A button that closes it, like Cancel, or the main action when it finishes at once."
          },
          {
            "name": "initialFocus",
            "type": "RefObject<HTMLElement | null> | boolean",
            "description": "AlertDialogPopup. Where focus goes when it opens. By default, the first button, which should be Cancel."
          },
          {
            "name": "container",
            "type": "HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null",
            "description": "AlertDialogPopup. The element the alert dialog and its backdrop open in. By default, the end of the page body, so no parent can clip them. Pass an element, or a ref itself (not ref.current), to open them inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no alert dialog, but still locks page scroll until it closes."
          },
          {
            "name": "effect",
            "type": "\"rise\" | \"grow\"",
            "default": "\"rise\"",
            "description": "AlertDialogPopup. How the alert dialog and its backdrop open and close. rise fades in and rises a little. grow fades in and grows in place, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none."
          },
          {
            "name": "className",
            "type": "string",
            "description": "AlertDialogPopup. Extra classes. Later classes win, so className='max-w-xl' replaces the width."
          }
        ],
        "a11y": [
          "The popup has role=\"alertdialog\". AlertDialogTitle names it, and AlertDialogDescription describes it, so screen readers read both when it opens.",
          "While it is open, the rest of the page is hidden from screen readers with aria-hidden and cannot be scrolled or reached.",
          "Opening it moves focus to the first button. Put Cancel first, so the safe choice has focus.",
          "Tab stays inside until it closes, even when pressed fast. Then focus goes back to the trigger.",
          "Escape closes it, as Cancel does. A click on the backdrop does not, so people answer on purpose.",
          "There is no Close button in the corner. Keep a Cancel button, since touch screen reader users cannot press Escape.",
          "The theme tests hold dialog text and muted text to 4.5:1 on the popover color, in light and dark."
        ],
        "examples": [
          {
            "title": "Confirm a delete (needs @txbrendan/button)",
            "code": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\n\nfunction DeleteProject({ onDelete }: { onDelete: () => void }) {\n  return (\n    <AlertDialog>\n      <AlertDialogTrigger render={<Button tone=\"danger\" emphasis=\"soft\" />}>Delete project</AlertDialogTrigger>\n      <AlertDialogPopup>\n        <AlertDialogTitle>Delete this project?</AlertDialogTitle>\n        <AlertDialogDescription>This deletes 24 files and 3 deploys. You cannot undo it.</AlertDialogDescription>\n        <AlertDialogFooter>\n          <AlertDialogClose render={<Button emphasis=\"outline\" />}>Cancel</AlertDialogClose>\n          <AlertDialogClose render={<Button tone=\"danger\" />} onClick={onDelete}>\n            Delete project\n          </AlertDialogClose>\n        </AlertDialogFooter>\n      </AlertDialogPopup>\n    </AlertDialog>\n  );\n}"
          },
          {
            "title": "Wait for the action, then close (needs @txbrendan/button)",
            "code": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nfunction RemoveMember({ name, remove }: { name: string; remove: () => Promise<void> }) {\n  const [open, setOpen] = useState(false);\n  const [loading, setLoading] = useState(false);\n  return (\n    <AlertDialog open={open} onOpenChange={(next) => !loading && setOpen(next)}>\n      <AlertDialogTrigger render={<Button emphasis=\"outline\" />}>Remove {name}</AlertDialogTrigger>\n      <AlertDialogPopup size=\"sm\">\n        <AlertDialogTitle>Remove {name} from the team?</AlertDialogTitle>\n        <AlertDialogDescription>They lose access to every project at once.</AlertDialogDescription>\n        <AlertDialogFooter>\n          <AlertDialogClose render={<Button emphasis=\"outline\" disabled={loading} />}>Cancel</AlertDialogClose>\n          <Button\n            tone=\"danger\"\n            loading={loading}\n            onClick={async () => {\n              setLoading(true);\n              await remove();\n              setLoading(false);\n              setOpen(false);\n            }}\n          >\n            Remove\n          </Button>\n        </AlertDialogFooter>\n      </AlertDialogPopup>\n    </AlertDialog>\n  );\n}"
          },
          {
            "title": "Leave without saving (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<AlertDialog>\n  <AlertDialogTrigger render={<Button emphasis=\"ghost\" />}>Back to projects</AlertDialogTrigger>\n  <AlertDialogPopup>\n    <AlertDialogTitle>Leave without saving?</AlertDialogTitle>\n    <AlertDialogDescription>Your changes to this page will be lost.</AlertDialogDescription>\n    <AlertDialogFooter>\n      <AlertDialogClose render={<Button emphasis=\"outline\" />}>Keep editing</AlertDialogClose>\n      <AlertDialogClose render={<Button tone=\"danger\" />}>Leave</AlertDialogClose>\n    </AlertDialogFooter>\n  </AlertDialogPopup>\n</AlertDialog>"
          },
          {
            "title": "Grows in place (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<AlertDialog>\n  <AlertDialogTrigger render={<Button emphasis=\"outline\" />}>Discard draft</AlertDialogTrigger>\n  <AlertDialogPopup effect=\"grow\">\n    <AlertDialogTitle>Discard this draft?</AlertDialogTitle>\n    <AlertDialogDescription>You cannot undo it.</AlertDialogDescription>\n    <AlertDialogFooter>\n      <AlertDialogClose render={<Button emphasis=\"outline\" />}>Cancel</AlertDialogClose>\n      <AlertDialogClose render={<Button tone=\"danger\" />}>Discard</AlertDialogClose>\n    </AlertDialogFooter>\n  </AlertDialogPopup>\n</AlertDialog>"
          }
        ]
      }
    },
    {
      "name": "alert",
      "type": "registry:ui",
      "title": "Alert",
      "description": "A message box for important information, with tones, emphasis levels, and optional icon, title, description, and actions.",
      "dependencies": [
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/alert.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Telling people something important about the page or their task: a failed payment, planned downtime, a finished import.",
            "Use tone to say what kind of message it is: danger for errors, warning for risks, success for done, brand or neutral for news.",
            "Soft, the default, or outline fits most messages. Solid is the loudest, so save it for the one message that must not be missed.",
            "Add role='alert' only for an urgent message that appears after the page loads, like a failed save.",
            "Add role='status' to an alert that stays on the page while its text changes, like 'Saving…' then 'Saved'."
          ],
          "avoid": [
            "role='alert' on a message that is there when the page loads. Screen readers would interrupt people for news that is not new.",
            "Buttons or links inside a solid alert. Button colors and focus rings are not checked on solid fills. Use soft or outline when the alert has actions.",
            "A problem with one field, row, or card. Say it under the thing with Notice.",
            "A choice people must make before they go on. Use a dialog.",
            "Short confirmations that should go away on their own. Use Toast."
          ]
        },
        "props": [
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color role."
          },
          {
            "name": "emphasis",
            "type": "\"solid\" | \"soft\" | \"outline\" | \"ghost\"",
            "default": "\"soft\"",
            "description": "How loud the alert is. Soft by default, unlike Badge and Button, because most alerts carry plain information."
          },
          {
            "name": "role",
            "type": "\"alert\" | \"status\"",
            "description": "Unset by default. Set it only for messages that appear or change after load. TypeScript rejects other roles."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='rounded-none' replaces the radius."
          },
          {
            "name": "AlertIcon",
            "type": "ComponentProps<\"div\">",
            "description": "Part. Holds one icon beside the first line of text. aria-hidden by default."
          },
          {
            "name": "AlertTitle",
            "type": "ComponentProps<\"div\">",
            "description": "Part. A short summary in medium weight. A div, so it never breaks your heading order."
          },
          {
            "name": "AlertDescription",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The details. Links inside it are underlined."
          },
          {
            "name": "AlertActions",
            "type": "ComponentProps<\"div\">",
            "description": "Part. A row of buttons or links under the text. It wraps on small screens."
          }
        ],
        "a11y": [
          "Renders a <div> with no role, so an alert that is there at load is read in order, like the rest of the page.",
          "role=\"alert\" makes screen readers read the message at once, even mid-sentence. Use it only for urgent messages added after load.",
          "role=\"status\" waits until the screen reader is quiet. Keep that alert on the page and change its text. Some screen readers miss a status region that arrives together with its text.",
          "AlertIcon is aria-hidden, and color is not read out. The title must say what kind of message it is, like \"Payment failed\".",
          "AlertTitle is a <div>, not a heading. Put an <h2> or <h3> inside it if the alert needs to be in the page outline.",
          "The theme tests hold alert text to 4.5:1: solid and soft text on their own fills, and outline and ghost text on the page background."
        ],
        "examples": [
          {
            "title": "Information that is on the page at load",
            "code": "import { Info } from \"lucide-react\";\n\n<Alert tone=\"brand\" emphasis=\"soft\">\n  <AlertIcon>\n    <Info />\n  </AlertIcon>\n  <AlertTitle>Planned maintenance</AlertTitle>\n  <AlertDescription>The dashboard is read-only on Sunday from 02:00 to 04:00 UTC.</AlertDescription>\n</Alert>"
          },
          {
            "title": "An error after a failed save",
            "code": "import { CircleAlert } from \"lucide-react\";\n\nfunction SaveError({ error }: { error: string | null }) {\n  if (!error) return null;\n  return (\n    <Alert role=\"alert\" tone=\"danger\" emphasis=\"soft\">\n      <AlertIcon>\n        <CircleAlert />\n      </AlertIcon>\n      <AlertTitle>Your changes were not saved</AlertTitle>\n      <AlertDescription>{error}</AlertDescription>\n    </Alert>\n  );\n}"
          },
          {
            "title": "A status that changes while it stays on the page",
            "code": "function SaveStatus({ saving }: { saving: boolean }) {\n  return (\n    <Alert role=\"status\" emphasis=\"outline\">\n      <AlertDescription>{saving ? \"Saving…\" : \"All changes saved\"}</AlertDescription>\n    </Alert>\n  );\n}"
          },
          {
            "title": "With actions (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Alert tone=\"warning\" emphasis=\"soft\">\n  <AlertTitle>Your trial ends in 3 days</AlertTitle>\n  <AlertDescription>\n    Add a payment method to keep your projects. <a href=\"/pricing\">Compare plans</a>\n  </AlertDescription>\n  <AlertActions>\n    <Button size=\"sm\" tone=\"warning\">\n      Add payment method\n    </Button>\n    <Button size=\"sm\" tone=\"warning\" emphasis=\"soft\">\n      Remind me later\n    </Button>\n  </AlertActions>\n</Alert>"
          }
        ]
      }
    },
    {
      "name": "approval-request",
      "type": "registry:ui",
      "title": "Approval Request",
      "description": "An AI agent asks before it acts: what it wants to do, why, and the command or change. Approve or Reject, then the outcome shows in place of the buttons and cannot be pressed again.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/card",
        "@txbrendan/checkbox",
        "@txbrendan/motion-timing",
        "@txbrendan/notice",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/approval-request.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "ai",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "An agent is about to do something hard to undo, like run a migration, send email, spend money, or deploy, and a person should say yes first.",
            "Put the action in title, like \"Run migration 0042 on production?\", and why in reason.",
            "Pass command for a shell command, or diff for a change to a file. Pass detail for anything else to look at first.",
            "Set tone to the risk. danger colors the tile and the Approve button red.",
            "Pass allowAlways for an action the person may trust from now on. onApprove gets { always }.",
            "Left alone, it holds its own outcome. To send the answer first, pass outcome, set loading while it sends, then set outcome to \"approved\" or \"rejected\".",
            "If the answer fails to send, keep outcome null and pass error.",
            "⌘ or Ctrl + Enter approves and Esc rejects, only while focus is in the card, so two cards on a page never both answer. The buttons' tooltips teach the keys. Pass keys={false} to turn them off.",
            "Pass keys=\"page\" when the page shows one request at a time, like a single chat. Then the keys work anywhere on the page. They do nothing while someone types in a field or a modal dialog is open.",
            "With keys=\"page\", Esc rejects only when no menu, popup, or dialog is open, since Esc closes those first. If two pending cards use it, only the one shown last answers, unless focus is in another request.",
            "The tooltips load the first time someone points at or tabs into a card, so a page that never uses the keys never loads them. Call loadApprovalTooltip() early, like when an agent run starts, to have them ready.",
            "Pass expiresIn, in seconds, for a request that should not wait forever. It pauses while the pointer or focus is on the card. When it runs out, the outcome is \"expired\" and onExpire is called."
          ],
          "avoid": [
            "An action the person started themselves, like deleting a file. Use Alert Dialog.",
            "An action that is easy to undo. Do it, and offer Undo in a toast.",
            "Plain consent to terms, by name. Use Agreement."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "Required. What the agent wants to do. It names the request."
          },
          {
            "name": "reason",
            "type": "ReactNode",
            "description": "Why it wants to, in plain words."
          },
          {
            "name": "command",
            "type": "string",
            "description": "A shell command it will run. Shows in a code block that scrolls sideways."
          },
          {
            "name": "diff",
            "type": "string",
            "description": "A unified diff. Added lines show green and removed lines red. The + and - stay in the text."
          },
          {
            "name": "detail",
            "type": "ReactNode",
            "description": "Anything else to look at first, like a list of recipients."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "Replaces the shield in the tile. Pass null for no tile."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "How risky the action is. Colors the tile and the Approve button."
          },
          {
            "name": "outcome",
            "type": "\"approved\" | \"rejected\" | \"expired\" | null",
            "description": "The outcome, when you hold it. null while it waits."
          },
          {
            "name": "defaultOutcome",
            "type": "\"approved\" | \"rejected\" | \"expired\" | null",
            "default": "null",
            "description": "The outcome it starts with, when it holds its own."
          },
          {
            "name": "onApprove",
            "type": "(answer: { always: boolean }) => void",
            "description": "Called on Approve or ⌘ Enter. always is true when Always allow was ticked."
          },
          {
            "name": "onReject",
            "type": "() => void",
            "description": "Called on Reject or Esc."
          },
          {
            "name": "expiresIn",
            "type": "number",
            "description": "Seconds to answer. Shows \"Expires in 0:42\" by the buttons, in warning for the last 10. Pauses while the pointer or focus is on the card."
          },
          {
            "name": "onExpire",
            "type": "() => void",
            "description": "Called when the countdown runs out. Holding the outcome yourself, set it to \"expired\" here."
          },
          {
            "name": "keys",
            "type": "boolean | \"page\"",
            "default": "true",
            "description": "⌘ or Ctrl + Enter approves and Esc rejects, while focus is in the card. \"page\" makes them work anywhere on the page."
          },
          {
            "name": "allowAlways",
            "type": "boolean",
            "default": "false",
            "description": "Shows an Always allow checkbox before the buttons."
          },
          {
            "name": "defaultAlways",
            "type": "boolean",
            "default": "false",
            "description": "Ticks Always allow at the start."
          },
          {
            "name": "alwaysLabel",
            "type": "ReactNode",
            "default": "\"Always allow this action\"",
            "description": "The checkbox's words."
          },
          {
            "name": "approveLabel",
            "type": "ReactNode",
            "default": "\"Approve\"",
            "description": "The Approve button's words."
          },
          {
            "name": "rejectLabel",
            "type": "ReactNode",
            "default": "\"Reject\"",
            "description": "The Reject button's words."
          },
          {
            "name": "outcomeNote",
            "type": "ReactNode",
            "description": "A short line after the outcome, like \"Ran in 4 s\"."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "In red under the buttons, like an answer that failed to send. Announced once."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "The pressed button shows a spinner, and nothing else can be pressed."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks both buttons and the checkbox."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\"",
            "default": "\"md\"",
            "description": "Text, tile, and button size. sm fits a chat thread."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the card. Later classes win."
          }
        ],
        "a11y": [
          "The card is a group named by the title, so each Approve is heard with what it approves.",
          "Once decided, the buttons are gone and focus moves to the outcome, so screen readers read Approved, Rejected, or Expired.",
          "The buttons carry aria-keyshortcuts from the start, and their tooltips show the keys. The keys work only while focus is in the card, or anywhere with keys=\"page\".",
          "The tooltips load on the first hover or focus. Focus stays on the button it was on while they load in.",
          "Keys never act while someone types in a field. With keys=\"page\", they wait while a modal dialog is open, and Esc waits while any menu, popup, or dialog is open.",
          "The countdown is a timer, which screen readers do not read out each second. It pauses while focus is in the card, so no one runs out of time while they read.",
          "The command and the diff are focusable, named groups, so a long line scrolls from the keyboard.",
          "Diff lines keep their + and -, so the change does not rest on color alone.",
          "While loading, the pressed button keeps focus and says it is busy. An error is described on both buttons and announced once.",
          "The tile's icon is hidden from screen readers. In forced-colors mode the tile draws a border."
        ],
        "examples": [
          {
            "title": "A risky command",
            "code": "<ApprovalRequest\n  tone=\"danger\"\n  title=\"Run migration 0042 on production?\"\n  reason=\"It adds a guests column to teams. Writes pause for about 40 seconds.\"\n  command=\"npm run db:migrate -- --env production --to 0042\"\n  allowAlways\n/>"
          },
          {
            "title": "Send the answer, then show it",
            "code": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { ApprovalRequest, type ApprovalOutcome } from \"@/components/ui/approval-request\";\n\nexport function DeployRequest({ send }: { send: (approved: boolean) => Promise<void> }) {\n  const [outcome, setOutcome] = useState<ApprovalOutcome | null>(null);\n  const [loading, setLoading] = useState(false);\n  const [error, setError] = useState<string>();\n  async function answer(approved: boolean) {\n    setLoading(true);\n    setError(undefined);\n    try {\n      await send(approved);\n      setOutcome(approved ? \"approved\" : \"rejected\");\n    } catch {\n      setError(\"The answer did not send. Try again.\");\n    } finally {\n      setLoading(false);\n    }\n  }\n  return (\n    <ApprovalRequest\n      title=\"Deploy web to production?\"\n      outcome={outcome}\n      loading={loading}\n      error={error}\n      onApprove={() => answer(true)}\n      onReject={() => answer(false)}\n    />\n  );\n}"
          },
          {
            "title": "Load the tooltips when a run starts",
            "code": "\"use client\";\n\nimport { ApprovalRequest, loadApprovalTooltip } from \"@/components/ui/approval-request\";\nimport { Button } from \"@/components/ui/button\";\n\nexport function AgentRun({ start, request }: { start: () => void; request?: { title: string; command: string } }) {\n  return (\n    <>\n      <Button\n        onClick={() => {\n          // A request may come, so its key tooltips are ready by then.\n          loadApprovalTooltip().catch(() => {});\n          start();\n        }}\n      >\n        Start agent run\n      </Button>\n      {request && <ApprovalRequest title={request.title} command={request.command} />}\n    </>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "area-chart",
      "type": "registry:ui",
      "title": "Area Chart",
      "description": "Filled areas that show how amounts change over time, alone or stacked into a total, with a themed tooltip, legend, and states.",
      "dependencies": [
        "recharts@^3.10.1",
        "react-is@19.3.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart",
        "@txbrendan/chart-key",
        "@txbrendan/dither",
        "@txbrendan/dither-motion",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/area-chart.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing how an amount changes over time, when the size of the amount matters, like storage used.",
            "Use stacked to show how parts add up to a total over time, like visitors by device.",
            "Pass data, a config with one entry per area, and the categoryKey for the x axis.",
            "Pass valueFormat to format the tooltip and the y axis.",
            "Pass legend=\"top\" for a key beside the title in place of the tooltip box. It shows the point under the pointer, and its numbers roll as you move. Press a series in the key to hide or show it.",
            "Dithered, the dots melt away toward the bottom and fade in from the axis, and hidden dots fill in near the pointer. Pass fill, start, line, intro, hover, or idle to change how they sit and move, or set them on a Dither around the chart."
          ],
          "avoid": [
            "Comparing trends that cross each other without stacking. Overlapping fills hide each other. Use LineChart.",
            "Categories with no order, like regions. Use BarChart.",
            "A small trend next to a number or in a table cell. Use Sparkline.",
            "More than five areas. The theme has five chart colors."
          ]
        },
        "props": [
          {
            "name": "data",
            "type": "TData[]",
            "description": "One object per point on the x axis, like { month: \"Jan\", desktop: 186 }."
          },
          {
            "name": "config",
            "type": "{ [key in keyof TData]?: { label: string; color?: string } }",
            "description": "The areas to draw, in order. Colors default to --chart-1 through --chart-5."
          },
          {
            "name": "categoryKey",
            "type": "keyof TData",
            "description": "The field that names each point, shown on the x axis."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the chart when it has no visible title."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height: 5, 7, or 9 times --control-h-md, so density scales it."
          },
          {
            "name": "stacked",
            "type": "boolean",
            "default": "false",
            "description": "Stacks the areas, so the top edge shows the total."
          },
          {
            "name": "curve",
            "type": "\"linear\" | \"monotone\" | \"step\"",
            "default": "\"monotone\"",
            "description": "How the top edge joins its points. Monotone is smooth and never overshoots a point."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "description": "Formats tooltip values, and y axis ticks in short form, like 12.5K."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for numbers. Fixed, so server and browser print the same text."
          },
          {
            "name": "legend",
            "type": "boolean | \"top\"",
            "description": "Shows the legend. By default it shows when there are two or more areas. \"top\" shows it as a key beside the title, with the point under the pointer in place of the tooltip box, and the last point at rest."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows a soft area shape at the same height. When data arrives, it fades out while the areas draw in."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Shows this message in place of the chart. true shows a default message."
          },
          {
            "name": "onRetry",
            "type": "() => void",
            "description": "Shows a retry button under the error."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data yet\"",
            "description": "Shown when data is empty."
          },
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\" | \"gradient\" | \"grain\"",
            "description": "dither draws each area as dots that thin out toward the bottom and fade in from the axis, gradient fades it down from the top, and grain speckles it. The edge stays solid. Hover fills it in. Left out, the chart follows the Texture around it."
          },
          {
            "name": "fill",
            "type": "\"scatter\" | \"steps\"",
            "default": "\"scatter\"",
            "description": "Dithered, how the dots thin toward the bottom: one by one, or in 8 even steps. Left out, the chart follows the Dither around it."
          },
          {
            "name": "start",
            "type": "\"fade\" | \"gap\"",
            "default": "\"fade\"",
            "description": "Dithered, how the areas start: fading in from the axis over 48 px, or 16 px in from it, then fading. Left out, the chart follows the Dither around it."
          },
          {
            "name": "line",
            "type": "\"fade\" | \"dots\"",
            "default": "\"fade\"",
            "description": "Dithered, how each line starts: fading in over the same 48 px as the dots, or breaking into dots there. Left out, the chart follows the Dither around it."
          },
          {
            "name": "intro",
            "type": "\"fade\" | \"sweep\" | \"rain\" | \"ripple\" | \"pop\" | \"fall\"",
            "default": "\"fade\"",
            "description": "Dithered, how the dots come in when the chart first shows. Motion off plays none. Left out, the chart follows the Dither around it."
          },
          {
            "name": "hover",
            "type": "\"fill\" | \"none\"",
            "default": "\"fill\"",
            "description": "Dithered, \"fill\" fills in hidden dots near the pointer, and they fade back when it leaves. Left out, the chart follows the Dither around it."
          },
          {
            "name": "idle",
            "type": "\"none\" | \"wave\" | \"twinkle\" | \"breathe\" | \"drift\"",
            "default": "\"none\"",
            "description": "Dithered, what the dots do at rest: a band of extra dots passing across, single dots blinking out and on, the thin edge filling in deeper and back, or soft patches of extra dots drifting across. Left out, the chart follows the Dither around it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='h-96' replaces the height."
          }
        ],
        "a11y": [
          "Renders a <figure> named by its title, or by aria-label. TypeScript requires one.",
          "Recharts' keyboard layer stays on: Tab focuses the chart, the arrow keys move between points, and the tooltip reads each one out. With the key on top, the key follows the arrow keys too, and the tooltip stays on the page for screen readers only, so each point is still read out.",
          "The key on top sits beside the title, not in it, so the chart's name never changes as the key moves. Each value holds the width of its longest one, so the key stays still as its numbers roll.",
          "Each series in the key is a toggle button. A hidden area fades out where it is, leaves the tooltip, and keeps the y axis still. In a stack, the rest glide down to close the gap once it has faded. The last one shown cannot hide. Tab reaches these buttons first, then the chart.",
          "Keeps one height while it loads, draws, fails, or has no data, so the page does not move. When the page prints, it asks the browser to keep a chart that fits on one page whole. In the light theme, it asks the browser to print legend dots and loading shapes even with backgrounds off.",
          "Loading, the error, and the empty text are read out through a status line that stays on the page.",
          "Each area's edge is a 2px line in --chart-1 to --chart-5, which the theme tests hold to 3:1 against the page. The fill under it fades out, so the edge carries the color.",
          "Areas draw in over three times --motion-duration-base, once the chart is on screen. They finish at once when the page prints. At 0ms they draw at once, and Recharts skips the animation for reduced motion.",
          "Dithered dots move only while the theme's pulse runs, so reduced motion and motion off keep them still. Forced colors keeps them still too, and shows every grid line. A print shows the still dots."
        ],
        "examples": [
          {
            "title": "One area, with units",
            "code": "<AreaChart\n  title=\"Storage used\"\n  data={[\n    { day: \"Mon\", used: 42.5 },\n    { day: \"Tue\", used: 44.1 },\n    { day: \"Wed\", used: 47.8 },\n  ]}\n  config={{ used: { label: \"Used\" } }}\n  categoryKey=\"day\"\n  valueFormat={{ style: \"unit\", unit: \"gigabyte\", maximumFractionDigits: 1 }}\n/>"
          },
          {
            "title": "Stacked",
            "code": "<AreaChart\n  title=\"All visitors\"\n  data={[\n    { month: \"Jan\", desktop: 186, mobile: 80 },\n    { month: \"Feb\", desktop: 305, mobile: 200 },\n    { month: \"Mar\", desktop: 237, mobile: 120 },\n  ]}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"month\"\n  stacked\n/>"
          },
          {
            "title": "Key beside the title in place of the tooltip",
            "code": "<AreaChart\n  title=\"Visitors by device\"\n  data={[\n    { month: \"Jan\", desktop: 186, mobile: 80 },\n    { month: \"Feb\", desktop: 305, mobile: 200 },\n    { month: \"Mar\", desktop: 237, mobile: 120 },\n  ]}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"month\"\n  legend=\"top\"\n/>"
          },
          {
            "title": "While it loads, and when it fails",
            "code": "<AreaChart\n  aria-label=\"All visitors\"\n  data={data ?? []}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"month\"\n  stacked\n  loading={isLoading}\n  error={error ? \"Visitors could not load.\" : undefined}\n  onRetry={() => refetch()}\n/>"
          }
        ]
      }
    },
    {
      "name": "audience",
      "type": "registry:ui",
      "title": "Audience",
      "description": "Who a message goes to, said before it is sent, with messages for juniors, people under 18, going to their parents or guardians.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/avatar-stack",
        "@txbrendan/button",
        "@txbrendan/hover-card",
        "@txbrendan/peek-card"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/audience.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "people",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Above a message, before it is sent, so the sender knows who will read it.",
            "Pass everyone the message is for in to. Mark juniors and give their guardians.",
            "Set juniorMessages from your app's setting. \"guardians\" sends juniors' messages to their parents instead. \"copy\" sends them to the junior with their parents copied.",
            "Name a group with toLabel, like Group A. It titles the peek on the count.",
            "Pass onAddGuardian so a junior with no parent on file comes with the fix.",
            "Use audienceReach(to, juniorMessages) for the same count elsewhere, like on a Send button."
          ],
          "avoid": [
            "Picking who a message goes to. This says who it reaches. It does not choose.",
            "A list of people who already read a message. Use Avatar Stack with a count."
          ]
        },
        "props": [
          {
            "name": "to",
            "type": "{ person: string; photo?: string; id?: string; email?: string; junior?: boolean; guardians?: { person: string; photo?: string; id?: string; email?: string }[] }[]",
            "description": "Required. Who the message is for. A parent of two juniors is counted once: by id, then email, then name."
          },
          {
            "name": "toLabel",
            "type": "string",
            "description": "What the group is called, like Group A. It titles the count's peek."
          },
          {
            "name": "juniorMessages",
            "type": "\"guardians\" | \"copy\"",
            "default": "\"guardians\"",
            "description": "Where messages to juniors go: to their parents instead, or to the junior with their parents copied."
          },
          {
            "name": "onAddGuardian",
            "type": "(junior: { person: string; photo?: string }) => void",
            "description": "The fix for a junior with no parent on file, like opening their profile."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Text and face size: meta, body, or section type."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Later classes win."
          }
        ],
        "a11y": [
          "Plain text: Goes to, then who. Faces are hidden from screen readers, since the names are in the text.",
          "A group's count is a peek trigger with a dotted underline. Focus or hover opens everyone, grouped by how they get it.",
          "The · before a phrase is hidden from screen readers, which hear a comma instead. When the phrase wraps, the · hides too.",
          "A junior it does not reach is said in danger text, with a button for the fix. It never relies on the color alone."
        ],
        "examples": [
          {
            "title": "One person",
            "code": "<Audience to={[{ person: \"Josh Tait\" }]} />"
          },
          {
            "title": "A junior, through their parents",
            "code": "<Audience\n  to={[{ person: \"Ella Clarke\", junior: true, guardians: [{ person: \"Kate Clarke\" }, { person: \"Mark Clarke\" }] }]}\n/>"
          },
          {
            "title": "A group, with a junior who has no parent on file",
            "code": "<Audience\n  to={group}\n  toLabel=\"Group A\"\n  juniorMessages=\"copy\"\n  onAddGuardian={(junior) => openProfile(junior.person)}\n/>"
          }
        ]
      }
    },
    {
      "name": "avatar-stack",
      "type": "registry:ui",
      "title": "Avatar Stack",
      "description": "Overlapping faces for a count of people, one initial each until photos load, with +N past the limit.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/avatar-stack.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "people",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Putting faces on a count of people: who is going, who replied, who has seen it.",
            "Pass everyone in people, in order. max sets how many faces show before +N.",
            "Set arrive where people join while the page is open, like check-ins. New faces pop in. The ones already there stay put.",
            "Name everyone in full somewhere people can reach, like a peek on the count."
          ],
          "avoid": [
            "One person. Use Avatar or Person.",
            "A list people must read name by name. Use a list of Person rows.",
            "Faces with no count or label near them. Say what the group is, like 21 going."
          ]
        },
        "props": [
          {
            "name": "people",
            "type": "{ person: string; photo?: string }[]",
            "description": "Required. Everyone in the count, in the order given."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Face size, as Avatar's: 21, 28, or 36px at the default density."
          },
          {
            "name": "max",
            "type": "number",
            "default": "5",
            "description": "Faces shown before +N."
          },
          {
            "name": "arrive",
            "type": "boolean",
            "default": "false",
            "description": "Faces added after the first paint pop in. The rest stay put."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "The stack's name for screen readers. Defaults to up to three names and how many more, like Sam Carter, Kate Clarke, Josh Tait and 18 more."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Set [--avatar-stack-ring:var(--background)] when the stack sits on the page, not a card."
          }
        ],
        "a11y": [
          "Renders one <span role=\"img\">. Its name lists up to three people and how many more, so screen readers hear one short line, not every face.",
          "Pass aria-label to say what the group is, like 21 going.",
          "The faces and the +N are hidden from screen readers. Name everyone in full where people can reach them.",
          "The pop only grows and fades. Under reduced motion it only fades."
        ],
        "examples": [
          {
            "title": "Who is going",
            "code": "<AvatarStack\n  people={[{ person: \"Sam Carter\" }, { person: \"Kate Clarke\" }, { person: \"Josh Tait\" }, { person: \"Mia Okafor\" }, { person: \"Tom Hale\" }, { person: \"Priya Shah\" }]}\n  max={4}\n  aria-label=\"6 going\"\n/>"
          },
          {
            "title": "Check-ins that pop in",
            "code": "\"use client\";\n\nimport { useState } from \"react\";\nimport { AvatarStack } from \"@/components/ui/avatar-stack\";\n\nexport function CheckIns() {\n  const [people, setPeople] = useState([{ person: \"Sam Carter\" }]);\n  return (\n    <div className=\"flex items-center gap-3\">\n      <AvatarStack people={people} arrive aria-label={`${people.length} checked in`} />\n      <button type=\"button\" onClick={() => setPeople((all) => [...all, { person: `Guest ${all.length}` }])}>\n        Check in\n      </button>\n    </div>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "avatar",
      "type": "registry:ui",
      "title": "Avatar",
      "description": "A round profile photo that falls back to initials in one of eight soft tints, picked from the name.",
      "dependencies": [
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/dither"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/avatar.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "people",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing who someone is, next to their name: in rows, tables, comments, and cards.",
            "A team or company, with solid set to its own color.",
            "Pass the full name. It names the avatar for screen readers, and it picks the initials and the tint.",
            "Use Person when the name shows beside the avatar. It hides the avatar from screen readers, so the name is read once."
          ],
          "avoid": [
            "A face with no name anywhere near it. A person's name always has their avatar beside it, and the other way round.",
            "Many overlapping faces for a count, like who is going. Use AvatarStack.",
            "A logo or an icon that is not a person or a team. Use the image or the icon itself.",
            "Setting tint to say something, like red for a problem. Tints only tell people apart.",
            "A light solid color, like white or pale yellow. The initials are near white, so they would not show. Use the photo or logo as src."
          ]
        },
        "props": [
          {
            "name": "name",
            "type": "string",
            "description": "Required. The full name. It names the avatar and gives the initials and the tint."
          },
          {
            "name": "src",
            "type": "string",
            "description": "Photo URL. The initials show until it loads, and stay if it fails. The photo fades in."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "21, 28, or 36px at the default density. Density scales it."
          },
          {
            "name": "tint",
            "type": "number",
            "description": "0 to 7. Picks a tint instead of the one the name gives. The same name always gets the same tint."
          },
          {
            "name": "solid",
            "type": "string",
            "description": "A team's own color, as any CSS color, behind light initials. A thin ring keeps a black or white color off the page."
          },
          {
            "name": "initials",
            "type": "1 | 2",
            "default": "2",
            "description": "How many letters show. Use 1 where faces overlap."
          },
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\" | \"gradient\" | \"grain\"",
            "description": "dither draws the photo as dots over its tint, gradient fades it from the top, and grain speckles it. Initials stay flat. Hover fills gradient and grain in. Left out, the avatar follows the Texture around it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='size-16' makes it bigger."
          }
        ],
        "a11y": [
          "Renders a <span role=\"img\"> named by name, so screen readers read the name once and skip the letters.",
          "The photo has an empty alt, since the avatar's own name already says who it is.",
          "When the name shows next to the avatar, pass aria-hidden, or use Person, which does it for you.",
          "The first five tints are the tones' soft pairs, held to 4.5:1 by the theme tests. The other three mix a chart color into the card, and avatar.test.tsx holds their initials to 4.5:1 in light and dark.",
          "A solid color comes from your app, so its contrast is yours to check. The initials use the near-white text color of the solid tones."
        ],
        "examples": [
          {
            "title": "With initials",
            "code": "<Avatar name=\"Sam Carter\" />"
          },
          {
            "title": "With a photo",
            "code": "<Avatar name=\"Kate Clarke\" src=\"/people/kate.jpg\" size=\"lg\" />"
          },
          {
            "title": "A grain photo that clears on hover",
            "code": "<Avatar name=\"Kate Clarke\" src=\"/people/kate.jpg\" size=\"lg\" texture=\"grain\" />"
          },
          {
            "title": "A solid brand color",
            "code": "<Avatar name=\"Design studio\" solid=\"#0b3d91\" />"
          },
          {
            "title": "Beside a name",
            "code": "<span className=\"inline-flex items-center gap-2\">\n  <Avatar name=\"Josh Tait\" aria-hidden />\n  Josh Tait\n</span>"
          }
        ]
      }
    },
    {
      "name": "back-link",
      "type": "registry:ui",
      "title": "Back Link",
      "description": "A quiet link back to the page above, or a short trail of levels, that sits over a page title.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/breadcrumb"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/back-link.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Put it right above the page title, so people can go up one level: <BackLink href=\"/schedule\">Schedule</BackLink>.",
            "Name the page it goes back to, not the word Back.",
            "Pass trail for a short path of levels, like Projects / Mobile app / Settings. The last level is the current page.",
            "With a router, pass its link as render: <BackLink render={<Link href=\"/schedule\" />}>Schedule</BackLink>.",
            "With no href and no onClick, it is a button that goes back in the browser history."
          ],
          "avoid": [
            "A long trail of four or more levels. Use Breadcrumb, which can fold the middle into an ellipsis.",
            "The main way around the app. It only goes up one level.",
            "A back button inside a dialog or a step flow. Use a Button there.",
            "Two trails on one page. Each one is a nav named Breadcrumb, so pass a different aria-label to each."
          ]
        },
        "props": [
          {
            "name": "href",
            "type": "string",
            "description": "Where the link goes. With no href, it renders a button that runs onClick, or goes back in history."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "default": "\"Back\"",
            "description": "The name of the page it goes back to, like \"Schedule\". A chevron sits before it."
          },
          {
            "name": "trail",
            "type": "{ label: string; href?: string }[]",
            "description": "Levels in place of one link, joined by slashes. Levels with an href are links. The last level is the current page."
          },
          {
            "name": "onClick",
            "type": "(event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => void",
            "description": "Runs on a click. On the button form, it replaces going back in history."
          },
          {
            "name": "render",
            "type": "ReactElement",
            "description": "Renders this element instead of the <a>, like a router link, with the same props and classes."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "On a trail, renames its nav from \"Breadcrumb\". On a link, replaces its name."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='text-foreground' replaces the muted color."
          }
        ],
        "a11y": [
          "The single form is a real <a> with an href, or a <button> when there is none, so it works with Tab, Enter, and screen readers.",
          "Its name is its text, like \"Schedule\". The chevron is hidden from screen readers.",
          "A trail is a <nav> named \"Breadcrumb\" with an ordered list, built on Breadcrumb. The last level carries aria-current=\"page\".",
          "The slashes are hidden from screen readers and left out of the list's count.",
          "Text is muted-foreground on the page, which the theme tests hold to 4.5:1 in light and dark. Hover turns it to the full text color."
        ],
        "examples": [
          {
            "title": "Back to the page above",
            "code": "<BackLink href=\"/schedule\">Schedule</BackLink>"
          },
          {
            "title": "A short trail",
            "code": "<BackLink\n  trail={[\n    { label: \"Projects\", href: \"/projects\" },\n    { label: \"Mobile app\", href: \"/projects/mobile-app\" },\n    { label: \"Settings\" },\n  ]}\n/>"
          },
          {
            "title": "Over a page title",
            "code": "<div className=\"flex flex-col gap-1\">\n  <BackLink href=\"/members\">Members</BackLink>\n  <h1 className=\"text-xl font-semibold\">Ava Patel</h1>\n</div>"
          },
          {
            "title": "With a router link",
            "code": "import Link from \"next/link\";\n\n<BackLink render={<Link href=\"/schedule\" />}>Schedule</BackLink>"
          }
        ]
      }
    },
    {
      "name": "badge",
      "type": "registry:ui",
      "title": "Badge",
      "description": "A small label for a status, category, or count, with tones, emphasis levels, and sizes.",
      "dependencies": [
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/badge.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Labeling a status or category next to other content: Paid, Draft, Beta, Failed.",
            "Showing a short count next to a label, like unread messages.",
            "Use tone to say what the label means and emphasis to say how much it should stand out.",
            "Use soft or outline in tables and long lists. Save solid for the one label that must stand out."
          ],
          "avoid": [
            "Anything people click. A badge is not focusable. Use a Button with size='sm', or a link styled with badgeVariants().",
            "Sentences or long text. A badge never wraps. Use an Alert or plain text.",
            "Showing meaning with color alone. The text must say the status, like 'Failed', not just turn red.",
            "Using tone='danger' for anything that is not an error or a risk."
          ]
        },
        "props": [
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color role."
          },
          {
            "name": "emphasis",
            "type": "\"solid\" | \"soft\" | \"outline\" | \"ghost\"",
            "default": "\"solid\"",
            "description": "How loud the badge is."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height, padding, and text size. Density scales them."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='rounded-full' replaces the radius."
          }
        ],
        "a11y": [
          "Renders a plain <span> with no role, so screen readers read its text in line with the content around it.",
          "It is not focusable and does nothing on click. Keyboard users skip past it.",
          "The text must carry the meaning. Some people cannot tell the tones apart.",
          "A badge with only an icon needs words for screen readers. Add <span className=\"sr-only\">Online</span> inside it.",
          "The theme tests hold badge text to 4.5:1: solid and soft text on their own fills, and outline and ghost text on the page background.",
          "Screen readers do not hear a count change. If the change matters, announce it in a role=\"status\" region."
        ],
        "examples": [
          {
            "title": "Status in a table row",
            "code": "<Badge tone=\"success\" emphasis=\"soft\">Paid</Badge>"
          },
          {
            "title": "Count next to a label",
            "code": "<span className=\"inline-flex items-center gap-2\">\n  Inbox\n  <Badge tone=\"brand\" size=\"sm\">\n    12<span className=\"sr-only\"> unread</span>\n  </Badge>\n</span>"
          },
          {
            "title": "With an icon",
            "code": "import { CircleCheck } from \"lucide-react\";\n\n<Badge tone=\"success\" emphasis=\"outline\">\n  <CircleCheck />\n  Verified\n</Badge>"
          },
          {
            "title": "A link that looks like a badge",
            "code": "import Link from \"next/link\";\nimport { badgeVariants } from \"@/components/ui/badge\";\n\n<Link href=\"/tags/react\" className={badgeVariants({ emphasis: \"outline\" })}>\n  react\n</Link>"
          }
        ]
      }
    },
    {
      "name": "ball-count",
      "type": "registry:ui",
      "title": "Ball Count",
      "description": "The count from a baseball board: balls, strikes, and outs as lit numbers with their lights, and who is at bat.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/ball-count.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Beside a line score while a baseball game is on.",
            "Pass balls, strikes, and outs as they stand. Each shows as a lit number and a row of lights.",
            "Pass batter with the shirt number at the plate, and their average as you print it, like \".267\".",
            "Reset balls and strikes to 0 for each new batter, and outs to 0 for each new half-inning."
          ],
          "avoid": [
            "A finished game. There is no count, so leave it off the board.",
            "Counting anything else, like sets or fouls. The lights stop at 3 balls, 2 strikes, and 2 outs."
          ]
        },
        "props": [
          {
            "name": "balls",
            "type": "number",
            "description": "Balls on the batter, 0 to 3. Higher values show as 3."
          },
          {
            "name": "strikes",
            "type": "number",
            "description": "Strikes on the batter, 0 to 2. Higher values show as 2."
          },
          {
            "name": "outs",
            "type": "number",
            "description": "Outs in the half-inning, 0 to 2. Higher values show as 2."
          },
          {
            "name": "batter",
            "type": "{ number: number; average?: string }",
            "description": "Who is at the plate. Leave it out between batters."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the list. Later classes win."
          }
        ],
        "a11y": [
          "It is a description list, so screen readers hear each name with its number, like \"Balls 1\".",
          "The number is text. The lights repeat it for the eye and are hidden from screen readers.",
          "Lit lights take system colors in forced colors, and unlit ones keep an outline, so the count still shows.",
          "It is not a live region, since the count changes on every pitch. Say what matters, like a run, elsewhere."
        ],
        "examples": [
          {
            "title": "A full count with two outs",
            "code": "<BallCount balls={3} strikes={2} outs={2} />"
          },
          {
            "title": "With the batter at the plate",
            "code": "<BallCount balls={1} strikes={2} outs={1} batter={{ number: 24, average: \".267\" }} />"
          }
        ]
      }
    },
    {
      "name": "bar-chart",
      "type": "registry:ui",
      "title": "Bar Chart",
      "description": "Bars that compare numbers across categories, side by side or stacked, with a themed tooltip, legend, and states.",
      "dependencies": [
        "recharts@^3.10.1",
        "react-is@19.3.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart",
        "@txbrendan/chart-key",
        "@txbrendan/dither",
        "@txbrendan/dither-motion",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/bar-chart.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Comparing amounts across a few categories, like visitors per month or sales per region.",
            "Pass data, a config with one entry per series, and the categoryKey that names each bar.",
            "Use stacked when the parts add up to a total that matters.",
            "Pass valueFormat, like { style: \"currency\", currency: \"USD\" }, to format the tooltip and the y axis.",
            "Dithered, each bar's dots snap to its own edges, and hidden dots fill in near the pointer. Pass thin to keep the dots even, thin them toward the axis, or thin them down each bar. Pass fill, intro, hover, or idle to change how they thin and move, or set those on a Dither around the chart.",
            "Pass legend=\"top\" for a key beside the title in place of the tooltip box. It shows the bar under the pointer, and its numbers roll as you move. Press a series in the key to hide or show it."
          ],
          "avoid": [
            "A trend over many points in time. Use LineChart or AreaChart.",
            "Parts of one whole. Use DonutChart for up to five parts.",
            "More than five series. The theme has five chart colors.",
            "Dozens of categories. Bars get too thin. Show the top few, or use a table."
          ]
        },
        "props": [
          {
            "name": "data",
            "type": "TData[]",
            "description": "One object per category, like { month: \"Jan\", desktop: 186 }."
          },
          {
            "name": "config",
            "type": "{ [key in keyof TData]?: { label: string; color?: string } }",
            "description": "The series to draw, in order. Colors default to --chart-1 through --chart-5."
          },
          {
            "name": "categoryKey",
            "type": "keyof TData",
            "description": "The field that names each category, shown on the x axis."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the chart when it has no visible title."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height: 5, 7, or 9 times --control-h-md, so density scales it."
          },
          {
            "name": "stacked",
            "type": "boolean",
            "default": "false",
            "description": "Stacks the series in one bar per category."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "description": "Formats tooltip values, and y axis ticks in short form, like $12.3K."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for numbers. Fixed, so server and browser print the same text."
          },
          {
            "name": "legend",
            "type": "boolean | \"top\"",
            "description": "Shows the legend. By default it shows when there are two or more series. \"top\" shows it as a key beside the title, with the bar under the pointer in place of the tooltip box, and the last bar at rest."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows skeleton bars at the same height. When data arrives, they fade out while the bars grow in."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Shows this message in place of the chart. true shows a default message."
          },
          {
            "name": "onRetry",
            "type": "() => void",
            "description": "Shows a retry button under the error."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data yet\"",
            "description": "Shown when data is empty."
          },
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\" | \"gradient\" | \"grain\"",
            "description": "dither draws the bars as dots, with a glow on dark pages, gradient fades each bar down from its top, and grain speckles it. Hover fills the bar under the pointer. Left out, the chart follows the Texture around it."
          },
          {
            "name": "thin",
            "type": "\"none\" | \"plot\" | \"bar\"",
            "default": "\"none\"",
            "description": "Dithered, where the dots thin out: \"none\" keeps them even down each bar, \"plot\" fills them solid at the top of the plot and thins them toward a light base at the axis, so short bars stay lighter, and \"bar\" thins them down each bar from its own top to a light base at the axis."
          },
          {
            "name": "fill",
            "type": "\"scatter\" | \"steps\"",
            "default": "\"scatter\"",
            "description": "Dithered, with thin, how the dots thin: one by one, or in 8 even steps. Left out, the chart follows the Dither around it."
          },
          {
            "name": "intro",
            "type": "\"fade\" | \"sweep\" | \"rain\" | \"ripple\" | \"pop\" | \"fall\"",
            "default": "\"fade\"",
            "description": "Dithered, how the dots come in when the chart first shows. Any but \"fade\" brings the bars in as dots, in place of their grow. Motion off plays none. Left out, the chart follows the Dither around it."
          },
          {
            "name": "hover",
            "type": "\"fill\" | \"none\"",
            "default": "\"fill\"",
            "description": "Dithered, \"fill\" fills in hidden dots near the pointer, and they fade back when it leaves. Even dots fill in toward solid. Left out, the chart follows the Dither around it."
          },
          {
            "name": "idle",
            "type": "\"none\" | \"wave\" | \"twinkle\" | \"breathe\" | \"drift\"",
            "default": "\"none\"",
            "description": "Dithered, what the dots do at rest: a band of extra dots passing across, single dots blinking out and on, the thin edge filling in deeper and back, or soft patches of extra dots drifting across. Left out, the chart follows the Dither around it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='h-96' replaces the height."
          }
        ],
        "a11y": [
          "Renders a <figure> named by its title, or by aria-label. TypeScript requires one.",
          "Recharts' keyboard layer stays on: Tab focuses the chart, the arrow keys move between categories, and the tooltip reads each one out. With the key on top, the key follows the arrow keys too, and the tooltip stays on the page for screen readers only, so each category is still read out.",
          "The key on top sits beside the title, not in it, so the chart's name never changes as the key moves. Each value holds the width of its longest one, so the key stays still as its numbers roll.",
          "Each series in the key is a toggle button. A hidden series fades out where it is, leaves the tooltip, and keeps the y axis still. In a stack, the rest glide down to close the gap once it has faded. The last one shown cannot hide. Tab reaches these buttons first, then the chart.",
          "Keeps one height while it loads, draws, fails, or has no data, so the page does not move. When the page prints, it asks the browser to keep a chart that fits on one page whole. In the light theme, it asks the browser to print legend dots and loading shapes even with backgrounds off.",
          "Loading, the error, and the empty text are read out through a status line that stays on the page.",
          "Series colors come from --chart-1 to --chart-5, which the theme tests hold to 3:1 against the page. The legend and tooltip name each series in text.",
          "Bars grow in over three times --motion-duration-base, once the chart is on screen. They finish at once when the page prints. At 0ms they draw at once, and Recharts skips the animation for reduced motion.",
          "Dithered dots move only while the theme's pulse runs, so reduced motion and motion off keep them still. Forced colors keeps them still too, and shows every grid line. A print shows the still dots."
        ],
        "examples": [
          {
            "title": "Two series",
            "code": "const data = [\n  { month: \"Jan\", desktop: 186, mobile: 80 },\n  { month: \"Feb\", desktop: 305, mobile: 200 },\n  { month: \"Mar\", desktop: 237, mobile: 120 },\n];\n\n<BarChart\n  title=\"Visitors by device\"\n  data={data}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"month\"\n/>"
          },
          {
            "title": "Key beside the title in place of the tooltip",
            "code": "<BarChart\n  title=\"Visitors by device\"\n  data={data}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"month\"\n  legend=\"top\"\n/>"
          },
          {
            "title": "Stacked, with currency",
            "code": "<BarChart\n  title=\"Revenue by plan\"\n  data={[\n    { quarter: \"Q1\", pro: 8200, team: 4200 },\n    { quarter: \"Q2\", pro: 9100, team: 6150 },\n  ]}\n  config={{ pro: { label: \"Pro\" }, team: { label: \"Team\" } }}\n  categoryKey=\"quarter\"\n  stacked\n  valueFormat={{ style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 }}\n/>"
          },
          {
            "title": "Dots that thin down each bar",
            "code": "<BarChart\n  title=\"Visitors by device\"\n  data={data}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"month\"\n  texture=\"dither\"\n  thin=\"bar\"\n  intro=\"rain\"\n/>"
          },
          {
            "title": "While it loads, and when it fails",
            "code": "<BarChart\n  aria-label=\"Visitors by device\"\n  data={data ?? []}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"month\"\n  loading={isLoading}\n  error={error ? \"Visitors could not load.\" : undefined}\n  onRetry={() => refetch()}\n/>"
          }
        ]
      }
    },
    {
      "name": "batting-order",
      "type": "registry:ui",
      "title": "Batting Order",
      "description": "A team's batting order from a baseball board: each player's shirt number and position, with the batter at the plate marked. One soft tint glides from player to player under the pointer.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/shirt-number",
        "@txbrendan/glide-highlight"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/batting-order.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A team's lineup on a baseball scoreboard or a game page.",
            "Pass the players in batting order, first to last, each with number and position, like \"SS\" or \"2B\".",
            "Pass atBat with the shirt number at the plate while that team bats. Leave it out while they field.",
            "Add name to each player for a team sheet. A board that shows numbers only leaves it out."
          ],
          "avoid": [
            "A squad list with no order. Use a list with ShirtNumber beside each name.",
            "A lineup that sorts or filters. Use DataTable."
          ]
        },
        "props": [
          {
            "name": "team",
            "type": "string",
            "description": "The team's name, which titles the table."
          },
          {
            "name": "players",
            "type": "{ number: number; position: string; name?: string }[]",
            "description": "The players in batting order. Shirt numbers must be unique, since each one is a key."
          },
          {
            "name": "atBat",
            "type": "number",
            "description": "The shirt number of the player at the plate."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the table. Later classes win. The table sits in a box that holds the hover tint, marked data-slot=\"batting-order-container\". className and the other props still go to the table."
          }
        ],
        "a11y": [
          "It is a table, captioned with the team's name and \"batting order\".",
          "Screen readers hear standard positions in words, like \"Shortstop\" for SS. Other positions read as you pass them.",
          "The batter at the plate has a brand plate, and screen readers hear \"at bat\" after their position. Forced colors paint fills away, so there the plate is outlined instead."
        ],
        "examples": [
          {
            "title": "Numbers and positions, with the batter marked",
            "code": "<BattingOrder\n  team=\"Rivers\"\n  atBat={28}\n  players={[\n    { number: 17, position: \"RF\" },\n    { number: 15, position: \"2B\" },\n    { number: 28, position: \"CF\" },\n  ]}\n/>"
          },
          {
            "title": "A team sheet with names",
            "code": "<BattingOrder team=\"Harbor\" players={[{ number: 11, position: \"SS\", name: \"A. Lane\" }, { number: 18, position: \"2B\", name: \"B. Cruz\" }]} />"
          }
        ]
      }
    },
    {
      "name": "beeswarm-chart",
      "type": "registry:ui",
      "title": "Beeswarm Chart",
      "description": "One dot per value along a single axis, stacked where values crowd, with a themed tooltip, legend, and states.",
      "dependencies": [
        "recharts@^3.10.1",
        "react-is@19.3.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/beeswarm-chart.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing how values spread out, like load time per page or deal size per customer, when each dot is one thing.",
            "Pass data, the valueKey that places each dot, and a config.",
            "Pass labelKey to name each dot in the tooltip.",
            "Change data, as from tabs, and each dot moves to its new value. Dots are matched by labelKey and group, or by their order without labelKey. New dots grow in, and gone dots shrink away.",
            "To move dots between tabs, keep one chart and change its data. A chart inside each tab panel draws in again each time its tab opens.",
            "Pass groupKey to color dots by a field, with one config entry per group value.",
            "Pass valueFormat, like { style: \"currency\", currency: \"USD\" }, to format the tooltip and the axis."
          ],
          "avoid": [
            "Thousands of dots. They shrink to fit and then squeeze together. Use a histogram, or a BarChart of counts.",
            "A trend over time. Use LineChart or AreaChart.",
            "Comparing a few totals. Use BarChart.",
            "More than five groups. The theme has five chart colors."
          ]
        },
        "props": [
          {
            "name": "data",
            "type": "TData[]",
            "description": "One object per dot, like { page: \"Home\", seconds: 1.2 }."
          },
          {
            "name": "valueKey",
            "type": "keyof TData",
            "description": "The number field that places each dot along the axis."
          },
          {
            "name": "config",
            "type": "{ [key: string]: { label: string; color?: string } }",
            "description": "Without groupKey, one entry keyed by valueKey. With groupKey, one entry per group value. Colors default to --chart-1 through --chart-5."
          },
          {
            "name": "labelKey",
            "type": "keyof TData",
            "description": "The field that names each dot in the tooltip."
          },
          {
            "name": "groupKey",
            "type": "keyof TData",
            "description": "The field that puts each dot in a group. Dots whose group has no config entry are left out."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the chart when it has no visible title."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height: 5, 7, or 9 times --control-h-md, so density scales it."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "description": "Formats tooltip values, and axis ticks in short form, like $12.3K."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for numbers. Fixed, so server and browser print the same text."
          },
          {
            "name": "legend",
            "type": "boolean",
            "description": "Shows the legend. By default it shows when there are two or more groups."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows a loose swarm of skeleton dots at the same height. When data arrives, they fade out while the dots come in."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Shows this message in place of the chart. true shows a default message."
          },
          {
            "name": "onRetry",
            "type": "() => void",
            "description": "Shows a retry button under the error."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data yet\"",
            "description": "Shown when no dot has a number and a config entry."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='h-96' replaces the height."
          }
        ],
        "a11y": [
          "Renders a <figure> named by its title, or by aria-label. TypeScript requires one.",
          "Recharts' keyboard layer stays on: Tab focuses the chart, the arrow keys step through every dot from the lowest value to the highest, and the tooltip reads each one out.",
          "The dot the tooltip describes gets a ring, so the arrow keys show where they are.",
          "Keeps one height while it loads, draws, fails, or has no data, so the page does not move. When the page prints, it asks the browser to keep a chart that fits on one page whole. In the light theme, it asks the browser to print legend dots and loading shapes even with backgrounds off.",
          "Loading, the error, and the empty text are read out through a status line that stays on the page.",
          "Group colors come from --chart-1 to --chart-5, which the theme tests hold to 3:1 against the page. The legend and tooltip name each group in text.",
          "Dots grow in over three times --motion-duration-base, once the chart is on screen. When the data changes, they move, grow, and shrink over the same time. At 0ms they draw at once, and Recharts skips the animation for reduced motion."
        ],
        "examples": [
          {
            "title": "One group",
            "code": "const pages = [\n  { page: \"Home\", seconds: 1.2 },\n  { page: \"Pricing\", seconds: 1.3 },\n  { page: \"Docs\", seconds: 1.25 },\n  { page: \"Blog\", seconds: 2.4 },\n];\n\n<BeeswarmChart\n  title=\"Load time by page\"\n  data={pages}\n  valueKey=\"seconds\"\n  labelKey=\"page\"\n  config={{ seconds: { label: \"Load time\" } }}\n  valueFormat={{ style: \"unit\", unit: \"second\", maximumFractionDigits: 2 }}\n/>"
          },
          {
            "title": "Colored by group",
            "code": "<BeeswarmChart\n  title=\"Load time by device\"\n  data={[\n    { page: \"Home\", seconds: 1.2, device: \"mobile\" },\n    { page: \"Home\", seconds: 0.7, device: \"desktop\" },\n    { page: \"Docs\", seconds: 1.6, device: \"mobile\" },\n    { page: \"Docs\", seconds: 0.9, device: \"desktop\" },\n  ]}\n  valueKey=\"seconds\"\n  labelKey=\"page\"\n  groupKey=\"device\"\n  config={{ mobile: { label: \"Mobile\" }, desktop: { label: \"Desktop\" } }}\n/>"
          },
          {
            "title": "Data that follows tabs",
            "code": "const [week, setWeek] = useState(\"this\");\n\n<Tabs value={week} onValueChange={(next) => setWeek(String(next))}>\n  <TabsList aria-label=\"Week\">\n    <TabsTab value=\"this\">This week</TabsTab>\n    <TabsTab value=\"last\">Last week</TabsTab>\n  </TabsList>\n  <TabsPanel value={week}>\n    <BeeswarmChart\n      title=\"Load time by page\"\n      data={week === \"this\" ? thisWeek : lastWeek}\n      valueKey=\"seconds\"\n      labelKey=\"page\"\n      config={{ seconds: { label: \"Load time\" } }}\n    />\n  </TabsPanel>\n</Tabs>"
          },
          {
            "title": "While it loads, and when it fails",
            "code": "<BeeswarmChart\n  aria-label=\"Load time by page\"\n  data={data ?? []}\n  valueKey=\"seconds\"\n  config={{ seconds: { label: \"Load time\" } }}\n  loading={isLoading}\n  error={error ? \"Load times could not load.\" : undefined}\n  onRetry={() => refetch()}\n/>"
          }
        ]
      }
    },
    {
      "name": "board",
      "type": "registry:ui",
      "title": "Board",
      "description": "Shows items grouped into lanes by stage, side by side, with a count and an Add button on each lane and a card people open for each item.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button-variants",
        "@txbrendan/motion-timing",
        "@txbrendan/rolling-number",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/board.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Work that moves through stages, like tasks, tickets, or requests, when people want to see every stage at once.",
            "Put one BoardLane per stage inside Board, and one BoardCard per item inside its lane. Give every card a key, so a card that leaves fades out in its own place.",
            "The lane counts its cards by itself. Pass count only when the lane shows part of a longer list.",
            "Pass onAdd to show an Add button at the end of a lane. Name it with addLabel when the items have a name, like \"Add task\".",
            "Pass onOpen to make a card one button. Badges, links, and buttons inside the card still take their own presses.",
            "The lanes share the width when they fit. When they do not, the row scrolls sideways lane by lane, and each edge with lanes past it fades out.",
            "Set loading while the items load. Each lane shows skeleton cards, and the cards fade in over them in a wave when it turns false. A board that already has cards waits 150 ms first, so a quick load never flashes skeletons.",
            "Board shows what it is given and does not fetch, so it has no error state. When the items fail to load, show a Notice or Alert above the board with a way to try again, and keep the lanes empty."
          ],
          "avoid": [
            "A long list people scan or sort. Use Table or List Row.",
            "A single stage. A list says the same thing with less.",
            "Dragging cards between lanes. Cards do not drag. Offer a Move action in the card instead."
          ]
        },
        "props": [
          {
            "name": "Board",
            "type": "BoardProps",
            "description": "The root. A row of lanes that scrolls sideways with snap when they do not fit."
          },
          {
            "name": "BoardLane",
            "type": "BoardLaneProps",
            "description": "Part. One stage: a soft fill with a title, a count, its cards, and an optional Add button."
          },
          {
            "name": "BoardCard",
            "type": "BoardCardProps",
            "description": "Part. One item, on a card. Its content is yours."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "On Board. Each lane shows skeleton cards that hold the space, and the counts wait. A board with cards waits 150 ms before it swaps."
          },
          {
            "name": "lanesLabel",
            "type": "string",
            "default": "\"Lanes\"",
            "description": "On Board. The name screen readers give the row of lanes."
          },
          {
            "name": "title",
            "type": "string | ReactNode",
            "description": "Required on BoardLane, where it names the stage, and on BoardCard, where it names the card's button."
          },
          {
            "name": "count",
            "type": "number",
            "description": "On BoardLane. How many items the stage holds. It rolls when it changes. Default: the number of cards in the lane."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "On BoardLane. The color of the dot beside the title."
          },
          {
            "name": "onAdd",
            "type": "() => void",
            "description": "On BoardLane. Shows a ghost Add button at the end of the lane, and is called when it is pressed."
          },
          {
            "name": "addLabel",
            "type": "string",
            "default": "\"Add item\"",
            "description": "On BoardLane. The Add button's words."
          },
          {
            "name": "action",
            "type": "ReactNode",
            "description": "On BoardLane. A control at the end of the lane's header, like an icon button that adds an item at the top of the lane."
          },
          {
            "name": "onOpen",
            "type": "() => void",
            "description": "On BoardCard. Makes the whole card one button that opens the item."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "On Board, the lanes. On BoardLane, the cards, each with a key. On BoardCard, the details under the title."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on any part. Later classes win."
          }
        ],
        "a11y": [
          "Each lane is a group named by its title and count, like \"To do, 4 items\". While the board loads, it says \"To do, loading\".",
          "A card with onOpen holds one real button, stretched over the card and named by the card's title. Tab reaches it, and Enter or Space opens it.",
          "Buttons and links inside a card sit above that button, so each is its own Tab stop and takes its own press.",
          "Tab moves through the cards lane by lane. When focus lands in a lane that is cut off or under an edge fade, the row scrolls that lane fully into view.",
          "When the row scrolls and nothing in it takes focus, the row itself is one Tab stop, so the arrow keys can scroll it. It is a group named by lanesLabel, \"Lanes\" by default, and its focus ring sits inside its edge.",
          "A card takes no press until it has faded halfway in, so a card that cannot be seen yet cannot be opened.",
          "A card that leaves is hidden from screen readers and from Tab while it fades out.",
          "The skeleton cards are hidden from screen readers, and the board has aria-busy while it loads.",
          "Forced-colors mode draws a border around each lane, since it paints the lane's fill away, and turns off the edge fades.",
          "The cards fade up in a wave. Reduced motion takes out the rise, and motion off shows everything at once."
        ],
        "examples": [
          {
            "title": "Tasks by stage",
            "code": "<Board aria-label=\"Tasks by stage\">\n  {stages.map((stage) => (\n    <BoardLane key={stage.id} title={stage.title} tone={stage.tone} onAdd={() => addTask(stage.id)}>\n      {stage.tasks.map((task) => (\n        <BoardCard key={task.id} title={task.title} onOpen={() => openTask(task.id)}>\n          <span className=\"text-xs text-muted-foreground\">Due {task.due}</span>\n        </BoardCard>\n      ))}\n    </BoardLane>\n  ))}\n</Board>"
          },
          {
            "title": "A lane that shows part of a longer list",
            "code": "<BoardLane title=\"Done\" tone=\"success\" count={128} addLabel=\"Add task\" onAdd={addTask}>\n  {recent.map((task) => (\n    <BoardCard key={task.id} title={task.title} />\n  ))}\n</BoardLane>"
          },
          {
            "title": "While the items load",
            "code": "<Board loading={!tasks} aria-label=\"Tasks by stage\">\n  <BoardLane title=\"To do\">{todo}</BoardLane>\n  <BoardLane title=\"In progress\" tone=\"brand\">{doing}</BoardLane>\n</Board>"
          }
        ]
      }
    },
    {
      "name": "bracket-match",
      "type": "registry:ui",
      "title": "Bracket Match",
      "description": "One knockout match on its own, 124px tall and as wide as its column, with a peek that adds what the tile has no room for: the round, the result in words, and facts like the venue or form.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/hover-card",
        "@txbrendan/knockout-bracket",
        "@txbrendan/peek-card"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/bracket-match.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A single knockout fixture in a feed, a list, or a side panel.",
            "Pass the same BracketMatchData a KnockoutBracket takes. It shows the date and time before kick-off, and FT once it's finished.",
            "Pass round, like \"Cup semi-final\", so its peek says which round it is.",
            "Pass onClick and selected to make it a match you choose, like a card that opens the match panel.",
            "Use badge for \"Live 9'\", \"AET\", or \"Forfeit\". It replaces FT. A level score with kicks reads FT (P)."
          ],
          "avoid": [
            "A whole draw. Use KnockoutBracket, which lays the tiles out round by round.",
            "A fixture outside a knockout, or with a flat team shape. Use MatchCard, which has no peek.",
            "Putting the peek's facts only in the peek. The tile already shows them, so the peek just repeats them."
          ]
        },
        "props": [
          {
            "name": "match",
            "type": "BracketMatchData",
            "description": "The match: { id, date?, time?, status?, home, away, winner?, badge? }. Each side is { team, score, penalties?, label? }."
          },
          {
            "name": "round",
            "type": "string",
            "description": "The round's name. It titles the peek, like \"Cup semi-final\". Without it, the teams do."
          },
          {
            "name": "facts",
            "type": "PeekFact[]",
            "description": "What the tile cannot show, for the peek, as [label, value] pairs, like [\"Venue\", \"Riverside Park\"] or each side's form. The peek never repeats the tile's teams, score, or date."
          },
          {
            "name": "selected",
            "type": "boolean",
            "default": "false",
            "description": "Draws it as the chosen match. Only a match with onClick can be chosen."
          },
          {
            "name": "onClick",
            "type": "() => void",
            "description": "Makes the match a button that says whether it is pressed."
          },
          {
            "name": "width",
            "type": "number",
            "description": "A fixed width in px, never under 240. By default it fills its column."
          },
          {
            "name": "effect",
            "type": "\"rise\" | \"grow\"",
            "default": "\"rise\"",
            "description": "How the peek opens and closes. rise fades in and moves out from the match. grow fades in and grows from the match, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the root. Later classes win."
          }
        ],
        "a11y": [
          "The tile names both sides, the scores, and the winner in text. The winner's caret and weight only show it.",
          "With onClick, the tile is a button with aria-pressed, and the peek follows its hover and focus. Enter stays the button's own.",
          "Without onClick, the tile is the peek's trigger: a button that says it opens a dialog.",
          "The peek is a dialog named by its title: the round, or the two teams when there is no round.",
          "A chosen match has a 2px outline that shows in forced colors."
        ],
        "examples": [
          {
            "title": "Finished, with the round and the venue in its peek",
            "code": "<BracketMatch\n  round=\"Cup semi-final\"\n  facts={[\n    [\"Venue\", \"Riverside Park\"],\n    [\"Rovers' form\", \"W W D W\"],\n  ]}\n  match={{\n    id: \"sf1\",\n    date: \"Sat 17 Oct\",\n    time: \"15:20\",\n    status: \"finished\",\n    home: { team: { name: \"Rovers\" }, score: 24 },\n    away: { team: { name: \"Lions\" }, score: 17 },\n  }}\n/>"
          },
          {
            "title": "Chosen, with onClick",
            "code": "<BracketMatch match={match} round=\"Final\" selected={selected === match.id} onClick={() => setSelected(match.id)} />"
          },
          {
            "title": "Live, at a fixed width",
            "code": "<BracketMatch\n  width={280}\n  match={{\n    id: \"f\",\n    time: \"15:00 · Pitch 1\",\n    badge: \"Live 9'\",\n    home: { team: { name: \"Hawks\" }, score: 3 },\n    away: { team: { name: \"Wolves\" }, score: 0 },\n  }}\n/>"
          },
          {
            "title": "Peek grows from the match",
            "code": "<BracketMatch\n  effect=\"grow\"\n  round=\"Cup final\"\n  match={{\n    id: \"f1\",\n    date: \"Sat 24 Oct\",\n    time: \"15:00\",\n    home: { team: { name: \"Rovers\" }, score: null },\n    away: { team: { name: \"Hawks\" }, score: null },\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "breadcrumb",
      "type": "registry:ui",
      "title": "Breadcrumb",
      "description": "A trail of links from the top of the site to the current page, in a labeled nav.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/breadcrumb.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing where a page sits in a deep hierarchy, so people can go up a level in one click.",
            "Put it at the top of the page, above the page heading.",
            "End with BreadcrumbPage for the current page. It is not a link, and it carries aria-current=\"page\".",
            "Put a BreadcrumbSeparator between items, never after the last one.",
            "For a long trail, keep the first and last two levels and put a BreadcrumbEllipsis in place of the middle.",
            "With a router, pass its link as render: <BreadcrumbLink render={<Link href=\"/docs\" />}>Docs</BreadcrumbLink>."
          ],
          "avoid": [
            "Flat sites one level deep. The main navigation already says where people are.",
            "Showing the steps of a flow, like checkout. Use StepDots or a list of steps instead.",
            "Replacing the main navigation. A breadcrumb shows the path to this page only.",
            "Two navs named \"Breadcrumb\" on one page. Pass a different aria-label to each."
          ]
        },
        "props": [
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Breadcrumb\"",
            "description": "On Breadcrumb. The landmark's name. Rename it when a page has more than one. Left off when you pass aria-labelledby."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"More pages\"",
            "description": "On BreadcrumbEllipsis. What screen readers hear in place of the dots."
          },
          {
            "name": "Breadcrumb",
            "type": "Omit<ComponentProps<\"nav\">, \"color\">",
            "description": "The landmark. A <nav> with aria-label=\"Breadcrumb\". Pass aria-label or aria-labelledby to rename it."
          },
          {
            "name": "BreadcrumbList",
            "type": "Omit<ComponentProps<\"ol\">, \"color\">",
            "description": "Part. The ordered list. It wraps on narrow screens."
          },
          {
            "name": "BreadcrumbItem",
            "type": "Omit<ComponentProps<\"li\">, \"color\">",
            "description": "Part. One level. Holds a BreadcrumbLink, a BreadcrumbPage, or a BreadcrumbEllipsis."
          },
          {
            "name": "BreadcrumbLink",
            "type": "Omit<ComponentProps<\"a\">, \"color\"> & { render?: ReactElement }",
            "description": "Part. A link to a level above. render swaps the <a> for your own element, like a router link, with the same props and classes."
          },
          {
            "name": "BreadcrumbPage",
            "type": "Omit<ComponentProps<\"span\">, \"color\">",
            "description": "Part. The current page, as text with aria-current=\"page\"."
          },
          {
            "name": "BreadcrumbSeparator",
            "type": "Omit<ComponentProps<\"li\">, \"color\">",
            "description": "Part. The mark between items, a chevron by default. Pass children to use another mark, like \"/\"."
          },
          {
            "name": "BreadcrumbEllipsis",
            "type": "Omit<ComponentProps<\"span\">, \"children\" | \"color\"> & { label?: string }",
            "description": "Part. Three dots for levels left out. label is what screen readers hear. Default \"More pages\"."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on any part. Later classes win, so className='text-base' on BreadcrumbList replaces the text size."
          }
        ],
        "a11y": [
          "Breadcrumb renders a <nav> named \"Breadcrumb\", so screen readers list it with the page's landmarks.",
          "The levels are an <ol>, so screen readers say how many there are and where each one is.",
          "Separators are <li role=\"presentation\" aria-hidden=\"true\">. Screen readers skip them, and they are left out of the item count.",
          "BreadcrumbPage carries aria-current=\"page\" and is not a link, so the current page is not a tab stop.",
          "BreadcrumbEllipsis has visually hidden text, \"More pages\" by default, so the gap is not silent. Its dots are aria-hidden.",
          "The ellipsis is not interactive. If people need the hidden levels, put it inside a button that opens a menu of them.",
          "Links use muted-foreground and the current page uses foreground. The theme tests hold both to 4.5:1 on the page and on cards.",
          "Links show the shared focus ring, which the theme tests hold to 3:1."
        ],
        "examples": [
          {
            "title": "A trail to the current page",
            "code": "<Breadcrumb>\n  <BreadcrumbList>\n    <BreadcrumbItem>\n      <BreadcrumbLink href=\"/\">Home</BreadcrumbLink>\n    </BreadcrumbItem>\n    <BreadcrumbSeparator />\n    <BreadcrumbItem>\n      <BreadcrumbLink href=\"/settings\">Settings</BreadcrumbLink>\n    </BreadcrumbItem>\n    <BreadcrumbSeparator />\n    <BreadcrumbItem>\n      <BreadcrumbPage>Billing</BreadcrumbPage>\n    </BreadcrumbItem>\n  </BreadcrumbList>\n</Breadcrumb>"
          },
          {
            "title": "With Next.js links",
            "code": "import Link from \"next/link\";\n\n<Breadcrumb>\n  <BreadcrumbList>\n    <BreadcrumbItem>\n      <BreadcrumbLink render={<Link href=\"/\" />}>Home</BreadcrumbLink>\n    </BreadcrumbItem>\n    <BreadcrumbSeparator />\n    <BreadcrumbItem>\n      <BreadcrumbPage>Docs</BreadcrumbPage>\n    </BreadcrumbItem>\n  </BreadcrumbList>\n</Breadcrumb>"
          },
          {
            "title": "A long trail with the middle left out",
            "code": "<Breadcrumb>\n  <BreadcrumbList>\n    <BreadcrumbItem>\n      <BreadcrumbLink href=\"/\">Home</BreadcrumbLink>\n    </BreadcrumbItem>\n    <BreadcrumbSeparator />\n    <BreadcrumbItem>\n      <BreadcrumbEllipsis />\n    </BreadcrumbItem>\n    <BreadcrumbSeparator />\n    <BreadcrumbItem>\n      <BreadcrumbLink href=\"/team\">Team</BreadcrumbLink>\n    </BreadcrumbItem>\n    <BreadcrumbSeparator />\n    <BreadcrumbItem>\n      <BreadcrumbPage>Members</BreadcrumbPage>\n    </BreadcrumbItem>\n  </BreadcrumbList>\n</Breadcrumb>"
          },
          {
            "title": "Slashes between items",
            "code": "<Breadcrumb>\n  <BreadcrumbList>\n    <BreadcrumbItem>\n      <BreadcrumbLink href=\"/docs\">Docs</BreadcrumbLink>\n    </BreadcrumbItem>\n    <BreadcrumbSeparator>/</BreadcrumbSeparator>\n    <BreadcrumbItem>\n      <BreadcrumbPage>Install</BreadcrumbPage>\n    </BreadcrumbItem>\n  </BreadcrumbList>\n</Breadcrumb>"
          }
        ]
      }
    },
    {
      "name": "breakdown-list",
      "type": "registry:ui",
      "title": "Breakdown List",
      "description": "Rows with their number and a pill bar, inside a card, for parts of a whole or a ranking, with targets, status tones, and change words.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart",
        "@txbrendan/motion-timing",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/breakdown-list.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Parts of a whole or a ranking with readable labels, like survey replies by team or tickets closed by person.",
            "Pass items, each with a label and a value, and a title or aria-label.",
            "Pass max for the value of a full bar, like 100 for percents. It defaults to the largest value.",
            "Give an item a target to draw a tick on its bar, and tone='success' or 'warning' when its value is a status.",
            "Give an item a change, like { text: 'Up 6', tone: 'success' }, for a short word on the right.",
            "Put an icon, a person's photo, or a color swatch in icon.",
            "Pass value: null when a row has no figure. It shows a dash, not a zero."
          ],
          "avoid": [
            "Steps that narrow, like sign-ups to purchases. Use FunnelChart.",
            "A trend over time. Use LineChart or AreaChart.",
            "Many categories compared at once with several series. Use BarChart.",
            "More rows than people can scan, like 50. Show the top ten and link to the rest."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "{ id?: string; label: string; value: number | null; meta?: ReactNode; icon?: ReactNode; target?: number; tone?: \"success\" | \"warning\" | \"danger\"; change?: { text: string; tone?: \"success\" | \"warning\" | \"danger\" } }[]",
            "description": "Required. One row each, in order. A row's tone colors its bar as a status. Left out, the bar takes --chart-1."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "A visible title. It names the list. TypeScript requires title, aria-label, or aria-labelledby."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the list when it has no visible title."
          },
          {
            "name": "max",
            "type": "number",
            "description": "The value of a full bar. Default: the largest value."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions | ((value: number) => string)",
            "description": "How values and targets print. Options work from a server component. A function works from a client component only."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for valueFormat options."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholder rows, as many as items, or three."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data yet\"",
            "description": "Shown when items is empty."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the card. Later classes win, so 'border-0 p-0' drops the card's edge and padding."
          }
        ],
        "a11y": [
          "Renders a <figure> named by its title, or by aria-label. TypeScript requires one. The rows are a list.",
          "Each row reads as its label, then its value, its target, and its change, like 'Design: 84%, target 80%, Up 6'.",
          "A row with no value reads 'no figure', and shows a dash with no bar, so it never reads as zero.",
          "The bars are hidden from screen readers. The track is quiet, so the number in text carries the meaning.",
          "Status bars use the tone's color, and plain bars use --chart-1, which the theme holds to 3:1 on the page.",
          "Bars grow in once the list is on screen, over three times --motion-duration-base. Reduced motion and motion off draw them at once.",
          "Loading is read out through a status line that stays on the page."
        ],
        "examples": [
          {
            "title": "Status bars against a target",
            "code": "<BreakdownList\n  title=\"Survey replies by team\"\n  max={100}\n  valueFormat={{ style: \"unit\", unit: \"percent\" }}\n  items={[\n    { label: \"Design\", value: 92, target: 80, tone: \"success\", change: { text: \"Up 6\", tone: \"success\" } },\n    { label: \"Engineering\", value: 64, target: 80, tone: \"warning\" },\n    { label: \"Marketing\", value: null },\n  ]}\n/>"
          },
          {
            "title": "A ranking with icons",
            "code": "import { Trophy, UserRound } from \"lucide-react\";\n\n<BreakdownList\n  aria-label=\"Tickets closed by person\"\n  items={[\n    { label: \"Ava Clarke\", value: 14, icon: <Trophy /> },\n    { label: \"Sam Okafor\", value: 11, icon: <UserRound /> },\n  ]}\n/>"
          },
          {
            "title": "While it loads",
            "code": "<BreakdownList title=\"Survey replies by team\" items={data ?? []} loading={isLoading} />"
          }
        ]
      }
    },
    {
      "name": "bulk-bar",
      "type": "registry:ui",
      "title": "Bulk Bar",
      "description": "The bar that rises while rows are selected, with the count and the verbs that work on all of them. Its keys work while it shows, and Esc clears the selection.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/tool-button",
        "@txbrendan/action-keys",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/bulk-bar.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A list or table where people pick rows, then act on all of them at once, like Remind or Mark available.",
            "Pass the same action objects your rows give HoverActions, so each verb works on one row or many.",
            "Use floating over a long list, inside a parent with position: relative. Leave room at the bottom so it never hides the last row.",
            "To place it yourself, like sticky at the bottom of a long page, leave floating off and wrap it in your own box.",
            "Give an action a result when its press changes something, like 'Sent to 3', so the button says what happened, with Undo."
          ],
          "avoid": [
            "A single row's actions. Use HoverActions or RowActions.",
            "More than three or four verbs. Put the rest in a Menu.",
            "A bar that shows with nothing selected. It leaves at count 0 on its own."
          ]
        },
        "props": [
          {
            "name": "count",
            "type": "number",
            "description": "Required. How many rows are selected. At 0 the bar goes away."
          },
          {
            "name": "noun",
            "type": "[string, string]",
            "default": "[\"item\", \"items\"]",
            "description": "The selected thing, singular and plural, like ['person', 'people']."
          },
          {
            "name": "actions",
            "type": "BulkBarAction[]",
            "default": "[]",
            "description": "The verbs for the selection. Each shows its icon and label, or its icon only when the labels do not fit on one line. A shortcut works while the bar shows, and the tooltip teaches it."
          },
          {
            "name": "BulkBarAction",
            "type": "{ label: string; icon?: ReactNode; shortcut?: string; tone?: \"neutral\" | \"danger\"; onClick?: () => void | Promise<unknown>; result?: ActionResult; disabled?: boolean }",
            "description": "Type. The same shape as HoverAction. A promise from onClick shows a spinner, and a failure shows Retry. result is ToolButton's ActionResult."
          },
          {
            "name": "onClear",
            "type": "() => void",
            "description": "Clears the selection. It adds the clear button, and Esc calls it too."
          },
          {
            "name": "floating",
            "type": "boolean",
            "default": "false",
            "description": "Floats 1rem above the bottom of the nearest positioned parent, centered. Otherwise it sits in the flow, centered, and keeps its space."
          },
          {
            "name": "keys",
            "type": "boolean",
            "default": "true",
            "description": "Listens for the actions' keys and Esc while the bar shows."
          },
          {
            "name": "clearLabel",
            "type": "string",
            "default": "\"Clear selection\"",
            "description": "Names the clear button."
          },
          {
            "name": "ref",
            "type": "Ref<HTMLDivElement>",
            "description": "Reaches the toolbar."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the toolbar. Later classes win."
          }
        ],
        "a11y": [
          "The bar is a toolbar named by its count, like '2 people selected'. It is one Tab stop, and the arrow keys move between its buttons.",
          "The count sits in a polite live region, so screen readers hear it change.",
          "While it is away, the bar is inert. It leaves the Tab order and the accessibility tree, and it ignores the pointer.",
          "If focus is inside when the bar leaves, focus goes back to where it came from.",
          "When the labels do not fit on one line, the actions show their icons only. Each keeps its name, and its tooltip shows the label and key.",
          "Keys are ignored while someone types, while a modifier is held, and inside an open dialog or menu. A control marked data-owns-escape keeps its Esc.",
          "The clear button sets aria-keyshortcuts to Escape, and each action with a single-key shortcut sets its own."
        ],
        "examples": [
          {
            "title": "Over a list",
            "code": "import { BellRing, ThumbsUp } from \"lucide-react\";\nimport { useState } from \"react\";\n\nfunction People({ names }: { names: string[] }) {\n  const [picked, setPicked] = useState<string[]>([]);\n  return (\n    <div className=\"relative pb-16\">\n      {names.map((name) => (\n        <label key={name} className=\"flex items-center gap-2\">\n          <input\n            type=\"checkbox\"\n            checked={picked.includes(name)}\n            onChange={(event) => setPicked((last) => (event.target.checked ? [...last, name] : last.filter((n) => n !== name)))}\n          />\n          {name}\n        </label>\n      ))}\n      <BulkBar\n        floating\n        count={picked.length}\n        noun={[\"person\", \"people\"]}\n        onClear={() => setPicked([])}\n        actions={[\n          { icon: <BellRing />, label: \"Remind\", shortcut: \"R\", result: { label: `Sent to ${picked.length}` } },\n          { icon: <ThumbsUp />, label: \"Mark available\", shortcut: \"A\", onClick: () => setPicked([]) },\n        ]}\n      />\n    </div>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "button-variants",
      "type": "registry:ui",
      "title": "Button Variants",
      "description": "The classes behind Button, for a link that should look like a button. It works in a server file.",
      "dependencies": [
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/button-variants.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A link to another page that should look like a button. Put buttonVariants() in its className.",
            "A server file, like a page or a layout. Import buttonVariants from here. Button runs in the browser, so a server file cannot call a function from it.",
            "The same options as Button: tone, emphasis, size, and iconOnly."
          ],
          "avoid": [
            "Running an action on the page. Use Button. It brings the loading state, the word morph, and the texture.",
            "Words that may run long. A link styled this way does not cut its words, so keep them short."
          ]
        },
        "props": [
          {
            "name": "buttonVariants",
            "type": "(options?: { tone?: \"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"; emphasis?: \"solid\" | \"soft\" | \"outline\" | \"ghost\"; size?: \"sm\" | \"md\" | \"lg\"; iconOnly?: boolean }) => string",
            "description": "Helper. The classes for one look of Button. Defaults: tone \"neutral\", emphasis \"solid\", size \"md\", iconOnly false."
          }
        ],
        "a11y": [
          "It only adds classes. The element keeps its own role, so a link is still read and used as a link.",
          "The focus ring, hover, and press are the same as Button's, and follow the --motion-* tokens.",
          "An icon-only link needs an aria-label that names where it goes, and a Tooltip that shows the same words."
        ],
        "examples": [
          {
            "title": "A link that looks like a soft brand button",
            "code": "import Link from \"next/link\";\nimport { buttonVariants } from \"@/components/ui/button-variants\";\n\n<Link href=\"/pricing\" className={buttonVariants({ tone: \"brand\", emphasis: \"soft\" })}>\n  See pricing\n</Link>"
          },
          {
            "title": "An icon-only link with a tooltip (needs @txbrendan/tooltip)",
            "code": "import Link from \"next/link\";\nimport { Settings } from \"lucide-react\";\nimport { buttonVariants } from \"@/components/ui/button-variants\";\nimport { Tooltip, TooltipPopup, TooltipTrigger } from \"@/components/ui/tooltip\";\n\n<Tooltip>\n  <TooltipTrigger render={<Link href=\"/settings\" aria-label=\"Settings\" className={buttonVariants({ emphasis: \"ghost\", iconOnly: true })} />}>\n    <Settings />\n  </TooltipTrigger>\n  <TooltipPopup>Settings</TooltipPopup>\n</Tooltip>"
          }
        ]
      }
    },
    {
      "name": "button",
      "type": "registry:ui",
      "title": "Button",
      "description": "A button that runs an action, with tones, emphasis levels, sizes, and a loading state.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/button-variants",
        "@txbrendan/dither",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/button.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Running an action on the current page: save, submit, open a dialog, delete.",
            "Use tone to say what kind of action it is and emphasis to say how important it is.",
            "Use one solid brand button per view for the main action. Use soft, outline, or ghost for the rest.",
            "Use loading while an action is running. It keeps the width and blocks repeat clicks.",
            "Change the words to show a new state in place, like Save to Saved. The button eases to the new width while the words crossfade. Compose changing words and icons with MotionSwap for one coordinated transition.",
            "Words too long for the space end in an ellipsis, beside an icon too. In a flex row, give the box that holds the button min-w-0. In a row that does not wrap, also add className=\"min-w-0 shrink\" to the button. Words inside your own element, like a span or a fragment, are yours to cut. A link styled with buttonVariants() does not cut its words.",
            "When a press cannot go ahead, answer on the button itself: call shake() on it, and change its words to what is missing, like \"Agree to First aid consent first\". The button stays where it was pressed."
          ],
          "avoid": [
            "Going to another page. Use a link styled with buttonVariants() so it stays a link. In a server file, import it from button-variants, since Button runs in the browser.",
            "Turning a setting on and off. Use a switch or a toggle instead.",
            "Disabling a submit button to hide form errors. Keep it enabled and show the errors.",
            "Showing why a press failed in a new line above the button. It pushes the button out from under the pointer. Say it on the button, or beside or below it.",
            "Using tone='danger' for anything that is not destructive."
          ]
        },
        "props": [
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color role."
          },
          {
            "name": "emphasis",
            "type": "\"solid\" | \"soft\" | \"outline\" | \"ghost\"",
            "default": "\"solid\"",
            "description": "How loud the button is."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height and padding. Density scales them."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows a spinner, keeps the width, blocks clicks, and stays focusable."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks interaction and removes the button from the tab order."
          },
          {
            "name": "iconOnly",
            "type": "boolean",
            "default": "false",
            "description": "Square button for one icon. TypeScript requires aria-label when true."
          },
          {
            "name": "render",
            "type": "ReactElement",
            "description": "Render prop. Renders this element instead of the <button>, with the same props. Pass nativeButton={false} if it is not a <button>."
          },
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\" | \"gradient\" | \"grain\"",
            "description": "Adds dots, a soft shine, or grain to the lower half, and a glow on dark pages, on solid and outline. Hover clears the shine and grain. Soft and ghost stay flat. buttonVariants on a link stays flat. Left out, the button follows the Texture around it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='rounded-none' replaces the radius."
          },
          {
            "name": "shake",
            "type": "(element: HTMLElement | null) => void",
            "description": "Shakes a button once, side to side, to say a press could not go ahead. It ends where it started, and does nothing when motion is off or reduced."
          }
        ],
        "a11y": [
          "Renders a native <button type=\"button\">, so Enter and Space work and screen readers announce a button.",
          "loading sets aria-disabled and aria-busy and keeps focus on the button, so keyboard users do not lose their place.",
          "iconOnly requires aria-label at the type level. The label names the action, like \"Delete row\", not the icon.",
          "The focus ring uses --ring, which the theme tests hold to 3:1 against the page and cards.",
          "Press scale follows the --motion-* tokens, and reduced motion turns it off. The spinner turns at a fixed speed.",
          "Words that change are read as the button's new name. The old words are hidden from screen readers while they fade. A shake says nothing, so pair it with a message that is read out, like role=\"alert\".",
          "The label transition and shake follow the --motion-* tokens. Reduced motion lands the new width at once, keeps only the words' fade, and skips the shake."
        ],
        "examples": [
          {
            "title": "Main action",
            "code": "<Button tone=\"brand\">Save changes</Button>"
          },
          {
            "title": "Destructive action with a quieter cancel",
            "code": "<div className=\"flex gap-2\">\n  <Button emphasis=\"ghost\">Cancel</Button>\n  <Button tone=\"danger\">Delete project</Button>\n</div>"
          },
          {
            "title": "Loading while a form submits",
            "code": "\"use client\";\nimport { useFormStatus } from \"react-dom\";\n\nfunction SubmitButton() {\n  const { pending } = useFormStatus();\n  return (\n    <Button type=\"submit\" tone=\"brand\" loading={pending}>\n      Create account\n    </Button>\n  );\n}"
          },
          {
            "title": "Icon-only",
            "code": "import { Trash2 } from \"lucide-react\";\n\n<Button iconOnly aria-label=\"Delete row\" emphasis=\"ghost\" tone=\"danger\">\n  <Trash2 />\n</Button>"
          },
          {
            "title": "A press that cannot go ahead says why on the button",
            "code": "\"use client\";\nimport { useRef, useState } from \"react\";\nimport { Button, shake } from \"@/components/ui/button\";\n\nfunction ContinueButton({ ready, onContinue }: { ready: boolean; onContinue: () => void }) {\n  const [asking, setAsking] = useState(false);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n  return (\n    <>\n      <Button\n        tone={asking ? \"danger\" : \"brand\"}\n        emphasis={asking ? \"soft\" : \"solid\"}\n        onClick={(event) => {\n          if (ready) return onContinue();\n          shake(event.currentTarget);\n          setAsking(true);\n          clearTimeout(timer.current);\n          timer.current = setTimeout(() => setAsking(false), 3200);\n        }}\n      >\n        {asking ? \"Fill in your email first\" : \"Continue\"}\n      </Button>\n      {asking && (\n        <span role=\"alert\" className=\"sr-only\">\n          Fill in your email first.\n        </span>\n      )}\n    </>\n  );\n}"
          },
          {
            "title": "A link that looks like a button",
            "code": "import Link from \"next/link\";\nimport { buttonVariants } from \"@/components/ui/button-variants\";\n\n<Link href=\"/pricing\" className={buttonVariants({ tone: \"brand\", emphasis: \"soft\" })}>\n  See pricing\n</Link>"
          },
          {
            "title": "Grain on the lower half, cleared on hover",
            "code": "<Button tone=\"brand\" texture=\"grain\">Save changes</Button>"
          }
        ]
      }
    },
    {
      "name": "calendar",
      "type": "registry:ui",
      "title": "Calendar",
      "description": "A month grid for picking one date or a range, with keyboard moves, date limits, and names in the user's language.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/calendar.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "dates",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Picking a date or a range when people need to see the days around it: a booking, a deadline, a trip.",
            "Give the calendar a name: aria-label, like \"Check-in\", or aria-labelledby with the id of a visible heading.",
            "Values are date strings: \"2026-10-20\" for one date, and { start, end } of them for a range. A date string has no time and no time zone, so it never moves a day on its way between the browser, the server, and the database. A Date object carries both, which causes off-by-one-day bugs.",
            "It works uncontrolled with defaultValue, even from a server component. Pass value with onValueChange to control it.",
            "In range mode, the first pick sends a one-day range, and the second pick sends the full range. The next pick starts over.",
            "A range people pick never covers a disabled date. After the first pick, days past the nearest one on each side cannot be picked. To start over, pick the first day again.",
            "Set name to send the value with a form: \"2026-10-20\", or \"2026-10-12/2026-10-16\" for a range.",
            "The server cannot know the user's date or language. It renders with no today mark, in en-US unless you pass locale, and the browser fills both in after hydration. Pass locale to skip that switch.",
            "With no value, defaultMonth, or month, the server sends a blank grid of the same size, and the browser shows the current month."
          ],
          "avoid": [
            "Dates far from today, like a birth date. Typing is faster there, so use an input.",
            "Picking a time. This calendar picks days only.",
            "Ranges that may cover a disabled date, like a stay over a holiday. After the first pick, a range stops at the nearest one.",
            "Picking many separate dates. It picks one date or one range."
          ]
        },
        "props": [
          {
            "name": "mode",
            "type": "\"single\" | \"range\"",
            "default": "\"single\"",
            "description": "Pick one date, or a start and an end date."
          },
          {
            "name": "defaultValue",
            "type": "string | { start: string; end: string } | null",
            "description": "The picked date at first render, as \"YYYY-MM-DD\". A { start, end } range in range mode."
          },
          {
            "name": "value",
            "type": "string | { start: string; end: string } | null",
            "description": "Controlled value. Use it with onValueChange."
          },
          {
            "name": "onValueChange",
            "type": "(value: string | { start: string; end: string }) => void",
            "description": "Called with the new value on each pick."
          },
          {
            "name": "min",
            "type": "string",
            "description": "The earliest date people can pick, as \"YYYY-MM-DD\". Earlier days are disabled, and focus stops there."
          },
          {
            "name": "max",
            "type": "string",
            "description": "The latest date people can pick. Later days are disabled, and focus stops there."
          },
          {
            "name": "disabledDates",
            "type": "string[] | ((date: string) => boolean)",
            "description": "Days people cannot pick. They can still get focus, and a range people pick cannot cross one. A function only works from a client component."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks picking and moving, and takes the calendar out of the tab order."
          },
          {
            "name": "weekStartsOn",
            "type": "0 | 1 | 2 | 3 | 4 | 5 | 6",
            "description": "The first column: 0 is Sunday, 1 is Monday. Defaults to the locale's custom."
          },
          {
            "name": "locale",
            "type": "string",
            "description": "A language tag for month and day names, like \"de-DE\". Defaults to the browser's language."
          },
          {
            "name": "defaultMonth",
            "type": "string",
            "description": "The month on screen at first render, as \"YYYY-MM\". Defaults to the picked date's month, or today's."
          },
          {
            "name": "month",
            "type": "string",
            "description": "Controlled month, as \"YYYY-MM\". Use it with onMonthChange."
          },
          {
            "name": "onMonthChange",
            "type": "(month: string) => void",
            "description": "Called with the new \"YYYY-MM\" when the month on screen changes."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Day size and text size. Days are as tall as controls of the same size. Density scales them."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the picked days."
          },
          {
            "name": "name",
            "type": "string",
            "description": "Form field name. Sends \"YYYY-MM-DD\", or \"YYYY-MM-DD/YYYY-MM-DD\" for a range."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the calendar, like \"Check-in\". TypeScript requires this or aria-labelledby. The grid is already named by the month."
          },
          {
            "name": "aria-labelledby",
            "type": "string",
            "description": "The id of a visible heading that names the calendar. Use it in place of aria-label."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the outer box. Later classes win, so className='p-0' drops the padding."
          }
        ],
        "a11y": [
          "TypeScript requires aria-label or aria-labelledby, so screen readers say what the date is for.",
          "The days form a grid (role=\"grid\") named by the month title. Each day is a gridcell named with its full date, like \"Tuesday, October 20, 2026\", in the calendar's language.",
          "Weekday headers show short names, or one letter in languages whose short names are long, like Arabic. Each header keeps the full day name in its abbr attribute.",
          "One day is in the tab order: the last focused day, else the picked day, else today, else the first day of the month.",
          "Arrow keys move by day and by week. Home and End go to the start and end of the week. Page Up and Page Down move by month, and with Shift by year. Enter or Space picks the day.",
          "Left and Right Arrow swap in right-to-left text, when a parent has dir=\"rtl\".",
          "Picked days have aria-selected, today has aria-current=\"date\", and days people cannot pick have aria-disabled.",
          "Disabled dates can still get focus, so people can hear them, but they cannot be picked. Days outside min and max cannot get focus.",
          "In range mode, after the first pick, days past the nearest disabled date get aria-disabled until the second pick. They can still get focus, but Enter and clicks do nothing there.",
          "The month buttons are named by the month they show, like \"November 2026\", so their names follow the locale. At min or max they set aria-disabled and keep focus.",
          "The month title is a polite live region, so screen readers hear the new month.",
          "Picked days use the tone's solid pair, and days inside a range use its soft pair. The theme tests hold both to 4.5:1.",
          "Hovered days use the accent pair, and weekday names use muted text. The theme tests hold both to 4.5:1. The focus ring uses --ring, held to 3:1 on the page and cards.",
          "Days people cannot pick are dimmed to half strength, and days from disabledDates are also struck through.",
          "On a screen narrower than the calendar, the days get narrower to fit, so the page never scrolls sideways."
        ],
        "examples": [
          {
            "title": "Pick a date",
            "code": "<Calendar aria-label=\"Due date\" name=\"due\" defaultValue=\"2026-10-20\" />"
          },
          {
            "title": "A range between two limits",
            "code": "<Calendar\n  aria-label=\"Trip\"\n  mode=\"range\"\n  tone=\"brand\"\n  min=\"2026-10-01\"\n  max=\"2026-12-31\"\n  defaultValue={{ start: \"2026-10-12\", end: \"2026-10-16\" }}\n/>"
          },
          {
            "title": "Days people cannot pick",
            "code": "<Calendar\n  aria-label=\"Delivery day\"\n  defaultMonth=\"2026-12\"\n  disabledDates={[\"2026-12-25\", \"2026-12-26\"]}\n/>"
          },
          {
            "title": "Controlled, in one language",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nfunction DueDate() {\n  const [date, setDate] = useState<string | null>(null);\n  return <Calendar aria-label=\"Fällig am\" locale=\"de-DE\" value={date} onValueChange={setDate} />;\n}"
          },
          {
            "title": "No weekends, from a client component",
            "code": "\"use client\";\n\n// A \"YYYY-MM-DD\" string parses as midnight UTC, so getUTCDay gives its weekday.\nconst weekend = (date: string) => [0, 6].includes(new Date(date).getUTCDay());\n\nfunction MeetingDay() {\n  return <Calendar aria-label=\"Meeting day\" disabledDates={weekend} />;\n}"
          },
          {
            "title": "Show a picked date",
            "code": "// Format in UTC, the zone a date string parses in, so the day never moves.\nconst label = new Intl.DateTimeFormat(\"en-US\", { dateStyle: \"medium\", timeZone: \"UTC\" }).format(\n  new Date(\"2026-10-20\"),\n);\n// \"Oct 20, 2026\""
          }
        ]
      }
    },
    {
      "name": "call-to-action",
      "type": "registry:ui",
      "title": "Call To Action",
      "description": "A next step offered in a card: a scene that shows it, what it is, why, how far along you are, and the step itself.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/card",
        "@txbrendan/icon",
        "@txbrendan/ring-progress",
        "@txbrendan/tool-button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/call-to-action.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Where the space would sit empty otherwise: a workspace still setting up, a feature nobody has tried yet.",
            "title says what the step is. text says why, in a line or two. Put a Scene in scene to show it.",
            "main is the step itself, a primary button with its icon. later is the quiet way out: \"Skip\" or \"Later\".",
            "Pass progress, like { done: 3, of: 4 }, when the step is one of a set.",
            "Give main a result, like { label: \"Invites sent\", onUndo, onCommit }, to show what the press did on the button, with Undo for 8 seconds."
          ],
          "avoid": [
            "More than one on a screen.",
            "Warnings or news. Use an alert.",
            "Steps people can't skip. Put them in the page's flow."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "string",
            "description": "Required. What the step is: \"Invite your team\". It names the card for screen readers."
          },
          {
            "name": "text",
            "type": "ReactNode",
            "description": "Why, in a line or two."
          },
          {
            "name": "scene",
            "type": "ReactNode",
            "description": "A Scene, inset 6px from the card's edge."
          },
          {
            "name": "progress",
            "type": "{ done: number; of: number }",
            "description": "How far along a set of steps is: a small ring and \"3 of 4\"."
          },
          {
            "name": "main",
            "type": "{ label: string; icon?: IconName | ReactNode; onClick?: () => void | Promise<unknown>; result?: ActionResult; loading?: boolean; disabled?: boolean }",
            "description": "Required. The step itself: a brand button with its icon. icon is a name from Icon's set or any icon. A returned promise shows it's busy."
          },
          {
            "name": "later",
            "type": "{ label: string; onClick?: () => void }",
            "description": "The quiet way out, a ghost button before the step."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the card. Later classes win."
          }
        ],
        "a11y": [
          "The card is a region named by its title.",
          "Tab goes to the way out, then the step. Both are buttons.",
          "Progress reads as \"3 of 4 done\". The ring is hidden, since the words say it.",
          "A result changes the step's own name, like \"Invites sent. Press to undo.\", and is said in a polite live region.",
          "Undo only undoes once 1.2 seconds have passed, so a double press never undoes. ⌘Z takes it back too."
        ],
        "examples": [
          {
            "title": "With progress, a way out, and Undo",
            "code": "import { UserPlus } from \"lucide-react\";\n\n<CallToAction\n  scene={\n    <Scene>\n      <SceneHint icon={<UserPlus />} label=\"Invite people\" />\n    </Scene>\n  }\n  title=\"Invite your team\"\n  text=\"They see the plan and pick up work, without asking you.\"\n  progress={{ done: 3, of: 4 }}\n  later={{ label: \"Skip\", onClick: () => console.log(\"skip\") }}\n  main={{\n    label: \"Invite people\",\n    icon: <UserPlus />,\n    result: { label: \"Invites sent\", onUndo: () => {}, onCommit: () => console.log(\"send invites\") },\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "card-field",
      "type": "registry:ui",
      "title": "Card Field",
      "description": "Card details as one group: the number, the expiry (MM / YY), and the security code, with one line of help or error under it. The number groups itself as it is typed and names the brand in words.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/field-frame",
        "@txbrendan/input",
        "@txbrendan/status-text"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/card-field.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Taking a card to pay for an order, a plan, or a booking.",
            "It is the look and the checks, for demos and prototypes. In production, use the payment provider's own hosted fields, so card numbers never reach your server.",
            "Set showErrors when Pay is pressed, rather than disabling Pay. Every problem then shows at once.",
            "When the bank says no, pass declined with why, like \"Nothing was taken.\", and onRetry. Clear it when the details change.",
            "Offer saved cards as selection cards above it, and this field for a new one."
          ],
          "avoid": [
            "Sending card numbers to your own server. Use the payment provider's hosted fields.",
            "Bank details or other numbers. Use an input.",
            "Showing a saved card. Show its brand and last four digits as text."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "{ number: string; expiry: string; cvc: string }",
            "description": "The parts as typed, like \"4242 4242 4242 4242\", \"12 / 28\", and \"123\". Pass it with onValueChange to control the field."
          },
          {
            "name": "defaultValue",
            "type": "Partial<CardValue>",
            "description": "The parts to start with, when the field keeps its own value."
          },
          {
            "name": "onValueChange",
            "type": "(value: CardValue, info: { brand: \"Visa\" | \"Mastercard\" | \"Amex\" | null; last4: string; complete: boolean }) => void",
            "description": "Called on each change, with the brand, the last four digits, and whether every part is complete and right."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Names the group, above it. Pressing it focuses the number. Without one, the group is named \"Card details\"."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "A short note under the group."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Other problems, in red under the group, with red rings on every part."
          },
          {
            "name": "showErrors",
            "type": "boolean",
            "default": "false",
            "description": "Shows every problem, empty parts too. Set it when Pay is pressed."
          },
          {
            "name": "declined",
            "type": "boolean | string",
            "description": "The bank declined it. \"Declined\" shows in red, then the string says why, in ink."
          },
          {
            "name": "onRetry",
            "type": "() => void | Promise<unknown>",
            "description": "With declined: a retry icon ending the line, named \"Try again\". Return a promise, and it waits on it."
          },
          {
            "name": "today",
            "type": "string",
            "description": "Today for the expiry check, as \"2026-09-29\", for demos and tests. Default: the browser's today, once the field is on the page."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height, padding, and text size of every part."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Turns every part off."
          },
          {
            "name": "ref",
            "type": "Ref<HTMLInputElement>",
            "description": "Reaches the card number input, where focus starts."
          },
          {
            "name": "id",
            "type": "string",
            "description": "Lands on the group, with aria-* and data-*. Its aria-describedby joins the note's."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the frame around the label, the group, and the note."
          }
        ],
        "a11y": [
          "The three parts sit in a group named by the label, or \"Card details\".",
          "Each part has its own name: \"Card number\", \"Expiry date, MM / YY\", and \"Security code\".",
          "The brand shows in words, never a logo, and describes the number input. It is said politely when it changes.",
          "A part with a problem is marked invalid and described by the note under the group, which is announced when it appears.",
          "Focus moves on once a part is complete and right. Backspace in an empty part goes back to the one before.",
          "The parts use the browser's card autofill and a number keypad, and take no name, so a plain form never posts the digits."
        ],
        "examples": [
          {
            "title": "Start with a test card",
            "code": "<CardField\n  label=\"Card\"\n  defaultValue={{ number: \"4242 4242 4242 4242\", expiry: \"12 / 28\", cvc: \"123\" }}\n  today=\"2026-09-29\"\n/>"
          },
          {
            "title": "The bank declined it",
            "code": "<CardField\n  label=\"Card\"\n  defaultValue={{ number: \"4000 0000 0000 0002\", expiry: \"12 / 28\", cvc: \"123\" }}\n  declined=\"Nothing was taken.\"\n  onRetry={() => {}}\n  today=\"2026-09-29\"\n/>"
          }
        ]
      }
    },
    {
      "name": "card",
      "type": "registry:ui",
      "title": "Card",
      "description": "A bordered surface that groups related content, with header, title, description, content, and footer parts.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/card.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Grouping one topic so it reads as a unit: a plan, a stat, a settings section, a list item with actions.",
            "Laying out several items of the same kind in a grid.",
            "Use only the parts you need. A card with just CardContent is fine.",
            "Density sets the padding and the space between parts, so cards tighten and loosen with the rest of the app."
          ],
          "avoid": [
            "Wrapping the whole page, or putting cards inside cards. Use headings and spacing instead.",
            "Important messages. Use an Alert, so the message has a tone.",
            "Making the whole card a click target with onClick on the div. Put a real link or button inside it.",
            "Rows of a table. Use a table, so screen readers can move by row and column."
          ]
        },
        "props": [
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on any part. Later classes win, so className='px-0' on CardContent lets a table run edge to edge."
          },
          {
            "name": "Card",
            "type": "ComponentProps<\"div\">",
            "description": "The surface. Pads top and bottom with --space-inset and spaces its parts with --space-stack."
          },
          {
            "name": "CardHeader",
            "type": "ComponentProps<\"div\">",
            "description": "Part. Stacks the title and description."
          },
          {
            "name": "CardTitle",
            "type": "ComponentProps<\"div\">",
            "description": "Part. Semibold title. A div, so put an <h2> or <h3> inside when the card needs a heading."
          },
          {
            "name": "CardDescription",
            "type": "ComponentProps<\"div\">",
            "description": "Part. Secondary text in muted-foreground."
          },
          {
            "name": "CardContent",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The body. Pads its sides with --space-inset."
          },
          {
            "name": "CardFooter",
            "type": "ComponentProps<\"div\">",
            "description": "Part. A row for actions or a summary. It wraps on small screens."
          }
        ],
        "a11y": [
          "Every part is a plain <div> with no role, so a card adds no noise for screen readers.",
          "CardTitle is not a heading. Put an <h2> or <h3> inside it at the right level when people should be able to jump between cards.",
          "Keep links and buttons inside the card. A clickable div is not reachable by keyboard.",
          "The theme tests hold card-foreground and muted-foreground to 4.5:1 on the card color, in light and dark."
        ],
        "examples": [
          {
            "title": "Header, content, and footer (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Card className=\"max-w-sm\">\n  <CardHeader>\n    <CardTitle>Pro plan</CardTitle>\n    <CardDescription>For teams that ship every week.</CardDescription>\n  </CardHeader>\n  <CardContent>\n    <p className=\"text-2xl font-semibold\">$24 per month</p>\n  </CardContent>\n  <CardFooter>\n    <Button tone=\"brand\">Upgrade</Button>\n    <Button emphasis=\"ghost\">Compare plans</Button>\n  </CardFooter>\n</Card>"
          },
          {
            "title": "A stat with only content",
            "code": "<Card>\n  <CardContent>\n    <p className=\"text-sm text-muted-foreground\">Active users</p>\n    <p className=\"text-3xl font-semibold tabular-nums\">12,480</p>\n  </CardContent>\n</Card>"
          },
          {
            "title": "A heading inside the title",
            "code": "<Card>\n  <CardHeader>\n    <CardTitle>\n      <h2>Billing</h2>\n    </CardTitle>\n    <CardDescription>Your next invoice is on 1 November.</CardDescription>\n  </CardHeader>\n</Card>"
          },
          {
            "title": "A list that runs edge to edge",
            "code": "<Card>\n  <CardHeader>\n    <CardTitle>Recent deploys</CardTitle>\n  </CardHeader>\n  <CardContent className=\"px-0\">\n    <ul className=\"divide-y border-y text-sm\">\n      <li className=\"px-(--space-inset) py-2\">main · 2 minutes ago</li>\n      <li className=\"px-(--space-inset) py-2\">fix-login · 1 hour ago</li>\n    </ul>\n  </CardContent>\n</Card>"
          }
        ]
      }
    },
    {
      "name": "champion-card",
      "type": "registry:ui",
      "title": "Champion Card",
      "description": "The champion's slot: a dashed \"Winner to be decided\" until crowned, then a warm card that springs in with sparkles and 3 seconds of confetti.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/champion-card.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The end of a bracket or a finals board, where the winner goes.",
            "Leave champion out until the final is decided. Then pass { name }, and the card springs in.",
            "Pass celebrate={false} for a past winner, so a page that loads already crowned throws no confetti.",
            "Use subtitle to say what they won, like \"Cup winners 2026\". It replaces the Champion badge.",
            "Set the width with className. It is 200px by default."
          ],
          "avoid": [
            "Ordinary results, like a match winner. Keep the confetti for the one champion.",
            "More than one on a page, since each throws its own confetti."
          ]
        },
        "props": [
          {
            "name": "champion",
            "type": "{ name: string } | null",
            "description": "Who won. null or left out until the winner is decided."
          },
          {
            "name": "subtitle",
            "type": "string",
            "default": "\"Champion\"",
            "description": "The badge on the crowned card."
          },
          {
            "name": "celebrate",
            "type": "boolean",
            "default": "true",
            "description": "Throws confetti when crowned. false crowns it without."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Set the width here, like w-64. Later classes win."
          }
        ],
        "a11y": [
          "The root is a polite live region, so crowning it while someone is on the page says the winner's name and badge.",
          "The trophy, sparkles, and confetti are hidden from screen readers.",
          "Under reduced motion the card and its sparkles only fade in, and it throws no confetti. With motion off, it crowns in place.",
          "The crowned card's text is the warning tone's soft text on its soft fill, and the badge is its solid pair. The theme holds both to 4.5:1.",
          "The confetti never takes a press."
        ],
        "examples": [
          {
            "title": "Before the final",
            "code": "<ChampionCard />"
          },
          {
            "title": "Crowned",
            "code": "<ChampionCard champion={{ name: \"Rovers\" }} subtitle=\"Cup winners 2026\" />"
          },
          {
            "title": "A past winner, with no confetti",
            "code": "<ChampionCard champion={{ name: \"Lions\" }} subtitle=\"Cup winners 2025\" celebrate={false} />"
          }
        ]
      }
    },
    {
      "name": "chart-key",
      "type": "registry:ui",
      "title": "Chart Key",
      "description": "A key for a chart's title row that shows the point under the pointer, with numbers that roll as it moves. Area Chart, Bar Chart, and Line Chart use it for legend=\"top\".",
      "dependencies": [
        "recharts@^3.10.1",
        "react-is@19.3.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart",
        "@txbrendan/glide-highlight",
        "@txbrendan/morph-label",
        "@txbrendan/motion-timing",
        "@txbrendan/rolling-number",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/chart-key.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A chart that shows its values in the title row in place of a tooltip box. Pass legend=\"top\" to Area Chart, Bar Chart, or Line Chart, which use this for you.",
            "A chart of your own on the chart base. Call useChartKey, pass its key as the Chart's legendContent, set legendPosition to \"top\", and put ChartKeyTracker inside the Recharts chart.",
            "Each series in the key is a button that hides or shows it. Give each series' layer keySeriesClass(hidden.has(key)) so it fades, and tooltipType=\"none\" while hidden. In a stack, pass the data through withoutHidden(data, gone), so the rest close the gap once it has faded."
          ],
          "avoid": [
            "A chart people read point by point with many series. Past three series the key wraps, and a tooltip by the pointer is easier to read.",
            "Charts with no order on the x axis, like a donut. The key follows the point under the pointer along the x axis."
          ]
        },
        "props": [
          {
            "name": "useChartKey",
            "type": "(options: { data; categoryKey; series; valueFormat?; locale? }) => { key; point; hidden; gone }",
            "description": "Hook. Builds the key for the Chart's legendContent, and the point to pass to ChartKeyTracker. At rest it shows the last point. hidden holds the series turned off in the key. gone holds those whose fade has ended."
          },
          {
            "name": "ChartKeyTracker",
            "type": "({ point }) => null",
            "description": "Put it inside the Recharts chart. It tells the key which point is under the pointer or the keyboard. It draws nothing."
          },
          {
            "name": "keySeriesClass",
            "type": "(hidden: boolean) => string",
            "description": "Classes for a series' layer, so a hidden one fades out where it is over --motion-duration-base."
          },
          {
            "name": "withoutHidden",
            "type": "(data, gone) => data",
            "description": "The data with each series in gone at zero, for a stacked chart. Recharts glides the stack to its new shape."
          }
        ],
        "a11y": [
          "The key sits beside the chart's title, not in it, so the chart's name never changes as the key moves.",
          "Each value holds the width of its longest one, so the key stays still as its numbers roll. Each number keeps its full text for screen readers.",
          "Keep the chart's tooltip on the page with the sr-only class, so the keyboard layer still reads each point out.",
          "Each series is a toggle button with aria-pressed. Hidden, its swatch empties to an outline and its value dims, and nothing changes size. The last series shown is aria-disabled, so the plot never goes blank.",
          "One highlight glides between the buttons as the pointer moves, and a press darkens it. Each button shows the focus ring from the keyboard.",
          "The digits roll on the motion variables. With motion off or reduced, they change at once.",
          "While the chart loads, each value holds a placeholder and the buttons are aria-disabled. When the values land, the buttons glide to their new places. With motion off or reduced, they move at once."
        ],
        "examples": [
          {
            "title": "Key beside the title, on a line chart",
            "code": "<LineChart\n  title=\"Visits by device\"\n  data={[\n    { day: \"Mon\", desktop: 1250, mobile: 860 },\n    { day: \"Tue\", desktop: 1410, mobile: 920 },\n  ]}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"day\"\n  legend=\"top\"\n/>"
          }
        ]
      }
    },
    {
      "name": "chart",
      "type": "registry:ui",
      "title": "Chart",
      "description": "The base every chart builds on: a frame that holds its height, theme series colors, a themed tooltip and legend, and loading, empty, and error views.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/motion-timing",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/chart.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Building a chart we do not ship, like a scatter or radar chart, so it looks and behaves like the others.",
            "Put the Recharts chart inside Chart's render function. Spread `name` on the chart, `animation` on each series, and `tooltipAnimation` on the Tooltip.",
            "Wrap the Recharts chart in Recharts' AnimationControllerProvider with `animationController`, so it draws in once it is on screen and ends drawn when the page prints.",
            "Pass <ChartTooltipContent /> as a Recharts Tooltip's content for a tooltip in theme colors.",
            "Use chartSeries(config) for each series' label and color, and chartColor(i) for a single color.",
            "Spread chartAxisProps on each axis and chartGridProps on the grid, so they match the charts we ship.",
            "Pass a skeleton that looks like your chart, so it loads in its own shape. If you build your own legend, call useChartLegendGlide(ref, waiting) so its items glide when values land."
          ],
          "avoid": [
            "A bar, line, area, or donut chart. Use BarChart, LineChart, AreaChart, or DonutChart. They already use this frame.",
            "A small trend inside a table cell or next to a number. Use Sparkline.",
            "A single number. Show the number in text.",
            "More than five series. The theme has five chart colors, and a sixth series repeats the first color."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "A visible title, shown as the figure's caption. It names the chart."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the chart when it has no visible title. TypeScript requires title, aria-label, or aria-labelledby."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height: 5, 7, or 9 times --control-h-md, so density scales it. Every state keeps it."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows a loading shape at the same height and sets aria-busy on the plot area. When data arrives, the shape fades out while the chart draws under it."
          },
          {
            "name": "loadingText",
            "type": "string",
            "default": "\"Loading chart\"",
            "description": "Read out to screen readers while loading."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Shows this message in place of the chart. true shows \"The chart could not load.\""
          },
          {
            "name": "onRetry",
            "type": "() => void",
            "description": "Shows a retry button under the error."
          },
          {
            "name": "retryText",
            "type": "string",
            "default": "\"Try again\"",
            "description": "The retry button's text."
          },
          {
            "name": "empty",
            "type": "boolean",
            "default": "false",
            "description": "True when there is nothing to draw. Shows emptyText."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data yet\"",
            "description": "Shown when the chart is empty."
          },
          {
            "name": "legend",
            "type": "{ key: string; label: ReactNode; color: string; value?: ReactNode }[]",
            "description": "Legend entries, shown under the chart while it loads and once it draws."
          },
          {
            "name": "skeleton",
            "type": "\"bars\" | \"area\" | \"line\" | \"dots\" | \"points\" | \"ring\"",
            "default": "\"bars\"",
            "description": "The loading shape. Pick the one that looks like the chart: area, line, dots for a swarm, points for a scatter, ring for a pie or donut."
          },
          {
            "name": "children",
            "type": "({ animation, tooltipAnimation, animationController, name }) => ReactNode",
            "description": "Draws the chart once it is ready. Leave it out to show only the frame and its states."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='h-96' replaces the height."
          }
        ],
        "a11y": [
          "Renders a <figure>. A title becomes its <figcaption>, which names the figure and the chart's SVG. TypeScript requires title, aria-label, or aria-labelledby.",
          "Keeps one height in every state, set in CSS, so the page does not move when data arrives. The height is in the server HTML. When the page prints, it asks the browser to keep a chart that fits on one page whole. In the light theme, it asks the browser to print legend dots and loading shapes even with backgrounds off.",
          "While loading, the plot area has aria-busy and the loading shapes are hidden from screen readers. With motion off, the shape goes at once. Reduced motion keeps the fade.",
          "A role=\"status\" line stays on the page outside the plot area. It reads out loading, the error, and the empty text when they are plain text.",
          "The error view shows a retry Button when you pass onRetry, so keyboard users can try again.",
          "Recharts' keyboard layer stays on: the chart's SVG is one tab stop, the arrow keys move between points, and the tooltip reads each point out.",
          "The SVG takes the focus-ring style, so keyboard focus shows on the chart.",
          "Series colors default to --chart-1 through --chart-5. The theme tests hold each to 3:1 against the page background, in light and dark. The legend and tooltip also name each series in text.",
          "Series draw in over three times --motion-duration-base, and only once the chart is on screen. A chart off screen waits with no timer running. When the page prints, every series jumps to its last frame, even in a chart never seen. The tooltip moves over --motion-duration-base. At 0ms, motion off, charts draw with no animation. Recharts also skips animation when the system asks for reduced motion."
        ],
        "examples": [
          {
            "title": "A chart we do not ship, on the frame",
            "code": "\"use client\";\n\nimport { AnimationControllerProvider, CartesianGrid, Scatter, ScatterChart, Tooltip, XAxis, YAxis } from \"recharts\";\n\nimport { Chart, ChartTooltipContent, chartAxisProps, chartColor, chartGridProps } from \"@/components/ui/chart\";\n\nconst homes = [\n  { size: 80, price: 210 },\n  { size: 120, price: 290 },\n  { size: 160, price: 410 },\n  { size: 210, price: 520 },\n];\n\nexport function PriceBySize() {\n  return (\n    <Chart title=\"Price by size\" empty={homes.length === 0}>\n      {({ animation, tooltipAnimation, animationController, name }) => (\n        <AnimationControllerProvider value={animationController}>\n          <ScatterChart responsive className=\"size-full\" data={homes} {...name}>\n            <CartesianGrid {...chartGridProps} />\n            <XAxis dataKey=\"size\" name=\"Size\" unit=\" m²\" type=\"number\" {...chartAxisProps} />\n            <YAxis dataKey=\"price\" name=\"Price\" unit=\"K\" type=\"number\" {...chartAxisProps} />\n            <Tooltip content={<ChartTooltipContent hideLabel />} cursor={{ stroke: \"var(--input)\" }} {...tooltipAnimation} />\n            <Scatter name=\"Homes\" fill={chartColor(0)} {...animation} />\n          </ScatterChart>\n        </AnimationControllerProvider>\n      )}\n    </Chart>\n  );\n}"
          },
          {
            "title": "Loading, empty, and error",
            "code": "<Chart title=\"Sales\" loading />\n\n<Chart title=\"Sales\" empty emptyText=\"No sales this week\" />\n\n<Chart title=\"Sales\" error=\"Sales could not load.\" onRetry={() => refetch()} />"
          },
          {
            "title": "A legend and a tooltip in theme colors",
            "code": "import { ChartLegend, ChartTooltipContent, chartSeries } from \"@/components/ui/chart\";\n\nconst series = chartSeries({ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } });\n\n<ChartLegend items={series} />\n\n<Tooltip content={<ChartTooltipContent valueFormat={{ style: \"currency\", currency: \"USD\" }} />} />"
          }
        ]
      }
    },
    {
      "name": "check-in-code",
      "type": "registry:ui",
      "title": "Check-in Code",
      "description": "An event's check-in code, for a host's phone or a tablet at the door. Faces arrive under it as people scan, and the count opens who is not in yet.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar-stack",
        "@txbrendan/hover-card",
        "@txbrendan/peek-card",
        "@txbrendan/qr-code",
        "@txbrendan/rolling-number"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/check-in-code.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "On a host's phone or a tablet at the door, as people arrive for a workshop or a meeting.",
            "Pass value, usually the event's check-in link. The code is drawn on the device, so it shows without signal.",
            "Pass code too, a short one to type, so nothing is camera-only. Digits are easiest on a phone keypad.",
            "Pass checkedIn, oldest first, and expected. The count rolls and is read out, and reads All 23 checked in, in green, once everyone is in.",
            "Pass notYet and onCheckIn, and each Not yet row in the count's peek gets Check in, for anyone without a phone or with a flat battery. Their face arrives at once, Undo takes it back for 8 seconds, and onCheckIn runs once the window ends.",
            "Change value to rotate the code. The new one fades in.",
            "Use layout=\"side\" on a wide screen, with the code on the left and the details on the right."
          ],
          "avoid": [
            "A toast for each check-in. The face arriving and the row turning to Checked in already say it.",
            "Telling people the count by color alone. All 23 checked in says it in words."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "string",
            "description": "What the code opens, usually the event's check-in link."
          },
          {
            "name": "title",
            "type": "string",
            "description": "The event, like \"Workshop\". It names the code and the peek."
          },
          {
            "name": "meta",
            "type": "string",
            "description": "When and where, like \"Tue 6 Oct · 19:00 · Room 1\"."
          },
          {
            "name": "hint",
            "type": "string",
            "default": "\"Scan with your phone camera to check in\"",
            "description": "What to do with the code."
          },
          {
            "name": "code",
            "type": "string",
            "description": "A short code to type instead, like \"482 719\"."
          },
          {
            "name": "checkedIn",
            "type": "{ person: string; photo?: string; at?: string | Date }[]",
            "description": "Who has checked in, oldest first."
          },
          {
            "name": "expected",
            "type": "number",
            "description": "How many are expected."
          },
          {
            "name": "notYet",
            "type": "{ person: string; photo?: string; meta?: string }[]",
            "description": "Who has not checked in yet, for the peek."
          },
          {
            "name": "onCheckIn",
            "type": "(person: { person: string; photo?: string }) => void | Promise<unknown>",
            "description": "Adds Check in to each Not yet row in the peek. It runs once the undo window ends."
          },
          {
            "name": "peek",
            "type": "Partial<PeekCardProps>",
            "description": "More PeekCard props for the count's peek, like actions: [{ label: \"Remind 9\" }]."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "The code's size: 120, 160, or 240px. Use \"lg\" on a tablet at the door, so it scans from further off."
          },
          {
            "name": "layout",
            "type": "\"stack\" | \"side\"",
            "default": "\"stack\"",
            "description": "The code in the middle, or the code left and the details right."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "The code is an image named \"Check-in code for\" and the title.",
          "The short code is text, so people can read it out or type it.",
          "The count is a peek trigger. Hover, focus, or a tap opens who is not in yet, then who is in, and Esc closes it.",
          "A polite live region reads the count each time it changes, like \"15 of 23 checked in\".",
          "The faces are hidden from screen readers. The count and the peek say who is in.",
          "Check in is a native button that slips to Checked in, with Undo, and says so once.",
          "All 23 checked in is green text in the success tone's soft text, which the theme holds to 4.5:1, and the words say it too."
        ],
        "examples": [
          {
            "title": "Large code, with a code to type and the count",
            "code": "<Card className=\"items-center\">\n  <CheckInCode\n    size=\"lg\"\n    value=\"https://example.com/in/team/482719\"\n    title=\"Workshop\"\n    meta=\"Tue 6 Oct · 19:00 · Engineering · Room 1\"\n    code=\"482 719\"\n    checkedIn={ins}\n    expected={23}\n    notYet={outs}\n    onCheckIn={checkIn}\n  />\n</Card>"
          }
        ]
      }
    },
    {
      "name": "checkbox",
      "type": "registry:ui",
      "title": "Checkbox",
      "description": "A box people tick to pick an option, with a built-in label, tones, sizes, and a mixed state.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/checkbox.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Picking any number of options from a list, like topics to follow.",
            "Agreeing to something before a form is sent, like the terms of service.",
            "Pass the label text as children. The box and the text share one label, so clicking either one toggles it.",
            "When the text has to sit apart from the box, give the box an id and point a label's htmlFor at it.",
            "A 'select all' box above a list. Set indeterminate when only some items are picked, and it shows the mixed state."
          ],
          "avoid": [
            "A setting that takes effect right away. Use a switch.",
            "Picking exactly one option from a list. Use a radio group.",
            "Options with a hint, a price, or a consequence. Use SelectionGroup multiple with a SelectionCard for each.",
            "An id with no label pointing at it. TypeScript accepts the id, but the box then has no name."
          ]
        },
        "props": [
          {
            "name": "children",
            "type": "ReactNode",
            "description": "Visible label text. Without it, TypeScript requires aria-label, aria-labelledby, or an id."
          },
          {
            "name": "id",
            "type": "string",
            "description": "Links a separate label. A label whose htmlFor matches names the box, and clicking it toggles the box."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the checked box."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Box size and label text size. Density scales them."
          },
          {
            "name": "checked",
            "type": "boolean",
            "description": "Controlled state. Use it with onCheckedChange."
          },
          {
            "name": "defaultChecked",
            "type": "boolean",
            "default": "false",
            "description": "Starting state when you do not control it."
          },
          {
            "name": "onCheckedChange",
            "type": "(checked: boolean, details) => void",
            "description": "Called with the new state when someone toggles the box."
          },
          {
            "name": "indeterminate",
            "type": "boolean",
            "default": "false",
            "description": "Shows a dash and sets aria-checked to mixed."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks toggling and removes the box from the tab order. The label text dims too."
          },
          {
            "name": "name",
            "type": "string",
            "description": "Form field name. The box sends its value with the form when checked."
          },
          {
            "name": "aria-invalid",
            "type": "boolean",
            "description": "Shows the danger border. Set it when validation fails, and link the message with aria-describedby."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the box. Later classes win, so className='[--checkbox-size:1.5rem]' makes it bigger."
          }
        ],
        "a11y": [
          "Renders a span with role=\"checkbox\" plus a hidden native checkbox, so the value is sent with the form.",
          "Space toggles it. Enter does not toggle it. Enter submits the form, as it does on a native checkbox.",
          "TypeScript requires visible children, aria-label, aria-labelledby, or an id. It cannot check that a label points at the id.",
          "In server HTML, only aria-label and aria-labelledby name the box. A label from children or an id is linked once JavaScript runs.",
          "Clicking the label text also toggles the box, so the target is bigger than the box.",
          "The unchecked border uses --input and the focus ring uses --ring. The theme tests hold both to 3:1 against the page and cards.",
          "The check mark uses the tone's foreground on its fill, which the theme tests hold to 4.5:1.",
          "Press scale and the check mark's pop-in follow --motion-* tokens. Reduced motion turns both off and keeps the fade."
        ],
        "examples": [
          {
            "title": "With a label",
            "code": "<Checkbox name=\"terms\" tone=\"brand\">\n  I accept the{\" \"}\n  <a href=\"/terms\" className=\"underline\">\n    terms of service\n  </a>\n</Checkbox>"
          },
          {
            "title": "A list of options",
            "code": "<fieldset className=\"grid gap-3\">\n  <legend className=\"mb-1 text-sm font-medium\">Email me about</legend>\n  <Checkbox name=\"topics\" value=\"releases\" defaultChecked>\n    New releases\n  </Checkbox>\n  <Checkbox name=\"topics\" value=\"tips\">\n    Tips and guides\n  </Checkbox>\n  <Checkbox name=\"topics\" value=\"events\">\n    Events near me\n  </Checkbox>\n</fieldset>"
          },
          {
            "title": "Select all, with the mixed state",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nconst files = [\"report.pdf\", \"notes.txt\", \"photo.png\"];\n\nfunction FilePicker() {\n  const [picked, setPicked] = useState([\"report.pdf\"]);\n  const all = picked.length === files.length;\n  return (\n    <div role=\"group\" aria-labelledby=\"files-label\" className=\"grid gap-3\">\n      <p id=\"files-label\" className=\"text-sm font-medium\">Files</p>\n      <Checkbox\n        checked={all}\n        indeterminate={picked.length > 0 && !all}\n        onCheckedChange={(checked) => setPicked(checked ? files : [])}\n      >\n        Select all\n      </Checkbox>\n      <div className=\"grid gap-3 ps-6\">\n        {files.map((file) => (\n          <Checkbox\n            key={file}\n            checked={picked.includes(file)}\n            onCheckedChange={(checked) => setPicked((now) => (checked ? [...now, file] : now.filter((f) => f !== file)))}\n          >\n            {file}\n          </Checkbox>\n        ))}\n      </div>\n    </div>\n  );\n}"
          },
          {
            "title": "Controlled",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nfunction MarketingConsent() {\n  const [checked, setChecked] = useState(false);\n  return (\n    <Checkbox checked={checked} onCheckedChange={setChecked}>\n      Send me product news\n    </Checkbox>\n  );\n}"
          },
          {
            "title": "No visible text, in a table row",
            "code": "<Checkbox aria-label=\"Select row: Invoice 1042\" size=\"sm\" />"
          },
          {
            "title": "Named by a separate label",
            "code": "<div className=\"flex items-center gap-2\">\n  <Checkbox id=\"beta\" />\n  <label htmlFor=\"beta\" className=\"text-sm\">\n    Join the beta\n  </label>\n</div>"
          }
        ]
      }
    },
    {
      "name": "chip-glide",
      "type": "registry:ui",
      "title": "Chip Glide",
      "description": "Moves a wrapping row of chips when values come and go. A removed chip fades where it sits, then the others glide to their new places. It shows nothing.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/chip-glide.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "motion",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A row of chips that people add to and remove from, like Token Field and Combobox with multiple.",
            "Make a leaving chip inert, so a press passes through it. Give the box content-start, so its lines stay put while it glides to a new height.",
            "rows and box alone glide the chips. hold and tip keep the pressed remove button in place and give it its tooltip."
          ],
          "avoid": [
            "A list that changes all at once, like search results. Fade the new list in.",
            "Rows that reorder by drag. Use a reorder list."
          ]
        },
        "props": [
          {
            "name": "useChipGlide",
            "type": "(values: readonly string[]) => { rows, box, hold, tip }",
            "description": "Hook. Pass the values in order. Returns the rows to render, the props for the wrapping box, and hold and tip for each remove button."
          },
          {
            "name": "rows",
            "type": "{ value: string; leaving: boolean }[]",
            "description": "What to render, in order. A removed value stays, leaving, until it has faded."
          },
          {
            "name": "box",
            "type": "{ ref, onPointerMove, onPointerLeave }",
            "description": "Spread on the element that wraps the chips and the text field. Put data-glide on each chip, set to its value, data-glide=\"\" on the text field, and data-spot on each remove button."
          },
          {
            "name": "hold",
            "type": "(value: string) => (event: PointerEvent) => void",
            "description": "The remove button's onPointerDown. A mouse holds the removed chip in place, faded, until it moves off the button. A finger does not."
          },
          {
            "name": "tip",
            "type": "(value: string) => { handle, id }",
            "description": "The remove button's tooltip. Spread it on the TooltipTrigger, and render one Tooltip per row, outside the chips, with this handle. It fades out where its chip was."
          }
        ],
        "a11y": [
          "A leaving chip is inert and hidden from screen readers while it fades, so it is never read twice or pressed.",
          "A press where a held chip was lands on the box behind it, not on the next chip or the text field.",
          "A chip removed from the keyboard hands focus to the next chip's remove button, or the one before it.",
          "Reduced motion keeps the fades, and puts each chip in its new place at once. Motion off does both at once."
        ],
        "examples": [
          {
            "title": "Remove chips and glide the rest (needs @txbrendan/tooltip)",
            "code": "function Labels({ values, onRemove }: { values: string[]; onRemove: (value: string) => void }) {\n  const glide = useChipGlide(values);\n  return (\n    <div {...glide.box} className=\"flex flex-wrap content-start gap-1\">\n      {glide.rows.map(({ value, leaving }) => (\n        <span key={value} data-glide={value} inert={leaving} className=\"inline-flex items-center gap-1 rounded-md bg-secondary ps-2\">\n          {value}\n          <TooltipTrigger {...glide.tip(value)} data-spot aria-label={`Remove ${value}`} onPointerDown={glide.hold(value)} onClick={() => onRemove(value)}>\n            ×\n          </TooltipTrigger>\n        </span>\n      ))}\n      {glide.rows.map(({ value }) => (\n        <Tooltip key={value} handle={glide.tip(value).handle}>\n          <TooltipPopup>Remove {value}</TooltipPopup>\n        </Tooltip>\n      ))}\n    </div>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "choice-chips",
      "type": "registry:ui",
      "title": "Choice Chips",
      "description": "Pills for short tags, like teams, plans, and days of the week. Pick several by default, with a check that springs in, or pick one with the arrow keys.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/field-frame"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/choice-chips.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Short tags only: teams, plans, days of the week. One or two words each.",
            "Pick several by default. Each chip is a checkbox, a Tab stop of its own, and Space ticks it.",
            "Pass multiple={false} to pick one. The chips become a radio group: one Tab stop, and the arrows move and choose.",
            "In a single choice, pressing the chosen chip again clears it, so the value can be null.",
            "Set max to cap how many can be picked. Say the cap in the label, like \"Teams · up to 3\". The rest dim once it is reached.",
            "Set error when the form is sent with nothing picked, like \"Pick at least one team\"."
          ],
          "avoid": [
            "An option with a hint, a price, or a consequence. Use a selection card.",
            "A long list, or one people search. Use a combobox.",
            "Turning one setting on or off. Use a switch.",
            "Free tags people make up. These chips only offer the options you pass."
          ]
        },
        "props": [
          {
            "name": "options",
            "type": "Array<string | { id: string; label: string; count?: number }>",
            "description": "The chips. A string is its own id and label. count shows a muted number after the label."
          },
          {
            "name": "multiple",
            "type": "boolean",
            "default": "true",
            "description": "Pick several, as checkboxes. false picks one, as a radio group, and value becomes one id or null."
          },
          {
            "name": "value",
            "type": "string[] | string | null",
            "description": "The ids picked: an array, or one id or null when multiple is false. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string[] | string | null",
            "description": "The ids picked at first render, when you do not control them."
          },
          {
            "name": "onValueChange",
            "type": "(value: string[] | string | null) => void",
            "description": "Called with the ids picked, or with one id or null when multiple is false."
          },
          {
            "name": "max",
            "type": "number",
            "description": "Most that can be picked. Once it is reached, the rest dim and do nothing when pressed."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Names the group, above it. Pressing it focuses the group's Tab stop."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "A short note under the chips. It describes the group."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Replaces the hint, in red, rings the open chips in red, and is announced once."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Chip height and text size: 28, 32, or 36px at the default density."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks every chip, and dims the label."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the frame. id, ref, and aria-* land on the group."
          }
        ],
        "a11y": [
          "Several answers: a group of checkboxes. Each chip is a Tab stop, and Space ticks it.",
          "One answer: a radio group. Tab lands on the chosen chip, or the first. The arrows move and choose, and wrap. Home and End jump to the ends.",
          "The label names the group, and the hint or error describes it. An error is announced once when it appears.",
          "Once max is reached, the rest are aria-disabled, so they stay findable with the keys.",
          "Focusing the group itself, as a form does with its first error, moves focus to its Tab stop.",
          "A chosen chip shows by its fill and, with several answers, a check. In forced-colors mode, where fills are gone, a single answer shows the check too."
        ],
        "examples": [
          {
            "title": "Several answers",
            "code": "<ChoiceChips\n  label=\"Teams · up to 3\"\n  options={[\"Design\", \"Engineering\", \"Support\", \"Marketing\"]}\n  defaultValue={[\"Engineering\"]}\n  max={3}\n/>"
          },
          {
            "title": "One answer",
            "code": "<ChoiceChips\n  multiple={false}\n  label=\"Plan\"\n  options={[\"Starter\", \"Team\", \"Business\", \"Scale\", \"Enterprise\"]}\n  defaultValue=\"Team\"\n/>"
          }
        ]
      }
    },
    {
      "name": "choice-poll",
      "type": "registry:ui",
      "title": "Choice Poll",
      "description": "A question with options people pick one of. After a vote, it shows each option's share as a bar with a percent and a count.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/button",
        "@txbrendan/badge"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/choice-poll.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms",
        "voting"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Asking a group one question with a few fixed answers, like which day suits a meeting.",
            "Pass every option with its votes so far. The poll works out each share and the total.",
            "It has three states. Open: people pick one option and press Vote. Voted: it shows the results. Closed: voting has ended and it shows the final results.",
            "Return a promise from onVote to save the vote. The results show at once, with savingText on this person's choice, until the promise settles. If it rejects, the choices come back with the pick still made, and errorText says the vote did not save.",
            "While a vote saves, the choices are gone, so no one can start a second vote. The root has data-pending until the save settles.",
            "Leave vote out to let the poll keep its own state. It then counts the vote it takes, and defaultVote marks an earlier vote.",
            "Pass vote to keep the vote in your own state. Set it to null until this person has voted. Once the save succeeds, and before your onVote promise resolves, set vote to their choice and add it to the votes you pass.",
            "For another language, pass voteText, yourVoteText, savingText, errorText, closedText, requiredText, votesText, and locale."
          ],
          "avoid": [
            "Questions where people can pick more than one answer. Use checkboxes instead.",
            "Letting people change their vote. Once someone votes, the poll shows results and the choices are gone.",
            "Ranking a long list of ideas that people add to. Use Feature Voting instead.",
            "Answers that are a number on a scale, like 1 to 5. Use a slider or a rating."
          ]
        },
        "props": [
          {
            "name": "question",
            "type": "ReactNode",
            "description": "The question. It names the choices and the results for screen readers."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "Optional details under the question, like when the poll closes."
          },
          {
            "name": "options",
            "type": "{ value: string; label: ReactNode; votes: number }[]",
            "description": "The answers, in the order to show them. value must be unique. votes is the count so far."
          },
          {
            "name": "vote",
            "type": "string | null",
            "description": "The option this person voted for, or null before they vote. Controlled. The poll shows the counts you pass as they are, plus a vote that is still saving."
          },
          {
            "name": "defaultVote",
            "type": "string",
            "description": "An earlier vote by this person when you do not control vote. The poll shows results at once and does not count it again."
          },
          {
            "name": "onVote",
            "type": "(value: string) => void | Promise<unknown>",
            "description": "Called with the picked option when someone presses Vote. Return a promise, and the poll shows the vote at once with savingText until it settles. A rejection takes the vote back and shows errorText. When you control vote, set it and the counts only after the save succeeds, before your promise resolves."
          },
          {
            "name": "closed",
            "type": "boolean",
            "default": "false",
            "description": "Voting has ended. Shows the final results with a Closed badge."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks voting. The results are not affected."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the picked choice, the Vote button, and the result bars."
          },
          {
            "name": "voteText",
            "type": "string",
            "default": "\"Vote\"",
            "description": "The Vote button's text."
          },
          {
            "name": "yourVoteText",
            "type": "string",
            "default": "\"Your vote\"",
            "description": "The badge on this person's choice in the results."
          },
          {
            "name": "savingText",
            "type": "string",
            "default": "\"Saving…\"",
            "description": "The badge on this person's choice while the vote saves. yourVoteText takes its place once it saves."
          },
          {
            "name": "errorText",
            "type": "string",
            "default": "\"Could not save your vote. Try again.\"",
            "description": "Shown above the Vote button when a vote does not save. Screen readers announce it."
          },
          {
            "name": "closedText",
            "type": "string",
            "default": "\"Closed\"",
            "description": "The badge on a closed poll."
          },
          {
            "name": "requiredText",
            "type": "string",
            "default": "\"Pick an option to vote.\"",
            "description": "Shown when someone presses Vote with nothing picked. Screen readers announce it."
          },
          {
            "name": "votesText",
            "type": "{ zero?: string; one?: string; two?: string; few?: string; many?: string; other: string }",
            "default": "{ one: \"{count} vote\", other: \"{count} votes\" }",
            "description": "How a count of votes reads in each plural form, with {count} for the number. The poll picks the form with Intl.PluralRules for locale. Polish, for example, needs one, few, many, and other."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "A language tag for the counts, the percents, and the plural forms, like 'de-DE'."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the root. Later classes win, so className='gap-6' spaces the parts more."
          }
        ],
        "a11y": [
          "Open, the choices are a radio group named by the question. Arrow keys move between choices and pick, and Space picks.",
          "Each radio is named by its label through aria-labelledby, so the name is in the server HTML too.",
          "Picking does not vote, so arrowing through the choices is safe. Vote is a separate button.",
          "Pressing Vote with nothing picked shows requiredText in a role=\"alert\" message and moves focus to the first choice.",
          "After someone votes here, focus moves to the results list, because the Vote button is gone. If the save fails, the choices come back and focus moves to the Vote button. Focus does not move if it is already somewhere else.",
          "Results are a list named by the question. Each item reads its label, then 'Saving…' or 'Your vote' on this person's choice, then the count and the percent. The bars are hidden from screen readers.",
          "A vote shows at once, so there is no spinner and no aria-busy. A failed save shows errorText in a role=\"alert\" message, and the Vote button's aria-describedby points to it.",
          "disabled takes the Vote button out of the tab order. The disabled choices stay in it, marked aria-disabled.",
          "Bars use the tone fill, which the theme tests hold to 3:1 on the page and on cards.",
          "Each bar sits on bg-track, which the theme tests hold to 1.25:1 on the page and on cards, so the full width shows. The track stays quiet, so a warning bar in light mode is only about 2.3:1 against it. The text carries every number.",
          "Results grow in after a vote over --motion-duration-slow, and changed numbers fade in. data-motion=\"off\" makes both instant. Reduced motion shows the bars at full width at once and drops the slide, but keeps the fade.",
          "Every word has a prop with an English default: voteText, yourVoteText, savingText, errorText, closedText, requiredText, and votesText. locale formats the numbers and picks each plural form."
        ],
        "examples": [
          {
            "title": "A poll that keeps its own state",
            "code": "<ChoicePoll\n  question=\"Which day works best for planning?\"\n  description=\"Pick one. Results show after you vote.\"\n  options={[\n    { value: \"mon\", label: \"Monday\", votes: 12 },\n    { value: \"wed\", label: \"Wednesday\", votes: 6 },\n    { value: \"fri\", label: \"Friday\", votes: 2 },\n  ]}\n/>"
          },
          {
            "title": "Save votes yourself",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nconst initial = [\n  { value: \"mon\", label: \"Monday\", votes: 12 },\n  { value: \"wed\", label: \"Wednesday\", votes: 6 },\n  { value: \"fri\", label: \"Friday\", votes: 2 },\n];\n\nfunction PracticePoll() {\n  const [options, setOptions] = useState(initial);\n  const [vote, setVote] = useState<string | null>(null);\n\n  // The poll shows the vote at once, marked as saving. If this throws, it takes the vote back and says so.\n  async function save(value: string) {\n    // Replace this with your own request. fetch does not throw on a server error, so check ok.\n    const response = await fetch(\"/api/planning-poll\", { method: \"POST\", body: value });\n    if (!response.ok) throw new Error(\"The vote did not save.\");\n    // Set the vote and the counts only after the save succeeds.\n    setOptions((all) => all.map((o) => (o.value === value ? { ...o, votes: o.votes + 1 } : o)));\n    setVote(value);\n  }\n\n  return (\n    <ChoicePoll\n      question=\"Which day works best for planning?\"\n      options={options}\n      vote={vote}\n      onVote={save}\n      tone=\"brand\"\n    />\n  );\n}"
          },
          {
            "title": "Final results",
            "code": "<ChoicePoll\n  question=\"Name for the new project?\"\n  options={[\n    { value: \"comets\", label: \"Comets\", votes: 31 },\n    { value: \"falcons\", label: \"Falcons\", votes: 18 },\n  ]}\n  vote=\"comets\"\n  closed\n/>"
          },
          {
            "title": "In another language",
            "code": "<ChoicePoll\n  question=\"Welcher Tag passt für die Planung?\"\n  options={[\n    { value: \"mo\", label: \"Montag\", votes: 1203 },\n    { value: \"mi\", label: \"Mittwoch\", votes: 1 },\n  ]}\n  voteText=\"Abstimmen\"\n  yourVoteText=\"Deine Stimme\"\n  closedText=\"Beendet\"\n  requiredText=\"Wähle eine Antwort, um abzustimmen.\"\n  votesText={{ one: \"{count} Stimme\", other: \"{count} Stimmen\" }}\n  locale=\"de-DE\"\n/>"
          }
        ]
      }
    },
    {
      "name": "choropleth-map",
      "type": "registry:ui",
      "title": "Choropleth Map",
      "description": "Regions shaded by value in one hue, in steps, with hatching for regions with no data, a legend that filters, and arrow keys that move from region to region.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/choropleth-map.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A value per country, state, or province, where where it is matters, like members by state or sales by country.",
            "Pass shape, a map already projected: { width, height, regions: [{ id, name, d, c }] }, with a title or aria-label.",
            "Pass values by region id, like { 'US-TX': 120 }. A region without a number is hatched and reads 'No data'.",
            "Pass steps to set where each shade starts, like [10, 50, 100] for four shades. By default the shades split the values into quarters.",
            "Pass onRegionSelect to make each region a button, and selected to outline the chosen one.",
            "This site builds its shapes into /maps: world.json for countries, and admin1/<ID>.json for each country's regions. Copy the ones you need into your app, or build your own in the same shape."
          ],
          "avoid": [
            "Exact numbers people must compare. Use BarChart or BreakdownList beside the map.",
            "Values that only follow area or population. Show a rate, like per 1,000 people, so big regions do not win by size.",
            "Streets, zooming, or panning. This map draws fixed shapes, not tiles."
          ]
        },
        "props": [
          {
            "name": "shape",
            "type": "ChoroplethShape",
            "description": "The map, already projected: { width, height, regions, outline?, frames?, dots? }. Each region is { id, name, d, c, dot? }, where d is an SVG path and c a point inside it."
          },
          {
            "name": "values",
            "type": "Record<string, number | null>",
            "description": "Each region's value, by region id."
          },
          {
            "name": "steps",
            "type": "number[]",
            "default": "the quartiles of values",
            "description": "Where each shade starts, lowest first. Three numbers make four shades."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "description": "How values read, in the readout and the legend, like { style: 'percent' }."
          },
          {
            "name": "selected",
            "type": "string | null",
            "description": "The region outlined as chosen."
          },
          {
            "name": "onRegionSelect",
            "type": "(id: string) => void",
            "description": "Called on click, Enter, or Space. Makes each region a button with aria-pressed."
          },
          {
            "name": "noDataText",
            "type": "string",
            "default": "\"No data\"",
            "description": "What a region with no value reads."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height. The map keeps its own shape inside it."
          },
          {
            "name": "loading",
            "type": "boolean",
            "description": "Shows the regions as placeholders, or a placeholder box when there is no shape yet."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Shows this message in place of the map. Pass onRetry for a retry button."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No regions to show\"",
            "description": "Shown when the shape has no regions."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "Names the map. Pass this, aria-label, or aria-labelledby."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for numbers. The default keeps the server and the browser the same."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the figure. '[--choropleth-color:var(--chart-3)]' sets the hue. It defaults to --chart-1."
          }
        ],
        "a11y": [
          "The map is a group named by its title. Each region is named by its name and value, like 'Texas: 120'.",
          "The map is one Tab stop. Arrow keys move to the nearest region that way, and Home and End go to the first and last by name.",
          "With onRegionSelect, each region is a button with aria-pressed, and Enter or Space chooses it.",
          "No data is a hatch, not only a lighter shade, so it reads apart from low values without color.",
          "The legend steps are buttons. Hovering or focusing one shows only its regions. Pressing one keeps it on, with aria-pressed.",
          "In forced colors, the shades become steps of the text color, the hatch stays, and the focused region is outlined in Highlight."
        ],
        "examples": [
          {
            "title": "Members by state",
            "code": "import usa from \"./maps/admin1/USA.json\";\n\n<ChoroplethMap title=\"Members by state\" shape={usa} values={{ \"US-TX\": 120, \"US-CA\": 210 }} />"
          },
          {
            "title": "Set steps and a format",
            "code": "<ChoroplethMap\n  aria-label=\"Turnout by prefecture\"\n  shape={japan}\n  values={turnout}\n  steps={[0.5, 0.55, 0.6]}\n  valueFormat={{ style: \"percent\" }}\n/>"
          },
          {
            "title": "Choose a country",
            "code": "<ChoroplethMap\n  title=\"Clubs by country\"\n  shape={world}\n  values={clubs}\n  selected={country}\n  onRegionSelect={setCountry}\n/>"
          }
        ]
      }
    },
    {
      "name": "club-switcher",
      "type": "registry:ui",
      "title": "Club Switcher",
      "description": "The club you're in, with its kit flag, name, and your roles, that opens a menu of every club you belong to.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/menu",
        "@txbrendan/team-flag"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/club-switcher.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The top of an app's rail, for people in more than one club: a coach in one, a parent in another.",
            "Give each club an id, its name, its kit colors for the TeamFlag, and your roles there, like \"Coach · Admin\".",
            "Pressing it opens every club under it, with a check on the current one. Pass onJoin to end the menu with Join another club.",
            "Pass value and onChange to control it, or defaultValue to start in a club. Without either it starts in the first.",
            "Use inline to draw the open menu in a card or a doc, with no button."
          ],
          "avoid": [
            "Drawing a club's crest. Its kit colors stand in for it. Brand logos are never drawn.",
            "Someone in one club only. Show the club's name and flag as plain text.",
            "Switching teams inside one club. Use a select or tabs."
          ]
        },
        "props": [
          {
            "name": "clubs",
            "type": "{ id: string; name: string; colors?: [string, string]; pattern?: \"hoops\" | \"halves\" | \"thirds\" | \"split\"; role?: string }[]",
            "description": "Required. Every club you belong to. colors are CSS colors from your data, set only on the flag's own style."
          },
          {
            "name": "value",
            "type": "string",
            "description": "The club you're in. Controlled, with onChange."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "The club you start in. Default the first."
          },
          {
            "name": "onChange",
            "type": "(id: string) => void",
            "description": "Runs with the club's id when you switch."
          },
          {
            "name": "onJoin",
            "type": "() => void",
            "description": "Offers \"Join another club\" at the end of the menu."
          },
          {
            "name": "inline",
            "type": "boolean",
            "default": "false",
            "description": "Draws the open menu in the page's flow, with no button. Nothing closes it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the button, or on the menu with inline. Later classes win."
          }
        ],
        "a11y": [
          "The button is named by the club and your roles, and says it opens a menu.",
          "The menu opens with focus on your club. The arrow keys move without wrapping, Home and End go to the ends, letters jump to a club, Enter switches, and Esc closes it and puts focus back.",
          "Each club is a menuitemradio, checked for the one you're in. The check is drawn, not only colored.",
          "Inline, the menu is one Tab stop that lands on your club, with the same keys. Nothing closes it.",
          "The kit flag is hidden from screen readers, since the name beside it says the club."
        ],
        "examples": [
          {
            "title": "At the top of a rail",
            "code": "<ClubSwitcher\n  clubs={[\n    { id: \"rovers\", name: \"Rovers\", colors: [\"#1e3a8a\", \"#ffffff\"], pattern: \"hoops\", role: \"Coach · Admin\" },\n    { id: \"saints\", name: \"Saints\", colors: [\"#b91c1c\", \"#111827\"], role: \"Parent of Ava\" },\n  ]}\n  defaultValue=\"rovers\"\n  onChange={(id) => console.log(\"switch to\", id)}\n  onJoin={() => console.log(\"join\")}\n/>"
          }
        ]
      }
    },
    {
      "name": "code-field",
      "type": "registry:ui",
      "title": "Code Field",
      "description": "One field for a code typed or pasted: a code sent by email or text, or a workspace's invite code. It drops anything else, and calls onComplete once the code is whole.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/field-frame",
        "@txbrendan/input"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/code-field.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A one-time code sent by email or text: characters=\"digits\", six long by default.",
            "A join code of letters and digits: characters=\"text\". It shows in capitals.",
            "Check the code in onComplete. It runs once the code is whole, so there is no Continue button to press.",
            "Say where the code went and how long it works in hint, like \"We sent it to riley@example.com. It works for 15 minutes.\"",
            "Put a wrong or expired code in error, like \"That code doesn't match\".",
            "Pass onResend to add a quiet \"Send a new code\" button after the hint or error. It reads \"Sent\" for 10 seconds after a press."
          ],
          "avoid": [
            "A row of one box per digit. One field takes a paste, autofill, and the phone's suggested code in one go.",
            "A password or a PIN people choose. Use an input with type=\"password\".",
            "Long codes people copy from a page, like a licence key. Use an input."
          ]
        },
        "props": [
          {
            "name": "length",
            "type": "number",
            "default": "6",
            "description": "How many characters make the code whole."
          },
          {
            "name": "characters",
            "type": "\"digits\" | \"text\"",
            "default": "\"digits\"",
            "description": "Digits only, or letters and digits in capitals. Anything else is dropped, so a pasted \"123 456\" reads 123456."
          },
          {
            "name": "value",
            "type": "string",
            "description": "The code. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "The code at first render, when you do not control it."
          },
          {
            "name": "onValueChange",
            "type": "(code: string) => void",
            "description": "Called with the cleaned code each time it changes."
          },
          {
            "name": "onComplete",
            "type": "(code: string) => void",
            "description": "Called once the code reaches its length. Check it here."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Names the field, above it."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "Where the code went and how long it works."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "A wrong or expired code. Replaces the hint, in red, and is announced."
          },
          {
            "name": "onResend",
            "type": "() => void",
            "description": "Adds a \"Send a new code\" button after the hint or error."
          },
          {
            "name": "resendLabel",
            "type": "string",
            "default": "\"Send a new code\"",
            "description": "The resend button's words."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height, padding, and text size. The same heights as Input."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks typing and the resend button."
          },
          {
            "name": "placeholder",
            "type": "string",
            "description": "A sample code, like \"TEAM26X\"."
          },
          {
            "name": "id",
            "type": "string",
            "description": "The input's id. Defaults to a generated one."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the frame. Other props, like name and aria-*, land on the input."
          }
        ],
        "a11y": [
          "A native input named by its label, so typing, pasting, and autofill work as usual.",
          "autocomplete=\"one-time-code\" lets phones offer the code from the message they got.",
          "Digits open the number keypad on phones. Text codes turn on capitals.",
          "The hint or error describes the input. An error sets aria-invalid and is announced once when it appears.",
          "The resend button keeps its width when it reads \"Sent\", and a polite live region says \"Sent a new code\".",
          "On touch screens the text is 16px, so iOS Safari does not zoom in when the field gets focus."
        ],
        "examples": [
          {
            "title": "A code sent by email",
            "code": "<CodeField\n  label=\"Code\"\n  hint=\"We sent it to riley@example.com. It works for 15 minutes.\"\n  onComplete={(code) => console.log(\"check\", code)}\n  onResend={() => console.log(\"resend\")}\n/>"
          },
          {
            "title": "Letters and digits, seven long",
            "code": "<CodeField\n  label=\"Invite code\"\n  characters=\"text\"\n  length={7}\n  placeholder=\"TEAM26X\"\n  hint=\"Your admin gives you this. It joins you to the right team.\"\n/>"
          },
          {
            "title": "A wrong code",
            "code": "<CodeField label=\"Code\" defaultValue=\"123456\" error=\"That code doesn't match.\" />"
          }
        ]
      }
    },
    {
      "name": "combobox",
      "type": "registry:ui",
      "title": "Combobox",
      "description": "A text field that filters a list as you type, with groups, a check on the picked item, and chips for picking several.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chip-glide",
        "@txbrendan/glide-highlight",
        "@txbrendan/input",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/combobox.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Picking one item from a long list, where typing is faster than scrolling: a country, a time zone, a teammate.",
            "One highlight glides from row to row as the pointer moves, and jumps when the arrow keys move it. Reduced motion makes it jump.",
            "Picking several items from a long list. Set multiple, and each pick shows as a chip in the field.",
            "Pass items as strings, as { value, label, disabled } objects, or as groups shaped { label, items }.",
            "The value is the item's string value: a string, or a string array when multiple is set. It works uncontrolled with defaultValue, even from a server component.",
            "Give the text field a name: aria-label, aria-labelledby, or an id that a label's htmlFor points at.",
            "Set name to send the value with a form. Multiple sends one entry per pick.",
            "The list opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container so the list keeps its colors. A container that clips its overflow can clip the list."
          ],
          "avoid": [
            "Fewer than about 7 items. Use a select or a radio group, so people see every choice at once.",
            "Free text, like a search box with suggestions. A combobox only accepts items from its list.",
            "Running commands or jumping to pages. Use a menu or links instead.",
            "Object values. Map each record to { value: id, label: name }, then look the record up by its id."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "(string | { value: string; label: string; disabled?: boolean })[] | { label: string; items: ... }[]",
            "description": "The choices. A string is both value and label. Groups show their label above their items."
          },
          {
            "name": "multiple",
            "type": "boolean",
            "default": "false",
            "description": "Pick any number of items. The value becomes a string array, and picks show as chips."
          },
          {
            "name": "defaultValue",
            "type": "string | null | string[]",
            "description": "The picked value at first render, when you do not control it. A string array when multiple is set."
          },
          {
            "name": "value",
            "type": "string | null | string[]",
            "description": "Controlled value. Use it with onValueChange."
          },
          {
            "name": "onValueChange",
            "type": "(value: string | null | string[], details) => void",
            "description": "Called with the new value on each pick. Deleting the text sets a single value to null."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the text field. TypeScript requires this, aria-labelledby, or id."
          },
          {
            "name": "id",
            "type": "string",
            "description": "The text field's id, for a label's htmlFor."
          },
          {
            "name": "placeholder",
            "type": "string",
            "description": "Hint text in the empty field. Hidden once a chip is in the field."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No results found.\"",
            "description": "Shown in the list when nothing matches. Screen readers announce it."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Field height, padding, and text size. The same heights as Input. Density scales them."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks typing and picking, and removes the field from the tab order."
          },
          {
            "name": "readOnly",
            "type": "boolean",
            "default": "false",
            "description": "Shows the value and the list but blocks changes."
          },
          {
            "name": "required",
            "type": "boolean",
            "default": "false",
            "description": "The form will not submit until something is picked."
          },
          {
            "name": "name",
            "type": "string",
            "description": "Form field name. The picked value is sent with the form."
          },
          {
            "name": "aria-invalid",
            "type": "boolean",
            "description": "Shows the danger border. Set it when validation fails, and link the message with aria-describedby."
          },
          {
            "name": "filter",
            "type": "((item, query: string) => boolean) | null",
            "description": "Decides which items match the text. The default matches labels, ignoring case and accents. Pass null when a server filters."
          },
          {
            "name": "inputValue",
            "type": "string",
            "description": "Controlled text. Use it with onInputValueChange, for example to search a server."
          },
          {
            "name": "onInputValueChange",
            "type": "(inputValue: string) => void",
            "description": "Called as the text changes, with the new text."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Controlled open state. Use it with onOpenChange."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Called when the list opens or closes."
          },
          {
            "name": "container",
            "type": "HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null",
            "description": "Where the list opens. By default, at the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. While it is null, the list waits and does not open. Only client code has elements and refs."
          },
          {
            "name": "effect",
            "type": "\"rise\" | \"grow\"",
            "default": "\"rise\"",
            "description": "How the list opens and closes. rise fades in and moves out from the field. grow fades in and grows from the field, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the field. Later classes win, so className='max-w-60' sets the width."
          }
        ],
        "a11y": [
          "The text field has role=\"combobox\". The list is a listbox, and each item is an option with aria-selected.",
          "Focus stays in the text field. aria-activedescendant tells screen readers which item is highlighted.",
          "Down Arrow opens the list on the first item. Up and Down Arrow move, Enter picks, and Escape closes. Typing filters.",
          "TypeScript requires aria-label, aria-labelledby, or an id. A label's htmlFor can name the field.",
          "The check mark is hidden from screen readers. aria-selected says which items are picked.",
          "The empty text is a polite live region, so screen readers hear when nothing matches.",
          "Group labels name their groups through aria-labelledby.",
          "With multiple, Left Arrow at the start of the text moves to the chips. Backspace or Delete removes the focused chip, and Backspace in empty text removes the last one.",
          "The open button is out of the tab order, so Tab moves on to the next control. Clicking anywhere in the field opens the list.",
          "The field shows the --ring outline while the text field inside has focus. The theme tests hold --ring to 3:1.",
          "Highlighted items use the accent pair, and group labels and empty text use muted text on the popover. The theme tests hold both to 4.5:1.",
          "The list fades in and slides by --motion-distance. It closes with a quicker fade and a slight shrink toward the field. Reduced motion keeps the fades and drops the slide and the shrink.",
          "The open button's label, 'Show options', and the chips' 'Remove …' labels are English. Edit the file to translate them."
        ],
        "examples": [
          {
            "title": "Pick one item",
            "code": "<Combobox\n  aria-label=\"Time zone\"\n  placeholder=\"Find a time zone\"\n  items={[\"Europe/Lisbon\", \"Europe/Oslo\", \"America/New_York\", \"Asia/Tokyo\"]}\n/>"
          },
          {
            "title": "With a label, a value, and a form name (needs @txbrendan/label)",
            "code": "import { Label } from \"@/components/ui/label\";\n\n<div className=\"grid max-w-xs gap-1.5\">\n  <Label htmlFor=\"country\">Country</Label>\n  <Combobox\n    id=\"country\"\n    name=\"country\"\n    defaultValue=\"pt\"\n    items={[\n      { value: \"no\", label: \"Norway\" },\n      { value: \"pt\", label: \"Portugal\" },\n      { value: \"se\", label: \"Sweden\" },\n    ]}\n  />\n</div>"
          },
          {
            "title": "Groups",
            "code": "<Combobox\n  aria-label=\"Ingredient\"\n  items={[\n    { label: \"Fruit\", items: [\"Apple\", \"Banana\", \"Pear\"] },\n    { label: \"Herbs\", items: [\"Basil\", \"Mint\", \"Thyme\"] },\n  ]}\n/>"
          },
          {
            "title": "Pick several, shown as chips",
            "code": "<Combobox\n  aria-label=\"Labels\"\n  multiple\n  name=\"labels\"\n  placeholder=\"Add labels\"\n  defaultValue={[\"bug\"]}\n  items={[\n    { value: \"bug\", label: \"Bug\" },\n    { value: \"docs\", label: \"Docs\" },\n    { value: \"design\", label: \"Design\" },\n  ]}\n/>"
          },
          {
            "title": "Controlled",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nconst people = [\n  { value: \"u1\", label: \"Ada Lovelace\" },\n  { value: \"u2\", label: \"Grace Hopper\" },\n];\n\nfunction AssigneePicker() {\n  const [assignee, setAssignee] = useState<string | null>(null);\n  return <Combobox aria-label=\"Assignee\" items={people} value={assignee} onValueChange={setAssignee} />;\n}"
          },
          {
            "title": "Grows from the field",
            "code": "<Combobox\n  aria-label=\"Time zone\"\n  placeholder=\"Find a time zone\"\n  items={[\"Europe/Lisbon\", \"Europe/Oslo\", \"America/New_York\", \"Asia/Tokyo\"]}\n  effect=\"grow\"\n/>"
          }
        ]
      }
    },
    {
      "name": "command-menu",
      "type": "registry:ui",
      "title": "Command Menu",
      "description": "Search everything from anywhere: ⌘K opens it over the page, results come grouped, and the arrows and Enter pick one.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/action-keys"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/command-menu.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "One per app, to jump to people, teams, events, pages, and actions from anywhere.",
            "Keep open in state. Set it in onOpen, which ⌘K (Ctrl+K) calls, and clear it in onClose.",
            "Put a Search button on screen that opens it too. Teach ⌘K in that button's tooltip, never in the layout.",
            "Give each item a group, like \"People\" or \"Actions\". Groups show in the order they first appear, five results each.",
            "Mark a few items recent. They show before anything is typed.",
            "Use inline to show it open in a card or a docs page."
          ],
          "avoid": [
            "Picking a value for a form. Use Combobox, which keeps the choice in the field.",
            "A short list of actions on one thing. Use Menu.",
            "Two command menus on one page with shortcut on. Only one should listen for ⌘K."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "CommandItem[]",
            "description": "The results: { id, person?, avatar?, label?, icon?, group?, meta?, keywords?, recent?, onSelect? }. A person has a name and an avatar element. Anything else has a label and an icon element."
          },
          {
            "name": "open",
            "type": "boolean",
            "default": "false",
            "description": "Whether it is open over the page. Not used with inline."
          },
          {
            "name": "onOpen",
            "type": "() => void",
            "description": "⌘K (Ctrl+K) was pressed while it was closed."
          },
          {
            "name": "onClose",
            "type": "() => void",
            "description": "Esc, a press outside, ⌘K, or a choice closed it."
          },
          {
            "name": "onSelect",
            "type": "(item: CommandItem) => void",
            "description": "Runs after a result is chosen. It closes first, then the item's own onSelect runs, then this."
          },
          {
            "name": "placeholder",
            "type": "string",
            "default": "\"Search people, teams, and pages\"",
            "description": "The field's placeholder."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Search\"",
            "description": "Names the dialog and the field for screen readers."
          },
          {
            "name": "shortcut",
            "type": "boolean",
            "default": "true",
            "description": "Listen for ⌘K on the page. A rich text editor keeps ⌘K for its links."
          },
          {
            "name": "inline",
            "type": "boolean",
            "default": "false",
            "description": "Open in the page with no scrim. Choosing a result or Esc clears the words."
          },
          {
            "name": "effect",
            "type": "\"rise\" | \"grow\"",
            "default": "\"rise\"",
            "description": "How the menu and its backdrop open and close. rise fades in and drops into place. grow fades in and grows in place, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none. Not used with inline."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the menu. Later classes win."
          }
        ],
        "a11y": [
          "It is a modal dialog named by label. Focus goes to the field, Tab stays inside even when pressed fast, and the page behind is inert.",
          "When it closes, focus goes back to what opened it.",
          "The field is a combobox. The arrows move the highlight and Enter chooses. Focus stays in the field.",
          "Esc clears typed words first. The next Esc closes it.",
          "Results are grouped under their group's name, which screen readers hear.",
          "Avatars and icons are hidden from screen readers, since the name says who or what it is."
        ],
        "examples": [
          {
            "title": "Open it with ⌘K or a button",
            "code": "\"use client\";\n\nimport { useState } from \"react\";\nimport { useRouter } from \"next/navigation\";\n\nconst ITEMS = [\n  { id: \"sam\", person: \"Sam Carter\", group: \"People\", meta: \"Design lead, Mobile app\", recent: true },\n  { id: \"design\", label: \"Design\", group: \"Teams\" },\n  { id: \"fees\", label: \"Fees\", group: \"Pages\" },\n];\n\nexport function Search() {\n  const [open, setOpen] = useState(false);\n  const router = useRouter();\n  return (\n    <>\n      <button type=\"button\" onClick={() => setOpen(true)}>Search</button>\n      <CommandMenu\n        items={ITEMS}\n        open={open}\n        onOpen={() => setOpen(true)}\n        onClose={() => setOpen(false)}\n        onSelect={(item) => router.push(`/${item.id}`)}\n      />\n    </>\n  );\n}"
          },
          {
            "title": "Open in the page",
            "code": "<CommandMenu\n  inline\n  items={[\n    { id: \"new\", label: \"New event\", group: \"Actions\", recent: true },\n    { id: \"fees\", label: \"Fees\", group: \"Pages\" },\n  ]}\n/>"
          },
          {
            "title": "Grows in place",
            "code": "\"use client\";\n\nimport { useState } from \"react\";\n\nconst ITEMS = [\n  { id: \"design\", label: \"Design\", group: \"Teams\" },\n  { id: \"fees\", label: \"Fees\", group: \"Pages\" },\n];\n\nexport function Search() {\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <button type=\"button\" onClick={() => setOpen(true)}>Search</button>\n      <CommandMenu items={ITEMS} open={open} onOpen={() => setOpen(true)} onClose={() => setOpen(false)} effect=\"grow\" />\n    </>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "composer",
      "type": "registry:ui",
      "title": "Composer",
      "description": "A message to people, written and sent in place, with who it reaches above it and Undo for 8 seconds after Send.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/audience",
        "@txbrendan/button",
        "@txbrendan/icon",
        "@txbrendan/kbd",
        "@txbrendan/menu",
        "@txbrendan/peek-card",
        "@txbrendan/textarea",
        "@txbrendan/tool-button",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/composer.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "ai",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Writing to a team, a group, or one person, under their thread.",
            "Pass everyone in to, as for Audience. Juniors go by juniorMessages.",
            "Show the message in its thread from onSend. Take it out in onUndo. Deliver it in onCommit, when the 8 seconds end.",
            "Pass later with times to offer Send later, like Mon 08:00."
          ],
          "avoid": [
            "An announcement in a sheet. Put Audience above a Textarea there, and make Send the sheet's main action.",
            "A one-line reply to a comment. Use Inline Reply.",
            "Long, formatted writing. Use Editor."
          ]
        },
        "props": [
          {
            "name": "to",
            "type": "Recipient[]",
            "description": "Required. Who the message is for. See Audience."
          },
          {
            "name": "toLabel",
            "type": "string",
            "description": "What the group is called, like Art class. It titles the count's peek and fills the placeholder."
          },
          {
            "name": "juniorMessages",
            "type": "\"guardians\" | \"copy\"",
            "default": "\"guardians\"",
            "description": "Where messages to juniors go: to their parents instead, or to the junior with their parents copied."
          },
          {
            "name": "placeholder",
            "type": "string",
            "default": "\"Write to {toLabel}\"",
            "description": "The field's placeholder."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "Words to start with, like a draft."
          },
          {
            "name": "later",
            "type": "{ id: string; label: string; at: string | Date }[]",
            "description": "Times to offer under Send later. Without it, there is only Send."
          },
          {
            "name": "sendLabel",
            "type": "string",
            "default": "\"Send\"",
            "description": "Send's words."
          },
          {
            "name": "onSend",
            "type": "(message: { text: string; at: string | Date | null; reach: AudienceReach }) => void",
            "description": "Pressed: show the message where it lands. at is null for now, or the chosen later time."
          },
          {
            "name": "onUndo",
            "type": "(message) => void",
            "description": "Taken back: take it out of the thread. The words are back in the field. The same object onSend got."
          },
          {
            "name": "onCommit",
            "type": "(message) => void | Promise<unknown>",
            "description": "Final: deliver it now. Return a promise, and a failure turns the button into Retry."
          },
          {
            "name": "onAddGuardian",
            "type": "(junior: { person: string; photo?: string }) => void",
            "description": "The fix for a junior with no parent on file. See Audience."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Text, field, and button size."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Turns the field and the buttons off."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Later classes win."
          }
        ],
        "a11y": [
          "The field is named Message to {toLabel}, and Audience's line describes it, so people hear who it goes to.",
          "⌘Enter or Ctrl+Enter sends from the field. Send's tooltip teaches it, and Send has aria-keyshortcuts.",
          "Send says what happened in its name: Sent to 3, then Sent to 3. Press to undo. The page's live region says it once.",
          "Undo arms a moment after a key press, or when the pointer leaves after a click, so a double press never undoes.",
          "In the empty field, ⌘Z takes back the message that just went and puts the words back. Anywhere else, ⌘Z does what the page's ⌘Z does.",
          "Send later is a menu button. Once a time is chosen, it says when, and a press on it undoes."
        ],
        "examples": [
          {
            "title": "To a group, with Send later",
            "code": "<Composer\n  to={group}\n  toLabel=\"Art class\"\n  later={[{ id: \"mon\", label: \"Mon 08:00\", at: \"2026-10-26T08:00\" }]}\n  onSend={(message) => setThread((all) => [...all, message])}\n  onUndo={(message) => setThread((all) => all.filter((m) => m !== message))}\n  onCommit={(message) => deliver(message)}\n/>"
          },
          {
            "title": "To one person",
            "code": "<Composer to={[{ person: \"Josh Tait\" }]} size=\"sm\" />"
          }
        ]
      }
    },
    {
      "name": "consensus-meter",
      "type": "registry:ui",
      "title": "Consensus Meter",
      "description": "How a group of raters lean, one segment per rating, and where their low, average, and high targets sit against the current value.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/badge",
        "@txbrendan/morph-label",
        "@txbrendan/motion-timing",
        "@txbrendan/rolling-number",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/consensus-meter.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing how analysts or reviewers lean, and where their price or score targets sit against today's value.",
            "Pass counts for the negative, neutral, and positive sides. Pass targets to add the track of low, average, and high.",
            "Pass verdict for the overall call. It turns green or red when most ratings lean one way.",
            "Change counts or targets, as from tabs, and the counts roll, the segments grow, shrink, and change color in place, the marks glide, and each label glides to its new column.",
            "Pass noun, like { one: \"analyst\", other: \"analysts\" }, for the count."
          ],
          "avoid": [
            "A rating out of five stars or a single score. Use Progress or a plain number.",
            "More than three sides. The split has negative, neutral, and positive only.",
            "Values over time. Use LineChart."
          ]
        },
        "props": [
          {
            "name": "counts",
            "type": "{ negative: number; neutral: number; positive: number }",
            "description": "How many ratings fall on each side. Up to 40 draw one segment each. More draw one segment per side, sized by its share. Counts below 0 count as 0, and parts round to whole ratings."
          },
          {
            "name": "targets",
            "type": "{ low: number; average: number; high: number; current: number }",
            "description": "The targets and the current value. The track always fits the current value, even outside the targets. A low above the high is read the other way round."
          },
          {
            "name": "verdict",
            "type": "ReactNode",
            "description": "The overall call, shown in a badge. Green when 60% or more lean positive, red when 60% or more lean negative. Its words morph when they change, and long words are cut short."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the meter. TypeScript requires aria-label or aria-labelledby."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "default": "US dollars, with cents on every target when any has them, and short form like $2.35M from $100,000 up",
            "description": "Formats the targets."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for numbers. Fixed, so server and browser print the same text."
          },
          {
            "name": "labels",
            "type": "{ negative?, neutral?, positive?, low?, average?, current?, high?: string }",
            "default": "Bearish, Neutral, Bullish, Low, Average, Current, High",
            "description": "The words for each side and each target."
          },
          {
            "name": "noun",
            "type": "{ one: string; other: string }",
            "default": "{ one: \"rating\", other: \"ratings\" }",
            "description": "What one rating is called, for the count."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholders at the same size."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No ratings yet\"",
            "description": "Shown when every count is 0."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "Renders a <figure> named by aria-label or aria-labelledby. TypeScript requires one.",
          "Every count and every target is printed as text, so nothing lives only in color. The segments and the track are hidden from screen readers, and a hidden line reads each target and how far it sits from the current value, which sighted people see on hover or tap.",
          "Each side's color sits beside its word: Bearish, Neutral, Bullish. The targets differ by shape: plain dots for the ends, rings for the average and the current value. The average and the line to it take the color of the lean, up or down. With forced colors, the bar, track, and marks take system colors, so they stay visible.",
          "Hovering a side lifts its segments and fades the others. Hovering a target grows its mark, fades the others, and swaps its word for how far it sits from the current value. On touch, a tap does the same, and a tap anywhere but on a part lets go. Each side has a strip 26px tall over the thin bar, and each mark a 24px target.",
          "Once on screen, the bar fills from the start and each target glides out from the current value. When the data changes, counts roll, segments resize, and marks and labels glide. With motion off, it all shows at once. Right to left, the labels and the track both run from the right.",
          "Loading and the empty text are read out through a status line that stays on the page."
        ],
        "examples": [
          {
            "title": "Ratings and targets",
            "code": "<ConsensusMeter\n  aria-label=\"Analyst consensus\"\n  counts={{ negative: 0, neutral: 1, positive: 6 }}\n  targets={{ low: 375, average: 398.57, high: 460, current: 282.32 }}\n  verdict=\"Strong buy\"\n  noun={{ one: \"analyst\", other: \"analysts\" }}\n/>"
          },
          {
            "title": "The split alone",
            "code": "<ConsensusMeter aria-label=\"Reviews\" counts={{ negative: 3, neutral: 4, positive: 2 }} verdict=\"Mixed\" />"
          },
          {
            "title": "While it loads",
            "code": "<ConsensusMeter aria-label=\"Analyst consensus\" counts={data?.counts ?? { negative: 0, neutral: 0, positive: 0 }} targets={data?.targets} loading={isLoading} />"
          }
        ]
      }
    },
    {
      "name": "context-window",
      "type": "registry:ui",
      "title": "Context Window",
      "description": "A meter for how much of a model's context window is used, like 84K of 200K tokens. It splits the use into parts with a legend, and turns warning near the limit.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/badge",
        "@txbrendan/button",
        "@txbrendan/card",
        "@txbrendan/notice",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/context-window.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "ai",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Show what fills a model's context in a chat or agent product, beside the composer or in a session panel.",
            "Pass limit, the window's size in tokens, and parts in the order the bar shows them, like system prompt, tools, files, and chat.",
            "Keep to five parts. Each takes the next chart color. Group small ones into Other.",
            "It turns warning at 80% of the limit and danger once full. Pass warnAt to warn earlier, like 0.6.",
            "Pass action for the next step near the limit, like a Compact chat or New chat button.",
            "Give a part its own action to free up room, like { label: \"Remove\" } on a file or { label: \"Compact\", loading } on the chat. Pass the parts again with the new tokens, and the bar moves.",
            "Pass legend={false} and size=\"sm\" for a small space."
          ],
          "avoid": [
            "A task that runs, like an upload. Use Progress.",
            "One plain share with no parts, like 72% of storage. Use Progress or Ring Progress.",
            "Billing or usage over a month, compared side by side. Use Breakdown List."
          ]
        },
        "props": [
          {
            "name": "parts",
            "type": "{ id?: string; label: string; tokens: number; action?: ContextWindowPartAction }[]",
            "description": "Required. What fills the window, in the order the bar shows them. Five at most."
          },
          {
            "name": "ContextWindowPartAction",
            "type": "{ label: string; onClick?: () => void; loading?: boolean; disabled?: boolean }",
            "description": "Type. A quiet button at the end of the part's legend row. Pass functions from a client component."
          },
          {
            "name": "limit",
            "type": "number",
            "description": "Required. The window's size in tokens, like 200000."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "default": "\"Context window\"",
            "description": "The card's title. It names the meter."
          },
          {
            "name": "model",
            "type": "ReactNode",
            "description": "A small tag beside the title, like the model's name."
          },
          {
            "name": "warnAt",
            "type": "number",
            "default": "0.8",
            "description": "The share of the limit, from 0 to 1, where it turns warning. It turns danger once full."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Colors the percent and the last line. Left out, it is neutral, then warning from warnAt, then danger once full. Passed, it stays that tone."
          },
          {
            "name": "legend",
            "type": "boolean",
            "default": "true",
            "description": "Shows each part's name, tokens, and action under the bar."
          },
          {
            "name": "action",
            "type": "ReactNode",
            "description": "A button in the last row, like Compact chat or New chat."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholders while the numbers load."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\"",
            "default": "\"md\"",
            "description": "Bar height and the number's size."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "Formats the numbers the short way, like 84K."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the card. Later classes win."
          }
        ],
        "a11y": [
          "The card is a group named by the title. The bar is a meter with the same name, and reads like \"84K of 200K tokens used, 42%\".",
          "The bar's colored parts are hidden from screen readers. The legend says each part's name and tokens in text.",
          "A part's action is named with the part, like \"Remove Files\", so each button says what it acts on.",
          "Near the limit, the last line says Almost full or Full in words, so the warning does not rest on color alone.",
          "In forced-colors mode the bar draws a border and its parts fill with the text color."
        ],
        "examples": [
          {
            "title": "Split by part, under the limit",
            "code": "<ContextWindow\n  model=\"claude-opus-5-5\"\n  limit={200000}\n  parts={[\n    { label: \"System prompt\", tokens: 4200 },\n    { label: \"Tools\", tokens: 12800 },\n    { label: \"Files\", tokens: 31000 },\n    { label: \"Chat\", tokens: 36000 },\n  ]}\n/>"
          },
          {
            "title": "Near the limit, with a next step",
            "code": "import { Button } from \"@/components/ui/button\";\nimport { ContextWindow } from \"@/components/ui/context-window\";\n\nexport function SessionMeter() {\n  return (\n    <ContextWindow\n      limit={200000}\n      parts={[\n        { label: \"Files\", tokens: 46100 },\n        { label: \"Chat\", tokens: 129700 },\n      ]}\n      action={\n        <Button size=\"sm\" emphasis=\"outline\">\n          Compact chat\n        </Button>\n      }\n    />\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "data-table",
      "type": "registry:ui",
      "title": "Data Table",
      "description": "A dense table on Table: sortable columns, people through Person, statuses, peeks, your row, zones, breaks, and quick actions per row.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/hover-card",
        "@txbrendan/hover-actions",
        "@txbrendan/motion-timing",
        "@txbrendan/peek-card",
        "@txbrendan/person",
        "@txbrendan/row-actions",
        "@txbrendan/skeleton",
        "@txbrendan/status-text",
        "@txbrendan/table"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/data-table.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Rows of records read across: a team, payments, attendance. Put it in a Card with className=\"px-0\" on the part that holds it, so it runs edge to edge.",
            "It is generic over the row: every callback sees your row type. Keep columns stable, outside the component or in useMemo, so a sort is not redone each render.",
            "A field shaped { person, photo } draws as Person. Use render for anything else.",
            "status puts the row's status right after a cell's value, usually the name. A table's status follows the name, never its own column.",
            "peek explains a value: who, when, why. Plain text gets a dotted underline.",
            "sortable adds a header button. The first press sorts text A to Z and numbers high to low, and the next press flips it. aria-sort says which.",
            "highlight marks your rows with a You tag and their own wash. zones tint a run of rows, like the top places, with a 2px rule where it meets the rest. breaks add a labeled line, like Your place.",
            "rowActions spring in over the row's end on hover or focus, and their keys work then. If they would cover a peek or a status, the table keeps a band at the end for them.",
            "hideBelow drops a column while the table is narrow, so the names keep their room."
          ],
          "avoid": [
            "A list of things that are not read across. Use ListRow.",
            "Sorting a table with zones or breaks. They follow row positions, so a sort moves them onto other rows.",
            "A column for the status. Put status on the name column.",
            "Colors alone for a zone. Say what it means in a peek or in words."
          ]
        },
        "props": [
          {
            "name": "columns",
            "type": "DataColumn<Row>[]",
            "description": "Required. Each: { key, label, short?, width?, align?, numeric?, render?(row, hovered), peek?(row), underline?, status?(row), rowHeader?, sortable?, sortValue?(row), hideBelow?, className? }. short shows in place of label, like \"P\" for Played, while label is read."
          },
          {
            "name": "rows",
            "type": "Row[]",
            "description": "Required. Long tables draw 100 rows at a time as the reader nears the end."
          },
          {
            "name": "rowKey",
            "type": "string | ((row, index) => string | number)",
            "description": "A stable key per row. Default row.id, then the position."
          },
          {
            "name": "breaks",
            "type": "{ after: number; label: string; tone?; side?: \"above\" | \"below\" }[]",
            "description": "A labeled line after a row index. Its arrow says whether the label is about the rows above or below."
          },
          {
            "name": "zones",
            "type": "{ from: number; to: number; tone? }[]",
            "description": "Runs of row indices tinted in a tone as one band, with a 2px rule where the band meets the rest."
          },
          {
            "name": "highlight",
            "type": "(row) => boolean",
            "description": "Rows that are yours: a You tag after the name and their own wash, or the tag alone inside a zone."
          },
          {
            "name": "highlightLabel",
            "type": "string",
            "default": "\"You\"",
            "description": "The tag on a highlighted row, which screen readers hear before its name."
          },
          {
            "name": "rowActions",
            "type": "(row) => HoverAction[]",
            "description": "Quick actions over the row's end while it is hovered or holds focus."
          },
          {
            "name": "onRowClick",
            "type": "(row) => void",
            "description": "Makes each row one press, named by its row header."
          },
          {
            "name": "sort",
            "type": "{ key: string; direction: \"ascending\" | \"descending\" } | null",
            "description": "Controlled sort."
          },
          {
            "name": "defaultSort",
            "type": "{ key: string; direction: \"ascending\" | \"descending\" } | null",
            "description": "The first sort, uncontrolled."
          },
          {
            "name": "onSortChange",
            "type": "(sort) => void",
            "description": "Hears each sort a header button asks for."
          },
          {
            "name": "caption",
            "type": "ReactNode",
            "description": "Names the table, under it."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the table when it has no caption."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Holds the space with three placeholder rows."
          },
          {
            "name": "empty",
            "type": "ReactNode",
            "default": "\"Nothing here yet\"",
            "description": "What shows with no rows."
          },
          {
            "name": "data-slot",
            "type": "string",
            "default": "\"data-table\"",
            "description": "The root's slot, for a part built on DataTable, like Leaderboard."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the wrapper. Set --data-table-edge to change the first and last columns' inset. Later classes win."
          }
        ],
        "a11y": [
          "A real <table>, with column headers. A column marked rowHeader is a <th scope=\"row\">, so each cell is read with the row's name.",
          "A sortable header is a button inside the <th>. The sorted header carries aria-sort, ascending or descending.",
          "With onRowClick, each row's press is one real button named by its row header. Its focus ring goes around the whole row. Controls in the row keep their own presses, and none sits inside another.",
          "A highlighted row's row header starts with \"You,\" for screen readers, so every cell is read with it. Its tag is words, so it stays in forced colors.",
          "A zone's edges are 2px borders, so they stay in forced colors. Its meaning needs words, like a peek on its rows.",
          "Row actions show on hover and on focus inside the row. Their single-key shortcuts work only while the row is lit.",
          "Each row carries data-peek-row, so a peek opens beside it, and ↑↓ step between peeks.",
          "While loading, the table is aria-busy."
        ],
        "examples": [
          {
            "title": "Sortable columns with people",
            "code": "type Member = { id: string; name: { person: string }; role: string; projects: number };\n\nconst columns: DataColumn<Member>[] = [\n  { key: \"name\", label: \"Name\", rowHeader: true, sortable: true, sortValue: (row) => row.name.person },\n  { key: \"role\", label: \"Role\" },\n  { key: \"projects\", label: \"Projects\", numeric: true, sortable: true },\n];\n\n<DataTable\n  aria-label=\"Team\"\n  columns={columns}\n  rows={[\n    { id: \"p1\", name: { person: \"Tom Hale\" }, role: \"Designer\", projects: 42 },\n    { id: \"p2\", name: { person: \"Josh Tait\" }, role: \"Engineer\", projects: 17 },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "date-picker",
      "type": "registry:ui",
      "title": "Date Picker",
      "description": "A field that shows a day, like \"Sat 3 Oct\", and opens a calendar under it to pick one. Weeks start on Monday, and the year shows only when it is not this year.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/calendar",
        "@txbrendan/field-frame",
        "@txbrendan/popover"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/date-picker.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "dates",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "One day for something people plan: a meeting, a workshop, an invoice's due date.",
            "Give it a label, like \"Event date\". Without one, pass aria-label.",
            "The value is a day as \"YYYY-MM-DD\", with no time and no time zone.",
            "Set min and max for the days people may pick, and disabledDates for days that are taken.",
            "Put what is wrong in error, like \"Pick a day from today on.\" It replaces the hint and is said once.",
            "Pass name to send the day with a form."
          ],
          "avoid": [
            "A start and an end day. Use a Calendar with mode=\"range\".",
            "A calendar that stays on the page, like a schedule. Use a Calendar.",
            "A birth date or another day far in the past. Typing it is quicker than paging back through months."
          ]
        },
        "props": [
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Names the field, above it. Pressing it focuses the button and does not open the calendar."
          },
          {
            "name": "value",
            "type": "string | null",
            "description": "The picked day, \"YYYY-MM-DD\". Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string | null",
            "description": "The picked day at first render, when you do not control it."
          },
          {
            "name": "onValueChange",
            "type": "(day: string) => void",
            "description": "Called with the new day when someone picks one."
          },
          {
            "name": "placeholder",
            "type": "string",
            "default": "\"Pick a date\"",
            "description": "Shown while no day is picked."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "A short note under the field."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Replaces the hint, in red, and is announced once."
          },
          {
            "name": "min",
            "type": "string",
            "description": "The earliest day people can pick, \"YYYY-MM-DD\"."
          },
          {
            "name": "max",
            "type": "string",
            "description": "The latest day people can pick, \"YYYY-MM-DD\"."
          },
          {
            "name": "disabledDates",
            "type": "string[] | (date: string) => boolean",
            "description": "Days people cannot pick."
          },
          {
            "name": "weekStartsOn",
            "type": "0 | 1 | 2 | 3 | 4 | 5 | 6",
            "default": "1",
            "description": "The first column. 1 is Monday."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-GB\"",
            "description": "A language tag for the button and the calendar."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height and text size of the button, the label, and the calendar."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks the button."
          },
          {
            "name": "name",
            "type": "string",
            "description": "Form field name. Sends the day, or nothing while none is picked."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the field when there is no visible label."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the frame. Later classes win."
          }
        ],
        "a11y": [
          "The button's name is the label and then the day, like \"Event date, Sat 3 Oct\". It says it opens a dialog, and whether it is open.",
          "The calendar opens with focus on the picked day, or today, or the month's first day.",
          "In the calendar the arrows move a day or a week, Home and End go to the week's ends, and Page Up and Page Down move a month, with Shift a year.",
          "Enter, Space, or a click picks a day. That closes the calendar and puts focus back on the button.",
          "Escape or a click outside closes it without a change.",
          "The hint or the error describes the button. The error is announced once when it appears."
        ],
        "examples": [
          {
            "title": "With a picked day and a hint",
            "code": "<DatePicker label=\"Event date\" defaultValue=\"2026-10-03\" hint=\"It starts at 10:00.\" />"
          },
          {
            "title": "Limits and taken days",
            "code": "<DatePicker\n  label=\"Friendly\"\n  min=\"2026-10-26\"\n  max=\"2026-12-20\"\n  disabledDates={[\"2026-10-31\", \"2026-11-07\"]}\n/>"
          }
        ]
      }
    },
    {
      "name": "dialog",
      "type": "registry:ui",
      "title": "Dialog",
      "description": "A window over the page, with a backdrop, for a choice or a short task that must be done or cancelled before going on.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/action-keys"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/dialog.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A choice people must make before they go on, like confirming a delete.",
            "A short form that needs full attention, like editing a profile.",
            "Always give it a DialogTitle. It names the dialog for screen readers.",
            "Put the actions in DialogFooter, with the safe choice before the main action, which goes last.",
            "An extra quiet action, like Discard, goes first as a Button with emphasis=\"ghost\". It sits at the start of the row, away from the main action.",
            "The dialog opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on DialogPopup so the dialog and its backdrop keep the box's colors."
          ],
          "avoid": [
            "Extra details or a quick edit beside a button. Use a popover, which does not block the page.",
            "A message that needs no answer. Use an alert on the page.",
            "Long forms or many steps. Use a page.",
            "Opening a dialog when the page loads. People did not ask for it.",
            "More than three actions, or long labels, in DialogFooter. It stays on one row, so on a phone the dialog would scroll sideways.",
            "closeButton={false} when the footer starts with Discard or Delete and no field comes before it. Focus would start on that button, so Enter would run it."
          ]
        },
        "props": [
          {
            "name": "Dialog",
            "type": "{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void; disablePointerDismissal?: boolean }",
            "description": "Root. Holds the open state and renders no element. Takes open, defaultOpen, onOpenChange, and disablePointerDismissal."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Dialog. Whether it is open, controlled. Use it with onOpenChange."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Dialog. Whether it is open at the start, when you do not control it."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean, details) => void",
            "description": "Dialog. Called with the new open state when it opens or closes."
          },
          {
            "name": "disablePointerDismissal",
            "type": "boolean",
            "default": "false",
            "description": "Dialog. Keeps a press outside from closing it. Esc and the Close button still close it."
          },
          {
            "name": "DialogTrigger",
            "type": "ComponentProps<\"button\"> & { render?: ReactElement }",
            "description": "Part. The button that opens it. Pass render={<Button />} to use your own button."
          },
          {
            "name": "DialogPopup",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The dialog, centered over a backdrop. It renders in a portal while open."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "DialogPopup. Most width: 24rem, 32rem, or 42rem. It never gets wider than the screen."
          },
          {
            "name": "closeButton",
            "type": "boolean",
            "default": "true",
            "description": "DialogPopup. Shows a Close button with an X in the top corner. Turn it off only when the footer has a Cancel button."
          },
          {
            "name": "DialogTitle",
            "type": "ComponentProps<\"h2\">",
            "description": "Part. The dialog's name. An <h2> by default."
          },
          {
            "name": "DialogDescription",
            "type": "ComponentProps<\"p\">",
            "description": "Part. Muted text under the title. Screen readers read it when the dialog opens."
          },
          {
            "name": "DialogFooter",
            "type": "Omit<ComponentProps<\"div\">, \"color\">",
            "description": "Part. One row of actions at the bottom. Ghost buttons sit at the start, and the rest line up at the end."
          },
          {
            "name": "DialogClose",
            "type": "ComponentProps<\"button\"> & { render?: ReactElement }",
            "description": "Part. A button that closes the dialog, like Cancel."
          },
          {
            "name": "initialFocus",
            "type": "RefObject<HTMLElement | null> | boolean",
            "description": "DialogPopup. Where focus goes when it opens. By default, the Close button, or the first control inside when there is none."
          },
          {
            "name": "container",
            "type": "HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null",
            "description": "DialogPopup. The element the dialog and its backdrop open in. By default, the end of the page body, so no parent can clip them. Pass an element, or a ref itself (not ref.current), to open them inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no dialog, but still locks page scroll until it closes."
          },
          {
            "name": "effect",
            "type": "\"rise\" | \"grow\"",
            "default": "\"rise\"",
            "description": "DialogPopup. How the dialog and its backdrop open and close. rise fades in and rises a little. grow fades in and grows in place, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none."
          },
          {
            "name": "className",
            "type": "string",
            "description": "DialogPopup. Extra classes. Later classes win, so className='max-w-xl' replaces the width."
          }
        ],
        "a11y": [
          "The popup has role=\"dialog\". DialogTitle names it, and DialogDescription describes it.",
          "While it is open, the rest of the page is hidden from screen readers with aria-hidden.",
          "Opening it moves focus to the Close button in the corner, which is first in the tab order. With closeButton={false}, focus goes to the first control inside.",
          "Tab stays inside until it closes, even when pressed fast. Then focus goes back to the trigger.",
          "Escape closes it, and so does a click on the backdrop. Set disablePointerDismissal on Dialog to ignore backdrop clicks.",
          "The page behind cannot be scrolled or reached while it is open.",
          "The Close button in the corner is named Close. Touch screen reader users cannot press Escape, so keep it, or keep a Cancel button.",
          "The theme tests hold dialog text and muted text to 4.5:1 on the popover color, and focus rings and input borders to 3:1, in light and dark."
        ],
        "examples": [
          {
            "title": "Confirm a delete (needs @txbrendan/button)",
            "code": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\n\nfunction DeleteProject({ onDelete }: { onDelete: () => void }) {\n  return (\n    <Dialog>\n      <DialogTrigger render={<Button tone=\"danger\" emphasis=\"soft\" />}>Delete project</DialogTrigger>\n      <DialogPopup size=\"sm\" closeButton={false}>\n        <DialogTitle>Delete this project?</DialogTitle>\n        <DialogDescription>This deletes every file in it. You cannot undo it.</DialogDescription>\n        <DialogFooter>\n          <DialogClose render={<Button emphasis=\"outline\" />}>Cancel</DialogClose>\n          <DialogClose render={<Button tone=\"danger\" />} onClick={onDelete}>\n            Delete project\n          </DialogClose>\n        </DialogFooter>\n      </DialogPopup>\n    </Dialog>\n  );\n}"
          },
          {
            "title": "A form that closes when it saves (needs @txbrendan/button, input, and label)",
            "code": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nfunction EditProfile({ name, save }: { name: string; save: (data: FormData) => Promise<void> }) {\n  const [open, setOpen] = useState(false);\n  return (\n    <Dialog open={open} onOpenChange={setOpen}>\n      <DialogTrigger render={<Button emphasis=\"outline\" />}>Edit profile</DialogTrigger>\n      <DialogPopup>\n        <DialogTitle>Edit profile</DialogTitle>\n        <DialogDescription>Your name shows on every comment you leave.</DialogDescription>\n        <form\n          action={async (data) => {\n            await save(data);\n            setOpen(false);\n          }}\n        >\n          <div className=\"mt-6 flex flex-col gap-1.5\">\n            <Label htmlFor=\"profile-name\">Name</Label>\n            <Input id=\"profile-name\" name=\"name\" defaultValue={name} />\n          </div>\n          <DialogFooter>\n            <DialogClose render={<Button emphasis=\"outline\" />}>Cancel</DialogClose>\n            <Button type=\"submit\" tone=\"brand\">\n              Save\n            </Button>\n          </DialogFooter>\n        </form>\n      </DialogPopup>\n    </Dialog>\n  );\n}"
          },
          {
            "title": "Wider, for a table or a long form (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Dialog>\n  <DialogTrigger render={<Button emphasis=\"outline\" />}>Import contacts</DialogTrigger>\n  <DialogPopup size=\"lg\">\n    <DialogTitle>Import contacts</DialogTitle>\n    <DialogDescription>Check each row before you import it.</DialogDescription>\n  </DialogPopup>\n</Dialog>"
          },
          {
            "title": "Grows in place (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Dialog>\n  <DialogTrigger render={<Button emphasis=\"outline\" />}>Rename</DialogTrigger>\n  <DialogPopup effect=\"grow\">\n    <DialogTitle>Rename file</DialogTitle>\n    <DialogDescription>The new name shows for everyone with access.</DialogDescription>\n  </DialogPopup>\n</Dialog>"
          }
        ]
      }
    },
    {
      "name": "dither-icon",
      "type": "registry:ui",
      "title": "Dither Icon",
      "description": "60 original monochrome icons with optional shifting dither dots, transparent backgrounds, and theme colors.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/dither"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/dither-icon.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "media",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Illustrated navigation, empty states, and retro interfaces.",
            "Use ditherIconNames to list all 60 icons, covering everyday controls, files, devices, objects, and nature.",
            "Use animated to swap interior dots while outlines, detail lines, and the ink count in each tile stay fixed. Thin solid strokes stay still.",
            "Provide a pause control for looping decorative animation. Use paused on one icon, or set --dither-icon-play-state: paused on a container to freeze a collection.",
            "Use 32, 64, or 96px to keep the 32-pixel grid sharp.",
            "getDitherIconSvg(name, fill) returns a still standalone SVG; fill accepts a hex color or defaults to currentColor. Inline SVG inherits text color; a downloaded file uses the viewer's default ink."
          ],
          "avoid": [
            "Small toolbar actions below 32px: use Icon or Solid Icon instead.",
            "Using texture or color alone to communicate status. Add a visible label."
          ]
        },
        "props": [
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\" | \"gradient\" | \"grain\"",
            "description": "dither draws the shape as dots, denser toward the bottom right, gradient as color that deepens the same way, and grain as still specks over a light wash. Hover fills each in to the solid shape, and shows a flat icon's dots. Left out, the icon follows the Texture around it, and is flat with none."
          },
          {
            "name": "animated",
            "type": "boolean",
            "default": "false",
            "description": "Dithered, loops four crisp dot patterns on the theme's pulse duration. No translation, scaling, or fading."
          },
          {
            "name": "paused",
            "type": "boolean",
            "default": "false",
            "description": "Freeze on the current animation frame. Has no effect on a still icon."
          },
          {
            "name": "fill",
            "type": "string",
            "description": "Optional SVG artwork fill. Use a chosen hex color to override the tone."
          },
          {
            "name": "name",
            "type": "DitherIconName",
            "default": "\"folder\"",
            "description": "One of the 60 names in ditherIconNames."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "32, 64, or 96 pixels."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The theme color used for every ink pixel."
          },
          {
            "name": "title",
            "type": "string",
            "description": "Accessible name for a standalone icon. Omit beside equivalent text."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes merged last on the SVG."
          }
        ],
        "a11y": [
          "Decorative icons are hidden from screen readers. A title makes the SVG a named image.",
          "The SVG does not take keyboard focus. Icon-only actions need their own accessible label.",
          "Reduced motion and the theme's Motion Off setting show the first frame without animating. Animation does not announce individual frames.",
          "Dithered shading is decorative; it is not a contrast guarantee."
        ],
        "examples": [
          {
            "title": "Animated texture",
            "code": "<DitherIcon name=\"planet\" texture=\"dither\" animated title=\"Planet\" />"
          },
          {
            "title": "With a label",
            "code": "<div className=\"flex items-center gap-3\"><DitherIcon name=\"folder\" size=\"sm\" /><span>Project files</span></div>"
          },
          {
            "title": "Standalone image",
            "code": "<DitherIcon name=\"trophy\" size=\"lg\" tone=\"brand\" title=\"Trophy\" />"
          }
        ]
      }
    },
    {
      "name": "dither-motion",
      "type": "registry:ui",
      "title": "Dither Motion",
      "description": "Moves a chart's dithered dots: an intro, a loop at rest, and dots that fill in near the pointer. It draws on a canvas under the chart.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/dither",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/dither-motion.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "motion",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A chart whose dithered areas should move. Put a canvas under its SVG, the same size, and pass both with the areas' paths and colors.",
            "Load it with import() on first hover or focus, or once the chart is on screen. The dots at rest are the SVG mask, so nothing waits on it.",
            "The same box the chart passes to DitherBoxMask, so the canvas and the mask draw the same dots. A chart with a mask per bar passes each bar's box as boxes, and the same thin."
          ],
          "avoid": [
            "Dots that never move. DitherBoxMask draws them with no script.",
            "Shapes other than filled areas. Lines and labels stay in the SVG, on top."
          ]
        },
        "props": [
          {
            "name": "startDitherMotion",
            "type": "(options: DitherMotionOptions) => DitherMotion",
            "description": "Helper. Sets up moving dots for one chart and returns intro, idle, point, and stop. Options: host, the chart's root; canvas; width and height, the SVG's size in CSS pixels; box; boxes, one per bar, each laying out its own dots; fill; start; thin; and shapes, each { d, paint, top?, bottom?, fade? }, bottom area first. Nothing draws until one of them asks."
          },
          {
            "name": "intro",
            "type": "(style: \"sweep\" | \"rain\" | \"ripple\" | \"pop\" | \"fall\") => Promise<void>",
            "description": "Plays an intro over the motion pulse time. It resolves when the last dot is in, or at once when motion is off."
          },
          {
            "name": "idle",
            "type": "(style: \"none\" | \"wave\" | \"twinkle\" | \"breathe\" | \"drift\") => void",
            "description": "A loop at rest. Wave passes a band of extra dots across, then rests. Twinkle blinks single dots out and on, each on its own clock. Breathe fills the thin edge in deeper and back. Drift moves soft patches of extra dots slowly across. It stops while the chart is off screen."
          },
          {
            "name": "point",
            "type": "(x: number | null, y?: number) => void",
            "description": "Fills in hidden dots within 56px of the pointer, in the SVG's pixels. Pass null when the pointer leaves, and they fade back."
          },
          {
            "name": "stop",
            "type": "() => void",
            "description": "Stops every motion, clears the canvas, and hands the dots back to the mask. Call it on unmount."
          }
        ],
        "a11y": [
          "Motion off and reduced motion set the pulse to 0, and then nothing moves. The mask keeps drawing the dots at rest.",
          "Forced colors and texture flat move nothing either, since the chart draws no dots then.",
          "The canvas is aria-hidden. The chart's SVG keeps its name, and the dots carry no meaning of their own."
        ],
        "examples": [
          {
            "title": "Play an intro on a dithered area",
            "code": "import { useEffect, useRef } from \"react\";\n\nfunction MovingArea({ d, box }: { d: string; box: DitherBox }) {\n  const host = useRef<HTMLDivElement>(null);\n  const canvas = useRef<HTMLCanvasElement>(null);\n  useEffect(() => {\n    if (!host.current || !canvas.current) return;\n    const motion = startDitherMotion({ host: host.current, canvas: canvas.current, width: 320, height: 160, box, shapes: [{ d, paint: \"var(--chart-1)\" }] });\n    void motion.intro(\"sweep\");\n    return () => motion.stop();\n  }, [d, box]);\n  return (\n    <Dither>\n      <div ref={host} className=\"relative h-40 w-80\">\n        <canvas ref={canvas} aria-hidden className=\"absolute inset-0 size-full\" />\n        <svg width={320} height={160} className=\"absolute inset-0\" aria-hidden>\n          <defs>\n            <DitherBoxMask id=\"area-dots\" box={box} />\n          </defs>\n          <path d={d} fill=\"var(--chart-1)\" mask=\"url(#area-dots)\" />\n        </svg>\n      </div>\n    </Dither>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "dither",
      "type": "registry:ui",
      "title": "Texture",
      "description": "Sets how the parts inside it draw their color, like charts, meters, and buttons: dots, a gradient, or grain, with a soft glow on dark pages.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/dither.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "motion",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A dashboard, a status page, or a landing page that wants a texture on its charts and meters.",
            "Wrap a card, a section, or the whole app in Texture. Every part inside that has a texture prop draws its color that way. Dither is dots, gradient fades from the full color to a faint wash, and grain is fine speckle.",
            "Hover a gradient or grain part to answer the pointer: gradient's full color runs down the part, and grain fills in to solid. With reduced motion, gradient fades in place.",
            "Pass texture on one part to change just that part, with no wrapper.",
            "Pass texture=\"flat\" on a part, or on an inner Texture, to keep it flat inside a Texture.",
            "Dither is the old name for Texture, and it still works.",
            "Pass fill, start, line, intro, hover, or idle to set how every Area Chart inside lays out and moves its dots. Each sets a --dither- variable, like --dither-intro, so the same choice can sit on <body> in CSS. Bar Chart reads fill, intro, hover, and idle. Its bars have no line or start.",
            "Parts that take texture: Sparkline, Progress, Ring Progress, Button, Avatar, and Slideshow take all four. Bar Chart and Area Chart take flat and dither, and draw flat under gradient or grain.",
            "A change of texture fades one into the other where it sits, over --motion-duration-slow. A box that moves --dither itself, like a card that cycles between flat and dots, sets --dither-fade: 0s on itself, so the parts inside follow it with no second fade.",
            "A chart of your own. Put DitherBoxMask in its SVG defs with the plot's box, and mask the filled shapes with it. The dots snap to the plot and thin toward its bottom. Pass thin \"none\" for even dots, thin \"dense\" for dots solid at the top that stay dense longer, or a box's from with thin \"from\" to thin from a bar's own top. Dither Motion moves them."
          ],
          "avoid": [
            "Text people must read. Textures thin out the shapes, so keep body text, labels, and numbers flat.",
            "Saying something only with a texture. It is a look, and the part still says its value in text or its name.",
            "A dense table or form. The texture adds noise where people need to scan.",
            "An animation class of your own on a dithered part, like a pulse while it loads. The dots' intro sets that part's animation, so one of the two is lost. Put yours on a wrapper around the part."
          ]
        },
        "props": [
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\" | \"gradient\" | \"grain\"",
            "default": "\"dither\"",
            "description": "The texture every part inside draws. \"flat\" turns it off again inside another Texture."
          },
          {
            "name": "fill",
            "type": "\"scatter\" | \"steps\"",
            "description": "How the dots in every Area Chart and Bar Chart inside thin toward the bottom: one by one, or in 8 even steps. Its own fill wins."
          },
          {
            "name": "start",
            "type": "\"fade\" | \"gap\"",
            "description": "How every Area Chart inside starts: fading in from the axis, or 16 px in from it. Its own start wins."
          },
          {
            "name": "line",
            "type": "\"fade\" | \"dots\"",
            "description": "How each Area Chart line inside starts: fading in, or breaking into dots. Its own line wins."
          },
          {
            "name": "intro",
            "type": "\"fade\" | \"sweep\" | \"rain\" | \"ripple\" | \"pop\" | \"fall\"",
            "description": "How the dots in every Area Chart and Bar Chart inside come in. Its own intro wins."
          },
          {
            "name": "hover",
            "type": "\"fill\" | \"none\"",
            "description": "Whether hidden dots fill in near the pointer, in every Area Chart and Bar Chart inside. Its own hover wins."
          },
          {
            "name": "idle",
            "type": "\"none\" | \"wave\" | \"twinkle\" | \"breathe\" | \"drift\"",
            "description": "What the dots in every Area Chart and Bar Chart inside do at rest. Its own idle wins."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "The parts that take the texture."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Texture draws no box of its own, since it is display: contents."
          }
        ],
        "a11y": [
          "Texture changes only how color is drawn. Names, roles, values, and keyboard behavior stay the same.",
          "Texture adds no box, so it never changes layout, focus order, or what a screen reader hears.",
          "A textured shape keeps a faint wash of its color under the dots, gradient, or grain, so it still reads as one piece.",
          "Hover only runs on screens that can hover, so a tap never leaves it stuck. It is a fade, and with reduced motion gradient fades in place instead of running down.",
          "The glow shows only on dark pages, through --elevation-glow, which is 0px in light.",
          "When a dithered part first shows, its dots come in one at a time, over 40% of --motion-pulse-duration, and only once. A part further down the page waits until it starts to come on screen, then plays at the same pace however fast it was scrolled to. Button and Avatar wait with CSS alone, in browsers that can tie an animation to scrolling, and play on mount in the rest.",
          "Button and Avatar follow their nearest scrolling box, and a box with overflow hidden or auto counts as one even when it cannot scroll. Inside such a box, their dots wait for the box to scroll them into view, and play on mount when they already sit in its view, even while the box itself is off screen. A box with overflow-clip still clips but is not a scrolling box, so parts inside it wait for the page.",
          "With reduced motion or motion off, the pulse is 0s, so every dot shows at once. A print shows every dot.",
          "A change of texture is a fade, so reduced motion keeps it. With motion off it swaps at once."
        ],
        "examples": [
          {
            "title": "A dithered dashboard card",
            "code": "<Texture>\n  <BarChart\n    title=\"Deploys to production\"\n    data={[{ week: \"W1\", deploys: 52 }, { week: \"W2\", deploys: 61 }, { week: \"W3\", deploys: 74 }]}\n    config={{ deploys: { label: \"Deploys\" } }}\n    categoryKey=\"week\"\n  />\n  <Progress value={28} tone=\"brand\" label=\"Compiling 214 modules\" showValue />\n</Texture>"
          },
          {
            "title": "Gradient meters",
            "code": "<Texture texture=\"gradient\">\n  <RingProgress value={68} tone=\"brand\" aria-label=\"CPU load\" />\n  <Progress value={28} tone=\"brand\" label=\"Compiling 214 modules\" showValue />\n</Texture>"
          },
          {
            "title": "One part, no wrapper",
            "code": "<Button tone=\"brand\" texture=\"grain\">Save changes</Button>"
          },
          {
            "title": "Keep one part flat",
            "code": "<Texture>\n  <RingProgress value={68} tone=\"brand\" aria-label=\"CPU load\" />\n  <RingProgress value={40} texture=\"flat\" aria-label=\"Memory\" />\n</Texture>"
          }
        ]
      }
    },
    {
      "name": "donut-chart",
      "type": "registry:ui",
      "title": "Donut Chart",
      "description": "A ring split into slices that show how parts make up a whole, with the total in the middle and every value in the legend.",
      "dependencies": [
        "recharts@^3.10.1",
        "react-is@19.3.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/donut-chart.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing how two to five parts make up one whole, like visitors by browser or seats by plan.",
            "Pass data with one object per slice, the categoryKey that names each slice, and the valueKey that sizes it.",
            "Map slice names to labels and colors with config, like { chrome: { label: \"Chrome\" } }.",
            "Pass centerLabel to say what the total in the middle counts."
          ],
          "avoid": [
            "Comparing sizes that are close. People judge angles poorly. Use BarChart.",
            "More than five slices. The theme has five chart colors. Group the rest into Other.",
            "Values that do not add up to a whole, or negative values. Use BarChart.",
            "Showing change over time. Use LineChart or AreaChart."
          ]
        },
        "props": [
          {
            "name": "data",
            "type": "TData[]",
            "description": "One object per slice, like { browser: \"chrome\", visitors: 275 }."
          },
          {
            "name": "categoryKey",
            "type": "keyof TData",
            "description": "The field that names each slice."
          },
          {
            "name": "valueKey",
            "type": "keyof TData",
            "description": "The number field that sizes each slice."
          },
          {
            "name": "config",
            "type": "{ [slice: string]: { label: string; color?: string } }",
            "description": "Labels and colors by slice name. A slice it leaves out shows its name and takes the next chart color."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the chart when it has no visible title."
          },
          {
            "name": "centerLabel",
            "type": "string",
            "description": "A word under the total in the middle, like \"Visitors\"."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height: 5, 7, or 9 times --control-h-md, so density scales it. The ring and the total scale with it."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "description": "Formats values in the tooltip, the legend, and the middle."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for numbers. Fixed, so server and browser print the same text."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows a skeleton ring at the same size, and holds the legend with config names and placeholder values. When data arrives, the ring fades out while the slices draw in."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Shows this message in place of the chart. true shows a default message."
          },
          {
            "name": "onRetry",
            "type": "() => void",
            "description": "Shows a retry button under the error."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data yet\"",
            "description": "Shown when there is no data, or every value is 0."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='h-96' replaces the height."
          }
        ],
        "a11y": [
          "Renders a <figure> named by its title, or by aria-label. TypeScript requires one.",
          "The legend is a list of every slice with its value in text, so the numbers do not depend on color or angle.",
          "The total in the middle is text, and screen readers read it after the chart.",
          "Recharts' keyboard layer stays on: Tab focuses the chart, the arrow keys move between slices, and the tooltip reads each one out. The ring is one tab stop.",
          "Keeps one height while it loads, draws, fails, or has no data, so the page does not move. When the page prints, it asks the browser to keep a chart that fits on one page whole. In the light theme, it asks the browser to print legend dots and loading shapes even with backgrounds off.",
          "Slice colors come from --chart-1 to --chart-5, which the theme tests hold to 3:1 against the page.",
          "Slices sweep in over three times --motion-duration-base, once the chart is on screen. They finish at once when the page prints. At 0ms they draw at once, and Recharts skips the animation for reduced motion."
        ],
        "examples": [
          {
            "title": "Slices with a label in the center",
            "code": "<DonutChart\n  title=\"Visitors by browser\"\n  data={[\n    { browser: \"chrome\", visitors: 275 },\n    { browser: \"safari\", visitors: 200 },\n    { browser: \"firefox\", visitors: 187 },\n  ]}\n  categoryKey=\"browser\"\n  valueKey=\"visitors\"\n  config={{ chrome: { label: \"Chrome\" }, safari: { label: \"Safari\" }, firefox: { label: \"Firefox\" } }}\n  centerLabel=\"Visitors\"\n/>"
          },
          {
            "title": "Currency",
            "code": "<DonutChart\n  title=\"Ad spend by channel\"\n  data={[\n    { channel: \"Search\", amount: 48200 },\n    { channel: \"Social\", amount: 31750 },\n  ]}\n  categoryKey=\"channel\"\n  valueKey=\"amount\"\n  centerLabel=\"Spent\"\n  valueFormat={{ style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 }}\n/>"
          },
          {
            "title": "While it loads, and when it fails",
            "code": "<DonutChart\n  aria-label=\"Visitors by browser\"\n  data={data ?? []}\n  categoryKey=\"browser\"\n  valueKey=\"visitors\"\n  loading={isLoading}\n  error={error ? \"Visitors could not load.\" : undefined}\n  onRetry={() => refetch()}\n/>"
          }
        ]
      }
    },
    {
      "name": "duty-slot",
      "type": "registry:ui",
      "title": "Duty Slot",
      "description": "One volunteer duty on one day, as a row in a card: its icon and name, what it involves, who is on it, and where it stands, ending in the quickest fix.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/hover-actions",
        "@txbrendan/status-text",
        "@txbrendan/suggestion"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/duty-slot.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "One row per duty, in a Card: refreshments, setup, first aid, the front desk, photos.",
            "The screen keeps who is on what and passes state. Confirmed is quiet. Waiting for a reply ends in Remind, Needs cover in Find cover, and Nobody yet in Find someone.",
            "Pass suggest with the best person to ask and why. Accepting reads Asked at once, with Undo, and onAsk runs once the window ends. Move the duty to asked, with that person, when it lands.",
            "Pass out with who dropped out and what they said. It shows in the peek on Needs cover, so the row never repeats it.",
            "Pass date for a list across days, like a month of Mondays."
          ],
          "avoid": [
            "Your own undo timer for the ask, the reminder, or Withdraw. Each waits out its window in the shared result engine, so ⌘Z takes it back.",
            "Saying who dropped out in detail as well. The peek already holds it.",
            "Touch-only screens with no other way to swap or withdraw. A tap on the row shows its hover actions, but offer them on the duty's own page too."
          ]
        },
        "props": [
          {
            "name": "duty",
            "type": "string",
            "description": "The duty, like \"Refreshments\" or \"Setup\"."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "The duty's icon, in ink. Default a clipboard."
          },
          {
            "name": "detail",
            "type": "string",
            "description": "What it involves, like \"Chairs and screens before 9\"."
          },
          {
            "name": "date",
            "type": "string",
            "description": "A date column, for a list across days, like \"Mon 5\"."
          },
          {
            "name": "person",
            "type": "{ person: string; photo?: string } | null",
            "description": "Who is on it, or who was asked."
          },
          {
            "name": "state",
            "type": "\"confirmed\" | \"asked\" | \"cover\" | \"open\"",
            "description": "Where it stands. Default confirmed with a person, open without one."
          },
          {
            "name": "out",
            "type": "{ person: string; photo?: string; said?: string }",
            "description": "Who dropped out and what they said, for the peek on Needs cover."
          },
          {
            "name": "suggest",
            "type": "{ person: string; photo?: string; reason?: string; message?: string }",
            "description": "The best person to ask, why, and the first message they get. Shows as a Suggestion while the duty needs someone."
          },
          {
            "name": "onAsk",
            "type": "(person: { person: string; photo?: string; message?: string }) => void | Promise<unknown>",
            "description": "Asks the suggested person once the undo window ends. A failed promise turns the chip into Retry."
          },
          {
            "name": "onFindCover",
            "type": "() => void",
            "description": "The fix for Needs cover and Nobody yet."
          },
          {
            "name": "onRemind",
            "type": "() => void | Promise<unknown>",
            "description": "Reminds the person who has not replied, once the undo window ends."
          },
          {
            "name": "onWithdraw",
            "type": "() => void | Promise<unknown>",
            "description": "Withdraws the ask once its undo window ends. Tell the person there."
          },
          {
            "name": "onSwap",
            "type": "() => void",
            "description": "A hover action on a confirmed duty."
          },
          {
            "name": "divider",
            "type": "boolean",
            "default": "false",
            "description": "A hairline above the row, inset from the card's edges."
          },
          {
            "name": "index",
            "type": "number",
            "description": "Place in a list. Rows fade up one after another as they first show."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "The duty, its detail, who is on it, and the status are all text, in reading order.",
          "Each status is words in its tone's soft text, which the theme holds to 4.5:1. The tones share colors, so the words say which.",
          "Each fix is a native button named for what it does, like \"Remind Tom Hale\", with the same words in its tooltip.",
          "Needs cover is a peek trigger. Hover, focus, or a tap opens who dropped out, and Esc closes it.",
          "The hover actions stay in the Tab order, and focus inside them shows the bar.",
          "The ask, the reminder, and Withdraw each say what happened once, through the page's one polite live region."
        ],
        "examples": [
          {
            "title": "Confirmed, waiting for a reply, and needs cover",
            "code": "<Card className=\"gap-0 py-1.5\">\n  <DutySlot duty=\"Refreshments\" icon={<Coffee />} detail=\"Morning break and lunch\" person={{ person: \"Sue Hale\" }} onSwap={swap} />\n  <DutySlot\n    duty=\"Setup\"\n    icon={<Monitor />}\n    detail=\"Chairs and screens before 9\"\n    person={{ person: \"Tom Hale\" }}\n    state=\"asked\"\n    onRemind={() => remind(\"setup\")}\n    onWithdraw={() => withdraw(\"setup\")}\n    divider\n  />\n  <DutySlot\n    duty=\"First aid\"\n    icon={<HeartPulse />}\n    state=\"cover\"\n    out={{ person: \"Mark Clarke\", said: \"Can't do it · Tue 29 Sep\" }}\n    suggest={{ person: \"Cal Nash\", reason: \"First aid trained, and free on Thursday\" }}\n    onAsk={(who) => ask(\"first-aid\", who)}\n    onFindCover={() => openCover(\"first-aid\")}\n    divider\n  />\n</Card>"
          }
        ]
      }
    },
    {
      "name": "editor",
      "type": "registry:ui",
      "title": "Editor",
      "description": "Long writing in a field's frame, with a glass toolbar over selected words and pasting that keeps only clean HTML.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/field-frame",
        "@txbrendan/tool-button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/editor.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Newsletters, minutes, reports, and announcements: writing with headings, lists, and links.",
            "Save from onChange. It gets clean HTML, \"\" when empty, and the words.",
            "Show saving with status, like a StatusText that says Saved 18:04. It ends the label's line.",
            "Show the saved HTML anywhere with Prose, in the same look."
          ],
          "avoid": [
            "A message to people. Use Composer.",
            "A line or two of plain text. Use Textarea or Input.",
            "Rich layouts, tables, or images. It keeps paragraphs, headings, lists, bold, italic, and links only."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "string",
            "description": "HTML. Controlled: the editor only reloads when this differs from what it last sent."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "HTML to start with."
          },
          {
            "name": "onChange",
            "type": "(html: string, info: { text: string; words: number }) => void",
            "description": "Clean HTML (\"\" when empty) and the words, on every change."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Names the writing area."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "A short note under the writing area. It describes it."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Replaces the hint while set, in red, and marks the writing area invalid."
          },
          {
            "name": "placeholder",
            "type": "string",
            "description": "Shown while it is empty. Never sent."
          },
          {
            "name": "rows",
            "type": "number",
            "default": "6",
            "description": "The least height, in lines. It grows with its words."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Padding and label size. The words stay 13/18 and headings 16/20."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Locks the words."
          },
          {
            "name": "showCount",
            "type": "boolean",
            "default": "false",
            "description": "A word count under the right edge."
          },
          {
            "name": "status",
            "type": "ReactNode",
            "description": "Ends the label's line, like a saving status."
          },
          {
            "name": "ref",
            "type": "Ref<HTMLDivElement>",
            "description": "Reaches the writing area."
          },
          {
            "name": "id",
            "type": "string",
            "description": "Goes on the writing area, so the label still points at it. aria-*, data-*, and handlers go there too."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the frame. Later classes win."
          }
        ],
        "a11y": [
          "The writing area is a role=\"textbox\" with aria-multiline, named by the label and described by the hint or error.",
          "Select words and a toolbar shows over them. Alt+F10 moves into it. It is one Tab stop, and ← → Home and End move along it.",
          "Each button says its state with aria-pressed. Its tooltip teaches its key: ⌘B, ⌘I, ⌘K, or # - or 1. then Space at the start of a line.",
          "Esc steps back: out of the link field, then from the toolbar to the selected words, then it hides the toolbar. In a Sheet, that Esc never closes the sheet.",
          "A link asks for its address in the toolbar. Enter applies it, and empty removes it. Only web and email addresses are linked.",
          "Under reduced motion the toolbar moves at once."
        ],
        "examples": [
          {
            "title": "A newsletter that saves as you write",
            "code": "<Editor\n  label=\"Newsletter · October\"\n  value={html}\n  showCount\n  status={<StatusText tone=\"success\">Saved 18:04</StatusText>}\n  onChange={(next) => save(next)}\n/>"
          },
          {
            "title": "Showing it",
            "code": "<Prose html={newsletter.html} />"
          }
        ]
      }
    },
    {
      "name": "estimate-chart",
      "type": "registry:ui",
      "title": "Estimate Chart",
      "description": "What was expected against what came in, per period: a ring for the estimate and a dot for the actual, colored by beat, miss, or match.",
      "dependencies": [
        "recharts@^3.10.1",
        "react-is@19.3.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/estimate-chart.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing a forecast next to the result for each period, like earnings per share against the estimate.",
            "Pass data with a period, an estimate, and an actual for each row, oldest first.",
            "Leave actual out for a period still to come. It shows its estimate ring alone.",
            "Change data, as from tabs, and each mark glides to its new value in a wave from left to right. Periods are matched by name.",
            "Pass matchWithin to count a near value as a match.",
            "Pass valueFormat, like { style: \"currency\", currency: \"USD\" }, to format the tooltip and the axis."
          ],
          "avoid": [
            "One value per period with no forecast. Use LineChart or BarChart.",
            "Many series at once. This chart compares one estimate with one actual.",
            "Hundreds of periods. The marks crowd together. Show the latest periods and offer more."
          ]
        },
        "props": [
          {
            "name": "data",
            "type": "{ period: string; estimate?: number | null; actual?: number | null }[]",
            "description": "One row per period, oldest first. Leave actual out for a period still to come. Periods may share a name, like Q1 in two years, and each keeps its place. An actual with no estimate draws a plain dot with an Actual key."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the chart when it has no visible title."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "description": "Formats tooltip values, and the value labels in short form, like $120B."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for numbers. Fixed, so server and browser print the same text."
          },
          {
            "name": "matchWithin",
            "type": "number",
            "default": "0",
            "description": "An actual this close to its estimate counts as a match."
          },
          {
            "name": "labels",
            "type": "{ estimate?, actual?, beat?, miss?, match?: string }",
            "description": "The words for each mark, in the legend and the tooltip."
          },
          {
            "name": "legend",
            "type": "boolean",
            "default": "true",
            "description": "Shows the legend: the estimate ring, then Beat, Miss, and Match once any period is scored. The keys stay put when the data changes. While loading, it shows the estimate ring with Beat, Miss, and Match."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height: 5, 7, or 9 times --control-h-md, so density scales it."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows skeleton points at the same height. When data arrives, they fade out while the chart draws in."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Shows this message in place of the chart. true shows a default message."
          },
          {
            "name": "onRetry",
            "type": "() => void",
            "description": "Shows a retry button under the error."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data yet\"",
            "description": "Shown when no period has an estimate or an actual."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='h-96' replaces the height."
          }
        ],
        "a11y": [
          "Renders a <figure> named by its title, or by aria-label. TypeScript requires one.",
          "Tab focuses the chart. The arrow keys step through the periods, and the tooltip reads out each one: its estimate, its actual, and whether it beat, missed, or matched, with the gap in percent.",
          "Beat, miss, and match are said in words in the legend and the tooltip, not by color alone. The estimate is a ring and the actual a filled dot, so the two differ by shape too.",
          "The period under the pointer or the arrow keys grows a little, and a line marks it. The value labels fade back while it is read, since the tooltip carries the numbers. Escape hides the tooltip and brings them back.",
          "The value labels have their own room at the start of the plot, so no mark covers one. Period labels at either end are nudged inside the chart, and long names are spaced so they never overlap. Right to left, the labels stay in their room. A ring its dot would almost cover is left out, and the tooltip still gives both numbers.",
          "Marks grow in from left to right once the chart is on screen. When the data changes, each glides to its new value in the same wave, and the value labels cross-fade. When periods come or go, the others slide over first. At 0ms everything draws at once, and Recharts skips the motion for reduced motion.",
          "Keeps one height while it loads, draws, fails, or has no data, so the page does not move."
        ],
        "examples": [
          {
            "title": "Earnings per share",
            "code": "<EstimateChart\n  title=\"Earnings per share\"\n  data={[\n    { period: \"Q1\", estimate: 1.5, actual: 1.6 },\n    { period: \"Q2\", estimate: 1.4, actual: 1.3 },\n    { period: \"Q3\", estimate: 1.7, actual: 1.7 },\n    { period: \"Q4\", estimate: 2.1 },\n  ]}\n  valueFormat={{ style: \"currency\", currency: \"USD\" }}\n/>"
          },
          {
            "title": "Data that follows tabs",
            "code": "const [measure, setMeasure] = useState(\"share\");\n\n<Tabs value={measure} onValueChange={(next) => setMeasure(String(next))}>\n  <TabsList aria-label=\"Measure\">\n    <TabsTab value=\"share\">Per share</TabsTab>\n    <TabsTab value=\"revenue\">Revenue</TabsTab>\n  </TabsList>\n  <TabsPanel value={measure}>\n    <EstimateChart\n      title=\"Estimate and actual per quarter\"\n      data={measure === \"share\" ? perShare : revenue}\n      valueFormat={measure === \"share\"\n        ? { style: \"currency\", currency: \"USD\" }\n        : { style: \"currency\", currency: \"USD\", notation: \"compact\" }}\n    />\n  </TabsPanel>\n</Tabs>"
          },
          {
            "title": "While it loads, and when it fails",
            "code": "<EstimateChart\n  aria-label=\"Earnings per share\"\n  data={data ?? []}\n  loading={isLoading}\n  error={error ? \"Quarters could not load.\" : undefined}\n  onRetry={() => refetch()}\n/>"
          }
        ]
      }
    },
    {
      "name": "feature-voting",
      "type": "registry:ui",
      "title": "Feature Voting",
      "description": "A list of ideas, each with an upvote button and its count, or an up and down vote stacked around the count. It shows how this person voted.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/button",
        "@txbrendan/badge",
        "@txbrendan/rolling-number",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/feature-voting.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms",
        "voting"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Letting people vote on a list of ideas, like feature requests or topics for a meeting.",
            "People can vote for as many ideas as they like. Pressing a button again takes the vote back.",
            "Pass layout=\"stack\" to let people vote up or down, like a forum. An up caret, the count, and a down caret stack in a column at the start of each idea, and votes is then ups minus downs, so it can go below zero.",
            "In the stack layout, pressing the other caret moves the vote in one press. Each caret has a tooltip that says what it does, and the count rolls to its new value.",
            "Pass each idea with its votes so far. A status like Planned shows as a badge beside the title.",
            "The list keeps the order you pass. Sort it yourself, like by votes, when you load the data.",
            "Return a promise from onVote to save each vote. The button and its count change at once, with savingText after the title, until the promise settles. If it rejects, the vote goes back and errorText shows under the idea.",
            "Leave voted out to let the list keep its own state. It then counts the votes it takes, and defaultVoted marks earlier votes, with defaultDownvoted for down votes. People can press again while a vote saves, and the newest press on each idea wins, even when an older save finishes later.",
            "Pass voted, and downvoted in the stack layout, to keep the votes in your own state, and include this person's votes in the counts you pass. Once a save succeeds, and before your onVote promise resolves, update them and the counts. Your state then decides, so if saves can finish out of order, skip one that a newer press replaced.",
            "While a vote saves, its item has data-pending, for your own styles. A vote up sets data-voted, and a vote down data-downvoted.",
            "For another language, pass upvoteText, downvoteText, savingText, errorText, votesText, and locale."
          ],
          "avoid": [
            "One question with a few fixed answers, where people pick one. Use Choice Poll instead.",
            "Rating one thing on a scale, like 1 to 5 stars.",
            "A single like button on a post. Use a Button with aria-pressed.",
            "Sorting the list again while someone votes. The idea would move away from their focus."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "{ value: string; title: ReactNode; description?: ReactNode; votes: number; status?: { label: string; tone?: \"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\" }; disabled?: boolean }[]",
            "description": "The ideas, in the order to show them. value must be unique. votes is the count so far, this person's included. In the stack layout it is ups minus downs, and it can be below zero. disabled blocks voting on one idea, like one that has shipped."
          },
          {
            "name": "layout",
            "type": "\"inline\" | \"stack\"",
            "default": "\"inline\"",
            "description": "inline puts one upvote button with its count at the end of each idea. stack puts an up caret, the count, and a down caret in a column at the start, so people can vote up or down."
          },
          {
            "name": "voted",
            "type": "string[]",
            "description": "The values of the ideas this person voted up. Controlled, together with downvoted. The list shows the counts you pass as they are, plus a vote that is still saving."
          },
          {
            "name": "downvoted",
            "type": "string[]",
            "description": "The values of the ideas this person voted down, in the stack layout. Controlled, together with voted."
          },
          {
            "name": "defaultVoted",
            "type": "string[]",
            "description": "Earlier votes up by this person when you do not control the votes. They are already in the counts, so the list does not add them again."
          },
          {
            "name": "defaultDownvoted",
            "type": "string[]",
            "description": "Earlier votes down by this person when you do not control the votes, in the stack layout. They are already in the counts."
          },
          {
            "name": "onVote",
            "type": "(value: string, voted: boolean, direction: \"up\" | \"down\") => void | Promise<unknown>",
            "description": "Called when someone votes on an idea (true) or takes the vote back (false), with the direction pressed. Moving a vote from up to down is one call, with true and \"down\". Return a promise, and the list shows the change at once with savingText until it settles. A rejection puts the earlier vote back and shows errorText under the idea."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks every vote button. The counts still show."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the vote buttons. A vote fills its button with it. The rest are outlined, or plain carets in the stack layout."
          },
          {
            "name": "upvoteText",
            "type": "string",
            "default": "\"Upvote\"",
            "description": "The word that starts each upvote button's name for screen readers. In the stack layout, the up caret's tooltip shows it too."
          },
          {
            "name": "downvoteText",
            "type": "string",
            "default": "\"Downvote\"",
            "description": "The word that starts each down caret's name for screen readers, and its tooltip, in the stack layout."
          },
          {
            "name": "savingText",
            "type": "string",
            "default": "\"Saving…\"",
            "description": "Shown after an idea's title while its vote saves. The vote button's aria-describedby points to it."
          },
          {
            "name": "errorText",
            "type": "string",
            "default": "\"Could not save your vote. Try again.\"",
            "description": "Shown under an idea when its vote does not save. Screen readers announce it."
          },
          {
            "name": "votesText",
            "type": "{ zero?: string; one?: string; two?: string; few?: string; many?: string; other: string }",
            "default": "{ one: \"{count} vote\", other: \"{count} votes\" }",
            "description": "How a count of votes reads in each plural form, with {count} for the number. Screen readers hear it in each button's name, and the button shows only the number. The list picks the form with Intl.PluralRules for locale."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "A language tag for the counts and the plural forms, like 'de-DE'."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the list. Later classes win, so className='divide-y-0' drops the lines between ideas."
          }
        ],
        "a11y": [
          "The ideas are a list. Each item holds the title, the details, the status badge, and the vote button.",
          "Each vote button is a toggle button. Its name reads upvoteText, the title, and the count, like 'Upvote Dark mode 48 votes'. aria-pressed says whether this person voted.",
          "The name comes from aria-labelledby on ids in the same item, so it is in the server HTML too.",
          "Space and Enter vote and take the vote back, like any button.",
          "A vote shows at once, so there is no spinner and no aria-busy. savingText shows after the title until the vote saves, and the button's aria-describedby points to it. Every button stays ready, so people can press again.",
          "A failed save shows errorText under the idea in a role=\"alert\" message. The button's aria-describedby points to it, and focus stays on the button.",
          "disabled, on the list or on one idea, takes those buttons out of the tab order.",
          "A voted button is filled and the rest are outlined, so the vote does not rest on color alone.",
          "A changed count fades in over --motion-duration-base. data-motion=\"off\" makes it instant. Reduced motion drops the slide but keeps the fade.",
          "In the stack layout, each caret is a toggle button with its own aria-pressed. Their names read the direction, the title, and the count, like 'Upvote Dark mode 48 votes' and 'Downvote Dark mode 48 votes'. The number between them is hidden from screen readers, since both names hold it.",
          "Each caret's tooltip says what it does. It opens after the pointer rests 150 ms, at once on keyboard focus, and Escape closes it. Touch shows none.",
          "The stack layout's count rolls to each new value. Reduced motion and data-motion=\"off\" jump straight to it.",
          "Every word has a prop with an English default: upvoteText, downvoteText, savingText, errorText, and votesText. locale formats the counts and picks each plural form."
        ],
        "examples": [
          {
            "title": "A list that keeps its own state",
            "code": "<FeatureVoting\n  items={[\n    { value: \"dark-mode\", title: \"Dark mode\", description: \"Easier on the eyes at night.\", votes: 48 },\n    { value: \"export\", title: \"Export to a spreadsheet\", votes: 31 },\n    { value: \"reminders\", title: \"Meeting reminders\", votes: 17 },\n  ]}\n  defaultVoted={[\"dark-mode\"]}\n/>"
          },
          {
            "title": "Save votes yourself",
            "code": "\"use client\";\n\nconst items = [\n  { value: \"dark-mode\", title: \"Dark mode\", votes: 48 },\n  { value: \"export\", title: \"Export to a spreadsheet\", votes: 31 },\n];\n\n// The list shows each vote at once and counts it. If this throws, it puts the vote back and says so.\n// It keeps its own state, so the newest press on an idea wins even when an older save finishes later.\nasync function save(value: string, voted: boolean) {\n  // Replace this with your own request. fetch does not throw on a server error, so check ok.\n  const response = await fetch(\"/api/votes\", { method: \"POST\", body: JSON.stringify({ idea: value, voted }) });\n  if (!response.ok) throw new Error(\"The vote did not save.\");\n}\n\nfunction IdeaBoard() {\n  return <FeatureVoting items={items} defaultVoted={[\"export\"]} onVote={save} tone=\"brand\" />;\n}"
          },
          {
            "title": "Statuses and a shipped idea",
            "code": "<FeatureVoting\n  items={[\n    { value: \"reminders\", title: \"Meeting reminders\", votes: 17, status: { label: \"In progress\", tone: \"warning\" } },\n    { value: \"dark-mode\", title: \"Dark mode\", votes: 48, status: { label: \"Planned\", tone: \"brand\" } },\n    { value: \"calendar\", title: \"Calendar sync\", votes: 52, status: { label: \"Shipped\", tone: \"success\" }, disabled: true },\n  ]}\n/>"
          },
          {
            "title": "Vote up or down in a stacked column",
            "code": "\"use client\";\n\nconst items = [\n  { value: \"dark-mode\", title: \"Dark mode\", description: \"Easier on the eyes at night.\", votes: 48 },\n  { value: \"sounds\", title: \"A sound for every message\", votes: -4 },\n];\n\n// voted is false when the vote is taken back, so the idea then has no vote from this person.\nasync function save(value: string, voted: boolean, direction: \"up\" | \"down\") {\n  const response = await fetch(\"/api/votes\", { method: \"POST\", body: JSON.stringify({ idea: value, vote: voted ? direction : null }) });\n  if (!response.ok) throw new Error(\"The vote did not save.\");\n}\n\nfunction IdeaBoard() {\n  return <FeatureVoting layout=\"stack\" items={items} defaultVoted={[\"dark-mode\"]} defaultDownvoted={[\"sounds\"]} onVote={save} />;\n}"
          },
          {
            "title": "In another language",
            "code": "<FeatureVoting\n  items={[\n    { value: \"dark-mode\", title: \"Dunkler Modus\", votes: 1204 },\n    { value: \"export\", title: \"Export als Tabelle\", votes: 1 },\n  ]}\n  upvoteText=\"Dafür stimmen\"\n  votesText={{ one: \"{count} Stimme\", other: \"{count} Stimmen\" }}\n  locale=\"de-DE\"\n/>"
          }
        ]
      }
    },
    {
      "name": "field-frame",
      "type": "registry:ui",
      "title": "Field Frame",
      "description": "The frame every field wears: a label above, the control, and a hint or an error under it, wired up for screen readers. Also exports FieldButton, the field-shaped trigger for lists and pickers.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/input"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/field-frame.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Frame any field with it, so every form reads, sizes, and announces the same way.",
            "Give the frame the control's id. The label gets <id>-label and the hint or error gets <id>-note.",
            "Spread fieldIds(id, { label, hint, error, control }) on the control. It sets aria-describedby, aria-invalid, and, for a button or a group, aria-labelledby. A button cannot take aria-invalid, so it gets data-invalid, which draws the red edge.",
            "fieldIds lives in a client file, so call it from a client component. In a server component, write the same ids by hand: aria-describedby=\"<id>-note\", and aria-labelledby=\"<id>-label <id>\" for a button.",
            "control=\"input\" for a native input or textarea. The label is a real <label> that points at it.",
            "control=\"button\" for a trigger whose text is its value, like a picker. It reads as the label and the value: \"Room, Boardroom\". Pressing the label focuses the button and does not open it.",
            "control=\"custom\" for a group or an editor. The label names it. Pass onLabel to say what pressing the label focuses.",
            "Use FieldButton for anything that opens a list or a picker from a field. It has the field's box and states, and a chevron that turns over while open.",
            "Put a saving status in status, and a counter like \"12/80\" in aside."
          ],
          "avoid": [
            "A label you hide. Every field needs a name people can see.",
            "Putting the error only in the red edge. Pass it as error, so it is said and shown in words.",
            "A form section with several fields. Use a heading or a fieldset for the section, and a frame for each field."
          ]
        },
        "props": [
          {
            "name": "id",
            "type": "string",
            "description": "Required. The control's id. The label and the note take ids built from it."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Names the control. Pressing it focuses the control."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "A short note under the control that describes it."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Replaces the hint while set, in red. Announced once each time a new message appears."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Label and note text size, and the gaps. Match the control's size."
          },
          {
            "name": "control",
            "type": "\"input\" | \"button\" | \"custom\"",
            "default": "\"input\"",
            "description": "What the frame holds, which decides how the label names it."
          },
          {
            "name": "onLabel",
            "type": "() => void",
            "description": "What pressing a label that is not a native label does. By default it focuses the element with the id."
          },
          {
            "name": "aside",
            "type": "ReactNode",
            "description": "Ends the note line, like a counter. Muted, with figures that keep their width."
          },
          {
            "name": "status",
            "type": "ReactNode",
            "description": "Ends the label line, like a saving status. Kept to the label's height."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Dims the label to match a disabled control."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the frame. Later classes win."
          }
        ],
        "a11y": [
          "For control=\"input\" the label is a native <label htmlFor>, so clicking it focuses the input and screen readers read it as the name.",
          "For a button or a group the label is plain text with an id, named through aria-labelledby. A native label would press the button.",
          "The hint or error has the id <id>-note. fieldIds points the control's aria-describedby at it.",
          "The error has role=\"alert\". It mounts fresh for each new message, so screen readers say it once when it appears.",
          "The error color is the danger tone's soft text, which the theme tests hold to 4.5:1. The words carry the meaning, not only the color.",
          "FieldButton sets aria-haspopup, aria-expanded, and aria-controls while open. A popup trigger, like PopoverTrigger, sets them itself instead."
        ],
        "examples": [
          {
            "title": "A text field with a hint",
            "code": "<FieldFrame id=\"shirt\" label=\"Shirt size\" hint=\"Shirts run small.\">\n  <Input id=\"shirt\" aria-describedby=\"shirt-note\" />\n</FieldFrame>"
          },
          {
            "title": "An error in place of the hint",
            "code": "<FieldFrame id=\"handle\" label=\"Username\" hint=\"Letters and numbers.\" error=\"That name is taken.\">\n  <Input id=\"handle\" defaultValue=\"admin\" {...fieldIds(\"handle\", { hint: \"Letters and numbers.\", error: \"That name is taken.\" })} />\n</FieldFrame>"
          },
          {
            "title": "A picker trigger",
            "code": "<FieldFrame id=\"room\" label=\"Room\" control=\"button\">\n  <FieldButton id=\"room\" filled {...fieldIds(\"room\", { label: \"Room\", control: \"button\" })}>\n    Boardroom\n  </FieldButton>\n</FieldFrame>"
          }
        ]
      }
    },
    {
      "name": "file-drop",
      "type": "registry:ui",
      "title": "File Drop",
      "description": "Files in: an empty slot with a dashed edge. While a file is over it, it takes the selection look and says \"Drop to add\". A press anywhere on it opens the picker.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/file-drop.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "dates",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Bringing files in, like a member list, an order list, or a team photo.",
            "Say what it is in title: \"Drop your member list here\". Say the types and the limit in hint: \"CSV or Excel · up to 10 MB\".",
            "The parent keeps the file list and does the uploading. Show each file in a file row.",
            "accept is checked for dropped files too, and multiple keeps only the first when it is off. Files of another type are left out, and it says so in red.",
            "Check size and contents on your server too. It only reads what the browser gives it."
          ],
          "avoid": [
            "A person's own photo. Use a photo field, which places and crops it.",
            "A link or text to paste. Use an input."
          ]
        },
        "props": [
          {
            "name": "onFiles",
            "type": "(files: File[]) => void",
            "description": "The files picked or dropped, already checked against accept and multiple."
          },
          {
            "name": "accept",
            "type": "string",
            "description": "The types it takes, like \".csv,.xlsx\" or \"image/*\". The picker shows only these, and drops are checked too."
          },
          {
            "name": "multiple",
            "type": "boolean",
            "default": "false",
            "description": "Takes several files at once. Off, it keeps the first."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "default": "\"Drop a file here\"",
            "description": "Say what it is: \"Drop your member list here\"."
          },
          {
            "name": "action",
            "type": "ReactNode",
            "default": "\"choose a file\"",
            "description": "The words of the button that opens the picker."
          },
          {
            "name": "dropTitle",
            "type": "ReactNode",
            "default": "\"Drop to add\"",
            "description": "Shown while a file is over it."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "The types and the limit, like \"CSV or Excel · up to 10 MB\"."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Padding and text size."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks the picker and drops. A file dropped on it is still caught, so the browser never opens it instead."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the zone."
          }
        ],
        "a11y": [
          "The one Tab stop is the \"choose a file\" button. Its description is the title and the hint, so it is heard as \"choose a file, Drop your member list here\".",
          "A press anywhere on the zone opens the same picker, for the mouse and touch.",
          "A polite status says what was added, like \"Added Members 2026.csv\".",
          "Files of another type are left out, and a red message says so once.",
          "While a file is over it, the dashed edge turns solid, so forced-colors mode shows the change too."
        ],
        "examples": [
          {
            "title": "A member list",
            "code": "<FileDrop\n  title=\"Drop your member list here\"\n  hint=\"CSV or Excel · up to 10 MB\"\n  accept=\".csv,.xlsx\"\n  multiple\n  onFiles={(files) => console.log(files)}\n/>"
          }
        ]
      }
    },
    {
      "name": "file-row",
      "type": "registry:ui",
      "title": "File Row",
      "description": "One file in a list: an icon by type, the name, \"412 KB · 214 rows\", and who added it, with their photo. It shows an upload's progress, its failure and the fix, or that it is done.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/avatar",
        "@txbrendan/button",
        "@txbrendan/status-text",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/file-row.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "dates",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Each file a file drop takes in, and files already attached, like a member list or a handbook.",
            "The parent keeps the list and does the uploading. Pass status and progress as it goes.",
            "Pass by with who added it. Their photo shows, or their initial until there is one.",
            "When the connection dropped, pass onRetry: a retry icon, named \"Try again\", follows \"Couldn't upload\".",
            "When the file itself is the problem, say why in error and pass onReplace: an upload icon picks a new file instead, since the same file fails the same way.",
            "Pass divider on every row but the first."
          ],
          "avoid": [
            "Bringing files in. Use a file drop, then show each file in one of these.",
            "A person's photo. Use a photo field.",
            "A long list to sort and filter. Use a table."
          ]
        },
        "props": [
          {
            "name": "name",
            "type": "string",
            "description": "The file's name. Its extension picks the icon: a sheet, a document, an image, a video, an archive, or a plain file."
          },
          {
            "name": "bytes",
            "type": "number",
            "description": "The size in bytes, shown as \"412 KB\". A KB is 1024 bytes."
          },
          {
            "name": "meta",
            "type": "ReactNode",
            "description": "Extra detail after the size, like \"214 rows\"."
          },
          {
            "name": "by",
            "type": "{ person: string; photo?: string }",
            "description": "Who added it, shown with their photo."
          },
          {
            "name": "status",
            "type": "\"uploading\" | \"done\" | \"error\"",
            "description": "Where the upload is. Without one, the row just lists the file."
          },
          {
            "name": "progress",
            "type": "number",
            "description": "0 to 1 while uploading. A thin bar under the row fills, and the percent shows after the name."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Why it failed, under the name, in ink: \"Row 12 has no email address\"."
          },
          {
            "name": "onRemove",
            "type": "() => void",
            "description": "Shows a remove button at the end of the row."
          },
          {
            "name": "onRetry",
            "type": "() => void | Promise<unknown>",
            "description": "On a failure: a retry icon, named \"Try again\". Return a promise, and it waits on it."
          },
          {
            "name": "onReplace",
            "type": "(file: File) => void",
            "description": "On a failure caused by the file: an upload icon opens the picker and hands back the new file. It takes the retry's place."
          },
          {
            "name": "accept",
            "type": "string",
            "description": "File types for onReplace's picker, like \".csv\"."
          },
          {
            "name": "divider",
            "type": "boolean",
            "default": "false",
            "description": "A line above the row."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Padding and text size."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the row."
          }
        ],
        "a11y": [
          "Every status shows in words: \"Uploading 42%\", \"Uploaded\" with a green check, or \"Couldn't upload\" in red.",
          "The bar is a progressbar named \"Uploading\" plus the file's name, with its percent.",
          "A polite status says when a file uploads or fails, once, as it happens.",
          "The retry and replace icons are buttons named \"Try again\" and \"Replace file\", with tooltips.",
          "Remove is a button named \"Remove\" plus the file's name, so a list of them is told apart.",
          "In forced-colors mode the bar's fill draws as a border, and the type icon keeps an edge."
        ],
        "examples": [
          {
            "title": "Uploaded",
            "code": "<FileRow\n  name=\"Members 2026.csv\"\n  bytes={421888}\n  meta=\"214 rows\"\n  by={{ person: \"Helen Ward\" }}\n  status=\"done\"\n  onRemove={() => {}}\n/>"
          },
          {
            "title": "The file is the problem",
            "code": "<FileRow\n  name=\"Contacts.csv\"\n  bytes={20480}\n  status=\"error\"\n  error=\"Row 12 has no email address.\"\n  accept=\".csv\"\n  onReplace={(file) => console.log(file)}\n/>"
          }
        ]
      }
    },
    {
      "name": "finals-board",
      "type": "registry:ui",
      "title": "Finals Board",
      "description": "Finals day on one board: two semi-finals feed the cup final and the bronze final, each pitch's matches sit on a shared timeline, and a hold moves the later matches on the final's pitch.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/card",
        "@txbrendan/motion-timing",
        "@txbrendan/switch",
        "@txbrendan/team-flag",
        "@txbrendan/tool-button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/finals-board.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Running a cup's finals day: record each winner as it happens, and hold the final's pitch when it runs late.",
            "Pass the four sides as the semi-finals pair them, and where and when each match is played. The slot and the hold's length come from the competition.",
            "Leave out schedule.bronze when there is no bronze final.",
            "Give each team its kit colors. A team without them shows its initials.",
            "Pass value and onChange to keep results in your data, or defaultValue to let the board keep its own. The same goes for held, onHold, and defaultHeld."
          ],
          "avoid": [
            "A draw bigger than four. Use KnockoutBracket, which takes any number of rounds.",
            "Showing a past finals day. Nothing here should be pressed then: use KnockoutBracket or MatchCard.",
            "A league day. Nothing feeds anything, so there is no bracket to fill in."
          ]
        },
        "props": [
          {
            "name": "semis",
            "type": "[[FinalsTeam, FinalsTeam], [FinalsTeam, FinalsTeam]]",
            "description": "The four sides, as the semi-finals pair them. Each is { id, name, colors?, pattern? }."
          },
          {
            "name": "schedule",
            "type": "{ sf1, sf2, final, bronze? }",
            "description": "Each match's { pitch, kickoff, title? }, with kickoff like \"13:00\". Without bronze, there is no bronze final."
          },
          {
            "name": "minutes",
            "type": "number",
            "default": "40",
            "description": "How long each match holds its pitch: the competition's slot."
          },
          {
            "name": "value",
            "type": "FinalsResults",
            "description": "Winners by match, as team ids, controlled. Like { sf1: \"rovers\", final: null }."
          },
          {
            "name": "defaultValue",
            "type": "FinalsResults",
            "description": "Winners at the start, when the board keeps its own."
          },
          {
            "name": "onChange",
            "type": "(results: FinalsResults) => void",
            "description": "Hears every result, and the old results back on ⌘Z."
          },
          {
            "name": "held",
            "type": "boolean",
            "description": "Whether the final's pitch is held now, controlled."
          },
          {
            "name": "defaultHeld",
            "type": "boolean",
            "default": "false",
            "description": "Whether it starts held, when the board keeps its own."
          },
          {
            "name": "onHold",
            "type": "(held: boolean) => void",
            "description": "Hears the hold switch."
          },
          {
            "name": "holdMinutes",
            "type": "number",
            "default": "15",
            "description": "How far a hold moves the matches on the final's pitch that have no result when it starts. A moved match keeps its new kickoff once recorded."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "Cup bracket and Day control are groups named by their titles.",
          "Each match's two teams are a radio group, named by the match and described by its pitch and kickoff. Tab reaches the group, the arrow keys move between the teams, and the chosen team is the winner.",
          "A recorded match also names its winner in text, like \"Rovers won.\", which the group's description reads with its pitch and kickoff.",
          "A final waits as a plain group that names who it waits for, like \"Semi-final 1 winner\", until both semi-finals are in.",
          "One live line says each result and each hold once, like \"Semi-final 1: Rovers won. Cup final and Bronze final cleared.\" ⌘Z says the undo in the page's own region.",
          "⌘Z works with focus on a team, since the teams are not text fields.",
          "A held match's new kickoff has \"+15 min\" beside it, in words as well as amber, so it shows in forced colors.",
          "The timeline is a named region with a list per pitch. It takes a Tab stop only when it scrolls sideways.",
          "The champion types itself in only when the final is recorded on the page, never on load. Screen readers hear the name whole. Under reduced motion or with motion off, it shows at once."
        ],
        "examples": [
          {
            "title": "Four teams on two pitches, with a bronze final",
            "code": "<FinalsBoard\n  semis={[[rovers, lions], [hawks, wolves]]}\n  schedule={{\n    sf1: { pitch: \"Pitch 1\", kickoff: \"13:00\" },\n    sf2: { pitch: \"Pitch 2\", kickoff: \"13:00\" },\n    final: { pitch: \"Pitch 1\", kickoff: \"14:30\" },\n    bronze: { pitch: \"Pitch 2\", kickoff: \"14:00\" },\n  }}\n/>"
          },
          {
            "title": "Results in your data",
            "code": "const [results, setResults] = useState<FinalsResults>({});\nconst [held, setHeld] = useState(false);\n\n<FinalsBoard semis={semis} schedule={schedule} value={results} onChange={setResults} held={held} onHold={setHeld} />"
          }
        ]
      }
    },
    {
      "name": "form-guide",
      "type": "registry:ui",
      "title": "Form Guide",
      "description": "A team's last results as W, D, and L on tinted squares, oldest first, with a peek that lists the scores, latest first.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/hover-card"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/form-guide.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A team's recent results in a table row, a fixture card, or a team header.",
            "Pass results oldest first. The latest sits on the right.",
            "Add score, opponent, away, and date to each result, and the guide opens a peek that lists them, latest first.",
            "In a table or list, mark each row with data-peek-row. The peek then opens beside the row, so the rows above and below stay in reach.",
            "Use size='sm' in dense tables."
          ],
          "avoid": [
            "A full results list. Use a table, with the date, the opponent, and the score in columns.",
            "Anything that is not a win, a draw, or a loss. Use Badge."
          ]
        },
        "props": [
          {
            "name": "results",
            "type": "{ result: \"W\" | \"D\" | \"L\"; score?: string; opponent?: string; away?: boolean; date?: string }[]",
            "description": "Required. The last results, oldest first. A win is green, a loss red, a draw ink, and the letter always says it. An empty list shows a dash."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Squares of 18, 22, or 28px at the default density."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Form\"",
            "description": "What screen readers hear before the results."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the row of squares. Later classes win."
          }
        ],
        "a11y": [
          "Screen readers hear 'Form, oldest to latest: won, won, lost'. The letters say the result, so color is never the only signal.",
          "With no details it is role=\"img\" with that name, and no tab stop.",
          "With details it is a HoverCard trigger: a focusable role=\"button\" with that name and aria-haspopup=\"dialog\".",
          "Focus opens the peek at once, a short rest of the pointer opens it, and so does a tap. Enter or Space moves focus into it, and Esc closes it and puts focus back.",
          "↑ and ↓ step to the peek above or below, as on every peek, and only one peek shows at a time.",
          "The peek is a dialog named 'Last 5, latest first', which it also shows as its title. It lists each result as a line of text.",
          "The squares use the tones' soft colors, which the theme holds to 4.5:1 with their letters."
        ],
        "examples": [
          {
            "title": "Results only",
            "code": "<FormGuide results={[{ result: \"W\" }, { result: \"W\" }, { result: \"L\" }, { result: \"D\" }, { result: \"W\" }]} />"
          },
          {
            "title": "With a peek",
            "code": "<FormGuide\n  results={[\n    { result: \"L\", score: \"12–19\", opponent: \"Bears\", away: true, date: \"Sat 12 Sep\" },\n    { result: \"W\", score: \"31–10\", opponent: \"Hawks\", date: \"Sat 19 Sep\" },\n    { result: \"W\", score: \"24–17\", opponent: \"Lions\", date: \"Sat 26 Sep\" },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "funnel-chart",
      "type": "registry:ui",
      "title": "Funnel Chart",
      "description": "Steps that narrow, like joining or trials, as numbered rows with a pill bar, each step's share of the first, and how many stopped before it.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/breakdown-list",
        "@txbrendan/chart"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/funnel-chart.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Steps people pass through in order, where some stop at each step: sign-up forms, trials, a checkout.",
            "Pass stages, first to last, each with a label and a value, and a title or aria-label.",
            "Each bar is its share of the first step. Each step after the first says how many stopped before it.",
            "Give a stage meta to replace the automatic 'stopped' line.",
            "Pass value: null for a step with no figure yet. It shows a dash, not a zero."
          ],
          "avoid": [
            "Parts of a whole or a ranking. Use BreakdownList. Its rows take the same shape.",
            "Steps that do not narrow, like a timeline. Use a list or a table.",
            "A trend over time. Use LineChart or AreaChart."
          ]
        },
        "props": [
          {
            "name": "stages",
            "type": "{ id?: string; label: string; value: number | null; meta?: ReactNode }[]",
            "description": "Required. The steps, first to last. The same shape as a BreakdownList row, so one set of rows fits either."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "A visible title. It names the funnel. TypeScript requires title, aria-label, or aria-labelledby."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the funnel when it has no visible title."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions | ((value: number) => string)",
            "description": "How each step's value prints, like v => `${v} people`. Options work from a server component. A function works from a client component only."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for numbers."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholder rows, as many as stages, or three."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data yet\"",
            "description": "Shown when stages is empty."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the card. Later classes win."
          }
        ],
        "a11y": [
          "Renders a <figure> named by its title, or by aria-label. TypeScript requires one. The steps are an ordered list.",
          "Each step reads as its label, its value, and its share of the first step, then its meta, like 'Paid: 51, 80% of the first step, 13 stopped before this step'.",
          "A step with no value reads 'no figure', and shows a dash with no bar.",
          "The bars are hidden from screen readers. The track is quiet, so the numbers in text carry the meaning.",
          "Bars use --chart-1, which the theme holds to 3:1 on the page.",
          "Bars grow in once the funnel is on screen, over three times --motion-duration-base. Reduced motion and motion off draw them at once."
        ],
        "examples": [
          {
            "title": "Each step, with its share of the first",
            "code": "<FunnelChart\n  title=\"New sign-ups this quarter\"\n  stages={[\n    { label: \"Started a form\", value: 64 },\n    { label: \"Paid\", value: 51 },\n    { label: \"First login\", value: 46 },\n  ]}\n/>"
          },
          {
            "title": "With its own lines",
            "code": "<FunnelChart\n  aria-label=\"Free trials\"\n  stages={[\n    { label: \"Started a trial\", value: 40 },\n    { label: \"Came back a second day\", value: 31, meta: \"A holiday slowed 6 down\" },\n    { label: \"Signed up\", value: null, meta: \"Counted on Friday\" },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "gantt-chart",
      "type": "registry:ui",
      "title": "Gantt Chart",
      "description": "Shows items as bars across a date range, in groups, with a fixed name column, week ticks, and a today marker. Use it to see when work runs and how far along it is.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/glide-highlight",
        "@txbrendan/skeleton",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/gantt-chart.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts",
        "data",
        "dates"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Work that spans days or weeks, like projects by stage, where start, end, and progress matter.",
            "Pass groups, each with its items. An item takes a label, start and end days, and progress from 0 to 1.",
            "Pass days as ISO strings, like '2026-10-13'.",
            "Pass onItemOpen so each bar is a button that opens its item, like a Sheet.",
            "Put a caption on a bar for a short count, like '45% · 9/20'. It shows beside the bar, so the fill reads whole.",
            "The chart has loading and empty states. A failed load is yours: show a Notice with a retry in its place.",
            "To build your own rows, like a list that turns into this chart, wrap them in GanttScale and use GanttHeader, GanttTrack, GanttGrid, and GanttToday for the date side."
          ],
          "avoid": [
            "Times within one day, like bookings. Use Time Grid.",
            "A list of events with no length. Use Timeline.",
            "One value over time. Use LineChart or AreaChart.",
            "Dragging bars to new dates. Bars open, they do not move."
          ]
        },
        "props": [
          {
            "name": "groups",
            "type": "{ id: string; title: ReactNode; items: GanttItem[] }[]",
            "description": "The rows, in groups, like stages. Each group shows its title and count, then its items. A group with no items still shows its title."
          },
          {
            "name": "GanttItem",
            "type": "{ id: string; label: string; meta?: ReactNode; start: string | Date; end: string | Date; progress?: number; tone?: \"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"; caption?: ReactNode }",
            "description": "One row. The bar runs from the start of start to the end of end. progress, from 0 to 1, fills that share in tone, neutral by default. caption shows beside the bar, and defaults to the progress as a percent."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the chart for screen readers."
          },
          {
            "name": "start",
            "type": "string | Date",
            "default": "the earliest item's start",
            "description": "The first day shown."
          },
          {
            "name": "end",
            "type": "string | Date",
            "default": "the latest item's end",
            "description": "The last day shown."
          },
          {
            "name": "today",
            "type": "string | Date",
            "description": "Places the today marker, a pill with the date in the header and a line down the chart. Left out, there is no marker. Pass it from the server, so the first render matches."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for dates and percents. One it does not know falls back to en-US."
          },
          {
            "name": "timeZone",
            "type": "string",
            "default": "\"UTC\"",
            "description": "The time zone that puts a Date, or a string with a time, on its day. ISO days, like '2026-10-13', stay as they are. One it does not know falls back to UTC."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholder rows while the items load."
          },
          {
            "name": "empty",
            "type": "ReactNode",
            "default": "\"Nothing scheduled yet.\"",
            "description": "What shows when no group has items. Give it a next step, like a button that adds one."
          },
          {
            "name": "onItemOpen",
            "type": "(id: string) => void",
            "description": "Makes each bar a button that opens its item, and lets a press on its row open it. Called with the item's id."
          },
          {
            "name": "GanttScale",
            "type": "GanttScaleProps",
            "description": "Part. Holds start, end, today, locale, and timeZone for the parts inside, and sets the layout variables. Put it around your own scroll box and rows. It is a size container."
          },
          {
            "name": "GanttHeader",
            "type": "GanttHeaderProps",
            "description": "Part. The dates along the top, a tick at each Monday. A tick near today gives way to the today pill. The right cell of the header row."
          },
          {
            "name": "GanttTrack",
            "type": "GanttTrackProps",
            "description": "Part. The right cell of one row, with its item's bar. Takes item, index (the row's place from the top across every group, which sets when its bar arrives), and onItemOpen."
          },
          {
            "name": "GanttGrid",
            "type": "GanttGridProps",
            "description": "Part. Faint week lines over the date side. Put it first inside the positioned box that holds the rows."
          },
          {
            "name": "GanttToday",
            "type": "GanttTodayProps",
            "description": "Part. The today marker over the date side. It arrives last: a pill with the date in the header, then the line draws down over the bars. Put it beside GanttGrid."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the root. The layout reads variables you can set here: --gantt-head-h (2.5rem), --gantt-group-h (2.5rem), --gantt-row-h (2.75rem), --gantt-name-w (12rem, down to 8.5rem in a narrow box), --gantt-day-w (0.625rem, the least width of a day; a wider box stretches the days), and --gantt-bg (the page color, behind the fixed names)."
          }
        ],
        "a11y": [
          "The chart is a region named by aria-label, around a table: a header row, a row group per group with its title as a row header, and a row per item with its name as the row header.",
          "With onItemOpen, each bar is a button named by its item, its dates, and its progress, like 'Payments page, Oct 19 – Nov 6, 45% done'. Focus scrolls it clear of the fixed names.",
          "Without onItemOpen, each row's cell says the same in hidden text. When the dates scroll, the scroll box is one Tab stop, so a keyboard can scroll them. With buttons inside, it is no stop of its own.",
          "Hover or keyboard focus on a bar shows its name, dates, progress, and caption in a tooltip. One tooltip shows at a time.",
          "A name cut off by the column shows in full in a tooltip on hover, or on a tap when the bars are not buttons. Screen readers read the whole name.",
          "The week lines and the today marker are hidden from screen readers. The header says the range and today's date.",
          "In forced colors, each bar keeps an outline, the done part and the today marker take the text color, the today line widens, and the week lines turn GrayText, so today stays the strongest line.",
          "With reduced motion, bars and the today line fade in place instead of growing."
        ],
        "examples": [
          {
            "title": "Items by stage, with today marked",
            "code": "<GanttChart\n  aria-label=\"Work by stage\"\n  start=\"2026-10-05\"\n  end=\"2026-11-15\"\n  today=\"2026-10-30\"\n  groups={[\n    {\n      id: \"doing\",\n      title: \"In progress\",\n      items: [\n        { id: \"payments\", label: \"Payments page\", meta: \"Web\", start: \"2026-10-19\", end: \"2026-11-06\", progress: 0.45, tone: \"brand\", caption: \"45% · 9/20\" },\n        { id: \"help\", label: \"Help center\", meta: \"Web\", start: \"2026-10-13\", end: \"2026-10-31\", progress: 0.9 },\n      ],\n    },\n  ]}\n  onItemOpen={(id) => openItem(id)}\n/>"
          },
          {
            "title": "Dates in another locale, with Date values put on their day in a time zone",
            "code": "<GanttChart\n  aria-label=\"Work by stage\"\n  locale=\"de-DE\"\n  timeZone=\"Europe/Berlin\"\n  today={new Date(\"2026-10-30T09:00:00Z\")}\n  groups={[\n    {\n      id: \"doing\",\n      title: \"In progress\",\n      items: [{ id: \"payments\", label: \"Payments page\", start: new Date(\"2026-10-19T08:00:00Z\"), end: new Date(\"2026-11-06T17:00:00Z\") }],\n    },\n  ]}\n/>"
          },
          {
            "title": "Your own name column, with the date side from the parts",
            "code": "<GanttScale start=\"2026-10-05\" end=\"2026-11-15\" today=\"2026-10-30\" locale=\"en-GB\" timeZone=\"Europe/London\">\n  <div className=\"overflow-x-auto\">\n    <div role=\"table\" aria-label=\"Work\" className=\"relative w-max min-w-full\">\n      <GanttGrid />\n      <GanttToday />\n      <div role=\"row\" className=\"grid h-(--gantt-head-h) grid-cols-[var(--gantt-name-w)_minmax(0,1fr)]\">\n        <div role=\"columnheader\" className=\"sticky start-0 z-10 bg-(--gantt-bg)\">Name</div>\n        <GanttHeader />\n      </div>\n      {items.map((item, index) => (\n        <div key={item.id} role=\"row\" className=\"grid h-(--gantt-row-h) grid-cols-[var(--gantt-name-w)_minmax(0,1fr)]\">\n          <div role=\"rowheader\" className=\"sticky start-0 z-10 bg-(--gantt-bg)\">{item.label}</div>\n          <GanttTrack item={item} index={index} onItemOpen={openItem} />\n        </div>\n      ))}\n    </div>\n  </div>\n</GanttScale>"
          }
        ]
      }
    },
    {
      "name": "glide-highlight",
      "type": "registry:ui",
      "title": "Glide Highlight",
      "description": "One highlight for a whole list, which glides from row to row on a spring instead of each row lighting on its own.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/glide-highlight.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "motion",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A list, menu, or row of links where the pointer sweeps across many rows. One highlight that glides reads as one motion, where a highlight per row blinks.",
            "Call useGlide with the list's ref and the highlight's ref. Call follow(row, event) from pointerover and pointermove, to(row) from focus or highlight events, and hide() when they end.",
            "follow leans the highlight toward the pointer during a quick sweep, so it crosses rows at an even speed. It settles back onto its row once the pointer rests.",
            "A list that already marks one row, like data-highlighted, needs no hook. Pass that attribute as track, and the highlight follows the marked row by itself.",
            "Give a row data-tone, like danger, and the highlight takes that tone when it arrives there.",
            "Pass instant for moves the keys make, so a held arrow key always shows one row fully lit.",
            "Style each row's text with data-[lit]:. A row gets data-lit once the highlight is more than halfway onto it, so text and highlight arrive together."
          ],
          "avoid": [
            "A single control's hover. One button needs no glide. Its own hover fill is enough.",
            "Showing the selected row. The highlight follows the pointer and the keys. Mark the chosen row with its own fill or aria-current.",
            "A list that is not positioned and isolated. The highlight sits behind the rows, so the list needs both, or it measures wrong and hides behind the page."
          ]
        },
        "props": [
          {
            "name": "useGlide",
            "type": "(listRef: RefObject<HTMLElement | null>, highlightRef: RefObject<HTMLSpanElement | null>, options?: { axis?: \"x\" | \"y\"; span?: \"row\" | \"full\" }) => Glide",
            "description": "Helper. Moves the highlight. axis is \"y\" for a column and \"x\" for a row of links. span \"row\" fits each row, and \"full\" spans the list's width so it only moves up and down."
          },
          {
            "name": "Glide",
            "type": "{ follow: (row: HTMLElement, pointer: { clientX: number; clientY: number }) => void; to: (row: HTMLElement, move?: { lead?: number; instant?: boolean }) => void; hide: (options?: { instant?: boolean }) => void; press: (pressed: boolean) => void; row: () => HTMLElement | null }",
            "description": "Type. follow sends it to the row under the pointer and leans it ahead on a quick sweep. to sends it to a row: it fades in there the first time and glides after that. hide fades it out where it is. press darkens it. row is the row it is on."
          },
          {
            "name": "GlideHighlight",
            "type": "ComponentProps<\"span\">",
            "description": "Part. The highlight. Put it first inside the list, with the same ref, and give it its fill as a class, like bg-foreground/8."
          },
          {
            "name": "span",
            "type": "\"row\" | \"full\"",
            "default": "\"row\"",
            "description": "On GlideHighlight. Match the span you passed useGlide."
          },
          {
            "name": "track",
            "type": "string",
            "description": "On GlideHighlight. An attribute that marks one row in its parent at a time, like data-highlighted. The highlight then follows that row with no hook: it glides when the pointer moved the mark and jumps when the keys did. It sets data-glide on the parent, so rows can drop their own fill there."
          }
        ],
        "a11y": [
          "The highlight is hidden from screen readers. It only shows where the pointer or the keys are.",
          "It does not take focus or show focus. Keep each row's own focus ring, or draw the ring on the highlight while it sits on the focused row.",
          "Reduced motion, or motion off, makes it jump from row to row instead of gliding.",
          "Forced colors paint its fill away. Give the lit row an outline there, so the highlight still shows."
        ],
        "examples": [
          {
            "title": "Glide one highlight down a list as the pointer moves",
            "code": "import { useRef } from \"react\";\n\nfunction GlideList() {\n  const list = useRef<HTMLUListElement>(null);\n  const highlight = useRef<HTMLSpanElement>(null);\n  const glide = useGlide(list, highlight);\n  return (\n    <ul\n      ref={list}\n      className=\"relative isolate flex flex-col\"\n      onPointerMove={(event) => {\n        const row = (event.target as Element).closest(\"button\");\n        if (row) glide.follow(row, event);\n      }}\n      onPointerLeave={() => glide.hide()}\n    >\n      <GlideHighlight ref={highlight} className=\"rounded-md bg-foreground/8\" />\n      {[\"Inbox\", \"Drafts\", \"Sent\"].map((label) => (\n        <li key={label}>\n          <button type=\"button\" className=\"w-full px-3 py-2 text-left text-muted-foreground data-[lit]:text-foreground\">\n            {label}\n          </button>\n        </li>\n      ))}\n    </ul>\n  );\n}\n\n<GlideList />"
          },
          {
            "title": "Follow the row a list already marks, with no hook",
            "code": "<ul role=\"listbox\" className=\"relative isolate\">\n  <GlideHighlight track=\"data-highlighted\" className=\"rounded-md bg-accent\" />\n  {/* Rows that set data-highlighted on the active one */}\n</ul>"
          },
          {
            "title": "Jump with no glide when the arrow keys move the highlight",
            "code": "glide.to(row, { instant: true });"
          }
        ]
      }
    },
    {
      "name": "heat-calendar",
      "type": "registry:ui",
      "title": "Heat Calendar",
      "description": "Weeks across and days down, in one hue whose strength is how much happened, or each day in its event type's color, with a legend that filters and a span you can measure.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart",
        "@txbrendan/motion-timing",
        "@txbrendan/rolling-number"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/heat-calendar.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Activity over months, like training attendance, logins, or match minutes, where the pattern matters more than one day.",
            "Pass values as values[week][day] from 0 to 1, oldest week first and Monday first, with a title or aria-label.",
            "Pass maxCount and unit, like 8 and 'sessions', so a day reads '4 sessions', not '50%'.",
            "For a club's calendar, pass events: an ISO day to its event or events. Each day takes its type's color.",
            "Pass types to name event types and set their order. Each color defaults to the next chart color.",
            "Pass endDate for the last day shown. Without it the calendar ends today, once the page is in the browser."
          ],
          "avoid": [
            "Exact numbers people must compare. Use BarChart or BreakdownList.",
            "A trend over a few points. Use LineChart or Sparkline.",
            "Booking or choosing a day. Use Calendar.",
            "More than about five event types. The colors stop being easy to tell apart."
          ]
        },
        "props": [
          {
            "name": "values",
            "type": "number[][]",
            "description": "Intensity mode. values[week][day] from 0 to 1, Monday first. The last row is the week that holds endDate."
          },
          {
            "name": "events",
            "type": "Record<string, HeatEvent | HeatEvent[]>",
            "description": "Event mode. An ISO day, like '2026-09-26', to its event or events. An event is a type id, like 'training', or { type, label?, opponent?, score? }. A score of [24, 17] reads 'Won 24–17 vs Harbour Town'."
          },
          {
            "name": "types",
            "type": "{ id: string; label: string; color?: string }[]",
            "description": "Event types in legend order. Default: each type in events as it first appears. A color is a theme token, like 'var(--chart-3)'."
          },
          {
            "name": "weeks",
            "type": "number",
            "default": "values.length, or 26",
            "description": "How many weeks to show."
          },
          {
            "name": "maxCount",
            "type": "number",
            "description": "The count a full cell stands for. With 8, a value of 0.5 reads '4 sessions'."
          },
          {
            "name": "unit",
            "type": "string",
            "description": "The noun after counts, plural, like 'sessions'. A count of one drops the final s."
          },
          {
            "name": "endDate",
            "type": "Date | string",
            "default": "today",
            "description": "The last day shown, as an ISO day or a Date. One it cannot read falls back to today. Days after it in its week are left blank."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "Names the calendar and its grid. Pass this, aria-label, or aria-labelledby."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for day and month names. The default keeps the server and the browser the same."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the figure. '[--heat-calendar-color:var(--chart-3)]' sets the one hue in intensity mode. It defaults to --chart-1."
          }
        ],
        "a11y": [
          "The days are a role=\"grid\", one row per weekday. Each day is a gridcell named by its date and what happened, like 'Sat, Sep 26: 4 sessions'.",
          "The grid is one Tab stop. Left and Right move a week, Up and Down move a day, and Home and End go to the first and last days.",
          "Enter or Space starts a span, and again ends it. A status line says the span's total. Escape clears it and goes no further.",
          "The legend steps are buttons. Hovering or focusing one shows only its days. Pressing one keeps it on, with aria-pressed.",
          "The readout over a day is hidden from screen readers, since each day already says the same.",
          "In forced colors, the days, the legend swatches, and the readout's dots keep their hues, so each day still matches its legend step or type. Empty days and the None step mix the user's page and text colors, so they stay faint. The level steps and the event colors, including colors set in types, pull toward the text color. So on a light or a dark page, the steps stay in order above None, and event days stand out from the page. Words, outlines, and the focus ring take the user's colors.",
          "With reduced motion, the cells fade in without the spring, and nothing lifts or glides."
        ],
        "examples": [
          {
            "title": "Training attendance",
            "code": "<HeatCalendar\n  title=\"Training attendance\"\n  values={weeks}\n  maxCount={8}\n  unit=\"sessions\"\n  endDate=\"2026-09-27\"\n/>"
          },
          {
            "title": "A club's calendar by event type",
            "code": "<HeatCalendar\n  title=\"Club calendar\"\n  weeks={12}\n  endDate=\"2026-09-27\"\n  types={[\n    { id: \"training\", label: \"Training\" },\n    { id: \"match\", label: \"Match\" },\n  ]}\n  events={{\n    \"2026-09-22\": { type: \"training\", label: \"Scrums and lineouts\" },\n    \"2026-09-26\": { type: \"match\", score: [24, 17], opponent: \"Harbour Town\" },\n  }}\n/>"
          },
          {
            "title": "In another hue",
            "code": "<HeatCalendar aria-label=\"Logins\" values={weeks} className=\"[--heat-calendar-color:var(--chart-3)]\" />"
          }
        ]
      }
    },
    {
      "name": "hover-actions",
      "type": "registry:ui",
      "title": "Hover Actions",
      "description": "A row's quick icon actions in a small glass toolbar that sits on the row and springs in while the row is hovered or holds focus. A small tooltip above each icon says what it does.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/tool-button",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/hover-actions.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The quick jobs on a list row, like Reply, Edit, Archive, or Move, so people finish them without leaving the page.",
            "Keep two to four actions. Put the rest in a Menu.",
            "Set visible while the row is hovered or holds focus. Focus inside the bar shows it too, so keyboard users always reach it.",
            "Give each action a shortcut, and use RowActions to make the keys work. The tooltip teaches each one.",
            "Use icons. Each icon's tooltip says what it does, so the bar stays small and never covers the row.",
            "Give an action a result when its press changes something, like an archive. The button says what happened, with Undo.",
            "Place it yourself, usually absolute at the row's right edge, inside a row with position: relative."
          ],
          "avoid": [
            "The only way to do something on touch screens. Touch has no hover, so keep the bar visible there, or offer the action in the row's own page.",
            "A row's main action, like opening it. Make the row itself the link or button.",
            "A long bar of actions. Use a Menu for more than four.",
            "Words in a row's bar, like a text Remind button. They crowd the row. Let the tooltip say the word."
          ]
        },
        "props": [
          {
            "name": "actions",
            "type": "HoverAction[]",
            "description": "Required. The actions, in order. Pass the same objects to RowActions and ActionKeys, so the key, the tooltip, and the button never drift apart."
          },
          {
            "name": "HoverAction",
            "type": "{ label: string; icon?: ReactNode; onClick?: () => void | Promise<unknown>; shortcut?: string; text?: boolean; tone?: \"neutral\" | \"danger\"; result?: ActionResult; disabled?: boolean }",
            "description": "Type. label names the button. shortcut is one key, taught in the tooltip. text shows the label beside the icon. A promise from onClick shows a spinner, and a failure shows Retry. result is ToolButton's ActionResult."
          },
          {
            "name": "visible",
            "type": "boolean",
            "default": "true",
            "description": "Shows the bar. Set it while the row is hovered or holds focus. Hidden, the bar stays in the Tab order, and focus inside shows it."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Actions\"",
            "description": "Names the toolbar. Name the row, like 'Actions for Tom Hale', when a list has many."
          },
          {
            "name": "ref",
            "type": "Ref<HTMLDivElement>",
            "description": "Reaches the toolbar. ActionKeys takes it as within."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the toolbar. Later classes win."
          }
        ],
        "a11y": [
          "The bar is a toolbar: one Tab stop, and the arrow keys move between its buttons.",
          "Each button is named by its action's label. The tooltip shows the label and the key to sighted mouse and keyboard users.",
          "A hidden bar is only see-through. It stays in the Tab order and the accessibility tree, and focus inside shows it at once.",
          "A hidden bar ignores the pointer, so a press never lands on a button no one can see.",
          "Results are said once through a polite live region, and each button's name follows its state."
        ],
        "examples": [
          {
            "title": "On a list row",
            "code": "import { Archive, Pencil, Reply } from \"lucide-react\";\nimport { useState } from \"react\";\n\nfunction PersonRow({ name }: { name: string }) {\n  const [lit, setLit] = useState(false);\n  return (\n    <div\n      className=\"relative flex h-11 items-center px-4\"\n      onMouseEnter={() => setLit(true)}\n      onMouseLeave={() => setLit(false)}\n      onFocus={() => setLit(true)}\n      onBlur={(event) => !event.currentTarget.contains(event.relatedTarget) && setLit(false)}\n    >\n      <a href=\"#\">{name}</a>\n      <HoverActions\n        visible={lit}\n        aria-label={`Actions for ${name}`}\n        className=\"absolute right-4 top-1/2 -translate-y-1/2\"\n        actions={[\n          { icon: <Reply />, label: \"Reply\", shortcut: \"R\" },\n          { icon: <Pencil />, label: \"Edit\", shortcut: \"E\" },\n          { icon: <Archive />, label: \"Archive\", shortcut: \"A\", result: { label: \"Archived\", onUndo: () => {} } },\n        ]}\n      />\n    </div>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "hover-card",
      "type": "registry:ui",
      "title": "Hover Card",
      "description": "A peek: hover, focus, or tap a person, status, count, or position to see the story behind it, without leaving the page.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/hover-card.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing the story behind a person, a status, a count, or a position, like who the 21 going are.",
            "Put a PeekCard in content. Its title names the peek, and in a peek it shows a glance: up to 3 facts and 2 actions.",
            "Mark each row of a list with data-peek-row. The peek then opens beside the row, so the rows above and below stay in reach.",
            "Set control when the trigger is itself a control, like an event card you press to select. It keeps its role and its Enter.",
            "Set underline on words in a sentence, so people can see there is more behind them.",
            "While a menu, list, or popover is open, hover opens no peek outside it. The peek waits, and opens once that closes if the pointer is still there."
          ],
          "avoid": [
            "Anything people must see to finish a task. Peeks are extra. Put the rest on the page the peek links to.",
            "A short name for an icon button. Use a Tooltip.",
            "A menu of choices, or a form. Use a Menu, a Popover, or a Dialog.",
            "Opening one on click. A peek opens on hover, focus, and tap. Use a Popover for a click."
          ]
        },
        "props": [
          {
            "name": "content",
            "type": "ReactNode",
            "description": "Required. What the peek shows. A PeekCard is the standard content."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "The trigger: a person, a status, a count, or a position."
          },
          {
            "name": "delay",
            "type": "number",
            "default": "320",
            "description": "How long the pointer rests on the trigger before the peek opens, in milliseconds."
          },
          {
            "name": "glance",
            "type": "boolean",
            "default": "true",
            "description": "A PeekCard inside shows up to 3 facts and 2 actions. False shows it whole."
          },
          {
            "name": "width",
            "type": "number",
            "default": "320",
            "description": "The peek's width in pixels. It never grows past the screen."
          },
          {
            "name": "side",
            "type": "\"auto\" | \"right\" | \"left\" | \"top\" | \"bottom\"",
            "default": "\"auto\"",
            "description": "auto opens beside a row marked data-peek-row, level with the row, or else below the trigger, lined up with its start. It flips when there is no room."
          },
          {
            "name": "inline",
            "type": "boolean",
            "default": "true",
            "description": "A span trigger that sits in a line of text. False makes it a block."
          },
          {
            "name": "control",
            "type": "boolean",
            "default": "false",
            "description": "The child is itself a control. It keeps its role and its Enter, and the peek follows its hover and focus."
          },
          {
            "name": "underline",
            "type": "boolean",
            "default": "false",
            "description": "A faint dotted underline under the whole trigger, on every line when it wraps."
          },
          {
            "name": "label",
            "type": "string",
            "description": "The peek's name for screen readers, when its content is not a PeekCard with a title."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Controlled. The peek shows while true. Hover, focus, Esc, and a press outside only ask, through onOpenChange."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Starts open, without taking focus."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Hears every open and close, or what the peek asks for when controlled."
          },
          {
            "name": "effect",
            "type": "\"rise\" | \"grow\"",
            "default": "\"rise\"",
            "description": "How the peek opens and closes. rise fades in and moves out from the trigger. grow fades in and grows from the trigger, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the trigger. Later classes win."
          },
          {
            "name": "useHoverCard",
            "type": "() => { titleId; registerTitle; glance; close } | null",
            "description": "Hook. Content inside a peek reads it to name the peek, to trim to a glance, and to close it."
          }
        ],
        "a11y": [
          "The trigger is a focusable role=\"button\" with aria-haspopup=\"dialog\", aria-expanded, and aria-controls while open. With control, the child keeps its own role instead.",
          "The peek opens on keyboard focus at once, and on hover after the delay. A tap opens it on touch, and a second tap on the trigger closes it.",
          "The peek is a non-modal role=\"dialog\". A PeekCard's title names it. Without one, label names it, or else the trigger's words.",
          "Enter or Space on the trigger moves focus into the peek. Tab moves through it, and past either end focus is back on the trigger. Esc closes it and puts focus back.",
          "↑ and ↓ step to the peek above or below, while the keyboard is on a trigger or in its peek.",
          "A trigger with no words, like an icon or a swatch, needs an aria-label. The console says so until it has one.",
          "It opens with a quick fade from its trigger's side, and closes with a quicker fade and a slight shrink."
        ],
        "examples": [
          {
            "title": "Words in a sentence",
            "code": "<p>\n  Design review on Tuesday:{\" \"}\n  <HoverCard underline label=\"Who is going\" content={<p>Sam, Kate, Josh, and 18 more.</p>}>\n    21 going\n  </HoverCard>\n</p>"
          },
          {
            "title": "Rows in a list",
            "code": "const people = [\n  { name: \"Sam Carter\", role: \"Design lead\" },\n  { name: \"Kate Clarke\", role: \"Support lead\" },\n];\n\n<ul>\n  {people.map((person) => (\n    <li key={person.name} data-peek-row>\n      <HoverCard label={person.name} content={<p>{person.role}</p>}>\n        {person.name}\n      </HoverCard>\n    </li>\n  ))}\n</ul>"
          },
          {
            "title": "Around a control",
            "code": "<HoverCard control label=\"Thursday's review\" content={<p>Starts 15:00 in the Boardroom.</p>}>\n  <button type=\"button\">Quarterly review</button>\n</HoverCard>"
          },
          {
            "title": "Grows from its trigger",
            "code": "<p>\n  Design review on Tuesday:{\" \"}\n  <HoverCard underline effect=\"grow\" label=\"Who is going\" content={<p>Sam, Kate, Josh, and 18 more.</p>}>\n    21 going\n  </HoverCard>\n</p>"
          }
        ]
      }
    },
    {
      "name": "hub-link",
      "type": "registry:ui",
      "title": "Hub Link",
      "description": "A place to go: a spot icon or an icon, a title, and a line under it, washed on hover.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/spot-icon"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/hub-link.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The links on a hub or home page: Calendar, People, Payments, each with a line saying what is there.",
            "Use spot when the link names a place to go. Use icon, in iconTone, for a quieter list.",
            "Use tone when the line under the title says something needs you, like \"Due today\" in warning.",
            "The wash hangs 10px past each side, so the icon and words keep the column's edge. Leave the column that room.",
            "Pass href for a link. Without it, the hub link is a button, for onClick."
          ],
          "avoid": [
            "A row in a list. Use ListRow.",
            "A call to action. Use Button.",
            "Color alone for something that needs you. The line must say it in words."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "string",
            "description": "Required. Where it goes, like \"Calendar\"."
          },
          {
            "name": "description",
            "type": "string",
            "description": "The line under the title."
          },
          {
            "name": "spot",
            "type": "ReactNode",
            "description": "The icon for a 32px SpotIcon. It takes the place of icon."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "A plain 20px icon, in iconTone."
          },
          {
            "name": "iconTone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"brand\"",
            "description": "The plain icon's color role. A spot is always the brand's."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Colors the description. neutral is muted."
          },
          {
            "name": "href",
            "type": "string",
            "description": "Makes it a link. Without it, it is a button."
          },
          {
            "name": "onClick",
            "type": "(event) => void",
            "description": "Runs on press."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the link. Later classes win."
          }
        ],
        "a11y": [
          "A link with href, and a button without it, named by its title and description.",
          "The spot and the icon are hidden from screen readers, since the title names the place.",
          "A toned description keeps its words, so color is never the only signal.",
          "The focus ring goes around the whole wash."
        ],
        "examples": [
          {
            "title": "Two links, one with a warning",
            "code": "import { CalendarDays, CreditCard } from \"lucide-react\";\n\n<nav className=\"flex flex-col gap-1 px-3\">\n  <HubLink spot={<CalendarDays />} title=\"Calendar\" description=\"Next: Sat 3 Oct · Demo day\" href=\"/calendar\" />\n  <HubLink spot={<CreditCard />} title=\"Payments\" description=\"Due today\" tone=\"warning\" href=\"/payments\" />\n</nav>"
          }
        ]
      }
    },
    {
      "name": "icon-color-picker",
      "type": "registry:ui",
      "title": "Icon Color Picker",
      "description": "Choose an artwork color with a native picker or hex field, with validation and a reset.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/input",
        "@txbrendan/button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/icon-color-picker.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Controlled color editing for icon artwork. Use null to retain the original color.",
            "Accepts three- or six-digit hex. Invalid input preserves the last valid value."
          ],
          "avoid": [
            "Replacing the interface theme. Use theme tokens for controls and text."
          ]
        },
        "props": [
          {
            "name": "original",
            "type": "string",
            "description": "Original hex used for the default swatch and placeholder."
          },
          {
            "name": "value",
            "type": "string | null",
            "description": "Chosen hex color, or null for the original."
          },
          {
            "name": "onValueChange",
            "type": "(value: string | null) => void",
            "description": "Called for valid colors and resets."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Icon color\"",
            "description": "Visible label and accessible name."
          },
          {
            "name": "resetLabel",
            "type": "string",
            "default": "\"Use default color\"",
            "description": "Reset action label."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Disables editing."
          }
        ],
        "a11y": [
          "Both inputs are labeled. Invalid hex has an associated message and aria-invalid.",
          "Chosen artwork colors are not contrast-checked. Controls retain theme colors."
        ],
        "examples": [
          {
            "title": "Controlled artwork",
            "code": "<IconColorPickerExample />"
          }
        ]
      }
    },
    {
      "name": "icon",
      "type": "registry:ui",
      "title": "Icon",
      "description": "A Lucide icon by name, from a small fixed set, sized at 14, 16, or 20px and colored by the text color.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/icon.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "media",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Drawing an icon from data, like a name stored with a menu item or a list row.",
            "Use size sm in compact rows, md by default, and lg in hub rows.",
            "Set the color with a text token class, like text-muted-foreground or text-primary.",
            "Pass title when the icon stands alone and means something. Leave it off when text beside it says the same thing."
          ],
          "avoid": [
            "Icons you write in code. Import them from lucide-react, like <Calendar />. Other parts take icons as elements.",
            "Names outside the set. They draw an empty box and warn once. Import that icon from lucide-react instead.",
            "Content spots, like a feature list, an empty state, or a card's header. Use SpotIcon.",
            "Raw colors. Use a text token class, so the icon follows the theme."
          ]
        },
        "props": [
          {
            "name": "name",
            "type": "IconName",
            "description": "A kebab-case Lucide name from the set: arrow-right, bell, calendar, camera, check, chevron-down, chevron-right, circle-alert, clock, credit-card, download, external-link, eye, file-text, flag, heart, house, image, info, link, lock, mail, map-pin, medal, megaphone, message-circle, minus, pencil, phone, plus, search, settings, share, shield, shirt, star, ticket, trash-2, triangle-alert, trophy, upload, user, users, x. iconNames lists them."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "sm is 14px, md is 16px, lg is 20px."
          },
          {
            "name": "strokeWidth",
            "type": "number",
            "default": "2",
            "description": "Line width. lg draws 1.75 unless you set it."
          },
          {
            "name": "title",
            "type": "string",
            "description": "Names the icon for screen readers. Without it the icon is hidden from them."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Set the color here, like text-muted-foreground. Later classes win."
          }
        ],
        "a11y": [
          "Without title the icon has aria-hidden, so text beside it is all a screen reader hears.",
          "With title it is role=\"img\" with that name. Use it only when the icon stands alone.",
          "An icon inside a button does not name the button. Give an icon-only button its own aria-label.",
          "It takes the text color, so a text token's contrast covers it."
        ],
        "examples": [
          {
            "title": "An icon beside text",
            "code": "<p className=\"flex items-center gap-1.5 text-muted-foreground\">\n  <Icon name=\"calendar\" />\n  Tuesday 6 October\n</p>"
          },
          {
            "title": "Sizes",
            "code": "<div className=\"flex items-center gap-3\">\n  <Icon name=\"bell\" size=\"sm\" />\n  <Icon name=\"bell\" />\n  <Icon name=\"bell\" size=\"lg\" />\n</div>"
          },
          {
            "title": "An icon from data, with a name",
            "code": "const status = { icon: \"triangle-alert\", label: \"Sign-in errors\" } as const;\n\n<Icon name={status.icon} title={status.label} className=\"text-muted-foreground\" />"
          }
        ]
      }
    },
    {
      "name": "inline-edit",
      "type": "registry:ui",
      "title": "Inline Edit",
      "description": "A value people change where it sits. The value is the button, and the field opens in its exact box.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/inline-edit.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "One short value on a page or a row that people change now and then: a name, a title, a place, a number.",
            "Pass label as a short noun, like \"location\". It names the value \"Edit location\" and the empty state \"Add location\".",
            "Save in onSave. Return the promise from your request, and the change shows at once, says Saving…, and comes back if it fails.",
            "When you control value, set it to the new text before your promise resolves."
          ],
          "avoid": [
            "Text that runs to several lines, like a description. Use a Textarea in a form or a dialog.",
            "Values that must change together, like a start and an end date. Edit them in one form with one Save.",
            "A form people fill in once from top to bottom. Use Input with a label.",
            "Adding a pencil or an Edit button beside the value. The value is already the button."
          ]
        },
        "props": [
          {
            "name": "label",
            "type": "string",
            "description": "What the value is, as a short noun. Required. It names the value and the empty state."
          },
          {
            "name": "value",
            "type": "string",
            "description": "The saved value. Controlled. Set it to the new text before your onSave promise resolves."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "default": "\"\"",
            "description": "The saved value, when you do not control value."
          },
          {
            "name": "onSave",
            "type": "(value: string) => void | Promise<unknown>",
            "description": "Called with the trimmed new text when it changed. Enter saves, and so does clicking away. Escape cancels. A promise shows Saving… until it settles. A rejection or a throw puts the old value back and shows errorText. The newest save wins."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Text size: 11, 13, or 16px. The box is as tall as a small control, so density scales it."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Shows the value as plain text. It is not a button and cannot be edited."
          },
          {
            "name": "placeholder",
            "type": "string",
            "description": "Shown in the empty field while editing."
          },
          {
            "name": "editText",
            "type": "string",
            "default": "\"Edit {label}\"",
            "description": "Words before the value in its name for screen readers. Set it to translate."
          },
          {
            "name": "addText",
            "type": "string",
            "default": "\"Add {label}\"",
            "description": "Shown with a plus when the value is empty. Set it to translate."
          },
          {
            "name": "savingText",
            "type": "string",
            "default": "\"Saving…\"",
            "description": "Shown after the value while its save runs."
          },
          {
            "name": "errorText",
            "type": "string",
            "default": "\"Could not save. Try again.\"",
            "description": "Shown under the value when a save fails."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer span. Later classes win, so className='text-base' replaces the size."
          }
        ],
        "a11y": [
          "The value is a native <button> named \"Edit {label}: {value}\", so screen readers hear what it is and what it holds. An empty value is named \"Add {label}\".",
          "Enter, Space, or a click opens a text field with the value, focused. A short value is selected, so typing replaces it.",
          "Enter saves and Escape cancels. Both put focus back on the value. Clicking away saves and leaves focus where you clicked.",
          "Escape stops at the field, so a dialog or popover around it stays open.",
          "While a save runs, the value button is described by the Saving… text. A failed save shows its message with role=\"alert\" and describes the button.",
          "The field takes the value's exact box, so nothing on the page moves when it opens or closes.",
          "On touch screens the text is 16px, so iOS Safari does not zoom in when the field opens.",
          "A disabled value is plain text with no button, so it does not look like something to press."
        ],
        "examples": [
          {
            "title": "Save to your server",
            "code": "<InlineEdit\n  label=\"location\"\n  defaultValue=\"Berlin\"\n  onSave={(location) => fetch(\"/api/profile\", { method: \"PATCH\", body: JSON.stringify({ location }) }).then((r) => {\n    if (!r.ok) throw new Error(\"Save failed\");\n  })}\n/>"
          },
          {
            "title": "Controlled",
            "code": "const [title, setTitle] = useState(\"Q3 planning\");\n\n<InlineEdit\n  label=\"title\"\n  size=\"lg\"\n  value={title}\n  onSave={async (next) => {\n    await saveTitle(next);\n    setTitle(next);\n  }}\n/>"
          },
          {
            "title": "Empty, in a table cell (needs @txbrendan/table)",
            "code": "<TableCell>\n  <InlineEdit label=\"owner\" placeholder=\"Name or email\" onSave={setOwner} />\n</TableCell>"
          }
        ]
      }
    },
    {
      "name": "inline-reply",
      "type": "registry:ui",
      "title": "Inline Reply",
      "description": "A reply or an edit written in place: a soft line that springs open into a field. Enter sends, and Esc cancels.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/inline-reply.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "ai",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Replying under a message, a comment, or a post, right where it is.",
            "Editing your own message in place, with mode edit and the message in defaultValue.",
            "Open it from a Reply button with open and onOpenChange. The cursor goes in."
          ],
          "avoid": [
            "A new message to a group. Use Composer, which says who it reaches.",
            "Long, formatted writing. Use Editor.",
            "A form field that waits for a Save button. Use Textarea."
          ]
        },
        "props": [
          {
            "name": "placeholder",
            "type": "string",
            "default": "\"Reply\" or \"Edit message\"",
            "description": "The collapsed line's words and the field's name, like \"Reply to Design\"."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "Words to start with, like the message being edited."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Controlled: the field shows while true. Opening it this way, or rendering it with open, puts the cursor in it."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Starts open, without taking focus."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Hears every open and close: a press on the line, a send, or Esc."
          },
          {
            "name": "autoFocus",
            "type": "boolean",
            "default": "false",
            "description": "Puts the cursor in it on first render when it starts open."
          },
          {
            "name": "mode",
            "type": "\"reply\" | \"edit\"",
            "default": "\"reply\"",
            "description": "Edit keeps the sent words in the field for the next edit."
          },
          {
            "name": "onSend",
            "type": "(text: string) => void",
            "description": "Enter sends the words, trimmed. Empty words are never sent."
          },
          {
            "name": "onCancel",
            "type": "() => void",
            "description": "Esc took it back. The words go back to defaultValue."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height and padding, as other fields."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Turns it off."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Later classes win."
          }
        ],
        "a11y": [
          "Collapsed, it is a button named by placeholder. Open, it is a textarea with the same name.",
          "The field is described by a hidden line: Enter sends, Shift+Enter starts a new line, Esc cancels.",
          "Esc cancels it and goes no further, so a sheet or a row around it stays.",
          "After a send or Esc from inside, focus goes back to the line, unless something around it took focus first.",
          "In forced colors, the collapsed line keeps an edge, since its gray fill is gone."
        ],
        "examples": [
          {
            "title": "Under a post",
            "code": "<InlineReply placeholder=\"Reply to Design\" onSend={(text) => reply(text)} />"
          },
          {
            "title": "Opened by a Reply button",
            "code": "const [replying, setReplying] = useState(false);\n\n<HoverActions actions={[{ label: \"Reply\", icon: <MessageCircle />, onClick: () => setReplying(true) }]} />\n{replying && <InlineReply placeholder=\"Reply to Anna\" open onOpenChange={setReplying} onSend={reply} />}"
          }
        ]
      }
    },
    {
      "name": "input",
      "type": "registry:ui",
      "title": "Input",
      "description": "A one-line text field with sizes and an error state.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/input.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Short free text: a name, an email address, a search, a number.",
            "Name it with a visible label. Give the input an id and point the label's htmlFor at it.",
            "Set the type that fits the value, like email, tel, url, or search, so phones show the right keyboard.",
            "Show errors with aria-invalid plus a message under the input, linked with aria-describedby."
          ],
          "avoid": [
            "Text that can run past one line, like a comment or a bio. Use a textarea.",
            "Choosing from a fixed list. Use a select or a radio group.",
            "Turning a setting on and off. Use a checkbox or a switch.",
            "Using placeholder as the label. It disappears as soon as someone types."
          ]
        },
        "props": [
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height, padding, and text size. Density scales them."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks typing and removes the input from the tab order."
          },
          {
            "name": "aria-invalid",
            "type": "boolean",
            "description": "Shows the danger border. Set it when validation fails, and link the message with aria-describedby."
          },
          {
            "name": "onValueChange",
            "type": "(value: string, details) => void",
            "description": "Called with the new text on every change. Use it with value for a controlled input."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='rounded-none' replaces the radius."
          }
        ],
        "a11y": [
          "Renders a native <input>, so typing, selection, autofill, and form submission work as usual.",
          "It has no name on its own. Pair it with a <label htmlFor>, or wrap it in a label.",
          "aria-invalid=\"true\" tells screen readers the value is wrong. Put the reason in text next to the input, not only in the red border.",
          "The border uses --input, which the theme tests hold to 3:1 against the page and cards.",
          "The focus ring uses --ring, which the theme tests hold to 3:1 against the page and cards.",
          "On touch screens the text is 16px, so iOS Safari does not zoom in when the input gets focus."
        ],
        "examples": [
          {
            "title": "With a label",
            "code": "<div className=\"grid gap-2\">\n  <label htmlFor=\"email\" className=\"text-sm font-medium\">\n    Email\n  </label>\n  <Input id=\"email\" type=\"email\" autoComplete=\"email\" placeholder=\"you@example.com\" />\n</div>"
          },
          {
            "title": "Error message the input points to",
            "code": "<div className=\"grid gap-2\">\n  <label htmlFor=\"handle\" className=\"text-sm font-medium\">\n    Username\n  </label>\n  <Input id=\"handle\" defaultValue=\"admin\" aria-invalid aria-describedby=\"handle-error\" />\n  <p id=\"handle-error\" className=\"text-sm text-destructive-soft-foreground\">\n    That name is taken. Try another.\n  </p>\n</div>"
          },
          {
            "title": "Small, in a toolbar",
            "code": "<Input size=\"sm\" type=\"search\" aria-label=\"Search projects\" placeholder=\"Search\" />"
          }
        ]
      }
    },
    {
      "name": "job-card",
      "type": "registry:ui",
      "title": "Job Card",
      "description": "One job on one line, like an import, a send, a sync, or an export: its status, what it is, a few words about it, and how long it took. A thin bar fills while it runs. It opens to the details.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/card",
        "@txbrendan/spinner"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/job-card.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "ai",
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "In an activity log or a jobs page, one card per job: what ran, on what, and what came of it.",
            "Pass status queued before it starts, running while it runs, then done, failed, or canceled.",
            "Pass progress from 0 to 1 while it runs, like 0.45 for 45%. A thin bar fills along the bottom edge, and the line shows the percent.",
            "Keep summary to a few words, like the file name or how many rows. The full data goes in details.",
            "Pass details as labeled sections, like Request and Response. An object or array shows as JSON. Pass a node to show it your own way.",
            "On failed, pass error in plain words, like \"Row 214 has no email.\" It shows under the line, so no one has to open the card to see it.",
            "Stack several in a flex column with a small gap for a list of jobs."
          ],
          "avoid": [
            "A job that needs a person to say yes before it runs. Ask first, then show the job.",
            "Many jobs grouped by who has to act next. Use the Task Inbox block.",
            "A status on a row or in a table. Use StatusText.",
            "A single upload with nothing to open. Use Progress.",
            "An agent's tool call. Use ToolCallCard."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "Required. What the job is, like \"Import contacts\"."
          },
          {
            "name": "status",
            "type": "\"queued\" | \"running\" | \"done\" | \"failed\" | \"canceled\"",
            "description": "Required. Where the job stands. queued is a dashed ring, running spins in the brand tone, done is a success check, failed is danger red, and canceled is quiet."
          },
          {
            "name": "summary",
            "type": "ReactNode",
            "description": "The job in a few words, like \"contacts.csv, 2,400 rows\". Shown on the closed line, cut short with an ellipsis."
          },
          {
            "name": "progress",
            "type": "number",
            "description": "running: how far along it is, from 0 to 1. A thin bar fills along the bottom edge, and the line shows the percent."
          },
          {
            "name": "details",
            "type": "{ label: string; value: ReactNode | Record<string, unknown> | unknown[] }[]",
            "default": "[]",
            "description": "What the open card shows, in order, like Request and Response. An object or array shows as JSON. With none, it says it is waiting, or that there are no details."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "failed: what went wrong, in plain words. It shows under the line, open or closed."
          },
          {
            "name": "duration",
            "type": "string",
            "description": "How long it took, has run so far, or when it ran, like \"1.5s\" or \"2m ago\". Update it while running for a live timer."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Starts open."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Controls whether it is open. Pair it with onOpenChange."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Called when the card opens or closes."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "The line can't be opened."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the card. Later classes win."
          }
        ],
        "a11y": [
          "The line is one button. It says the status in words, the title, the summary, the percent, and the time, and it tells screen readers if it is open.",
          "Enter or Space opens and closes it.",
          "Status is shown three ways: words for screen readers, an icon shape, and a color.",
          "While running with progress, a progress bar named by the title sits outside the button.",
          "A change of status is said once, politely, in a hidden status region: Import contacts: Done.",
          "The error is plain text under the line, so it is read without opening the card.",
          "The spinner stops and the bar jumps to its value when the theme turns motion off, and for reduced motion. The panel then opens at once."
        ],
        "examples": [
          {
            "title": "Running, with progress",
            "code": "<JobCard title=\"Import contacts\" status=\"running\" summary=\"contacts.csv, 2,400 rows\" progress={0.45} duration=\"12s\" />"
          },
          {
            "title": "Done, with details",
            "code": "<JobCard\n  title=\"Send welcome email\"\n  status=\"done\"\n  summary=\"ana@example.com\"\n  duration=\"0.4s\"\n  details={[\n    { label: \"Request\", value: { to: \"ana@example.com\", template: \"welcome\" } },\n    { label: \"Response\", value: { id: \"msg_812\", status: 202 } },\n  ]}\n/>"
          },
          {
            "title": "Failed",
            "code": "<JobCard title=\"Sync calendar\" status=\"failed\" summary=\"Team events\" duration=\"4.2s\" error=\"The calendar's access ran out. Connect it again.\" />"
          },
          {
            "title": "Queued",
            "code": "<JobCard title=\"Export report\" status=\"queued\" summary=\"Q3 sales, PDF\" />"
          }
        ]
      }
    },
    {
      "name": "kbd",
      "type": "registry:ui",
      "title": "Kbd",
      "description": "A keyboard key, shown as a small label with a border, or as quiet text inside a field, with a group part for key combinations.",
      "dependencies": [
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/kbd.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing which key to press, in help text, menus, tooltips, and empty states.",
            "Use KbdGroup for a combination, with one Kbd per key and a + between them.",
            "Write the key the way it is printed on the keyboard: Ctrl, Shift, Enter, Esc.",
            "Use size to match the text around it: sm in small print, md beside text-sm, lg beside text-base.",
            "Use emphasis=\"ghost\" for a shortcut hint inside a field, like a search box, where a bordered key would look like a second control."
          ],
          "avoid": [
            "Making a key clickable. A Kbd is a label. Put the action on a Button.",
            "Printing a key beside a button on the page. Teach it in the button's tooltip with TooltipPopup's shortcut prop.",
            "Putting aria-label on a Kbd. A <kbd> has no role, so ARIA does not allow a label on it. Use visually hidden text instead.",
            "Showing a shortcut that does not work on the person's platform. Show Ctrl or ⌘ to match their system.",
            "Code or file names. Use <code> for those."
          ]
        },
        "props": [
          {
            "name": "emphasis",
            "type": "\"solid\" | \"ghost\"",
            "default": "\"solid\"",
            "description": "solid draws a border and a fill. ghost shows only the text, at the same size."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height, width, and text size. Density scales them."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='rounded-full' replaces the radius."
          },
          {
            "name": "Kbd",
            "type": "Omit<ComponentProps<\"kbd\">, \"color\">",
            "description": "One key. Renders a <kbd>. At least as wide as it is tall, so one letter makes a square key."
          },
          {
            "name": "KbdGroup",
            "type": "Omit<ComponentProps<\"kbd\">, \"color\">",
            "description": "Part. Holds the keys of one combination. Renders a <kbd>, as HTML asks for key combinations."
          },
          {
            "name": "kbdVariants",
            "type": "({ emphasis, size }) => string",
            "description": "The key's classes, for styling another element as a key."
          }
        ],
        "a11y": [
          "Renders a native <kbd>. Screen readers read its text, so the text must say the key.",
          "Screen readers may read symbols badly or skip them. For ⌘, ⌥, or ⇧, hide the symbol with aria-hidden and add the key name in a span with className='sr-only'.",
          "Never put aria-label on a Kbd. A <kbd> has no role, so ARIA does not allow a label on it, and screen readers may skip it.",
          "Write a + between keys in a KbdGroup, so the text itself says the keys go together.",
          "Not focusable and not interactive. It adds no tab stop.",
          "Solid text is muted-foreground on muted. Ghost text is muted-foreground on what is behind it. The theme tests hold muted-foreground to 4.5:1 on muted, background, card, and popover, in light and dark."
        ],
        "examples": [
          {
            "title": "One key in a sentence",
            "code": "<p className=\"text-sm text-muted-foreground\">\n  Press <Kbd>Esc</Kbd> to close.\n</p>"
          },
          {
            "title": "A key combination",
            "code": "<KbdGroup>\n  <Kbd>Ctrl</Kbd>+<Kbd>K</Kbd>\n</KbdGroup>"
          },
          {
            "title": "A symbol key with a name screen readers can read",
            "code": "<KbdGroup>\n  <Kbd>\n    <span aria-hidden>⌘</span>\n    <span className=\"sr-only\">Command</span>\n  </Kbd>\n  +<Kbd>K</Kbd>\n</KbdGroup>"
          },
          {
            "title": "A quiet hint inside a field",
            "code": "<Kbd emphasis=\"ghost\" aria-hidden>/</Kbd>"
          },
          {
            "title": "A shortcut beside a menu action",
            "code": "<div className=\"flex items-center justify-between gap-4 text-sm\">\n  <span>Search</span>\n  <KbdGroup>\n    <Kbd size=\"sm\">Ctrl</Kbd>+<Kbd size=\"sm\">K</Kbd>\n  </KbdGroup>\n</div>"
          }
        ]
      }
    },
    {
      "name": "knockout-bracket",
      "type": "registry:ui",
      "title": "Knockout Bracket",
      "description": "A knockout draw round by round, up to three rounds side by side, paged one round at a time with chevrons. Cards, elbows, and titles glide together.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/team-flag"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/knockout-bracket.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A cup or a play-off draw, from the widest round to the final.",
            "Pass rounds widest first. rounds[r].matches[k] is fed by matches 2k and 2k+1 of the round before.",
            "Give each side its team, or team: null with a label like \"QF 3 winner\" until it is known.",
            "Pass thirdPlace to draw a third-place play-off under the final.",
            "Pass onSelect, or a selectedId, to make each match a button you can choose, like for a match panel beside it.",
            "It shows three rounds from 808px wide, two from 528px, and one below that. Each round is 240 to 300px wide, and the bracket never draws narrower than 240px."
          ],
          "avoid": [
            "A league table or a round robin. Use a table, since nothing feeds anything.",
            "A single fixture in a feed. Use BracketMatch, which adds a peek, or MatchCard.",
            "The draw as a picture. Use KnockoutWheel for the circle."
          ]
        },
        "props": [
          {
            "name": "rounds",
            "type": "BracketRound[]",
            "description": "Widest round first. Each is { name, matches }, and each match is BracketMatchData."
          },
          {
            "name": "initialRound",
            "type": "number",
            "default": "0",
            "description": "The index of the leftmost round on mount. It pages on to the final, no further."
          },
          {
            "name": "thirdPlace",
            "type": "BracketMatchData",
            "description": "A third-place play-off, drawn under the final."
          },
          {
            "name": "thirdPlaceLabel",
            "type": "string",
            "default": "\"Third-place play-off\"",
            "description": "The title over the third-place match."
          },
          {
            "name": "selectedId",
            "type": "string",
            "description": "Controlled: the chosen match's id."
          },
          {
            "name": "defaultSelectedId",
            "type": "string",
            "description": "The match chosen first, when you don't control it. It also makes each match a button."
          },
          {
            "name": "onSelect",
            "type": "(match: BracketMatchData) => void",
            "description": "Makes each match a button, and hears the one chosen."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the root. Later classes win."
          }
        ],
        "a11y": [
          "The bracket is a group named \"Knockout bracket\". Pass aria-label to name it yourself.",
          "Each round is a list, named by its title. The third-place match comes after the final.",
          "Each match names both sides, the scores, and the winner in text. The winner's caret and weight only show it.",
          "Every round stays in the reading order, even when it is paged out of view. Focus that lands in a hidden round pages to it.",
          "The chevrons say \"Previous round\" and \"Next round\", and stay focusable at the ends. A page turn is said politely.",
          "A chosen match is a button with aria-pressed, and a 2px outline that shows in forced colors.",
          "The elbows are hidden from screen readers."
        ],
        "examples": [
          {
            "title": "Semi-finals into a final, with sides not known yet",
            "code": "// rounds come from your data. Each side is { team: { name, colors?, pattern? } | null, score, penalties?, label? }.\n<KnockoutBracket\n  rounds={[\n    {\n      name: \"Semi-finals\",\n      matches: [\n        { id: \"sf1\", status: \"finished\", home: { team: { name: \"Rovers\" }, score: 24 }, away: { team: { name: \"Lions\" }, score: 17 } },\n        { id: \"sf2\", date: \"Sat 17 Oct\", time: \"15:20\", home: { team: { name: \"Hawks\" }, score: null }, away: { team: { name: \"Wolves\" }, score: null } },\n      ],\n    },\n    {\n      name: \"Final\",\n      matches: [{ id: \"f\", date: \"Sat 24 Oct\", time: \"15:00\", home: { team: null, label: \"SF 1 winner\", score: null }, away: { team: null, label: \"SF 2 winner\", score: null } }],\n    },\n  ]}\n/>"
          },
          {
            "title": "Choose a match",
            "code": "const [selected, setSelected] = useState(\"sf1\");\n\n<KnockoutBracket rounds={rounds} selectedId={selected} onSelect={(match) => setSelected(match.id)} />"
          },
          {
            "title": "With a third-place play-off",
            "code": "<KnockoutBracket\n  rounds={rounds}\n  thirdPlace={{\n    id: \"tp\",\n    date: \"Sat 24 Oct\",\n    time: \"13:00\",\n    home: { team: null, label: \"SF 1 loser\", score: null },\n    away: { team: null, label: \"SF 2 loser\", score: null },\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "knockout-wheel",
      "type": "registry:ui",
      "title": "Knockout Wheel",
      "description": "The draw as a circle: the champion at the hub with a trophy and a warm glow, and each round a ring further out. Hover or focus a team to light its run.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/knockout-bracket",
        "@txbrendan/motion-timing",
        "@txbrendan/team-flag"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/knockout-wheel.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A season's draw at a glance, like on a cup's home page or once the final is played.",
            "Pass the same rounds a KnockoutBracket takes, widest first.",
            "Pass initialRound to start the outer ring at a later round, so a big draw stays readable.",
            "Give each team its kit colors. Each node is the team's TeamFlag, cut to a circle.",
            "Pair it with a KnockoutBracket or a list when people need the dates and scores at a glance. The wheel says them only to screen readers and on focus."
          ],
          "avoid": [
            "Reading the dates, times, or pitches. Use KnockoutBracket, which shows them on each match.",
            "Draws of more than 32 sides. The outer ring gets too tight to point at. Start at a later round with initialRound.",
            "A league. Nothing feeds anything, so there is no wheel to draw."
          ]
        },
        "props": [
          {
            "name": "rounds",
            "type": "BracketRound[]",
            "description": "Widest round first. rounds[r].matches[k] is fed by matches 2k and 2k+1 of the round before."
          },
          {
            "name": "initialRound",
            "type": "number",
            "default": "0",
            "description": "The index of the outermost round to draw."
          },
          {
            "name": "maxWidth",
            "type": "number",
            "default": "544",
            "description": "The widest it draws, in px. It fills a narrower container, down to 280px, or maxWidth if less. Under 280px the hub shows the trophy alone."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Knockout wheel\"",
            "description": "Names the wheel."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "The wheel is a group, with each round's ring a list named by the round, outermost first, then the champion.",
          "Each team is a button that names the team, the round, and the result, like \"Rovers, Semi-finals: beat Lions 24–17\". So each match names both teams and who won.",
          "One Tab stop. Up goes toward the champion, down to the round before, left and right around the ring, and Home and End to a ring's ends. The group's description says so.",
          "Enter, Space, or a tap keeps a team's run lit, and the button says it is pressed. Press it again, or Esc, to let go.",
          "The lit run is a thicker line and a ring around each of its nodes, so it shows in forced colors too, not only by its color.",
          "The rings and the lines are drawn in a hidden SVG. The hub's readout is hidden too, since each button says the same.",
          "Nodes spring in only from the motion tokens. Under reduced motion they fade, and with motion off they just show."
        ],
        "examples": [
          {
            "title": "A whole draw, played out",
            "code": "// rounds is the same BracketRound[] a KnockoutBracket takes.\n<KnockoutWheel rounds={rounds} />"
          },
          {
            "title": "From the quarter-finals, in a narrow column",
            "code": "<KnockoutWheel rounds={rounds} initialRound={1} maxWidth={320} aria-label=\"Cup draw from the quarter-finals\" />"
          }
        ]
      }
    },
    {
      "name": "label",
      "type": "registry:ui",
      "title": "Label",
      "description": "A visible name for a form control, linked to it by id.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/label.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Naming an input, textarea, checkbox, or switch with text people can see.",
            "Give the control an id and pass the same value to htmlFor. Clicking the label then focuses an input or toggles a checkbox.",
            "Checkbox and Switch can also take their text as children. That needs no Label at all.",
            "Set disabled on the label when its control is disabled, so both look the same.",
            "Put short extra words, like (optional), inside the label in a muted span."
          ],
          "avoid": [
            "Naming a group of controls, like a set of radio buttons. Use a fieldset with a legend.",
            "Help text or error messages. Put them in a separate element and link it with aria-describedby.",
            "Hiding the label to save space. If there is truly no room, give the control an aria-label instead."
          ]
        },
        "props": [
          {
            "name": "htmlFor",
            "type": "string",
            "description": "The id of the control it names. TypeScript requires it, so a label is never left unlinked."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Dims the label and shows a not-allowed cursor. It does not disable the control."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='text-base' replaces the text size."
          }
        ],
        "a11y": [
          "Renders a native <label>. Screen readers read its text as the control's name.",
          "Clicking the label focuses an input or textarea, and toggles a checkbox or switch.",
          "htmlFor is required at the type level, so an agent cannot ship a label that names nothing.",
          "Text uses --foreground, which the theme tests hold to 4.5:1 against the page.",
          "disabled only changes the look. Disable the control too, or keyboard users can still reach it."
        ],
        "examples": [
          {
            "title": "Above an input",
            "code": "<div className=\"grid gap-2\">\n  <Label htmlFor=\"project-name\">Project name</Label>\n  <input id=\"project-name\" className=\"h-10 rounded-md border border-input px-3\" />\n</div>"
          },
          {
            "title": "With a hint inside the label",
            "code": "<Label htmlFor=\"nickname\">\n  Nickname <span className=\"font-normal text-muted-foreground\">(optional)</span>\n</Label>"
          },
          {
            "title": "Next to a checkbox",
            "code": "<div className=\"flex items-center gap-2\">\n  <input type=\"checkbox\" id=\"newsletter\" />\n  <Label htmlFor=\"newsletter\">Send me the monthly newsletter</Label>\n</div>"
          },
          {
            "title": "For a disabled control",
            "code": "<div className=\"grid gap-2\">\n  <Label htmlFor=\"plan\" disabled>\n    Plan\n  </Label>\n  <input id=\"plan\" defaultValue=\"Team\" disabled className=\"h-10 rounded-md border border-input px-3\" />\n</div>"
          }
        ]
      }
    },
    {
      "name": "leaderboard",
      "type": "registry:ui",
      "title": "Leaderboard",
      "description": "Ranked people for one number, on DataTable: the place, the person, and the value. Ties share a place, and your row follows a Your place line.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/data-table",
        "@txbrendan/person"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/leaderboard.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "One number per person: tickets closed, sales, minutes, a test score. Put it in a Card with className=\"px-0\", and name it with the card's title through aria-labelledby.",
            "Equal values share a place, like \"=3\", and the next place skips them. People with no value are left out.",
            "you marks your row with a blue bar and its own wash. Outside the top limit, your row follows a Your place line.",
            "better=\"lower\" ranks times and the like. format draws the value, like seconds as 12:34."
          ],
          "avoid": [
            "Many numbers per person, or a sort the reader picks. Use DataTable.",
            "Teams in a league. Use Standings."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "{ id: string; person: string; photo?: string; meta?: string; value: number | null }[]",
            "description": "Required. The people, in any order. meta shows under the name, like a team or a position."
          },
          {
            "name": "unit",
            "type": "string",
            "default": "\"score\"",
            "description": "Names the value column, like \"tickets\". Its first letter is made a capital."
          },
          {
            "name": "better",
            "type": "\"higher\" | \"lower\"",
            "default": "\"higher\"",
            "description": "Which way ranks first."
          },
          {
            "name": "you",
            "type": "string",
            "description": "Your item's id."
          },
          {
            "name": "limit",
            "type": "number",
            "default": "5",
            "description": "How many rows show before your own place."
          },
          {
            "name": "format",
            "type": "(value: number) => string",
            "description": "Draws a value. Ties still compare the numbers."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Player\"",
            "description": "The people column's header."
          },
          {
            "name": "caption",
            "type": "ReactNode",
            "description": "Names the table, under it."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the table when it has no caption."
          },
          {
            "name": "aria-labelledby",
            "type": "string",
            "description": "Names the table by another element, like the Card's title."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Holds the space with placeholder rows."
          },
          {
            "name": "empty",
            "type": "ReactNode",
            "default": "\"No scores yet\"",
            "description": "What shows when no one has a value."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the wrapper. Later classes win."
          }
        ],
        "a11y": [
          "A real <table>. Each person's name is the row header, so the place and the value are read with it.",
          "Your row starts with \"You,\" for screen readers. Its bar is a border, so it stays in forced colors.",
          "The Your place line is a row of its own, with words, so it is read in order."
        ],
        "examples": [
          {
            "title": "Your row after the top 5, with a tie",
            "code": "<Leaderboard\n  aria-label=\"Tickets closed\"\n  unit=\"tickets\"\n  label=\"Name\"\n  you=\"p6\"\n  items={[\n    { id: \"p1\", person: \"Mia Okafor\", meta: \"Support\", value: 9 },\n    { id: \"p2\", person: \"Cal Nash\", meta: \"Sales\", value: 7 },\n    { id: \"p3\", person: \"Tom Hale\", meta: \"Support\", value: 7 },\n    { id: \"p4\", person: \"Ana Ruiz\", meta: \"Design\", value: 4 },\n    { id: \"p5\", person: \"Sam Carter\", meta: \"Engineering\", value: 3 },\n    { id: \"p6\", person: \"Josh Tait\", meta: \"Support\", value: 2 },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "league-scores",
      "type": "registry:ui",
      "title": "League Scores",
      "description": "Other games around the league, from a baseball board: each game's inning, both teams, their runs, and who is pitching.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/league-scores.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Scores from other parks on a baseball scoreboard, one table per league or group.",
            "Pass each game with away and home, each with a short name, runs, and pitcher's number.",
            "Pass inning as the inning being played, or a word like \"F\" once it is final.",
            "Pass note for a line under the games, like the next series at this park."
          ],
          "avoid": [
            "The game being played here. Use LineScore.",
            "Standings or a table of results. Use Standings."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "string",
            "description": "The league or group these games are in. It titles the table."
          },
          {
            "name": "games",
            "type": "{ away: LeagueScoresSide; home: LeagueScoresSide; inning?: number | string }[]",
            "description": "Each side is { name, runs?, pitcher? }. Visitors show on top, home below."
          },
          {
            "name": "note",
            "type": "ReactNode",
            "description": "A line under the games."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the table. Later classes win."
          }
        ],
        "a11y": [
          "It is a table captioned with title. Each team is a row header.",
          "Screen readers hear the column names in full: Inning, Team, Runs, and Pitcher.",
          "Each game is its own table body, and its inning spans both of its rows."
        ],
        "examples": [
          {
            "title": "Two games in one group",
            "code": "<LeagueScores\n  title=\"North\"\n  games={[\n    { inning: 9, away: { name: \"Bay\", runs: 5, pitcher: 10 }, home: { name: \"Pine\", runs: 6, pitcher: 10 } },\n    { inning: \"F\", away: { name: \"Oak\", runs: 2, pitcher: 4 }, home: { name: \"Lake\", runs: 0, pitcher: 10 } },\n  ]}\n  note=\"Bay here Mon, Tue & Wed\"\n/>"
          }
        ]
      }
    },
    {
      "name": "lightbox",
      "type": "registry:ui",
      "title": "Lightbox",
      "description": "Photos and videos one at a time, over everything, dark in both themes like a photo viewer, with your photo consent rule applied.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/action-keys",
        "@txbrendan/button",
        "@txbrendan/icon",
        "@txbrendan/kbd",
        "@txbrendan/media-image",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/lightbox.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "media",
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Viewing a set of photos or videos large, one at a time.",
            "Open it from a photo's own button, and pass open and onOpenChange. It has no trigger of its own.",
            "Name the set with label, like 'Photos from the team offsite'. Screen readers hear it as it opens.",
            "Pass manage only to people who manage your media. They see who has no consent on file, and why a photo is hidden.",
            "Use MediaGrid for a grid of tiles that opens it, with focus back on the photo you were looking at."
          ],
          "avoid": [
            "One photo that never needs to be seen larger. Use MediaImage.",
            "A gallery of documents or files. It is for photos and videos of people.",
            "Actions that change many photos. Its actions are for the photo shown."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "MediaItem[]",
            "description": "Required. The photos and videos, in order. See MediaImage for MediaItem."
          },
          {
            "name": "index",
            "type": "number",
            "default": "0",
            "description": "The photo it opens on. While it is open, it follows a new index."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Controlled: shown while true. Pair it with onOpenChange."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Opens on mount, like a link to one photo."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean, index: number) => void",
            "description": "It opened or closed, with the photo it was on. It closes on Esc, Close, and a press on the space around the photo."
          },
          {
            "name": "onClose",
            "type": "(index: number) => void",
            "description": "It closed by Esc or Close, with the photo it was on."
          },
          {
            "name": "onIndexChange",
            "type": "(index: number) => void",
            "description": "It moved to another photo."
          },
          {
            "name": "manage",
            "type": "boolean",
            "default": "false",
            "description": "For people who manage your media: names in consent notes, and why a photo is hidden."
          },
          {
            "name": "actions",
            "type": "(item: MediaItem) => { icon: ReactNode; label: string; onClick: () => void }[]",
            "description": "Icon buttons beside Close for the photo shown, like Download, Share, and Delete."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Photos\"",
            "description": "The set's name. It titles the viewer and is read as it opens."
          },
          {
            "name": "photoConsent",
            "type": "{ who?: \"everyone\" | \"juniors\" | \"nobody\"; missing?: \"blur\" | \"hide\" }",
            "default": "{ who: \"everyone\", missing: \"blur\" }",
            "description": "Your consent rule. See MediaImage."
          },
          {
            "name": "returnFocus",
            "type": "(index: number) => HTMLElement | null | undefined",
            "description": "Where focus goes as it closes, given the photo it was on. By default it goes back where it was."
          },
          {
            "name": "inline",
            "type": "boolean",
            "default": "false",
            "description": "Draws it in the page's flow, with no backdrop and no Close, like a picture of the viewer in a doc."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the viewer. Later classes win."
          }
        ],
        "a11y": [
          "A modal dialog named by label. The page behind is inert and does not scroll, and Tab stays inside, even when pressed fast.",
          "As it opens, focus moves onto the viewer itself, so its name is read. A page that does not have focus keeps it where it is.",
          "← and → move between photos without wrapping, and Home and End go to the ends. Right to left, the arrows swap. Each move is said in a polite live region, like '2 of 5. Launch'.",
          "Previous and Next stay focusable at the ends, so focus is never lost when one stops moving.",
          "Esc closes it, even from Close's tooltip, which teaches the key. Focus goes back where it was, or to returnFocus.",
          "The caption names only people with nothing held back. Consent notes are words, not only a blur."
        ],
        "examples": [
          {
            "title": "From a photo's button",
            "code": "const [open, setOpen] = useState(false);\n\n<button type=\"button\" onClick={() => setOpen(true)} aria-label=\"View photos from the launch\">\n  <MediaImage item={photos[0]} alt=\"\" className=\"size-32\" />\n</button>\n<Lightbox items={photos} open={open} onOpenChange={setOpen} label=\"Photos from the launch\" />"
          },
          {
            "title": "With actions, for a media manager",
            "code": "import { Download, Trash2 } from \"lucide-react\";\n\n<Lightbox\n  items={photos}\n  open={open}\n  onOpenChange={setOpen}\n  manage\n  actions={(photo) => [\n    { icon: <Download />, label: \"Download\", onClick: () => download(photo) },\n    { icon: <Trash2 />, label: \"Delete\", onClick: () => remove(photo) },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "line-chart",
      "type": "registry:ui",
      "title": "Line Chart",
      "description": "Lines that show how numbers change over time, with a themed tooltip, legend, and states.",
      "dependencies": [
        "recharts@^3.10.1",
        "react-is@19.3.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart",
        "@txbrendan/chart-key"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/line-chart.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing a trend over time or over ordered steps, like daily visits or weekly conversion.",
            "Comparing the trends of up to five series on one scale.",
            "Pass data, a config with one entry per line, and the categoryKey for the x axis.",
            "Pass valueFormat, like { style: \"percent\" }, to format the tooltip and the y axis.",
            "Pass legend=\"top\" for a key beside the title in place of the tooltip box. It shows the point under the pointer, and its numbers roll as you move. Press a series in the key to hide or show it."
          ],
          "avoid": [
            "Categories with no order, like regions. Use BarChart.",
            "Showing how parts add up to a total over time. Use AreaChart with stacked.",
            "A small trend next to a number or in a table cell. Use Sparkline.",
            "More than five lines. The theme has five chart colors."
          ]
        },
        "props": [
          {
            "name": "data",
            "type": "TData[]",
            "description": "One object per point on the x axis, like { day: \"Mon\", visits: 1250 }."
          },
          {
            "name": "config",
            "type": "{ [key in keyof TData]?: { label: string; color?: string } }",
            "description": "The lines to draw, in order. Colors default to --chart-1 through --chart-5."
          },
          {
            "name": "categoryKey",
            "type": "keyof TData",
            "description": "The field that names each point, shown on the x axis."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the chart when it has no visible title."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height: 5, 7, or 9 times --control-h-md, so density scales it."
          },
          {
            "name": "curve",
            "type": "\"linear\" | \"monotone\" | \"step\"",
            "default": "\"monotone\"",
            "description": "How lines join their points. Monotone is smooth and never overshoots a point."
          },
          {
            "name": "dots",
            "type": "boolean",
            "default": "false",
            "description": "Marks every point with a dot. A chart with one point always shows it."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "description": "Formats tooltip values, and y axis ticks in short form, like 12.5K."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for numbers. Fixed, so server and browser print the same text."
          },
          {
            "name": "legend",
            "type": "boolean | \"top\"",
            "description": "Shows the legend. By default it shows when there are two or more lines. \"top\" shows it as a key beside the title, with the point under the pointer in place of the tooltip box, and the last point at rest."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows a soft line shape at the same height. When data arrives, it fades out while the lines draw in."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Shows this message in place of the chart. true shows a default message."
          },
          {
            "name": "onRetry",
            "type": "() => void",
            "description": "Shows a retry button under the error."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data yet\"",
            "description": "Shown when data is empty."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='h-96' replaces the height."
          }
        ],
        "a11y": [
          "Renders a <figure> named by its title, or by aria-label. TypeScript requires one.",
          "Recharts' keyboard layer stays on: Tab focuses the chart, the arrow keys move between points, and the tooltip reads each one out. With the key on top, the key follows the arrow keys too, and the tooltip stays on the page for screen readers only, so each point is still read out.",
          "The key on top sits beside the title, not in it, so the chart's name never changes as the key moves. Each value holds the width of its longest one, so the key stays still as its numbers roll.",
          "Each series in the key is a toggle button. A hidden line fades out where it is, leaves the tooltip, and keeps the y axis still. The last one shown cannot hide. Tab reaches these buttons first, then the chart.",
          "Keeps one height while it loads, draws, fails, or has no data, so the page does not move. When the page prints, it asks the browser to keep a chart that fits on one page whole. In the light theme, it asks the browser to print legend dots and loading shapes even with backgrounds off.",
          "Loading, the error, and the empty text are read out through a status line that stays on the page.",
          "Line colors come from --chart-1 to --chart-5, which the theme tests hold to 3:1 against the page. The legend and tooltip name each line in text.",
          "Lines draw in over three times --motion-duration-base, once the chart is on screen. They finish at once when the page prints. At 0ms they draw at once, and Recharts skips the animation for reduced motion."
        ],
        "examples": [
          {
            "title": "Two lines",
            "code": "const data = [\n  { day: \"Mon\", desktop: 1250, mobile: 860 },\n  { day: \"Tue\", desktop: 1410, mobile: 920 },\n  { day: \"Wed\", desktop: 1180, mobile: 1040 },\n];\n\n<LineChart\n  title=\"Visits by device\"\n  data={data}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"day\"\n/>"
          },
          {
            "title": "Key beside the title in place of the tooltip",
            "code": "<LineChart\n  title=\"Visits by device\"\n  data={[\n    { day: \"Mon\", desktop: 1250, mobile: 860 },\n    { day: \"Tue\", desktop: 1410, mobile: 920 },\n    { day: \"Wed\", desktop: 1180, mobile: 1040 },\n  ]}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"day\"\n  legend=\"top\"\n/>"
          },
          {
            "title": "Percent, with a dot on every point",
            "code": "<LineChart\n  title=\"Conversion rate\"\n  data={[\n    { week: \"W1\", rate: 0.021 },\n    { week: \"W2\", rate: 0.026 },\n    { week: \"W3\", rate: 0.031 },\n  ]}\n  config={{ rate: { label: \"Conversion\" } }}\n  categoryKey=\"week\"\n  dots\n  valueFormat={{ style: \"percent\", minimumFractionDigits: 1 }}\n/>"
          },
          {
            "title": "While it loads, and when it fails",
            "code": "<LineChart\n  aria-label=\"Visits by device\"\n  data={data ?? []}\n  config={{ desktop: { label: \"Desktop\" }, mobile: { label: \"Mobile\" } }}\n  categoryKey=\"day\"\n  loading={isLoading}\n  error={error ? \"Visits could not load.\" : undefined}\n  onRetry={() => refetch()}\n/>"
          }
        ]
      }
    },
    {
      "name": "line-score",
      "type": "registry:ui",
      "title": "Line Score",
      "description": "A baseball line score: each team's runs by inning on number plates, then lit runs, hits, and errors, with the half-inning in play marked.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/rolling-number"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/line-score.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The top of a baseball scoreboard, a game page, or a game card that needs every inning.",
            "Pass each team's runs by inning. Leave an inning out until it starts, and its plate stays empty.",
            "Pass \"X\" for a half that was never played, like the home side's last when it already leads.",
            "Pass inning while the game is on, like { number: 5, half: \"top\" }. Its plate is outlined and the caption says \"Top of the 5th\".",
            "Leave inning out once the game is over, and pass status, like \"Final\".",
            "Runs total themselves. Hits and errors come from your data.",
            "On a surface other than a card, set --line-score-surface to its color, so the team names cover the innings as they scroll."
          ],
          "avoid": [
            "A game with no innings, like football or rugby. Use MatchCard.",
            "Only the final score. Print it, or use MatchCard.",
            "More than two teams. A line score is one game."
          ]
        },
        "props": [
          {
            "name": "away",
            "type": "LineScoreTeam",
            "description": "The visitors, who bat first: { name, short?, runs, hits?, errors? }. runs is (number | \"X\")[], by inning."
          },
          {
            "name": "home",
            "type": "LineScoreTeam",
            "description": "The home side, who bat in the bottom of each inning. The same shape as away."
          },
          {
            "name": "innings",
            "type": "number",
            "default": "9",
            "description": "How many innings the game is scheduled for. Extra innings add columns."
          },
          {
            "name": "inning",
            "type": "{ number: number; half: \"top\" | \"bottom\" }",
            "description": "The half-inning being played now. Leave it out before the game and once it is over."
          },
          {
            "name": "status",
            "type": "string",
            "description": "A word for the game's state when no inning is in play, like \"Final\" or \"Rain delay\"."
          },
          {
            "name": "caption",
            "type": "string",
            "default": "\"Line score\"",
            "description": "The table's name."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer div, which holds the container query. Later classes win."
          }
        ],
        "a11y": [
          "It is a table with a caption. Each team is a row header, and each inning, R, H, and E is a column header.",
          "Screen readers hear \"Inning 5\", not just \"5\", and Runs, Hits, and Errors in full.",
          "On a narrow board the short name shows, and screen readers still hear the full one.",
          "The half in play is outlined, and the caption says it in words, like \"Top of the 5th\". The outline still shows in forced colors.",
          "An inning that has not started reads \"no score\".",
          "One polite line says each scoring change once, like \"Rivers score 2 runs in the 4th. Harbor 1, Rivers 5.\" Never on load, and never for a pitch.",
          "The innings take a Tab stop only when they scroll sideways, so a keyboard can scroll them too. Then they are a region named by the caption.",
          "Runs, hits, and errors roll to each new value. Reduced motion and motion off jump straight there."
        ],
        "examples": [
          {
            "title": "A game in the bottom of the 4th",
            "code": "<LineScore\n  away={{ name: \"Harbor\", short: \"HAR\", runs: [0, 1, 0, 0], hits: 4, errors: 1 }}\n  home={{ name: \"Rivers\", short: \"RIV\", runs: [1, 0, 2], hits: 3 }}\n  inning={{ number: 4, half: \"bottom\" }}\n/>"
          },
          {
            "title": "A finished game where the home side did not bat in the 9th",
            "code": "<LineScore\n  away={{ name: \"Harbor\", runs: [0, 1, 0, 0, 2, 0, 0, 1, 0], hits: 9, errors: 1 }}\n  home={{ name: \"Rivers\", runs: [1, 0, 2, 1, 0, 0, 1, 0, \"X\"], hits: 8 }}\n  status=\"Final\"\n/>"
          },
          {
            "title": "On a dark board of your own",
            "code": "<div className=\"dark bg-success-soft p-4 text-foreground [--line-score-surface:var(--success-soft)]\">\n  <LineScore away={away} home={home} inning={inning} />\n</div>"
          }
        ]
      }
    },
    {
      "name": "list-row",
      "type": "registry:ui",
      "title": "List Row",
      "description": "A row inside a card: a date or a photo or icon, the title and its status, a meta line, and a trailing chip, with hover actions over its end.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/hover-card",
        "@txbrendan/hover-actions",
        "@txbrendan/peek-card",
        "@txbrendan/row-actions",
        "@txbrendan/status-text"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/list-row.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Lists inside a Card: events, people, reminders, files. Wrap the rows in one <div>, so the card's gap does not split them.",
            "Rows are siblings. Each draws an inset hairline under itself, and the last one in its parent draws none.",
            "status puts a StatusText right after the title, with its fix: { label: 'Declined', tone: 'danger', fix: { label: 'Try again', onClick } }.",
            "hoverActions spring in over the row's end while it is hovered, focused, or focused from outside. The trailing content fades under them, and their keys work only then.",
            "peek opens a peek on the title: PeekCard props for people behind a count, fixes for a problem, or facts. A node, like PeekSpread, gets 480px.",
            "aside keeps something in view beside the actions, like a Suggestion chip. The row keeps room for the actions, so nothing moves.",
            "onClick makes the whole row one control named by its title. Controls inside it keep their own presses.",
            "onSelect makes it selectable. selectOn photo toggles on the photo, and row toggles on the whole row, with Space, Enter, and X.",
            "Keep leadSize the same down a list, so every title starts at the same x."
          ],
          "avoid": [
            "A row inside a table. Use DataTable.",
            "A pencil or an edit button for the title. Make the value the target with InlineEdit.",
            "Hover actions as the only way to do something. Put the main action in the row's press or its page too.",
            "Rows in <li> wrappers. Each would be the last in its parent, so no hairlines would show."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "Required. Words wrap and never break mid-word. An element, like an email address, stays on one line and truncates."
          },
          {
            "name": "meta",
            "type": "ReactNode",
            "description": "A muted line under the title."
          },
          {
            "name": "date",
            "type": "string",
            "description": "A date column at the start, like \"Sat 3 Oct\". The first word sits small above the rest."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "An icon in the lead slot, in iconTone."
          },
          {
            "name": "iconTone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The icon's color role. neutral is muted."
          },
          {
            "name": "leading",
            "type": "ReactNode",
            "description": "A photo, like an Avatar, or anything else in the lead slot, in place of icon."
          },
          {
            "name": "leadSize",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "The square lead slot: 21, 28, or 36px, the Avatar sizes."
          },
          {
            "name": "trailing",
            "type": "ReactNode",
            "description": "A chip, count, or button at the end. It fades while the hover actions show."
          },
          {
            "name": "status",
            "type": "{ label: string; tone?; icon?: ReactNode; fix?: StatusFix; detail? }",
            "description": "The row's status, right after the title, drawn by StatusText."
          },
          {
            "name": "divider",
            "type": "boolean",
            "default": "true",
            "description": "The inset hairline under the row. The last row in its parent never draws one."
          },
          {
            "name": "onClick",
            "type": "() => void",
            "description": "Makes the whole row one control, named by its title."
          },
          {
            "name": "hoverActions",
            "type": "HoverAction[]",
            "description": "Quick actions over the row's end while it is lit. Each action's shortcut works then too."
          },
          {
            "name": "peek",
            "type": "PeekCardProps | ReactNode",
            "description": "The title's peek, on hover, focus, or tap."
          },
          {
            "name": "peekWidth",
            "type": "number",
            "description": "The peek's width in pixels. Default 280, 380 with people, 320 with fixes, and 480 for a node."
          },
          {
            "name": "index",
            "type": "number",
            "description": "The row's place in its list. It fades up in turn as it first renders."
          },
          {
            "name": "onSelect",
            "type": "() => void",
            "description": "Makes the row selectable."
          },
          {
            "name": "selected",
            "type": "boolean",
            "default": "false",
            "description": "A square check on the photo's corner, and a 2px ring around it. It never hides the face."
          },
          {
            "name": "selectOn",
            "type": "\"photo\" | \"row\"",
            "default": "\"photo\"",
            "description": "photo: the photo toggles, and the rest of the row runs onClick. row: the whole row toggles, and onClick is not used."
          },
          {
            "name": "selecting",
            "type": "boolean",
            "default": "false",
            "description": "Anything in the list is selected: checks stay in view, a row press toggles, and the hover actions step aside for a BulkBar."
          },
          {
            "name": "focused",
            "type": "boolean",
            "default": "false",
            "description": "Lit from outside, as a list that moves its own highlight does. Shows the hover actions and turns their keys on."
          },
          {
            "name": "aside",
            "type": "ReactNode",
            "description": "Stays in view beside the hover actions, like a Suggestion chip."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the row. Later classes win."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "SelectBadge only, the check for custom rows: sm sits on a 21px photo, md on 28px, lg on 36px."
          }
        ],
        "a11y": [
          "With onClick, the row's press is one real button stretched over the row, named by the title and described by the meta line. The focus ring goes around the whole row.",
          "Controls inside the row, like the status fix, the peek, the trailing button, and the hover actions, are their own Tab stops and keep their own presses. No control sits inside another.",
          "selectOn photo makes the photo a checkbox named \"Select\" and the title. selectOn row makes the row's press a checkbox, and X toggles it too.",
          "The selected ring is an outline, and the check has a border, so both stay in forced colors.",
          "Hover actions show on hover and on focus inside the row. Their single-key shortcuts work only while the row is lit.",
          "The row carries data-peek-row, so a peek opens beside it, and ↑↓ step between the peeks of a list."
        ],
        "examples": [
          {
            "title": "Dated rows with an icon",
            "code": "import { CalendarDays } from \"lucide-react\";\n\n<div className=\"rounded-xl border bg-card py-3\">\n  <ListRow date=\"Sat 3 Oct\" icon={<CalendarDays />} iconTone=\"brand\" title=\"Design review\" meta=\"Room 1 · 15:00\" />\n  <ListRow date=\"Sat 10 Oct\" icon={<CalendarDays />} iconTone=\"brand\" title=\"Demo day\" meta=\"Online · 14:30\" />\n</div>"
          },
          {
            "title": "A person with a status and quick actions",
            "code": "import { MessageSquare, UserRound } from \"lucide-react\";\n\n<ListRow\n  icon={<UserRound />}\n  title=\"Josh Tait\"\n  meta=\"Engineer\"\n  status={{ label: \"No reply\", tone: \"warning\", fix: { label: \"Remind Josh\", onClick: () => {} } }}\n  hoverActions={[{ icon: <MessageSquare />, label: \"Message\", shortcut: \"M\" }]}\n/>"
          },
          {
            "title": "Selectable",
            "code": "import { UserRound } from \"lucide-react\";\n\n<ListRow icon={<UserRound />} title=\"Tom Hale\" selectOn=\"row\" selected onSelect={() => {}} />"
          }
        ]
      }
    },
    {
      "name": "match-card",
      "type": "registry:ui",
      "title": "Match Card",
      "description": "A knockout match tile with a flat shape: the time and pitch, an FT or Live badge, and two team rows with kit, score, and the winner's caret.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/knockout-bracket",
        "@txbrendan/team-flag"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/match-card.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A knockout match from data shaped as two teams, each with its own score, like a match day list.",
            "Pass when as the time and pitch, like \"15:20 · Pitch 2\".",
            "Pass status \"FT\" once it's finished, \"Live 9'\" while it's on, or leave it out before kick-off.",
            "At FT the side with the higher score gets the caret. On a level score, pass each side's penalties, and the one with more goes through.",
            "Pass onClick and selected to make it a match you choose."
          ],
          "avoid": [
            "A draw of many rounds. Use KnockoutBracket.",
            "A match that needs a peek with the round's name. Use BracketMatch.",
            "A league match that can end level. A level FT with no kicks shows no winner, which reads oddly in a cup but is right in a league, so say the result elsewhere."
          ]
        },
        "props": [
          {
            "name": "when",
            "type": "string",
            "description": "The time and pitch, like \"15:20 · Pitch 2\"."
          },
          {
            "name": "status",
            "type": "string",
            "description": "\"FT\", \"Live 9'\", or another word like \"AET\". Left out before kick-off. Any word but Live means it's over."
          },
          {
            "name": "home",
            "type": "MatchTeam",
            "description": "{ name, score?, penalties?, colors?, pattern? }. colors are the kit's two colors, as KitColors."
          },
          {
            "name": "away",
            "type": "MatchTeam",
            "description": "The same shape as home."
          },
          {
            "name": "selected",
            "type": "boolean",
            "default": "false",
            "description": "Draws it as the chosen match. Only a card with onClick can be chosen."
          },
          {
            "name": "onClick",
            "type": "() => void",
            "description": "Makes the card a button that says whether it is pressed."
          },
          {
            "name": "width",
            "type": "number",
            "description": "A fixed width in px, never under 240. By default it fills its column."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "The card names both teams and their scores, and says who won in text. The caret and the weight only show it.",
          "After kicks, it reads FT (P), shows each side's kicks in brackets, and says \"won 4–3 on kicks\".",
          "With onClick, it is a button with aria-pressed. A chosen card has a 2px outline that shows in forced colors.",
          "A Live badge says Live in words. Its red is not the only sign."
        ],
        "examples": [
          {
            "title": "Before kick-off",
            "code": "<MatchCard when=\"15:20 · Pitch 2\" home={{ name: \"QF 3 winner\" }} away={{ name: \"QF 4 winner\" }} />"
          },
          {
            "title": "Finished, after kicks",
            "code": "<MatchCard\n  when=\"16:00 · Pitch 1\"\n  status=\"FT\"\n  home={{ name: \"Hawks\", score: 14, penalties: 3 }}\n  away={{ name: \"Wolves\", score: 14, penalties: 4 }}\n/>"
          },
          {
            "title": "Live, and chosen",
            "code": "<MatchCard\n  when=\"15:00 · Pitch 1\"\n  status=\"Live 9'\"\n  home={{ name: \"Rovers\", score: 7, colors: kit, pattern: \"hoops\" }}\n  away={{ name: \"Lions\", score: 0 }}\n  selected={selected === \"final\"}\n  onClick={() => setSelected(\"final\")}\n/>"
          }
        ]
      }
    },
    {
      "name": "match-clock",
      "type": "registry:ui",
      "title": "Match Clock",
      "description": "The match clock on playing time, across halves, half-time, extra time, and full time: one line for a scoreboard, or the timekeeper's console with its buttons.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/match-clock.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Scoreboards, match cards, and headers: the inline layout reads \"Second half 9:05\", with Time off or Time up in amber.",
            "The timekeeper's screen: the console layout shows the period, big digits, the match's periods as a track, what happens next, and two buttons.",
            "A card's bottom row when the digits already show elsewhere: the controls layout shows only the next line and the buttons.",
            "Keep the clock's value in your app and sync it between devices. It holds no running timer, so every device works out the same time from the same value.",
            "Pass onChange to run the clock. It hands back the next value and the action, so you can log it, sync it, and offer Undo.",
            "Set the digits to the referee's watch: press them in the console, type the time, and press Enter. Sin bins on SuspensionRow move with it.",
            "Pick a preset format, like \"15s\", \"sevens\", or \"u16\", or pass your competition's own MatchFormat.",
            "Record matchPlayed(clock) when a card is shown, and pass it as SuspensionRow's at. matchClockDisplay(clock, format) gives the time for the match log."
          ],
          "avoid": [
            "Stopping the clock at time up. Play goes on until the ball is next dead, and that time still counts for the sin bin.",
            "Counting the time in your own interval. The clock ticks by itself, so nothing else re-renders each second.",
            "extraTime without extra in the format. Sevens, sevens finals, and 10s have it by law. Add your competition's for 15s and the age grades.",
            "A countdown for training drills. This is a match clock on playing time."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "MatchClockValue | null",
            "description": "{ phase, period, played, periodStart, running, since }. null before the match is set up, and it reads \"Not started\". initialMatchClock() gives a clock before kick-off."
          },
          {
            "name": "format",
            "type": "MatchFormatId | MatchFormat",
            "default": "\"15s\"",
            "description": "A preset id from matchFormats, or { label, players, half, bin, halfTime?, extra? } in minutes."
          },
          {
            "name": "layout",
            "type": "\"inline\" | \"console\" | \"controls\"",
            "default": "\"inline\"",
            "description": "inline is one line. console is the timekeeper's view. controls is the next line and the buttons only."
          },
          {
            "name": "onChange",
            "type": "(next: MatchClockValue, action: MatchClockAction) => void",
            "description": "Makes it the timekeeper's clock. The console and controls show the buttons, and the console's digits can be set."
          },
          {
            "name": "extraTime",
            "type": "boolean",
            "default": "false",
            "description": "A drawn knockout. Ending the second half leads to a break, then extra time. The format needs extra."
          },
          {
            "name": "kickOff",
            "type": "string",
            "description": "Scheduled kick-off, shown before the match, like \"14:20\"."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "The digits are role=\"timer\", which screen readers do not read out each second.",
          "With onChange, a polite live region says what each button did, like \"Clock stopped at 12:04\", and says \"Time up\" once as the period runs out.",
          "The buttons are native buttons with words. The main one is solid, so it is not told apart by color alone.",
          "The settable digits are a button named with the time and what a press does. The field it opens takes Esc to cancel, before any sheet or dialog around it closes.",
          "Time off and Time up are words, in the warning tone's soft text, which the theme holds to 4.5:1.",
          "The period track is hidden from screen readers, since the period's name says the same. Each segment keeps a border in forced-colors mode."
        ],
        "examples": [
          {
            "title": "On a scoreboard",
            "code": "// clock comes from your app's synced state.\n<MatchClock value={clock} format=\"sevens\" kickOff=\"14:20\" />"
          },
          {
            "title": "The timekeeper's console",
            "code": "\"use client\";\n\nimport { useState } from \"react\";\nimport { initialMatchClock, MatchClock } from \"@/components/ui/match-clock\";\n\nexport function Timekeeper() {\n  const [clock, setClock] = useState(() => initialMatchClock());\n  return <MatchClock layout=\"console\" format=\"15s\" value={clock} onChange={(next) => setClock(next)} kickOff=\"14:20\" />;\n}"
          },
          {
            "title": "With extra time after a level second half",
            "code": "<MatchClock layout=\"console\" format=\"10s\" extraTime value={clock} onChange={setClock} />"
          },
          {
            "title": "Driving it without its buttons",
            "code": "import { matchClockDisplay, stepMatchClock } from \"@/components/ui/match-clock\";\n\nconst stopped = stepMatchClock(clock, \"15s\", \"stop\");\nconst reads = matchClockDisplay(stopped, \"15s\"); // \"52:14\""
          }
        ]
      }
    },
    {
      "name": "media-grid",
      "type": "registry:ui",
      "title": "Media Grid",
      "description": "Photos and videos as a grid of square tiles that open the Lightbox, with your photo consent rule applied.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/icon",
        "@txbrendan/lightbox",
        "@txbrendan/media-image"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/media-grid.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "media",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A set of photos and videos, like an event's gallery or a team's album.",
            "Name the set with label. It names the list and the lightbox.",
            "Pass manage only to people who manage your media. They see hidden photos as tiles that say why.",
            "Set minSize for smaller or larger tiles. Columns fill the width.",
            "Return false from onOpen to open a photo yourself, like on its own page."
          ],
          "avoid": [
            "One photo. Use MediaImage.",
            "Files, documents, or a list of attachments.",
            "Photos that people pick, as in a form. Its tiles open the viewer, they do not select."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "MediaItem[]",
            "description": "Required. The photos and videos, in order. See MediaImage for MediaItem."
          },
          {
            "name": "manage",
            "type": "boolean",
            "default": "false",
            "description": "For people who manage your media: hidden photos show as tiles that say why, and the lightbox names people."
          },
          {
            "name": "minSize",
            "type": "number",
            "default": "150",
            "description": "The smallest a tile gets, in pixels. Tiles sit 6px apart."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Photos\"",
            "description": "The set's name, for the list and the lightbox."
          },
          {
            "name": "actions",
            "type": "(item: MediaItem) => { icon: ReactNode; label: string; onClick: () => void }[]",
            "description": "Icon buttons in the lightbox for the photo shown."
          },
          {
            "name": "onOpen",
            "type": "(index: number, item: MediaItem) => void | false",
            "description": "A tile was opened, with its place in items. Return false to open it yourself instead of in the lightbox."
          },
          {
            "name": "photoConsent",
            "type": "{ who?: \"everyone\" | \"juniors\" | \"nobody\"; missing?: \"blur\" | \"hide\" }",
            "default": "{ who: \"everyone\", missing: \"blur\" }",
            "description": "Your consent rule. See MediaImage."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the list. Later classes win."
          }
        ],
        "a11y": [
          "A list named by label. Each tile is a button named by its photo, plus video and its length, a blur, or why it is hidden.",
          "One Tab stop. ← and → move along a row, ↑ and ↓ move between rows by the grid's own columns, and Home and End go to the ends. Right to left, ← and → swap.",
          "Enter or Space opens the photo in the Lightbox. When it closes, focus is on the tile of the photo you were looking at.",
          "Members never meet a hidden photo, not even in the Tab order."
        ],
        "examples": [
          {
            "title": "A gallery as members see it",
            "code": "<MediaGrid items={photos} label=\"Photos from the team offsite\" />"
          },
          {
            "title": "For a media manager, with actions",
            "code": "import { Download, Trash2 } from \"lucide-react\";\n\n<MediaGrid\n  items={photos}\n  label=\"Photos from the launch\"\n  manage\n  minSize={120}\n  actions={(photo) => [\n    { icon: <Download />, label: \"Download\", onClick: () => download(photo) },\n    { icon: <Trash2 />, label: \"Delete\", onClick: () => remove(photo) },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "media-image",
      "type": "registry:ui",
      "title": "Media Image",
      "description": "A photo, or a video's still, with your photo consent rule applied: faces blurred, or the photo hidden, until consent is on file.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/media-image.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "media",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Any photo of people, so a face with no consent on file never shows.",
            "Tag each person with their consent and, where you know it, their face. A tagged face is blurred alone. With no face, the whole photo is.",
            "Size its box. It fills it, and fit says whether the photo covers the box or fits inside it.",
            "Pass blurredSrc, a copy the server blurred, wherever you can. The blur here is only a look, and the original still reaches the browser.",
            "Pass your app's rule as photoConsent: who needs consent, and whether a photo without it is blurred or hidden.",
            "Use MediaGrid for a set of photos and Lightbox to view one large."
          ],
          "avoid": [
            "A person's profile photo. Use Avatar, which they chose to share.",
            "Hiding a face from a screen reader only. The rule also hides it from sight, so pass every tagged person.",
            "An image with no people in it, like a crest or a map. Use a plain img."
          ]
        },
        "props": [
          {
            "name": "item",
            "type": "MediaItem",
            "description": "Required. The photo or video, with everyone tagged in it."
          },
          {
            "name": "MediaItem",
            "type": "{ src?: string; picture?: ReactNode; aspect?: number; blurredSrc?: string; alt: string; caption?: string; media?: \"photo\" | \"video\"; video?: string; poster?: string; duration?: string; people?: MediaPerson[]; id?: string | number }",
            "description": "Type. src is the photo or the video's still. picture draws your own element in its place, like a next/image, with the same blur and hiding. aspect is width over height, so face blurs land before the photo loads. media defaults to video when video is set."
          },
          {
            "name": "MediaPerson",
            "type": "{ person: string; photo?: string; consent?: \"allowed\" | \"missing\" | \"withdrawn\"; junior?: boolean; face?: { x: number; y: number; w: number; h: number } }",
            "description": "Type. Someone tagged in it. consent left out means missing. face is where their face is, as fractions of the photo."
          },
          {
            "name": "fit",
            "type": "\"cover\" | \"contain\"",
            "default": "\"cover\"",
            "description": "cover fills the box, cropped from the middle. contain fits the whole photo inside it."
          },
          {
            "name": "alt",
            "type": "string",
            "description": "Replaces item.alt. Pass \"\" when something around it already names the photo."
          },
          {
            "name": "lazy",
            "type": "boolean",
            "default": "true",
            "description": "Loads the photo as it nears the screen. Turn it off for the one photo a viewer shows."
          },
          {
            "name": "photoConsent",
            "type": "{ who?: \"everyone\" | \"juniors\" | \"nobody\"; missing?: \"blur\" | \"hide\" }",
            "default": "{ who: \"everyone\", missing: \"blur\" }",
            "description": "Your consent rule. who says whose face needs consent on file. missing says whether such a face is blurred or the photo hidden. Withdrawn consent always hides the photo."
          },
          {
            "name": "badge",
            "type": "boolean",
            "default": "true",
            "description": "A blurred photo says so in a small badge: Face blurred, Faces blurred, or Blurred. Turn it off when the words around it say so."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the box. Later classes win."
          },
          {
            "name": "consentOf",
            "type": "(item: MediaItem, rule?: PhotoConsentRule) => MediaConsent",
            "description": "Function. What a photo may show: state clear, blurred, or hidden, who is missing, who withdrew, and whether the whole photo blurs."
          }
        ],
        "a11y": [
          "The photo is named by item.alt, or by alt. Pass alt=\"\" when a caption beside it already names it.",
          "A blurred photo's badge is text, so screen readers hear it after the name.",
          "A hidden photo loads nothing. Its box says Consent withdrawn, or Waiting for consent, in words.",
          "The badge and each face's blur keep a border in forced-colors mode."
        ],
        "examples": [
          {
            "title": "A photo with a junior who has no consent on file",
            "code": "<div className=\"size-48 overflow-hidden rounded-lg\">\n  <MediaImage\n    item={{\n      src: \"/photos/team-offsite.jpg\",\n      alt: \"Team photo at the offsite\",\n      people: [\n        { person: \"Josh Tait\", consent: \"allowed\" },\n        { person: \"Ella Clarke\", consent: \"missing\", junior: true, face: { x: 0.52, y: 0.2, w: 0.1, h: 0.14 } },\n      ],\n    }}\n  />\n</div>"
          },
          {
            "title": "Hide instead of blur, for juniors only",
            "code": "<div className=\"aspect-video w-full overflow-hidden rounded-lg\">\n  <MediaImage\n    item={{ src: \"/photos/summer-picnic.jpg\", alt: \"Summer picnic\", people: [{ person: \"Ella Clarke\", junior: true }] }}\n    fit=\"contain\"\n    photoConsent={{ who: \"juniors\", missing: \"hide\" }}\n  />\n</div>"
          }
        ]
      }
    },
    {
      "name": "menu",
      "type": "registry:ui",
      "title": "Menu",
      "description": "A list of actions or settings that opens from a button, with groups, check and radio items, and submenus.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/glide-highlight"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/menu.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A set of actions for one thing, like Rename, Duplicate, and Delete for a file.",
            "One highlight glides from row to row as the pointer moves, and jumps when the arrow keys move it. Reduced motion makes it jump.",
            "A row of extra actions behind an icon button, when the page has no room for them.",
            "View settings that change the page at once: MenuCheckboxItem for on and off, MenuRadioGroup for one of a few choices.",
            "Use tone='danger' on an item that deletes something or cannot be undone.",
            "Put related items in a MenuGroup with a MenuGroupLabel, and split groups with MenuSeparator.",
            "A few links that go with the actions, like Settings or Billing. Use MenuLinkItem.",
            "The list opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on the top MenuPopup so the list keeps its colors. A container that clips its overflow can clip the list."
          ],
          "avoid": [
            "A site's main navigation. Use links in a nav.",
            "Picking a value for a form field. Use a select.",
            "Fields or long text inside it. Use a popover.",
            "The only way to reach an action people use all the time. Show it as a button.",
            "Submenus more than one level deep. They are hard to use with a mouse and on phones."
          ]
        },
        "props": [
          {
            "name": "Menu",
            "type": "{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void; modal?: boolean }",
            "description": "Root. Holds the open state and renders no element. Takes open, defaultOpen, onOpenChange, and modal. modal is false by default, so a press outside closes the menu and still reaches the page, and one press moves from one open menu to the next. Set modal to block the page and its scroll while it is open."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Menu. Whether it is open, controlled. Use it with onOpenChange."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Menu. Whether it is open at the start, when you do not control it."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean, details) => void",
            "description": "Menu. Called with the new open state when it opens or closes."
          },
          {
            "name": "modal",
            "type": "boolean",
            "default": "false",
            "description": "Menu. Blocks the page and its scroll while it is open."
          },
          {
            "name": "MenuTrigger",
            "type": "ComponentProps<\"button\"> & { render?: ReactElement }",
            "description": "Part. The button that opens it. Pass render={<Button />} to use your own button."
          },
          {
            "name": "MenuPopup",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The list. It renders in a portal while open. Use it inside MenuSubmenu too."
          },
          {
            "name": "MenuItem",
            "type": "ComponentProps<\"div\"> & { tone?: \"neutral\" | \"danger\"; disabled?: boolean; onClick?: () => void }",
            "description": "Part. One action. Clicking it, or Enter or Space, runs onClick and closes the menu."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"danger\"",
            "default": "\"neutral\"",
            "description": "MenuItem. danger colors the item for an action that deletes or cannot be undone."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "MenuItem, MenuCheckboxItem, MenuRadioItem, or MenuSubmenuTrigger. Dims the item, and it cannot be chosen."
          },
          {
            "name": "MenuLinkItem",
            "type": "ComponentProps<\"a\"> & { closeOnClick?: boolean; label?: string }",
            "description": "Part. A link, as an <a>. Pass href, or render={<Link href=\"/settings\" />} for your router's link. It has no tone and no disabled state."
          },
          {
            "name": "closeOnClick",
            "type": "boolean",
            "default": "true",
            "description": "MenuLinkItem. Closes the menu when the link is clicked, so it does not stay open after a router changes the page."
          },
          {
            "name": "MenuCheckboxItem",
            "type": "ComponentProps<\"div\"> & { checked?: boolean; defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void }",
            "description": "Part. Turns a setting on and off, with a check mark. The menu stays open."
          },
          {
            "name": "MenuRadioGroup",
            "type": "ComponentProps<\"div\"> & { value?: string; defaultValue?: string; onValueChange?: (value: string) => void }",
            "description": "Part. Holds MenuRadioItems. Only one can be chosen."
          },
          {
            "name": "MenuRadioItem",
            "type": "ComponentProps<\"div\"> & { value: string }",
            "description": "Part. One choice in a MenuRadioGroup, with a dot when chosen. The menu stays open."
          },
          {
            "name": "MenuGroup",
            "type": "ComponentProps<\"div\">",
            "description": "Part. Groups related items."
          },
          {
            "name": "MenuGroupLabel",
            "type": "ComponentProps<\"div\">",
            "description": "Part. A small muted heading. Screen readers read it as the group's name."
          },
          {
            "name": "MenuSeparator",
            "type": "ComponentProps<\"div\">",
            "description": "Part. A line between groups. It is a top border, so a border color class recolors it."
          },
          {
            "name": "MenuSubmenu",
            "type": "{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void }",
            "description": "Root of a submenu, inside a MenuPopup. Holds a MenuSubmenuTrigger and a MenuPopup."
          },
          {
            "name": "MenuSubmenuTrigger",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The item that opens a submenu, with an arrow at the end."
          },
          {
            "name": "side",
            "type": "\"top\" | \"right\" | \"bottom\" | \"left\" | \"inline-start\" | \"inline-end\"",
            "default": "\"bottom\", or \"inline-end\" in a submenu",
            "description": "MenuPopup. Which side of the trigger it opens on. It flips when there is no room."
          },
          {
            "name": "align",
            "type": "\"start\" | \"center\" | \"end\"",
            "default": "\"start\"",
            "description": "MenuPopup. How it lines up with the trigger. Use end for a trigger at the end of a row."
          },
          {
            "name": "collisionPadding",
            "type": "number | { top?: number; right?: number; bottom?: number; left?: number }",
            "default": "8",
            "description": "MenuPopup. Least gap between the list and the screen edge, in pixels. It keeps the list off the edge on phones."
          },
          {
            "name": "container",
            "type": "HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null",
            "description": "MenuPopup. The element the list opens in. By default, the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no list. A submenu follows its parent menu there, so it needs no container of its own."
          },
          {
            "name": "effect",
            "type": "\"rise\" | \"grow\"",
            "default": "\"rise\"",
            "description": "MenuPopup. How the list opens and closes. rise fades in and moves out from the trigger. grow fades in and grows from the trigger, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none. A submenu opens beside its menu, so give its MenuPopup the same effect."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Any part. Extra classes. Later classes win, so className='min-w-56' on MenuPopup sets its width."
          }
        ],
        "a11y": [
          "The trigger has aria-haspopup and aria-expanded. The list has role=\"menu\", and items are menuitem, menuitemcheckbox, or menuitemradio.",
          "Enter, Space, or the down arrow on the trigger opens the menu and moves focus to the first item.",
          "Up and down arrows move between items, Home and End jump to the ends, and typing a letter jumps to the next item that starts with it.",
          "The right arrow opens a submenu, and the left arrow or Escape closes it. Escape closes the menu and puts focus back on the trigger.",
          "A disabled item is dimmed and has aria-disabled. Arrow keys still stop on it, so screen reader users know it is there, but it cannot be chosen.",
          "Check and radio items set aria-checked. The check mark and the dot are hidden from screen readers.",
          "A MenuLinkItem is an <a> with role=\"menuitem\". Arrow keys reach it like any item, and Enter follows the link.",
          "The item under keyboard focus gets the highlight and a focus ring.",
          "Danger items say what they do in words, like Delete, so the color is never the only sign.",
          "The theme tests hold item text and group labels to 4.5:1 on the popover color, and a highlighted item to 4.5:1 on its fill, in light and dark.",
          "The theme tests hold a danger item's text to 4.5:1 on the popover color and on its fill, and the focus ring to 3:1 on the popover color, in light and dark."
        ],
        "examples": [
          {
            "title": "Actions for one file (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Menu>\n  <MenuTrigger render={<Button emphasis=\"outline\" />}>File</MenuTrigger>\n  <MenuPopup>\n    <MenuItem>Rename</MenuItem>\n    <MenuItem>Duplicate</MenuItem>\n    <MenuSeparator />\n    <MenuItem tone=\"danger\">Delete</MenuItem>\n  </MenuPopup>\n</Menu>"
          },
          {
            "title": "View settings with check and radio items (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Menu>\n  <MenuTrigger render={<Button emphasis=\"outline\" />}>View</MenuTrigger>\n  <MenuPopup>\n    <MenuGroup>\n      <MenuGroupLabel>Show</MenuGroupLabel>\n      <MenuCheckboxItem defaultChecked>Toolbar</MenuCheckboxItem>\n      <MenuCheckboxItem>Status bar</MenuCheckboxItem>\n    </MenuGroup>\n    <MenuSeparator />\n    <MenuGroup>\n      <MenuGroupLabel>Sort by</MenuGroupLabel>\n      <MenuRadioGroup defaultValue=\"name\">\n        <MenuRadioItem value=\"name\">Name</MenuRadioItem>\n        <MenuRadioItem value=\"date\">Date edited</MenuRadioItem>\n      </MenuRadioGroup>\n    </MenuGroup>\n  </MenuPopup>\n</Menu>"
          },
          {
            "title": "A submenu (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Menu>\n  <MenuTrigger render={<Button emphasis=\"outline\" />}>Share</MenuTrigger>\n  <MenuPopup>\n    <MenuItem>Copy link</MenuItem>\n    <MenuSubmenu>\n      <MenuSubmenuTrigger>Send to</MenuSubmenuTrigger>\n      <MenuPopup>\n        <MenuItem>Email</MenuItem>\n        <MenuItem>Text message</MenuItem>\n      </MenuPopup>\n    </MenuSubmenu>\n  </MenuPopup>\n</Menu>"
          },
          {
            "title": "Links with a router (needs @txbrendan/button)",
            "code": "import Link from \"next/link\";\n\nimport { Button } from \"@/components/ui/button\";\n\n<Menu>\n  <MenuTrigger render={<Button emphasis=\"outline\" />}>Account</MenuTrigger>\n  <MenuPopup>\n    <MenuLinkItem render={<Link href=\"/settings\" />}>Settings</MenuLinkItem>\n    <MenuLinkItem render={<Link href=\"/billing\" />}>Billing</MenuLinkItem>\n    <MenuSeparator />\n    <MenuItem>Sign out</MenuItem>\n  </MenuPopup>\n</Menu>"
          },
          {
            "title": "Run code when an item is chosen",
            "code": "\"use client\";\n\nfunction FileMenu({ onDelete }: { onDelete: () => void }) {\n  return (\n    <Menu>\n      <MenuTrigger>File</MenuTrigger>\n      <MenuPopup>\n        <MenuItem tone=\"danger\" onClick={onDelete}>\n          Delete\n        </MenuItem>\n      </MenuPopup>\n    </Menu>\n  );\n}"
          },
          {
            "title": "Grows from its trigger (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Menu>\n  <MenuTrigger render={<Button emphasis=\"outline\" />}>Sort</MenuTrigger>\n  <MenuPopup effect=\"grow\">\n    <MenuItem>Newest first</MenuItem>\n    <MenuItem>Oldest first</MenuItem>\n  </MenuPopup>\n</Menu>"
          }
        ]
      }
    },
    {
      "name": "message-row",
      "type": "registry:ui",
      "title": "Message Row",
      "description": "A message with quick actions. Pointing at it or moving focus into it springs in Reply, and Edit on your own, with R and E keys. A reply or an edit opens in place.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/hover-actions",
        "@txbrendan/inline-reply",
        "@txbrendan/peek-card",
        "@txbrendan/peek-person",
        "@txbrendan/person",
        "@txbrendan/row-actions"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/message-row.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "ai",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Messages in a chat, a team feed, or an activity list, stacked in a card like list rows.",
            "Set own on the viewer's messages, so they get Edit. Pass me, so a new reply shows their name and photo.",
            "Save what onAction hands you. The reply or edit already shows in place, and new props with it replace the local copy.",
            "Give peek the author's role, team, and next event, so their name opens a card."
          ],
          "avoid": [
            "A new message to a group. Use Composer, which says who it reaches.",
            "Rows that are not messages, like orders or files. Use a list row.",
            "Extra actions without a one-key shortcut. Each row's keys are how the keyboard reaches them fast."
          ]
        },
        "props": [
          {
            "name": "author",
            "type": "{ person: string; photo?: string } | string",
            "description": "Required. Who posted it. Initials show until the photo loads."
          },
          {
            "name": "time",
            "type": "string",
            "description": "Required. When, like \"18:02\"."
          },
          {
            "name": "text",
            "type": "string",
            "description": "Required. The message. Line breaks show."
          },
          {
            "name": "own",
            "type": "boolean",
            "default": "false",
            "description": "The viewer's own message: adds Edit, with E."
          },
          {
            "name": "me",
            "type": "{ person: string; photo?: string } | string",
            "default": "\"You\"",
            "description": "Who is replying, so a new reply shows their name and photo."
          },
          {
            "name": "context",
            "type": "string",
            "description": "Where it was posted, like \"in Design\". It follows the time."
          },
          {
            "name": "replies",
            "type": "MessageReply[]",
            "description": "Replies already there, each { author, text }."
          },
          {
            "name": "onAction",
            "type": "(action: \"reply\" | \"edit\", text: string) => void",
            "description": "A reply was sent or the message was edited, and it shows in place. Save it here."
          },
          {
            "name": "peek",
            "type": "Omit<PeekCardProps, \"person\" | \"photo\">",
            "description": "The author's peek, like role, team, and next event. Without it, the name is plain."
          },
          {
            "name": "extraActions",
            "type": "HoverAction[]",
            "description": "More quick actions after Reply and Edit, like Pin. Give each a one-key shortcut."
          },
          {
            "name": "divider",
            "type": "boolean",
            "default": "true",
            "description": "A 1px hairline under it, inset to its padding. The last row has none."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the row. Later classes win."
          }
        ],
        "a11y": [
          "Each message is an article, named by its author and time, and a Tab stop of its own.",
          "Focus on the message, or anywhere in it, shows its actions, the same as pointing at it. R replies and E edits while they show.",
          "The keys stay quiet while you type in a field.",
          "Reply puts the cursor in a field named Reply to {first name}. Enter sends, and Esc cancels without closing anything around it.",
          "When the reply or edit closes, focus comes back to the message, so the keyboard keeps its place.",
          "Replies are a list named Replies to {author}. The hairline is a border, so it stays in forced colors."
        ],
        "examples": [
          {
            "title": "A plain message, with its time",
            "code": "<MessageRow author={{ person: \"Anna Price\" }} time=\"18:02\" text=\"Team sync moves to Room 3 on Wednesday.\" />"
          },
          {
            "title": "Your own, with replies saved",
            "code": "<MessageRow\n  author={me}\n  me={me}\n  own\n  time=\"18:10\"\n  context=\"in Design\"\n  text={message.text}\n  replies={message.replies}\n  onAction={(action, text) => (action === \"edit\" ? saveEdit(message.id, text) : saveReply(message.id, text))}\n/>"
          },
          {
            "title": "With a peek on the name",
            "code": "<MessageRow\n  author={{ person: \"Sam Carter\" }}\n  time=\"Mon 09:12\"\n  text=\"Demo day is Tuesday at 6.\"\n  peek={{ subtitle: \"Head of design · Design\", facts: [[\"Next\", \"Design review, Tue 19:00\"]] }}\n/>"
          }
        ]
      }
    },
    {
      "name": "morph-label",
      "type": "registry:ui",
      "title": "Morph Label",
      "description": "Words that morph in place when they change: the width eases to the new words while the old ones blur out and the new ones blur in.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/morph-label.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "motion",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Words that change where people are looking, like Follow to Following, Save to Saved, or a short status beside a value.",
            "Use MorphLabel explicitly when a word change needs the blur treatment. Button uses MotionSwap for its default crossfade.",
            "Whatever holds it grows or shrinks with it, so a button eases to its new width instead of snapping."
          ],
          "avoid": [
            "Long text or a paragraph. It is for a few words on one line. Words wider than the space end in an ellipsis, so in a flex row give the box that holds it min-w-0.",
            "Words that change many times a second, like a timer. Use Rolling Number for numbers that count.",
            "Saying something only through the change. Words that matter, like an error, also need a role=\"alert\" or role=\"status\" so they are read out."
          ]
        },
        "props": [
          {
            "name": "children",
            "type": "string | number",
            "description": "The words. When they change, the old ones blur out as the new ones blur in."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the label. Later classes win."
          }
        ],
        "a11y": [
          "Screen readers read only the new words. The old words are hidden from them while they fade.",
          "It does not announce the change. Put it in a live region, or add a role=\"status\" message, when the change must be heard.",
          "The morph follows the --motion-* tokens. Reduced motion lands the new width at once and keeps only the fade, in place. With motion off, the words swap at once."
        ],
        "examples": [
          {
            "title": "Words that change on a press",
            "code": "const [following, setFollowing] = useState(false);\n\n<Button emphasis={following ? \"soft\" : \"solid\"} onClick={() => setFollowing((on) => !on)}>\n  {following ? \"Following\" : \"Follow\"}\n</Button>"
          },
          {
            "title": "A short status that changes in place",
            "code": "<p className=\"text-sm text-muted-foreground\" role=\"status\">\n  <MorphLabel>{saving ? \"Saving…\" : \"Saved just now\"}</MorphLabel>\n</p>"
          }
        ]
      }
    },
    {
      "name": "motion-timing",
      "type": "registry:ui",
      "title": "Motion Timing",
      "description": "Runs a part's motion from script on the theme's motion variables: times, entrances, height glides, swaps, and scroll holds. It shows nothing.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/motion-timing.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "motion",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A motion time in script, like the duration for element.animate(). Read the variable where the part sits and pass it to toMs.",
            "Skip the animation when toMs reads 0. Motion off sets every motion time to 0ms.",
            "A motion variable read during render. Pass subscribeToMotion to useSyncExternalStore and read the variable in the snapshot, so a change of motion feel applies at once.",
            "An entrance that should wait until the part is on screen, like a chart that draws in. useSeen turns true once and stays true.",
            "A box whose contents changed. Measure its height first, then call glideHeight in a layout effect.",
            "One block swapped for another in place, like a button for its outcome. Use fadeSwap, and glideHeight with swap.",
            "A press that closes something above the pressed control. Call holdInView in the press."
          ],
          "avoid": [
            "Animations a CSS transition can run. A transition reads the motion variables in its classes, as the bar example shows. Motion off sets them to 0ms. Reduced motion keeps them, so a transition that moves or grows something should run only when motion is safe.",
            "A media query for reduced motion in script. The variables already change under it, and subscribeToMotion says when to read them again.",
            "Knowing whether a part is on screen right now. useSeen never turns back to false."
          ]
        },
        "props": [
          {
            "name": "toMs",
            "type": "(value: string) => number",
            "description": "Helper. A plain CSS time, like \"180ms\" or \"0.18s\", in milliseconds, with its unit in either case. It reads the number at the start and the unit at the end. A missing variable, a bare number, a negative time, or calc() reads as 0."
          },
          {
            "name": "subscribeToMotion",
            "type": "(onChange: () => void) => () => void",
            "description": "Helper. A subscribe for useSyncExternalStore. Calls onChange when a data-motion attribute changes anywhere on the page, or when reduced motion turns on or off."
          },
          {
            "name": "glideHeight",
            "type": "(element: HTMLElement, from: number, speed?: \"base\" | \"slow\", swap?: boolean) => Animation | undefined",
            "description": "Helper. Glides the element from the height it had, in px, to the height it has now, clipped at its bottom so nothing in it moves. With swap, it waits half the fast time, as fadeSwap's new block does. Runs nothing with motion off or reduced, except that a swap that shrinks holds its height until the old block fades."
          },
          {
            "name": "holdInView",
            "type": "(element: HTMLElement, within: Element) => () => void",
            "description": "Helper. Keeps the element where it is on screen, by scrolling its scroller each frame, while anything in within moves. A wheel, touch, or key lets go, and a new hold on the same scroller takes over. Returns a stop."
          },
          {
            "name": "fadeSwap",
            "type": "(leaving: Element, arriving?: Element | null) => Animation | undefined",
            "description": "Helper. Fades the leaving block where it sat on the fast time, and the arriving one in after half that time, so their words never show at once. Returns the leaving fade, or undefined with motion off."
          },
          {
            "name": "useSeen",
            "type": "(element: Element | null) => boolean",
            "description": "Hook. True once the element has been on screen, and from then on. Pass null to wait. The server and the first render read false. Without IntersectionObserver, it is true at once."
          }
        ],
        "a11y": [
          "Motion off sets every motion time to 0ms. Skip the animation when toMs reads 0, and a part follows it with no check of its own.",
          "Reduced motion keeps the motion times, so fades still play. It sets --motion-distance to 0px, so read it before you move anything, and fade in place when it is 0.",
          "useSeen holds only the entrance. Content is in the page and read by screen readers before it is seen."
        ],
        "examples": [
          {
            "title": "Fade a box in over the base motion time",
            "code": "import { useEffect, useRef, type ReactNode } from \"react\";\n\nfunction FadeIn({ children }: { children: ReactNode }) {\n  const box = useRef<HTMLDivElement>(null);\n  useEffect(() => {\n    const element = box.current;\n    if (!element || typeof element.animate !== \"function\") return;\n    const style = getComputedStyle(element);\n    const duration = toMs(style.getPropertyValue(\"--motion-duration-base\"));\n    if (!duration) return;\n    const fade = element.animate([{ opacity: 0 }, { opacity: 1 }], { duration, easing: style.getPropertyValue(\"--motion-ease-out\").trim() || \"ease-out\" });\n    return () => fade.cancel();\n  }, []);\n  return <div ref={box}>{children}</div>;\n}\n\n<FadeIn>New items appear here.</FadeIn>"
          },
          {
            "title": "Grow a bar in once it is on screen",
            "code": "import { useState } from \"react\";\n\nfunction GrowingBar({ value }: { value: number }) {\n  const [track, setTrack] = useState<HTMLDivElement | null>(null);\n  const seen = useSeen(track);\n  return (\n    <div ref={setTrack} className=\"h-2 rounded-full bg-track\">\n      <div\n        className=\"h-full rounded-full bg-primary motion-safe:transition-[width] motion-safe:duration-(--motion-duration-slow) motion-safe:ease-(--motion-ease-out)\"\n        style={{ width: seen ? `${value}%` : \"0%\" }}\n      />\n    </div>\n  );\n}\n\n<GrowingBar value={64} />"
          },
          {
            "title": "Read the base motion time during render, and again when the motion feel changes",
            "code": "import { useState, useSyncExternalStore } from \"react\";\n\nfunction MotionReadout() {\n  const [element, setElement] = useState<HTMLParagraphElement | null>(null);\n  const base = useSyncExternalStore(\n    subscribeToMotion,\n    () => (element ? toMs(getComputedStyle(element).getPropertyValue(\"--motion-duration-base\")) : 0),\n    () => 0,\n  );\n  return <p ref={setElement}>{base ? `Moves over ${base} ms` : \"Motion is off\"}</p>;\n}\n\n<MotionReadout />"
          },
          {
            "title": "Swap a button for its outcome, and glide the box to the new height",
            "code": "import { useLayoutEffect, useRef, useState } from \"react\";\n\nfunction SaveRow() {\n  const [saved, setSaved] = useState(false);\n  const [leaving, setLeaving] = useState(false);\n  const box = useRef<HTMLDivElement>(null);\n  const from = useRef(0);\n  useLayoutEffect(() => {\n    const element = box.current;\n    const old = element?.querySelector(\"[data-leaving]\");\n    if (!leaving || !element || !old) return;\n    glideHeight(element, from.current, \"base\", true);\n    const fade = fadeSwap(old, element.querySelector(\"p\"));\n    if (!fade) return setLeaving(false);\n    fade.finished.then(() => setLeaving(false), () => {});\n    return () => fade.cancel();\n  }, [leaving]);\n  return (\n    <div ref={box} className=\"relative\">\n      {(!saved || leaving) && (\n        <button\n          data-leaving={saved ? \"\" : undefined}\n          inert={saved}\n          className={saved ? \"absolute top-0\" : undefined}\n          onClick={() => {\n            from.current = box.current?.offsetHeight ?? 0;\n            setSaved(true);\n            setLeaving(true);\n          }}\n        >\n          Save changes\n        </button>\n      )}\n      {saved && <p>Saved</p>}\n    </div>\n  );\n}\n\n<SaveRow />"
          },
          {
            "title": "Keep Show less under the pointer while the list above it closes",
            "code": "import { useRef, useState } from \"react\";\n\nfunction ShowLess({ items }: { items: string[] }) {\n  const [open, setOpen] = useState(false);\n  const list = useRef<HTMLDivElement>(null);\n  return (\n    <div ref={list}>\n      <ul>\n        {(open ? items : items.slice(0, 3)).map((item) => (\n          <li key={item}>{item}</li>\n        ))}\n      </ul>\n      <button\n        onClick={(event) => {\n          if (open && list.current) holdInView(event.currentTarget, list.current);\n          setOpen(!open);\n        }}\n      >\n        {open ? \"Show less\" : \"Show more\"}\n      </button>\n    </div>\n  );\n}\n\n<ShowLess items={[\"Item 1\", \"Item 2\", \"Item 3\", \"Item 4\", \"Item 5\"]} />"
          }
        ]
      }
    },
    {
      "name": "motion",
      "type": "registry:ui",
      "title": "Motion",
      "description": "Continuous transitions for changing labels, icons, and local layouts. Keeps the live controls and focus in place.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/motion.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Use MotionSwap with a meaningful value when a label or icon changes state.",
            "Use Motion around a local group whose items are added, removed, or rearranged. Keep keys stable. Nested lists use the none axis so their parent alone owns the occupied size.",
            "Use useMotionLayout(ref) on an existing positioned element when an extra wrapper would change its semantics.",
            "The latest state is immediately usable; only the visual transition takes time."
          ],
          "avoid": [
            "Wrapping the whole page or nesting boundaries that animate the same geometry.",
            "Animating each keystroke, pointer drag, or slider scrub. These track the input directly.",
            "Remounting controls to replay an entrance. Initial content renders at rest."
          ]
        },
        "props": [
          {
            "name": "useMotionLayout",
            "type": "(ref: RefObject<HTMLElement | null>, options?: { axis?: \"height\" | \"both\" | \"none\"; items?: boolean }) => void",
            "description": "Apply local layout motion to an existing positioned element."
          },
          {
            "name": "MotionControl",
            "type": "({ children: ReactElement }) => ReactElement",
            "description": "Add size continuity to one existing control without a wrapper."
          },
          {
            "name": "motionSettings",
            "type": "(node: Element) => { duration: number; fade: number; easing: string; travel: number }",
            "description": "Read the motion tokens in scope at node, for script animation. duration is the layout time and fade the fast time, in ms. easing is the ease-out curve. travel is the distance in px. Motion off makes the numbers 0; reduced motion makes duration and travel 0."
          },
          {
            "name": "axis",
            "type": "\"height\" | \"both\" | \"none\"",
            "default": "\"height\"",
            "description": "Resize height, both dimensions, or use none for an items-only boundary inside a resizing parent."
          },
          {
            "name": "items",
            "type": "boolean",
            "default": "true",
            "description": "Motion also moves its direct children and fades additions and removals."
          },
          {
            "name": "value",
            "type": "string | number | boolean",
            "description": "MotionSwap's meaningful state identifier; a change fades or rolls its contents, as effect sets."
          },
          {
            "name": "effect",
            "type": "\"fade\" | \"roll\"",
            "default": "\"fade\"",
            "description": "MotionSwap: roll compact chart readouts vertically; keep the default fade for other labels. Reduced motion keeps only the fade."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "The current content, immediately available to keyboard and assistive technology."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Layout classes on the boundary. Later classes win."
          }
        ],
        "a11y": [
          "Live controls stay mounted, preserving focus and drafts. Outgoing visuals are inert, unnamed, and hidden from screen readers.",
          "Motion off is immediate. Reduced motion removes spatial transitions and keeps short fades.",
          "Without browser animation support, all content updates immediately."
        ],
        "examples": [
          {
            "title": "A local boundary",
            "code": "<Motion>Content</Motion>"
          },
          {
            "title": "An existing control",
            "code": "<MotionControl><button type=\"button\">Save</button></MotionControl>"
          },
          {
            "title": "An existing element",
            "code": "function LocalList() { const ref = useRef<HTMLUListElement>(null); useMotionLayout(ref); return <ul ref={ref} className=\"relative\"><li>Design</li></ul>; }"
          },
          {
            "title": "A result label",
            "code": "<MotionSwap value={saved}>{saved ? \"Saved\" : \"Save changes\"}</MotionSwap>"
          },
          {
            "title": "A rolling chart readout",
            "code": "<MotionSwap value={day} effect=\"roll\">{day}</MotionSwap>"
          },
          {
            "title": "A script fade on the theme's times",
            "code": "const { fade, easing } = motionSettings(node); node.animate([{ opacity: 0 }, { opacity: 1 }], { duration: fade, easing });"
          },
          {
            "title": "A changing list",
            "code": "<Motion className=\"flex flex-col gap-2\">{items.map(item => <div key={item.id}>{item.name}</div>)}</Motion>"
          }
        ]
      }
    },
    {
      "name": "no-access",
      "type": "registry:ui",
      "title": "No Access",
      "description": "What someone sees when they open something they cannot: what it is, whose it is, and Request access with an 8-second Undo.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/spot-icon"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/no-access.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Put it where the content would be, when someone opens a page, file, or team they cannot see.",
            "Name what it is in title, so they know they reached the right thing.",
            "Pass owner, so they know who will get the request. Put their avatar in owner.avatar.",
            "Send the request in onRequest. It runs once the 8-second undo window ends, or when the part leaves the page.",
            "Pass asked when they asked before, so the button shows the record instead of asking twice."
          ],
          "avoid": [
            "Hiding that something exists when its name is private too. Show a plain not-found page instead.",
            "Sending the request on press. onRequest waits for the undo window, so Undo never takes back a sent email.",
            "Content that is only loading or empty. Use an empty state or a skeleton."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "string",
            "description": "What it is, like \"Budget review, before Saturday 3 October\"."
          },
          {
            "name": "owner",
            "type": "{ person: string; avatar?: ReactNode }",
            "description": "Whose it is. avatar is an element, like an Avatar with their photo."
          },
          {
            "name": "hint",
            "type": "string",
            "default": "\"Ask for access and they get an email. You hear back when they answer.\"",
            "description": "What happens when they ask."
          },
          {
            "name": "asked",
            "type": "{ at?: string | Date } | null",
            "description": "Asked before. The button shows \"Asked\", with the day and time when at is given."
          },
          {
            "name": "onRequest",
            "type": "() => void",
            "description": "Sends the request, once the 8-second undo window ends."
          },
          {
            "name": "headingLevel",
            "type": "1 | 2 | 3 | 4 | 5 | 6",
            "default": "2",
            "description": "The title's heading level, so it fits the page's outline."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Later classes win."
          }
        ],
        "a11y": [
          "The title is a real heading, at the level you give.",
          "Request access is a native button. After a press it reads \"Asked. Press to undo.\" and focus stays on it.",
          "Undo arms when the pointer leaves, or after 1.2 seconds for the keys and touch, so a double press never undoes.",
          "A polite status line says the request was made, and says when it was undone.",
          "Once sent, the button is a record: it stays in the tab order with aria-disabled and full contrast.",
          "The lock spot is hidden from screen readers, since the title says what it is."
        ],
        "examples": [
          {
            "title": "A page they cannot see",
            "code": "\"use client\";\n\nexport function LockedPage() {\n  return (\n    <NoAccess\n      title=\"Budget review, before Saturday 3 October\"\n      owner={{ person: \"Sam Carter\" }}\n      onRequest={() => fetch(\"/api/access-requests\", { method: \"POST\" })}\n    />\n  );\n}"
          },
          {
            "title": "Asked before",
            "code": "<NoAccess\n  title=\"Pay notes, Design team\"\n  owner={{ person: \"Priya Shah\" }}\n  asked={{ at: \"2026-10-02T09:20:00\" }}\n/>"
          }
        ]
      }
    },
    {
      "name": "notice",
      "type": "registry:ui",
      "title": "Notice",
      "description": "One quiet line under the thing a problem or note is about, with a tone-colored icon and no box.",
      "dependencies": [
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/notice.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Saying a problem in one line, right under the field, row, or card it is about.",
            "Put the few words that say what happened in NoticeStatus, like 'Declined'. The reason after them stays in the text color.",
            "Use tone to say what kind of note it is: danger for a problem, warning for a risk, success for done, neutral or brand for a plain note.",
            "Use size='sm' under a field, and the default 'md' under a card or a row.",
            "Add role='alert' only for an urgent problem that appears after the page loads, like a failed save."
          ],
          "avoid": [
            "A message about a whole page or section, like planned downtime. Use Alert.",
            "A choice people must make before they go on. Use a dialog.",
            "A tinted box or a banner around the line. The point of a notice is that it stays quiet.",
            "role='alert' on a notice that is there when the page loads. Screen readers would interrupt people for news that is not new.",
            "More than one notice under the same thing. Say the most important problem, and its fix."
          ]
        },
        "props": [
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "What kind of note it is. Colors the icon and NoticeStatus."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\"",
            "default": "\"md\"",
            "description": "Text and icon size. sm reads at the meta size for a note under a field."
          },
          {
            "name": "icon",
            "type": "ReactNode | null",
            "description": "Replaces the tone's icon. Pass null for no icon. Hidden from screen readers either way."
          },
          {
            "name": "role",
            "type": "\"alert\" | \"status\"",
            "description": "Unset by default. Set it only for notices that appear or change after load. TypeScript rejects other roles."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the line. Later classes win."
          },
          {
            "name": "NoticeStatus",
            "type": "ComponentProps<\"span\">",
            "description": "Part. The few words that say what happened. They take the tone's text color."
          }
        ],
        "a11y": [
          "Renders a <div> with no role, so a notice that is there at load is read in order, like the rest of the page.",
          "role=\"alert\" reads the notice at once. role=\"status\" waits until the screen reader is quiet. Use either only for notices added or changed after load.",
          "The icon is aria-hidden, and color is not read out. The words must say what kind of note it is, like \"Declined\" or \"Not saved\".",
          "The icon and NoticeStatus use the tone's soft foreground. The theme tests hold it to 4.5:1 on the page, on cards, and in popovers.",
          "Under a field, point the field's aria-describedby at the notice's id, so the reason is read with the field."
        ],
        "examples": [
          {
            "title": "A problem under a row",
            "code": "<div className=\"grid gap-1\">\n  <p className=\"text-sm font-medium\">October invoices</p>\n  <Notice tone=\"danger\">\n    <NoticeStatus>Declined.</NoticeStatus> The card on file has expired.\n  </Notice>\n</div>"
          },
          {
            "title": "Under a field, read with it",
            "code": "<div className=\"grid gap-1.5\">\n  <label htmlFor=\"team-email\">Team email</label>\n  <input id=\"team-email\" aria-describedby=\"team-email-note\" aria-invalid defaultValue=\"sam@\" />\n  <Notice id=\"team-email-note\" tone=\"danger\" size=\"sm\">\n    Add the part after the @, like sam@example.com.\n  </Notice>\n</div>"
          },
          {
            "title": "A consequence, with a link to the fix",
            "code": "<Notice tone=\"warning\">\n  <NoticeStatus>Due at 14:40.</NoticeStatus> Later bookings in Room 3 move with it.{\" \"}\n  <a href=\"/schedule\">Open the schedule</a>\n</Notice>"
          },
          {
            "title": "A save that failed after load",
            "code": "function SaveNote({ error }: { error: string | null }) {\n  if (!error) return null;\n  return (\n    <Notice role=\"alert\" tone=\"danger\">\n      <NoticeStatus>Not saved.</NoticeStatus> {error}\n    </Notice>\n  );\n}"
          },
          {
            "title": "Your own icon, or none",
            "code": "import { Clock } from \"lucide-react\";\n\n<div className=\"grid gap-2\">\n  <Notice icon={<Clock />}>Replies close on Friday at 18:00.</Notice>\n  <Notice icon={null} size=\"sm\">\n    Only admins can see this.\n  </Notice>\n</div>"
          }
        ]
      }
    },
    {
      "name": "notifications",
      "type": "registry:ui",
      "title": "Notifications",
      "description": "A bell with the count of what's new, and the list it opens, grouped by day, with Mark all as read.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/action-keys",
        "@txbrendan/avatar",
        "@txbrendan/badge",
        "@txbrendan/button",
        "@txbrendan/popover",
        "@txbrendan/tool-button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/notifications.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "overlays",
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The bell in an app's header: what happened elsewhere while you were away, like an approval or a meeting moved.",
            "Give each item a title for what happened, the person who did it (or an icon), and meta for when and where: \"18:04 · Design\".",
            "Group items with group: \"Today\", \"Yesterday\", \"Earlier\". Groups show in the order they first appear.",
            "Opening one marks it read, closes the list, and calls onOpenItem. Take people to what it is about there.",
            "Mark all as read clears the dots at once, with Undo for 8 seconds. ⌘Z takes it back too. onReadAll runs when the window ends.",
            "Use inline to draw the open list in a card or a doc, with no bell."
          ],
          "avoid": [
            "News with a place on the screen. Show it there, like a status on its row.",
            "The result of something you just did. Show it on the control you pressed.",
            "Messages people reply to. Use a messages list."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "NotificationItem[]",
            "description": "Required. { id, title, person?, photo?, icon?: ReactNode, meta?, group?, read? }."
          },
          {
            "name": "onOpenItem",
            "type": "(item: NotificationItem) => void",
            "description": "Opens what it is about."
          },
          {
            "name": "onRead",
            "type": "(ids: string[]) => void",
            "description": "Marked read by opening it."
          },
          {
            "name": "onReadAll",
            "type": "(ids: string[]) => void",
            "description": "Mark all as read, once its 8-second undo window ends."
          },
          {
            "name": "inline",
            "type": "boolean",
            "default": "false",
            "description": "Draws the open list in the page's flow, with no bell."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the bell, or on the list with inline. Later classes win."
          }
        ],
        "a11y": [
          "The bell is a button named with the count, like \"Notifications, 3 new\". It says it opens a dialog.",
          "The list is a dialog named \"Notifications\". Tab moves through Mark all as read, Close, and the rows, and wraps inside the list, even when pressed fast. The rows carry tabindex=\"0\", so Safari keeps them in Tab too. Esc or Close shuts it and puts focus back on the bell.",
          "Each row is a button. A new one ends with a dot, which screen readers hear as \"New\", and forced-colors mode draws as a ring.",
          "Mark all as read says how many it marked in a polite live region. Its Undo undoes only once 1.2 seconds have passed, so a double press never undoes."
        ],
        "examples": [
          {
            "title": "In a header",
            "code": "import { CalendarDays } from \"lucide-react\";\n\n<Notifications\n  items={[\n    { id: \"1\", person: \"Kate Clarke\", title: \"Kate Clarke agreed to the launch plan\", meta: \"18:04 · Design\", group: \"Today\" },\n    { id: \"2\", icon: <CalendarDays />, title: \"Saturday's workshop moved to 11:00\", meta: \"Calendar\", group: \"Yesterday\", read: true },\n  ]}\n  onOpenItem={(item) => console.log(\"open\", item.id)}\n/>"
          }
        ]
      }
    },
    {
      "name": "number-stepper",
      "type": "registry:ui",
      "title": "Number Stepper",
      "description": "Minus, the number, plus. For small counts like seats, guests, or minutes, and for live scores.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/input",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/number-stepper.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Small counts people nudge up or down: seats offered, guests per table, minutes per meeting.",
            "Give it a name: aria-label, like \"Seats offered\", or an id that a visible label's htmlFor points at.",
            "Set min and max. The buttons stop there, and Home and End jump there.",
            "Put the unit in suffix, like \"seats\". For a unit that can change, pass a UnitSwitch as the suffix.",
            "Set score for a live scoreboard: no box, round buttons, and a big number.",
            "The number rolls when it changes, up when it grows and down when it shrinks. With motion off, it changes at once."
          ],
          "avoid": [
            "Money and long numbers people type, like a price or a phone number. Use an input.",
            "Picking from a few named options. Use a radio group or a select.",
            "Ranges people drag across, like a volume. Use a slider."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "number | null",
            "description": "The number. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "number",
            "description": "The number at first render. Defaults to min, or 0."
          },
          {
            "name": "onValueChange",
            "type": "(value: number | null) => void",
            "description": "Called with each new number, or null when the text is cleared."
          },
          {
            "name": "min",
            "type": "number",
            "description": "The lowest number. The minus button stops there."
          },
          {
            "name": "max",
            "type": "number",
            "description": "The highest number. The plus button stops there."
          },
          {
            "name": "step",
            "type": "number",
            "default": "1",
            "description": "How much the buttons and the arrow keys move it."
          },
          {
            "name": "largeStep",
            "type": "number",
            "default": "10",
            "description": "How much Page Up and Page Down, or Shift with an arrow, move it."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height and text size. The same heights as Input."
          },
          {
            "name": "score",
            "type": "boolean",
            "default": "false",
            "description": "No box, round buttons, and a big number, for live scores."
          },
          {
            "name": "format",
            "type": "Intl.NumberFormatOptions",
            "description": "How the number shows, like { style: \"percent\" }."
          },
          {
            "name": "suffix",
            "type": "ReactNode",
            "description": "A muted unit after the number, like \"seats\", or a UnitSwitch."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks the buttons and typing."
          },
          {
            "name": "name",
            "type": "string",
            "description": "Form field name. The number is sent with the form."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the number. Required unless you pass aria-labelledby, or an id with a visible label."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the box. Later classes win."
          }
        ],
        "a11y": [
          "The number is a text field, so people can type it as well as step it.",
          "Its type needs a name: aria-label, aria-labelledby, or an id for a label's htmlFor.",
          "The arrow keys step it, Shift with an arrow and Page Up and Page Down move 10, and Home and End jump to min and max.",
          "The buttons are named \"Decrease\" and \"Increase\", and they are not Tab stops, since the keys do the same. Holding one repeats.",
          "The suffix describes the field, so screen readers hear the unit after the number.",
          "The roll reads --motion-duration-base. When reduced motion sets it to 0, the number changes with no movement."
        ],
        "examples": [
          {
            "title": "Seats, with min, max, and a unit",
            "code": "<NumberStepper aria-label=\"Seats offered\" defaultValue={4} min={1} max={8} suffix=\"seats\" />"
          },
          {
            "title": "The big score look",
            "code": "<NumberStepper aria-label=\"Design score\" score defaultValue={0} min={0} />"
          },
          {
            "title": "Minutes, with a visible label",
            "code": "<label htmlFor=\"length\">Meeting length</label>\n<NumberStepper id=\"length\" defaultValue={45} min={10} max={60} step={5} size=\"sm\" />"
          }
        ]
      }
    },
    {
      "name": "pagination",
      "type": "registry:ui",
      "title": "Pagination",
      "description": "Previous, next, and numbered page links in a labeled nav, with the current page marked and an ellipsis for gaps.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button-variants"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/pagination.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Moving through a long list split into pages, like search results, orders, or a table.",
            "Mark the page people are on with current. It sets aria-current=\"page\" and draws an outline.",
            "Keep the first and last page, the pages beside the current one, and a PaginationEllipsis for each gap.",
            "Set disabled on Previous on the first page and on Next on the last, so the row keeps its shape.",
            "Put the page in the URL, like ?page=3, so people can share it and the back button works.",
            "With a router, pass its link as render: <PaginationLink render={<Link href=\"?page=2\" />}>2</PaginationLink>.",
            "Set the same size on every link and the ellipsis in one row."
          ],
          "avoid": [
            "Short lists that fit on one screen. Show them all.",
            "Feeds people scroll through without a goal. A Load more button keeps their place better.",
            "Steps of a form or a flow. Use StepDots, or Back and Next buttons.",
            "Showing every page of a long list. Phones wrap the row. Show about seven items at most.",
            "Two navs named \"Pagination\" on one page, like above and below a table. Pass a different aria-label to each."
          ]
        },
        "props": [
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "On PaginationLink, PaginationPrevious, PaginationNext, and PaginationEllipsis. Height and width. Density scales them."
          },
          {
            "name": "current",
            "type": "boolean",
            "default": "false",
            "description": "On PaginationLink. The page people are on. Sets aria-current=\"page\" and an outline."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "On PaginationLink, PaginationPrevious, and PaginationNext. Drops href and onClick, sets aria-disabled, and dims the link. It is not a tab stop."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Pagination\"",
            "description": "On Pagination. The landmark's name. Rename it when a page has more than one. Left off when you pass aria-labelledby."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"More pages\"",
            "description": "On PaginationEllipsis. What screen readers hear in place of the dots."
          },
          {
            "name": "render",
            "type": "ReactElement",
            "description": "On PaginationLink, PaginationPrevious, and PaginationNext. Renders your element, like a router link, with the link's props and classes. A disabled link ignores it and renders an <a>."
          },
          {
            "name": "Pagination",
            "type": "Omit<ComponentProps<\"nav\">, \"color\">",
            "description": "The landmark. A <nav> with aria-label=\"Pagination\". Pass aria-label or aria-labelledby to rename it."
          },
          {
            "name": "PaginationContent",
            "type": "Omit<ComponentProps<\"ul\">, \"color\">",
            "description": "Part. The list of items. It wraps as a last resort, so it never pushes the page sideways."
          },
          {
            "name": "PaginationItem",
            "type": "Omit<ComponentProps<\"li\">, \"color\">",
            "description": "Part. One item in the row."
          },
          {
            "name": "PaginationLink",
            "type": "Omit<ComponentProps<\"a\">, \"color\"> & { current?, disabled?, size?, render? }",
            "description": "Part. One page, styled with buttonVariants: ghost, or outline when current. At least as wide as it is tall."
          },
          {
            "name": "PaginationPrevious",
            "type": "Omit<ComponentProps<\"a\">, \"color\"> & { disabled?, size?, render? }",
            "description": "Part. Named \"Previous page\". Below the sm breakpoint the words are visually hidden and the link turns square."
          },
          {
            "name": "PaginationNext",
            "type": "Omit<ComponentProps<\"a\">, \"color\"> & { disabled?, size?, render? }",
            "description": "Part. Named \"Next page\". Below the sm breakpoint the words are visually hidden and the link turns square."
          },
          {
            "name": "PaginationEllipsis",
            "type": "Omit<ComponentProps<\"span\">, \"children\" | \"color\"> & { label?, size? }",
            "description": "Part. Three dots for pages left out. label is what screen readers hear. Default \"More pages\"."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on any part. Later classes win, so className='rounded-full' on PaginationLink replaces the radius."
          }
        ],
        "a11y": [
          "Pagination renders a <nav> named \"Pagination\", so screen readers list it with the page's landmarks.",
          "The items are a <ul>, so screen readers say how many there are.",
          "current sets aria-current=\"page\". The current page also gets an outline, so it is marked by shape, not only by color.",
          "Previous and Next are named \"Previous page\" and \"Next page\". On narrow screens the words are visually hidden but still read. Their chevrons are aria-hidden.",
          "A disabled link has no href, so it is not a tab stop. It keeps role=\"link\" and aria-disabled=\"true\", so screen readers still say it is there and unavailable.",
          "PaginationEllipsis has visually hidden text, \"More pages\" by default. Its dots are aria-hidden, and it is not interactive.",
          "Links are native <a> elements, so Tab moves between them and Enter follows them.",
          "Link text uses the tone's soft foreground, held to 4.5:1 on the page. The current page's outline is held to 3:1, and so is the focus ring."
        ],
        "examples": [
          {
            "title": "Page 1 of 10",
            "code": "<Pagination>\n  <PaginationContent>\n    <PaginationItem>\n      <PaginationPrevious disabled />\n    </PaginationItem>\n    <PaginationItem>\n      <PaginationLink href=\"?page=1\" current>\n        1\n      </PaginationLink>\n    </PaginationItem>\n    <PaginationItem>\n      <PaginationLink href=\"?page=2\">2</PaginationLink>\n    </PaginationItem>\n    <PaginationItem>\n      <PaginationLink href=\"?page=3\">3</PaginationLink>\n    </PaginationItem>\n    <PaginationItem>\n      <PaginationEllipsis />\n    </PaginationItem>\n    <PaginationItem>\n      <PaginationLink href=\"?page=10\">10</PaginationLink>\n    </PaginationItem>\n    <PaginationItem>\n      <PaginationNext href=\"?page=2\" />\n    </PaginationItem>\n  </PaginationContent>\n</Pagination>"
          },
          {
            "title": "With Next.js links",
            "code": "import Link from \"next/link\";\n\n<Pagination>\n  <PaginationContent>\n    <PaginationItem>\n      <PaginationPrevious render={<Link href=\"?page=4\" />} />\n    </PaginationItem>\n    <PaginationItem>\n      <PaginationLink render={<Link href=\"?page=5\" />} current>\n        5\n      </PaginationLink>\n    </PaginationItem>\n    <PaginationItem>\n      <PaginationNext render={<Link href=\"?page=6\" />} />\n    </PaginationItem>\n  </PaginationContent>\n</Pagination>"
          },
          {
            "title": "A row built from the current page",
            "code": "import { Fragment } from \"react\";\n\nfunction Pages({ page, total }: { page: number; total: number }) {\n  const shown = [...new Set([1, page - 1, page, page + 1, total])]\n    .filter((n) => n >= 1 && n <= total)\n    .sort((a, b) => a - b);\n  return (\n    <Pagination>\n      <PaginationContent>\n        <PaginationItem>\n          <PaginationPrevious href={`?page=${page - 1}`} disabled={page === 1} />\n        </PaginationItem>\n        {shown.map((n, i) => (\n          <Fragment key={n}>\n            {i > 0 && n - shown[i - 1] > 1 ? (\n              <PaginationItem>\n                <PaginationEllipsis />\n              </PaginationItem>\n            ) : null}\n            <PaginationItem>\n              <PaginationLink href={`?page=${n}`} current={n === page}>\n                {n}\n              </PaginationLink>\n            </PaginationItem>\n          </Fragment>\n        ))}\n        <PaginationItem>\n          <PaginationNext href={`?page=${page + 1}`} disabled={page === total} />\n        </PaginationItem>\n      </PaginationContent>\n    </Pagination>\n  );\n}"
          },
          {
            "title": "Above and below a table",
            "code": "<Pagination aria-label=\"Pages, top\">\n  {/* ... */}\n</Pagination>\n{/* the table */}\n<Pagination aria-label=\"Pages, bottom\">\n  {/* ... */}\n</Pagination>"
          }
        ]
      }
    },
    {
      "name": "panel-switch",
      "type": "registry:ui",
      "title": "Panel Switch",
      "description": "Switches what a box shows for a new tab, filter, or item. The box holds still and glides to its new height.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/panel-switch.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "motion",
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A box that stays on screen while its contents change: a tab's panel, a filtered list, or the item picked in a list.",
            "Give each set of contents its own value. A new value switches the box. New children under the same value update in place.",
            "Keep buttons that act on what the box shows, like Approve or Next, outside it, so the one you press never slides away.",
            "Use glideFrom for a box inside that grows on its own, like a list that shows more rows. It glides on the same curve."
          ],
          "avoid": [
            "A new page, a popup that opens, or a box that just appeared. Those keep their own motion.",
            "New cards arriving in a grid. Let each one fade up where it lands.",
            "Words on a button that change. Button already morphs its own words."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "string",
            "description": "What the box shows. A new value switches the contents."
          },
          {
            "name": "order",
            "type": "number",
            "default": "0",
            "description": "Where the value sits in its list. A later one slides in from the right and an earlier one from the left, mirrored in right-to-left pages."
          },
          {
            "name": "effect",
            "type": "\"slide\" | \"fade\" | \"instant\"",
            "default": "\"slide\"",
            "description": "slide moves the new contents in from the side they come from. fade changes them in place, like a question that turns into its answer. Both glide the box to its new height. instant shows the change at once, with no motion."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "What the box shows for this value."
          },
          {
            "name": "glideFrom",
            "type": "(element: HTMLElement, from: number) => void",
            "description": "Helper. Glides a box that just grew or shrank from the height it had, on the switch's curve. Read its height before the change, and call it after."
          }
        ],
        "a11y": [
          "The leaving contents are hidden from screen readers and cannot take focus or a click.",
          "Focus inside the leaving contents goes to the tab that picked the new ones, or else into the new ones. With nothing there to take it, the box holds it, out of the Tab order. It is never dropped to the page.",
          "Reduced motion keeps the fade, and drops the slide and the height glide.",
          "Tell screen readers what changed, like a count of results, in a live region beside it."
        ],
        "examples": [
          {
            "title": "Switch what a box shows by value",
            "code": "<PanelSwitch value={tab}>\n  <p>{tab}</p>\n</PanelSwitch>"
          },
          {
            "title": "Switch a panel's contents when a tab is picked",
            "code": "const [tab, setTab] = useState(\"overview\");\n\n<>\n  <Tabs value={tab} onValueChange={setTab}>\n    <TabsList aria-label=\"Sections\">\n      <TabsTab value=\"overview\">Overview</TabsTab>\n      <TabsTab value=\"activity\">Activity</TabsTab>\n    </TabsList>\n  </Tabs>\n  <PanelSwitch value={tab} order={tab === \"overview\" ? 0 : 1}>\n    {tab === \"overview\" ? <p>Three items are open.</p> : <p>Two items changed today.</p>}\n  </PanelSwitch>\n</>"
          },
          {
            "title": "Change a question into its answer in place",
            "code": "<PanelSwitch value={picked ? \"done\" : \"pick\"} effect=\"fade\">\n  {picked ? <p>Owner: {picked}</p> : <button type=\"button\" onClick={() => setPicked(\"Tom Hale\")}>Pick an owner</button>}\n</PanelSwitch>"
          },
          {
            "title": "Show a change at once when a link lands on hidden contents",
            "code": "<PanelSwitch value={item} order={index} effect={fromLink ? \"instant\" : \"slide\"}>\n  <p>{item}</p>\n</PanelSwitch>"
          },
          {
            "title": "Glide a list inside the box as it shows more rows",
            "code": "const rows = [\"First\", \"Second\", \"Third\", \"Fourth\", \"Fifth\"];\nconst list = useRef<HTMLUListElement>(null);\nconst before = useRef(0);\nconst [open, setOpen] = useState(false);\n\nuseLayoutEffect(() => {\n  if (open && list.current) glideFrom(list.current, before.current);\n}, [open]);\n\n<>\n  <ul ref={list}>\n    {(open ? rows : rows.slice(0, 3)).map((row) => (\n      <li key={row}>{row}</li>\n    ))}\n  </ul>\n  <button\n    type=\"button\"\n    onClick={() => {\n      before.current = list.current?.offsetHeight ?? 0;\n      setOpen(true);\n    }}\n  >\n    Show more\n  </button>\n</>"
          }
        ]
      }
    },
    {
      "name": "peek-card",
      "type": "registry:ui",
      "title": "Peek Card",
      "description": "The standard content of a peek: who or what, status, facts, the people behind a count, fixes, a note, and next actions.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/button",
        "@txbrendan/hover-card",
        "@txbrendan/kbd",
        "@txbrendan/person",
        "@txbrendan/tool-button",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/peek-card.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "As a HoverCard's content. Its title names the peek, and in a glance it shows up to 3 facts and 2 actions. People, fixes, and the note always show.",
            "Facts are [label, value, tone?] rows. Say who and when, like ['Back', 'Mon 19 Oct · HR'], so people know where a fact came from.",
            "A fact value that is a PersonValue, like { person: 'Steve Rowe' }, shows as a Person with a photo.",
            "people lists the people behind a count, like the 6 who have not replied, each with a status and an action like Remind.",
            "fixes lists ways to fix a problem, best first. The first gets the one-press button. detail says what happens.",
            "Give a press a result, like { label: 'Sent' }. The button slips to it in place. With onUndo, coming back shows Undo for 8 seconds, and ⌘Z undoes it too. Send the change in onCommit.",
            "Return a promise from onClick or onCommit, and give the result failed words. The button waits while it runs, and turns into Retry if it fails.",
            "Other controls that leave a result use useActionResult from tool-button, and resultFaces from this file for their faces. undoable, in tool-button, puts a change with no control, like a saved edit, on the page's ⌘Z stack.",
            "Put the key in shortcut. It shows in the button's tooltip only. Wire the key itself with ActionKeys."
          ],
          "avoid": [
            "A long form or a menu of choices. Use a Popover, a Menu, or a Dialog.",
            "A toast to repeat what a pressed button already says. The button carries the result.",
            "More than one solid action. The main action is the one solid button.",
            "A go action as the main action. Going somewhere is never the main action."
          ]
        },
        "props": [
          {
            "name": "person",
            "type": "string | PersonValue",
            "description": "Who it is about: a name, or { person, photo }. Shows their avatar."
          },
          {
            "name": "photo",
            "type": "string",
            "description": "The person's photo, beside a name."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "An icon for what is not a person, in a soft tile."
          },
          {
            "name": "iconTone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The icon tile's color role."
          },
          {
            "name": "leading",
            "type": "ReactNode",
            "description": "Your own avatar or icon, in place of person or icon."
          },
          {
            "name": "title",
            "type": "string",
            "description": "Names the peek. Default: the person's name."
          },
          {
            "name": "subtitle",
            "type": "string",
            "description": "A muted line under the title, like \"Workshop · Tue 29 Sep · 19:00\"."
          },
          {
            "name": "status",
            "type": "{ label: string; tone?: \"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\" }",
            "description": "Colored status text at the top right. Neutral is muted."
          },
          {
            "name": "facts",
            "type": "[label: string, value: ReactNode | PersonValue, tone?: PeekTone][]",
            "description": "Label and value rows. A glance shows the first 3."
          },
          {
            "name": "people",
            "type": "{ person; photo?; meta?; group?; done?; status?; action? }[]",
            "description": "The people behind a count. group puts them under a heading. done is the record from app state, shown in place of the action. action can ask a question with choices, like a team."
          },
          {
            "name": "peopleAction",
            "type": "{ label; choices?; ask?; onClick?; result?: ActionResult }",
            "description": "The action for a row that has none, like Remind. A choice joins the result, like Approved · Support."
          },
          {
            "name": "fixes",
            "type": "{ label; detail?; doneDetail?; icon?; cta?; onClick?; result?: ActionResult }[]",
            "description": "Fixes for a problem, best first. cta labels the button, default \"Fix\". Once pressed, doneDetail replaces detail."
          },
          {
            "name": "note",
            "type": "ReactNode",
            "description": "A short muted note."
          },
          {
            "name": "actions",
            "type": "{ label; icon?; shortcut?; emphasis?; go?; all?; dismiss?; href?; iconOnly?; onClick?; result?: ActionResult }[]",
            "description": "Next actions on a thin lip at the foot. The first is the main, solid on the right, unless one sets emphasis. outline sits just left of it, and ghost on the left. all marks every person whose action has that label as done. dismiss closes the peek after the click."
          },
          {
            "name": "more",
            "type": "{ label?; href?; onClick? }",
            "description": "The way to the full page. A plain button beside the main action, or a link under the actions when it has href."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the card. Later classes win."
          },
          {
            "name": "ActionResult",
            "type": "{ label; doneLabel?; say?; failed?; onUndo?; undone?; onCommit?; seconds?; toast? }",
            "description": "Type, from tool-button. What a press leaves. label shows once pressed and doneLabel once the window ends. failed is what Retry says. seconds is the window, default 8. toast goes to the toast slot, only for what the button can't show."
          }
        ],
        "a11y": [
          "In a HoverCard, the title names the peek's dialog. On its own, the card has no role.",
          "A press says its result once, through one polite live region for the page. The button's name follows its state: the label, then the result, with \"Press to undo.\" while a press undoes, then the record.",
          "A press that fails is named \"Retry:\" and what failed, so a voice command for Retry finds it.",
          "Undo arms only after the pointer leaves, or 1.2 seconds after a key or touch press, so a double press never undoes.",
          "⌘Z or Ctrl+Z undoes the newest change still in its window. In a text field it stays the field's own undo.",
          "An icon-only action is named by its label, which shows in its tooltip. A shortcut key shows in the tooltip too.",
          "An action with choices sets aria-expanded and moves focus to the first choice. Esc puts the choices away and focus goes back to the action.",
          "Rows with a group sit in a list named by the group heading."
        ],
        "examples": [
          {
            "title": "Who is going",
            "code": "<PeekCard\n  title=\"21 going\"\n  subtitle=\"Workshop · Tue 29 Sep · 19:00\"\n  people={[\n    { person: \"Sam Carter\", meta: \"Host\", status: { label: \"Going\", tone: \"success\" } },\n    { person: \"Josh Tait\", meta: \"Engineer\", status: { label: \"No reply\" } },\n  ]}\n  peopleAction={{ label: \"Remind\", result: { label: \"Sent\" } }}\n  more={{ label: \"See all 21\", href: \"/events/29-sep\" }}\n/>"
          },
          {
            "title": "A person, in a peek",
            "code": "<HoverCard\n  content={\n    <PeekCard\n      person=\"Josh Tait\"\n      subtitle=\"Engineer · Engineering\"\n      status={{ label: \"Out sick\", tone: \"danger\" }}\n      facts={[[\"Back\", \"Mon 19 Oct · HR\"], [\"Next\", \"Team sync, Thu 10:00\"]]}\n      actions={[{ label: \"Message\" }, { label: \"Profile\", go: true, href: \"/people/josh-tait\" }]}\n    />\n  }\n>\n  Josh Tait\n</HoverCard>"
          },
          {
            "title": "Fixes, best first",
            "code": "<PeekCard\n  title=\"Two people in one slot\"\n  status={{ label: \"Clash\", tone: \"warning\" }}\n  fixes={[\n    { label: \"Move Josh to backup\", detail: \"Kate stays on call.\", cta: \"Move\", result: { label: \"Moved\" } },\n    { label: \"Swap with Kate\", detail: \"Josh is on call, Kate is backup.\", cta: \"Swap\", result: { label: \"Swapped\" } },\n  ]}\n/>"
          },
          {
            "title": "A press that can fail",
            "code": "<PeekCard\n  title=\"Josh Tait\"\n  actions={[\n    {\n      label: \"Remind\",\n      onClick: () => sendReminder(\"josh\"),\n      result: { label: \"Reminder sent\", failed: \"Couldn't send the reminder\", onUndo: cancelReminder },\n    },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "peek-person",
      "type": "registry:ui",
      "title": "Peek Person",
      "description": "An avatar and a name that opens the person's PeekCard on hover, focus, or tap.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/hover-card",
        "@txbrendan/peek-card",
        "@txbrendan/person"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/peek-person.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "people",
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Naming a person where people want more without leaving the page: a list row, a comment, an assignee.",
            "It looks like Person and takes the same props, plus peek: the PeekCard to show, like status, facts, and actions.",
            "The peek's person comes from name and src. meta is its subtitle unless peek sets one.",
            "Mark each row of a list with data-peek-row, so the peek opens beside the row."
          ],
          "avoid": [
            "A name with nothing more to show. Use Person.",
            "A name that is itself a link to the profile. Put the link in the peek's more, so the name stays a peek."
          ]
        },
        "props": [
          {
            "name": "name",
            "type": "string",
            "description": "Required. The person's full name. It titles the peek too."
          },
          {
            "name": "src",
            "type": "string",
            "description": "Photo URL, for the avatar and the peek."
          },
          {
            "name": "meta",
            "type": "string",
            "description": "A muted line under the name. It is the peek's subtitle unless peek sets one."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Avatar and name size."
          },
          {
            "name": "strong",
            "type": "boolean",
            "default": "false",
            "description": "The name in the strong weight, as a row's title."
          },
          {
            "name": "peek",
            "type": "Omit<PeekCardProps, \"person\" | \"photo\">",
            "description": "The PeekCard to show: status, facts, people, actions, and more."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Controlled. The peek shows while true. Hover, focus, and Esc only ask, through onOpenChange."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Starts with the peek open, without taking focus."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Hears the peek open and close, or what it asks for when controlled."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the trigger. Later classes win."
          }
        ],
        "a11y": [
          "The trigger is a focusable button that opens a dialog. Its name is the person's name and meta.",
          "The avatar is hidden, so the name is read once.",
          "The peek is named by the person's name, its PeekCard title. Enter moves into it, and Esc closes it and puts focus back."
        ],
        "examples": [
          {
            "title": "In a list row, with a status and an action",
            "code": "<PeekPerson\n  name=\"Josh Tait\"\n  meta=\"Engineer · Mobile app\"\n  peek={{\n    status: { label: \"Off sick\", tone: \"danger\" },\n    facts: [[\"Back\", \"Mon 19 Oct\"]],\n    actions: [{ label: \"Message\" }],\n    more: { label: \"Profile\", href: \"/people/josh-tait\" },\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "peek-spread",
      "type": "registry:ui",
      "title": "Peek Spread",
      "description": "A two-page peek for an event: the plan on the left, and who is going on the right.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/button",
        "@txbrendan/hover-card",
        "@txbrendan/person"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/peek-spread.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "As a HoverCard's content on an event's title, with width 480. Its title names the peek.",
            "plan lists the blocks in order. minutes draws the proportion bar and the total. at shows a time instead, like 15:00.",
            "facts are [label, value] rows under the plan. With a byline or an action, they sit at the foot, just above it. A PersonValue, like { person: 'Steve Rowe' }, shows with a photo.",
            "people lists who is going. rsvp adds a mark at the row's end, and group splits the list, like Design and Engineering. Leave group out when each row's meta already names the team.",
            "The plan sets the height and never scrolls. The people page takes that height and scrolls inside it."
          ],
          "avoid": [
            "A peek about one person or one thing. Use PeekCard.",
            "A full event page. Link to it with action.",
            "A plan with more than 8 blocks. The peek is a glance. Put the rest on the plan's page."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "string",
            "description": "Required. Names the peek."
          },
          {
            "name": "subtitle",
            "type": "string",
            "description": "When and where, like \"Thu 1 Oct · 10:00 · Room 1\"."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "An icon in a soft tile before the title."
          },
          {
            "name": "iconTone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"brand\"",
            "description": "The icon tile's color role."
          },
          {
            "name": "planTitle",
            "type": "string",
            "default": "\"Plan\"",
            "description": "Heading over the plan."
          },
          {
            "name": "plan",
            "type": "{ label: string; minutes?: number; at?: string }[]",
            "description": "Plan blocks in order. minutes draws the bar and the total. at shows a time instead."
          },
          {
            "name": "facts",
            "type": "[label: string, value: ReactNode | PersonValue][]",
            "description": "Rows under the plan. With a byline or an action, they sit at the foot of the column, just above it."
          },
          {
            "name": "by",
            "type": "PersonValue",
            "description": "Who made the plan, at its foot with their photo: \"By Sam Carter\"."
          },
          {
            "name": "people",
            "type": "{ person; photo?; meta?; group?; rsvp?: \"yes\" | \"maybe\" | \"none\" | \"no\" }[]",
            "description": "Who is going. Each group lists going, maybe, no reply, then can't make it."
          },
          {
            "name": "peopleTitle",
            "type": "string",
            "default": "\"{n} going\"",
            "description": "Heading over the people. n counts going and no rsvp."
          },
          {
            "name": "peopleMeta",
            "type": "string",
            "description": "A muted line beside the people title, like \"6 no reply\"."
          },
          {
            "name": "action",
            "type": "{ label: string; href?: string; onClick? }",
            "description": "A link at the foot of the plan, like \"Open plan\"."
          },
          {
            "name": "height",
            "type": "number",
            "default": "252",
            "description": "Least height of both pages, in pixels. The plan grows past it. The people page scrolls inside it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the spread. Later classes win."
          }
        ],
        "a11y": [
          "In a HoverCard, the title names the peek's dialog.",
          "The plan is a group named by its heading. Its bar is hidden from screen readers, since each block reads its own minutes.",
          "The people list scrolls, so it is a Tab stop named by its heading, and the keyboard scrolls it with the arrow keys.",
          "Its pages are named groups, not landmarks, so two spreads with the same headings on one page never repeat a landmark.",
          "Each rsvp mark has words for screen readers: Going, Maybe, No reply, or Can't make it.",
          "In a narrow peek the pages stack, and the people page keeps its own height."
        ],
        "examples": [
          {
            "title": "On an event's title",
            "code": "<HoverCard\n  width={480}\n  glance={false}\n  content={\n    <PeekSpread\n      title=\"Design workshop\"\n      subtitle=\"Thu 1 Oct · 10:00 · Room 1\"\n      plan={[\n        { label: \"Welcome\", minutes: 15 },\n        { label: \"Research\", minutes: 25 },\n        { label: \"Prototype\", minutes: 30 },\n      ]}\n      facts={[[\"Host\", { person: \"Steve Rowe\" }]]}\n      by={{ person: \"Sam Carter\" }}\n      people={[\n        { person: \"Josh Tait\", meta: \"Designer\", rsvp: \"yes\" },\n        { person: \"Kate Clarke\", meta: \"Engineer\", rsvp: \"maybe\" },\n      ]}\n      peopleMeta=\"6 no reply\"\n    />\n  }\n>\n  Design workshop\n</HoverCard>"
          }
        ]
      }
    },
    {
      "name": "period-picker",
      "type": "registry:ui",
      "title": "Period Picker",
      "description": "One period for a page of charts: period pills, the days they cover said once, Custom dates, and a switch to compare with the period before.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/input",
        "@txbrendan/label",
        "@txbrendan/switch",
        "@txbrendan/tabs"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/period-picker.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "dates",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The period for a whole page of charts and numbers, like a monthly sales report.",
            "Pass periods, each with an id and a label. Give each a range, like 'Jul 1 to Sep 29', so the days are said once.",
            "Give each period a previous, like 'last quarter', so the switch reads 'Compare with last quarter'.",
            "Changes apply at once. Update the charts in onValueChange, with no Apply button.",
            "Pass today, as an ISO day, so Custom starts on the four weeks up to it and no date can pass it."
          ],
          "avoid": [
            "A range for one chart. Give that chart its own tabs.",
            "Picking one day, like a due date. Use Calendar.",
            "A form people submit later. Use date fields in the form."
          ]
        },
        "props": [
          {
            "name": "periods",
            "type": "{ id: string; label: string; range?: string; previous?: string }[]",
            "description": "Required. The period pills, in order. Custom comes after them."
          },
          {
            "name": "value",
            "type": "{ period: string; compare?: boolean; from?: string; to?: string }",
            "description": "The picked period, when controlled. from and to are ISO days, set only when period is 'custom'."
          },
          {
            "name": "defaultValue",
            "type": "PeriodValue",
            "default": "the first period",
            "description": "The first value when uncontrolled."
          },
          {
            "name": "onValueChange",
            "type": "(value: PeriodValue) => void",
            "description": "Called at once on each change. While a custom end is before its start, it waits."
          },
          {
            "name": "custom",
            "type": "boolean",
            "default": "true",
            "description": "Offer Custom, which opens From and To date fields."
          },
          {
            "name": "compare",
            "type": "boolean",
            "default": "true",
            "description": "Offer the switch to compare with the period before."
          },
          {
            "name": "today",
            "type": "string",
            "default": "today",
            "description": "Today as an ISO day. Custom starts on the four weeks up to it, and no date can pass it. One that is not a whole day falls back to the browser's today."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\"",
            "default": "\"neutral\"",
            "description": "The color of the picked pill."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Period\"",
            "description": "Names the pills for screen readers."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for the custom days, like 'Sep 2 to Sep 29'."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the root. Later classes win."
          }
        ],
        "a11y": [
          "The pills are Tabs, one Tab stop, moved with the arrow keys. Enter or Space picks one, so passing a pill does not reload the charts. The list is named 'Period' and described by the days it covers.",
          "The days are a polite live region, so a new period's days are read once.",
          "The compare switch is named by its words, like 'Compare with last quarter'.",
          "From and To are native date fields with visible labels, so each browser gives its own keyboard and screen reader support.",
          "An end before the start marks To as invalid and says so in a role=\"alert\" line under the fields."
        ],
        "examples": [
          {
            "title": "For a page of charts",
            "code": "const PERIODS = [\n  { id: \"quarter\", label: \"This quarter\", range: \"Jul 1 to Sep 29\", previous: \"last quarter\" },\n  { id: \"month\", label: \"This month\", range: \"Sep 1 to Sep 29\", previous: \"August\" },\n];\n\nconst [period, setPeriod] = useState({ period: \"quarter\" });\n\n<PeriodPicker periods={PERIODS} value={period} onValueChange={setPeriod} today=\"2026-09-29\" />"
          },
          {
            "title": "Pills only",
            "code": "<PeriodPicker periods={PERIODS} custom={false} compare={false} />"
          }
        ]
      }
    },
    {
      "name": "person",
      "type": "registry:ui",
      "title": "Person",
      "description": "An avatar and a name, with an optional meta line. The way to name a person anywhere.",
      "dependencies": [
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/person.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "people",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Naming a person anywhere: table cells, list rows, comments, cards, and popovers. A name always has its avatar beside it.",
            "meta adds a muted line under the name, like a role and a team.",
            "Set strong when the name is the row's title.",
            "A slot that takes a person, like a PeekCard fact or a table cell, takes a PersonValue, { person, photo, meta }, and renders Person from it. isPersonValue tells it apart from text."
          ],
          "avoid": [
            "A bare name with no avatar, in a place that names people. Use Person.",
            "A count of people. Use AvatarStack.",
            "A name that opens the person's details on hover. Use PeekPerson.",
            "Long meta text. It cuts off with an ellipsis on one line."
          ]
        },
        "props": [
          {
            "name": "name",
            "type": "string",
            "description": "Required. The person's full name."
          },
          {
            "name": "src",
            "type": "string",
            "description": "Photo URL. The avatar shows initials until it loads."
          },
          {
            "name": "meta",
            "type": "string",
            "description": "A muted line under the name, like \"Product lead · Design\"."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "The avatar's size, as Avatar's. The name is 11, 13, or 16px, and the meta line 11, 11, or 13px."
          },
          {
            "name": "strong",
            "type": "boolean",
            "default": "false",
            "description": "The name in the strong weight."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Later classes win."
          },
          {
            "name": "PersonValue",
            "type": "{ person: string; photo?: string; meta?: string }",
            "description": "Type. How to pass a person into a slot. Render it as <Person name={value.person} src={value.photo} meta={value.meta} />."
          },
          {
            "name": "isPersonValue",
            "type": "(value: unknown) => value is PersonValue",
            "description": "Function. True for a PersonValue, false for text and elements."
          }
        ],
        "a11y": [
          "The avatar is hidden from screen readers, so the name is read once.",
          "Renders plain <span> elements, so it reads in line inside a link, a button, or a table cell.",
          "The name and meta cut off with an ellipsis when there is no room. Give the full text somewhere, like the person's page or a peek.",
          "The meta line uses the muted text color, held to 4.5:1 on the page, cards, and popovers."
        ],
        "examples": [
          {
            "title": "Name and role",
            "code": "<Person name=\"Sam Carter\" meta=\"Product lead · Design\" />"
          },
          {
            "title": "A row's title",
            "code": "<Person name=\"Kate Clarke\" src=\"/people/kate.jpg\" strong />"
          },
          {
            "title": "From a PersonValue",
            "code": "import { Person, isPersonValue, type PersonValue } from \"@/components/ui/person\";\n\nfunction Cell({ value }: { value: string | PersonValue }) {\n  return isPersonValue(value) ? <Person name={value.person} src={value.photo} meta={value.meta} size=\"sm\" /> : value;\n}"
          }
        ]
      }
    },
    {
      "name": "photo-field",
      "type": "registry:ui",
      "title": "Photo Field",
      "description": "A person's own photo, changed in place: their photo at header size, then Change photo and Remove. A new photo opens in a circle to place, and saves as a 256px square.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/avatar",
        "@txbrendan/button",
        "@txbrendan/field-frame",
        "@txbrendan/tool-button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/photo-field.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "dates",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Every name has a photo. Put this in account settings, in joining, and in a guardian's sheet for their child.",
            "Say who sees the photo in hint, like \"Your workspace and the teams you're on see it.\"",
            "A chosen file, or one dropped on the photo, opens in a circle to place. Drag it or use the arrow keys, and zoom with the buttons or + and -.",
            "Use photo calls onValueChange with a 256px square JPEG data URL. Remove calls it with null. Upload it from there.",
            "Saving and removing go on the page's ⌘Z stack for 8 seconds. ⌘Z calls onValueChange again with the old photo.",
            "The photo changes where it is, so nothing else needs to say so. No toast."
          ],
          "avoid": [
            "Other files, like documents or lists. Use a file drop.",
            "Team badges or logos that must keep their shape. The photo is cut to a circle.",
            "Choosing among photos already uploaded. Show them as choices instead."
          ]
        },
        "props": [
          {
            "name": "name",
            "type": "string",
            "description": "Whose photo it is. Their initials show until there is a photo."
          },
          {
            "name": "value",
            "type": "string | null",
            "description": "The photo now: a URL, or null for initials. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string | null",
            "default": "null",
            "description": "The photo at first render, when you do not control it."
          },
          {
            "name": "onValueChange",
            "type": "(photo: string | null) => void",
            "description": "The new photo as a 256px square JPEG data URL, or null when removed. ⌘Z calls it again with the old one."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Names the field, above it."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "Who sees the photo. It describes the field."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Replaces the hint, in red, and is announced once. A file that is not a photo shows its own error here."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "The photo at 48, 56, or 64px, the circle to place it in, and the buttons."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks the buttons and drops."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the frame. id and aria-* land on the group."
          }
        ],
        "a11y": [
          "The label names the group, and the hint or error describes it.",
          "Change photo opens the file picker. A file can also be dropped on the photo.",
          "Placing, focus moves to the circle. The arrow keys move the photo, Shift moves it further, + and - zoom, Enter uses it, and Esc puts it back without closing a sheet around it.",
          "Zoom in and Zoom out are named buttons too, so placing never needs a drag.",
          "Once saved or removed, focus returns to Change photo or Add a photo, and a polite status says what changed.",
          "A file held over the photo shows an outline, which forced-colors mode draws too."
        ],
        "examples": [
          {
            "title": "A profile photo, with who sees it",
            "code": "<PhotoField\n  label=\"Profile photo\"\n  name=\"Riley Shaw\"\n  hint=\"Your workspace and the teams you're on see it.\"\n  onValueChange={(photo) => console.log(photo)}\n/>"
          }
        ]
      }
    },
    {
      "name": "pixel-icon",
      "type": "registry:ui",
      "title": "Pixel Icon",
      "description": "300 original retro desktop icons, drawn on a 32-pixel grid with detailed silhouettes, beveled edges, and theme colors.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/pixel-icon.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "media",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Retro interfaces, file browsers, playful empty states, and illustrated navigation.",
            "Use pixelIconCatalog for all 300 names and their 15 categories. Each icon has distinct artwork; directional and file-state variants are included.",
            "Use sm at 32px, md at 64px, or lg at 96px to preserve whole pixels.",
            "Use getPixelIconSvg(name, palette) to export a standalone SVG. The palette has 14 entries, including transparent at index 0. Resolve pixelIconPaints in the scoped theme before exporting; pixelIconTokens lists their source tokens. The Library block handles this conversion.",
            "The complete set ships together. Use the Pixel Icon Library block to search, copy, or download individual SVGs and the full ZIP."
          ],
          "avoid": [
            "Dense 16px toolbars: use Icon instead.",
            "Color alone to communicate status. Pair each illustration with a label.",
            "Importing the collection solely for one tiny icon when bundle size is critical."
          ]
        },
        "props": [
          {
            "name": "palette",
            "type": "PixelIconPalette",
            "description": "Optional 14-entry artwork palette, including transparency at index 0. Missing entries use the theme."
          },
          {
            "name": "name",
            "type": "PixelIconName",
            "default": "\"desktop-computer\"",
            "description": "One of the 300 names in pixelIconCatalog."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "32, 64, or 96 pixels. The underlying grid is always 32 × 32."
          },
          {
            "name": "title",
            "type": "string",
            "description": "Accessible name for a meaningful standalone icon. Omit when neighboring text names it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes on the SVG; later classes win. Prefer whole multiples of 32px."
          }
        ],
        "a11y": [
          "Decorative icons are hidden from screen readers. With title, the SVG is an image with that accessible name.",
          "SVGs do not take keyboard focus. A surrounding icon-only action still needs its own accessible label.",
          "Artwork follows the theme. Decorative shading is not a text-contrast claim."
        ],
        "examples": [
          {
            "title": "With a visible label",
            "code": "<div className=\"flex items-center gap-3\"><PixelIcon name=\"folder-open\" size=\"sm\" /><span>Project files</span></div>"
          },
          {
            "title": "Standalone illustration",
            "code": "<PixelIcon name=\"desktop-computer\" size=\"lg\" title=\"Desktop computer\" />"
          },
          {
            "title": "All available names",
            "code": "import { PixelIcon, pixelIconCatalog } from \"@/components/ui/pixel-icon\";\n\n<div className=\"flex flex-wrap gap-4\">{pixelIconCatalog.map(({ name }) => <PixelIcon key={name} name={name} title={name.replaceAll(\"-\", \" \")} />)}</div>"
          }
        ]
      }
    },
    {
      "name": "popover",
      "type": "registry:ui",
      "title": "Popover",
      "description": "A small panel that opens from a button, with an arrow that points back at it, for extra details or a quick form.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/popover.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Extra details people ask for, like shipping terms or who edited a file last.",
            "A quick form that belongs to one button, like renaming a file or picking a date range.",
            "Help text behind an info button. Touch screens and screen readers can open a popover, unlike a tooltip.",
            "Give it a PopoverTitle. It names the popover for screen readers.",
            "The panel opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on PopoverPopup so the panel keeps its colors. A container that clips its overflow can clip the panel."
          ],
          "avoid": [
            "A label for an icon button. Use a tooltip.",
            "A list of actions. Use a menu, which has arrow-key movement.",
            "A task that must be finished or cancelled before going on. Use a dialog, which blocks the page.",
            "Long content or several steps. Use a dialog or a page."
          ]
        },
        "props": [
          {
            "name": "Popover",
            "type": "{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void; modal?: boolean | \"trap-focus\" }",
            "description": "Root. Holds one popover's open state and renders no element. Takes open, defaultOpen, onOpenChange, and modal."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Popover. Whether it is open, controlled. Use it with onOpenChange."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Popover. Whether it is open at the start, when you do not control it."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean, details) => void",
            "description": "Popover. Called with the new open state when it opens or closes."
          },
          {
            "name": "modal",
            "type": "boolean | \"trap-focus\"",
            "default": "false",
            "description": "Popover. true blocks the page and its scroll while it is open. \"trap-focus\" keeps focus inside and leaves the page free. Either way, put a PopoverClose inside, so touch screen readers can leave it."
          },
          {
            "name": "PopoverTrigger",
            "type": "ComponentProps<\"button\"> & { render?: ReactElement; openOnHover?: boolean }",
            "description": "Part. The button that opens it. Pass render={<Button />} to use your own button."
          },
          {
            "name": "PopoverPopup",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The panel. It renders in a portal, above the page, while open."
          },
          {
            "name": "PopoverTitle",
            "type": "ComponentProps<\"h2\">",
            "description": "Part. The popover's name, in semibold. An <h2> by default. Use render={<h3 />} to fit your headings."
          },
          {
            "name": "PopoverDescription",
            "type": "ComponentProps<\"p\">",
            "description": "Part. Muted text under the title. Screen readers read it when the popover opens."
          },
          {
            "name": "PopoverClose",
            "type": "ComponentProps<\"button\"> & { render?: ReactElement }",
            "description": "Part. A button that closes the popover."
          },
          {
            "name": "side",
            "type": "\"top\" | \"right\" | \"bottom\" | \"left\"",
            "default": "\"bottom\"",
            "description": "PopoverPopup. Which side of the trigger it opens on. It flips when there is no room."
          },
          {
            "name": "align",
            "type": "\"start\" | \"center\" | \"end\"",
            "default": "\"center\"",
            "description": "PopoverPopup. How it lines up with the trigger."
          },
          {
            "name": "sideOffset",
            "type": "number",
            "default": "12, or 6 with no arrow",
            "description": "PopoverPopup. Gap between the trigger and the panel, in pixels. The arrow sits in this gap."
          },
          {
            "name": "collisionPadding",
            "type": "number | { top?: number; right?: number; bottom?: number; left?: number }",
            "default": "8",
            "description": "PopoverPopup. Least gap between the panel and the screen edge, in pixels. It keeps the panel off the edge on phones."
          },
          {
            "name": "arrow",
            "type": "boolean",
            "default": "true",
            "description": "PopoverPopup. Shows a small arrow that points at the trigger."
          },
          {
            "name": "container",
            "type": "HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null",
            "description": "PopoverPopup. The element the panel opens in. By default, the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no panel."
          },
          {
            "name": "effect",
            "type": "\"rise\" | \"grow\"",
            "default": "\"rise\"",
            "description": "PopoverPopup. How the panel opens and closes. rise fades in and moves out from the trigger. grow fades in and grows from the trigger, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none."
          },
          {
            "name": "className",
            "type": "string",
            "description": "PopoverPopup. Extra classes. Later classes win, so className='w-96' replaces the width."
          }
        ],
        "a11y": [
          "The panel has role=\"dialog\". PopoverTitle names it, and PopoverDescription describes it.",
          "The trigger has aria-expanded, and aria-haspopup=\"dialog\" tells screen readers what it opens.",
          "Opening it moves focus to the first control inside. With no control, or when opened by touch, focus goes to the panel itself.",
          "Escape closes it, and so does a click outside. Closing it puts focus back on the trigger.",
          "Focus is not trapped. Tab past the last control closes it and moves on to the next control after the trigger.",
          "To keep focus inside, set modal=\"trap-focus\" on Popover. Keep a PopoverClose inside too, so touch screen reader users can get out.",
          "The arrow is only a picture. Screen readers skip it.",
          "The theme tests hold popover text and muted text to 4.5:1 on the popover color, and focus rings and input borders to 3:1, in light and dark."
        ],
        "examples": [
          {
            "title": "Details behind a button (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Popover>\n  <PopoverTrigger render={<Button emphasis=\"outline\" />}>Shipping</PopoverTrigger>\n  <PopoverPopup>\n    <PopoverTitle>Free shipping</PopoverTitle>\n    <PopoverDescription>Orders over $50 ship free in the US.</PopoverDescription>\n  </PopoverPopup>\n</Popover>"
          },
          {
            "title": "A quick form (needs @txbrendan/button, input, and label)",
            "code": "import { Button } from \"@/components/ui/button\";\nimport { Input } from \"@/components/ui/input\";\nimport { Label } from \"@/components/ui/label\";\n\nfunction RenameFile({ name, rename }: { name: string; rename: (data: FormData) => void }) {\n  return (\n    <Popover>\n      <PopoverTrigger render={<Button emphasis=\"outline\" />}>Rename</PopoverTrigger>\n      <PopoverPopup>\n        <PopoverTitle>Rename file</PopoverTitle>\n        <form action={rename} className=\"mt-3 flex flex-col gap-3\">\n          <div className=\"flex flex-col gap-1.5\">\n            <Label htmlFor=\"file-name\">Name</Label>\n            <Input id=\"file-name\" name=\"name\" size=\"sm\" defaultValue={name} />\n          </div>\n          <div className=\"flex justify-end gap-2\">\n            <PopoverClose render={<Button size=\"sm\" emphasis=\"ghost\" />}>Cancel</PopoverClose>\n            <Button type=\"submit\" size=\"sm\" tone=\"brand\">\n              Save\n            </Button>\n          </div>\n        </form>\n      </PopoverPopup>\n    </Popover>\n  );\n}"
          },
          {
            "title": "Above the trigger, with no arrow (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Popover>\n  <PopoverTrigger render={<Button emphasis=\"ghost\" />}>Details</PopoverTrigger>\n  <PopoverPopup side=\"top\" arrow={false}>\n    <PopoverTitle>Last edited</PopoverTitle>\n    <PopoverDescription>Today at 9:41 by Sam Lee.</PopoverDescription>\n  </PopoverPopup>\n</Popover>"
          },
          {
            "title": "Grows from its trigger (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Popover>\n  <PopoverTrigger render={<Button emphasis=\"outline\" />}>Details</PopoverTrigger>\n  <PopoverPopup effect=\"grow\">\n    <PopoverTitle>Last edited</PopoverTitle>\n    <PopoverDescription>Today at 9:41.</PopoverDescription>\n  </PopoverPopup>\n</Popover>"
          }
        ]
      }
    },
    {
      "name": "position-slot",
      "type": "registry:ui",
      "title": "Position Slot",
      "description": "One player in one position on a team sheet: their photo, their name, then \"10 · Fly-half\", with pick-then-place moves.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/position-slot.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Team sheets and depth charts: one slot per shirt number.",
            "Name positions with positionOf(format, number), for 15s, sevens, 10s, and U13. It says \"Replacement\" past the starters.",
            "Move players by pick, then place: a click on a player picks them up (held), then pass holding to every other slot. A click there swaps or places them.",
            "Mouse drags work too, through onPick and onDrop. Keep the moves in your own state either way.",
            "Set a player's status to injured or unavailable. It shows in place of the position name.",
            "Put the player's avatar in player.avatar."
          ],
          "avoid": [
            "Drag as the only way to move someone. Always give onClick too.",
            "A plain list of people. Use a list or a table.",
            "Boxing each slot in a card. A slot has no box, so a sheet of them breathes."
          ]
        },
        "props": [
          {
            "name": "number",
            "type": "number",
            "description": "The shirt number, which says where they play."
          },
          {
            "name": "position",
            "type": "string",
            "description": "The position's name, like \"Fly-half\". positionOf(\"15s\", 10) gives it."
          },
          {
            "name": "player",
            "type": "{ person: string; avatar?: ReactNode; status?: \"injured\" | \"unavailable\" } | null",
            "description": "Who plays here. null or left out when the position is empty."
          },
          {
            "name": "holding",
            "type": "PositionSlotPlayer | null",
            "description": "Someone picked up in another slot. This slot shows a dashed edge, and its hint says \"Swap with …\" or \"Put … here\"."
          },
          {
            "name": "held",
            "type": "boolean",
            "default": "false",
            "description": "This slot's player is the one picked up."
          },
          {
            "name": "onClick",
            "type": "() => void",
            "description": "Picks up this player, or places the held one here. Without it the slot is not a button."
          },
          {
            "name": "onPick",
            "type": "() => void",
            "description": "A mouse drag started here."
          },
          {
            "name": "onDrop",
            "type": "() => void",
            "description": "Something was dropped here."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks clicks and drags."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "With onClick it is a native button, so Enter and Space pick up and place, as a click does.",
          "A held slot has aria-pressed=\"true\".",
          "The hint, like \"Swap with Noah Kerr\", stays in the button's name at all times, so screen readers hear what a press does.",
          "Injured and Not available are words, not only colors. They use the danger and warning soft text, which the theme holds to 4.5:1 on the page and cards.",
          "Photos are hidden from screen readers, since the name is right beside them.",
          "positionOf and positions come from a client file. Call them in client code, or pass position names from the server."
        ],
        "examples": [
          {
            "title": "A read-only slot",
            "code": "<PositionSlot number={10} position=\"Fly-half\" player={{ person: \"Noah Kerr\" }} />"
          },
          {
            "title": "Names from the format",
            "code": "\"use client\";\n\nimport { PositionSlot, positionOf } from \"@/components/ui/position-slot\";\n\nexport function FrontRow() {\n  return [1, 2, 3].map((number) => (\n    <PositionSlot key={number} number={number} position={positionOf(\"15s\", number)} />\n  ));\n}"
          },
          {
            "title": "Pick, then place",
            "code": "\"use client\";\n\nimport { useState } from \"react\";\nimport { PositionSlot, positionOf, type PositionSlotPlayer } from \"@/components/ui/position-slot\";\n\nexport function Backs() {\n  const [sheet, setSheet] = useState<Record<number, PositionSlotPlayer | null>>({\n    9: { person: \"Sam Carter\" },\n    10: { person: \"Noah Kerr\" },\n    12: null,\n  });\n  const [held, setHeld] = useState<number | null>(null);\n\n  const press = (number: number) => {\n    if (held === null) {\n      if (sheet[number]) setHeld(number);\n      return;\n    }\n    if (held !== number) setSheet({ ...sheet, [number]: sheet[held], [held]: sheet[number] });\n    setHeld(null);\n  };\n\n  return Object.keys(sheet).map(Number).map((number) => (\n    <PositionSlot\n      key={number}\n      number={number}\n      position={positionOf(\"15s\", number)}\n      player={sheet[number]}\n      held={held === number}\n      holding={held !== null && held !== number ? sheet[held] : null}\n      onClick={() => press(number)}\n    />\n  ));\n}"
          }
        ]
      }
    },
    {
      "name": "price-chart",
      "type": "registry:ui",
      "title": "Price Chart",
      "description": "A price over time against a baseline like the previous close: green above it, red below, with volume and each point's open, high, low, and close.",
      "dependencies": [
        "recharts@^3.10.1",
        "react-is@19.3.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/price-chart.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing how a price moved over a day, a week, or a year, like a stock, a coin, or a fund.",
            "Pass data with a time and a close for each point, oldest first. Add open, high, low, and volume to show them in the tooltip.",
            "Pass baseline, like the previous close, for the dashed line. Above it the line is green, below it red. Left out, the first close is the baseline.",
            "Switch ranges, as from tabs, and the new range draws in from the left as the old one fades. A new first point counts as a new range.",
            "Pass timeZone, like \"America/New_York\", to show times where the market is."
          ],
          "avoid": [
            "Several series at once. Use LineChart.",
            "Values that are not prices or have no baseline worth comparing to. Use AreaChart.",
            "A tiny trend in a table cell. Use Sparkline."
          ]
        },
        "props": [
          {
            "name": "data",
            "type": "{ time: number | string | Date; close: number; open?, high?, low?, volume?: number | null }[]",
            "description": "One point per time, oldest first. Points are spaced evenly, so nights and weekends leave no gap. A date and time with no zone, like \"2026-10-29T14:30\", is read as UTC. A point with no time is left out."
          },
          {
            "name": "baseline",
            "type": "number",
            "description": "The dashed line the price is measured from. Default: the first close."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "A visible title. It names the chart. TypeScript requires title, aria-label, or aria-labelledby."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the chart when it has no visible title."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "default": "US dollars, with three significant digits when every price is under a cent",
            "description": "Formats prices in the tooltip, and on the axis in short form. The axis adds digits when short labels would repeat."
          },
          {
            "name": "timeFormat",
            "type": "Intl.DateTimeFormatOptions",
            "description": "Formats the tooltip's time. Default: the time of day for points within a day, the date for daily points."
          },
          {
            "name": "tickFormat",
            "type": "Intl.DateTimeFormatOptions",
            "description": "Formats the time axis. Default: hours for a day, days up to two months, months up to two years, then years."
          },
          {
            "name": "timeZone",
            "type": "string",
            "default": "\"UTC\"",
            "description": "The time zone for every date. Fixed, so server and browser print the same text. One the browser does not know falls back to UTC."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for numbers and dates."
          },
          {
            "name": "volume",
            "type": "boolean",
            "default": "true when any point has volume",
            "description": "Shows volume bars along the bottom."
          },
          {
            "name": "labels",
            "type": "{ close?, open?, high?, low?, volume?, baseline?, change?: string }",
            "default": "baseline \"Start\"",
            "description": "The words for each value. Pass baseline: \"Previous close\" for a day."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height: 5, 7, or 9 times --control-h-md, so density scales it."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows a soft area shape at the same height. When data arrives, it fades out while the price draws in."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Shows this message in place of the chart. true shows a default message."
          },
          {
            "name": "onRetry",
            "type": "() => void",
            "description": "Shows a retry button under the error."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data yet\"",
            "description": "Shown when there are no points."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='h-96' replaces the height."
          }
        ],
        "a11y": [
          "Renders a <figure> named by its title, or by aria-label. TypeScript requires one.",
          "A hidden line reads the range at once: the last close, the change from the baseline, and the high and low.",
          "Tab focuses the chart. The arrow keys step through the points, and the tooltip reads out each one: its time, close, open, high, low, volume, and the change from the baseline.",
          "Up and down are said as a signed percent in the tooltip, not by color alone. The baseline is named in its pill.",
          "A line glides to the point under the pointer, and its dot grows in. The price labels fade back and the pill steps aside while it is read, since the tooltip carries the numbers. Escape hides the tooltip, the line, and the dot, and brings the labels back.",
          "A range on its way out takes no focus and no pointer, so Tab always lands on the chart being shown. Right to left, time still runs left to right. A long baseline name is cut short.",
          "The line draws in from the left once the chart is on screen. A new range draws in the same way as the old one fades. At 0ms everything draws at once, and Recharts skips the motion for reduced motion.",
          "Keeps one height while it loads, draws, fails, or has no data, so the page does not move."
        ],
        "examples": [
          {
            "title": "A day against the previous close",
            "code": "<PriceChart\n  title=\"Price today\"\n  data={points}\n  baseline={279.85}\n  timeZone=\"America/New_York\"\n  labels={{ baseline: \"Previous close\" }}\n/>"
          },
          {
            "title": "Ranges that follow tabs",
            "code": "const [range, setRange] = useState(\"1D\");\n\n<Tabs value={range} onValueChange={(next) => setRange(String(next))}>\n  <TabsList aria-label=\"Range\">\n    <TabsTab value=\"1D\">1D</TabsTab>\n    <TabsTab value=\"1M\">1M</TabsTab>\n    <TabsTab value=\"1Y\">1Y</TabsTab>\n  </TabsList>\n  <TabsPanel value={range}>\n    <PriceChart title=\"Price over the range\" data={byRange[range]} baseline={range === \"1D\" ? previousClose : undefined} />\n  </TabsPanel>\n</Tabs>"
          },
          {
            "title": "While it loads",
            "code": "<PriceChart title=\"Price today\" data={data ?? []} loading={isLoading} />"
          }
        ]
      }
    },
    {
      "name": "progress-log",
      "type": "registry:ui",
      "title": "Progress Log",
      "description": "The steps of a long piece of work, like an import, a setup, or a deploy. The list grows while it runs, then folds into one line, like Done in 34s, 5 steps. A failure stays open and says why.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/spinner",
        "@txbrendan/timeline"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/progress-log.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "ai",
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Show the steps of work that takes a while, like importing a file, setting up an account, or publishing a site.",
            "Pass loading while it runs. Add each step to the end of steps as it starts. The last one spins.",
            "Set loading to false when it is done. The list folds into one line that opens again.",
            "Pass total when you know how many steps there are. The line then counts them, like Step 3 of 5.",
            "If it stops, set loading to false and pass error in plain words. The last step turns red, and the list stays open.",
            "Pass duration, like 34s, and keep it current while loading for a live timer. The folded line uses it.",
            "Give each step an id, so a new one fades in and the rest stay still."
          ],
          "avoid": [
            "A single wait with no steps. Use Spinner.",
            "One job with its details and result. Use JobCard.",
            "Progress you can measure, like 3 of 5 files sent, with no steps to show. Use Progress.",
            "An agent's own steps while it thinks. Use ThinkingState."
          ]
        },
        "props": [
          {
            "name": "steps",
            "type": "{ id?: string; label: ReactNode; detail?: ReactNode; time?: string }[]",
            "description": "Required. The steps so far, oldest first. While loading, the last one is under way."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "The work is still going. The list stays open and grows. When it turns false, the list folds into one line."
          },
          {
            "name": "total",
            "type": "number",
            "description": "How many steps there will be. The line counts them while loading, like Step 3 of 5."
          },
          {
            "name": "duration",
            "type": "string",
            "description": "How long it has run, or ran in all, like \"34s\"."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "The work stopped on its last step. Say why in plain words. It shows under the line, and the list stays open."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "default": "\"Working\"",
            "description": "The line while loading. It pulses slowly."
          },
          {
            "name": "summary",
            "type": "ReactNode",
            "description": "The folded line once done. Default \"Done in 34s, 5 steps\", or \"Failed at step 3 of 5, after 34s\" with an error."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "description": "Starts open or folded. Default: open while loading or failed, folded when done."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Controls whether it is open. Pair it with onOpenChange. Then it no longer folds itself."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Called when it opens or folds."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Later classes win."
          }
        ],
        "a11y": [
          "The line is one button that opens and folds the steps, and it tells screen readers if it is open.",
          "The steps are an ordered list named Steps. The one under way says (working), and a failed one says (failed), to screen readers.",
          "Each new step is said politely in a hidden status region, and so is the summary when the work ends.",
          "A failure is shown three ways: the words Failed at, an icon, and the danger color. The reason is plain text under the line.",
          "The spinners and the pulse stop when the theme turns motion off, and for reduced motion. The list then opens and folds at once."
        ],
        "examples": [
          {
            "title": "While running",
            "code": "<ProgressLog\n  loading\n  total={4}\n  duration=\"12s\"\n  steps={[\n    { id: \"upload\", label: \"Uploaded contacts.csv\", detail: \"2,400 rows\" },\n    { id: \"check\", label: \"Checking each row\" },\n  ]}\n/>"
          },
          {
            "title": "Done",
            "code": "<ProgressLog\n  duration=\"34s\"\n  steps={[\n    { id: \"upload\", label: \"Uploaded contacts.csv\" },\n    { id: \"check\", label: \"Checked 2,400 rows\" },\n    { id: \"merge\", label: \"Merged 64 duplicates\" },\n  ]}\n/>"
          },
          {
            "title": "Failed",
            "code": "<ProgressLog\n  total={4}\n  duration=\"9s\"\n  error=\"Row 214 has no email. Fix it and import again.\"\n  steps={[\n    { id: \"upload\", label: \"Uploaded contacts.csv\" },\n    { id: \"check\", label: \"Checking each row\" },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "progress",
      "type": "registry:ui",
      "title": "Progress",
      "description": "A bar that shows how much of a task is done, with a label, an optional value, tones, sizes, and a state for when no one knows yet.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/dither"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/progress.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A task that takes more than a second or two and has a known end, like an upload or an import.",
            "Pass value={null} while the amount is not known yet. The bar pulses until you pass a number.",
            "Give it a label that says what is happening, like 'Uploading 3 photos'. Add showValue to show the percent.",
            "Use tone='success' or tone='danger' only to match what the task means, like a quota that is nearly full."
          ],
          "avoid": [
            "A value that is not a task, like disk space used or a password's strength. That needs role=\"meter\", which Progress does not set.",
            "Short waits under a second. Show nothing, or a spinner in the button that started it.",
            "Content that is still loading on the page. Use a skeleton in its place.",
            "Steps in a form. Use step dots or a list of steps."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "number | null",
            "description": "Required. How much is done, from min to max. null shows the pulsing state for an unknown amount."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Visible text above the bar. Without it, TypeScript requires aria-label or aria-labelledby."
          },
          {
            "name": "showValue",
            "type": "boolean",
            "default": "false",
            "description": "Shows the value as text at the end of the label row. It shows nothing while value is null."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the filled part."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Bar height: 4, 8, or 12px at the default density. lg also uses larger label text."
          },
          {
            "name": "min",
            "type": "number",
            "default": "0",
            "description": "The value at the empty end."
          },
          {
            "name": "max",
            "type": "number",
            "default": "100",
            "description": "The value at the full end. At max, the root gets data-complete."
          },
          {
            "name": "format",
            "type": "Intl.NumberFormatOptions",
            "description": "How the value reads, as text and to screen readers. The default is a percent of the range."
          },
          {
            "name": "getAriaValueText",
            "type": "(formattedValue: string | null, value: number | null) => string",
            "description": "Custom words for screen readers, like '3 of 10 files'."
          },
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\" | \"gradient\" | \"grain\"",
            "description": "dither draws the filled part as dots, gradient fades it from faint at the start to full at the moving end, and grain speckles it. Hover fills gradient and grain in. A bar with no value keeps its stripes. Left out, the bar follows the Texture around it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the root. Later classes win, so className='max-w-xs' narrows it."
          }
        ],
        "a11y": [
          "Renders role=\"progressbar\" with aria-valuenow, aria-valuemin, aria-valuemax, and aria-valuetext.",
          "With value={null}, aria-valuenow is left out, which tells screen readers the amount is unknown.",
          "TypeScript requires a visible label, aria-label, or aria-labelledby, so the bar always has a name.",
          "In server HTML, only aria-label and aria-labelledby name the bar. The visible label is linked once JavaScript runs.",
          "Screen readers do not announce each change. Say when the task ends, for example with a message in an aria-live region.",
          "The fill uses the tone's color on the muted track. No contrast pair checks the fill, so the label and value carry the meaning.",
          "The fill slides with --motion-duration-slow. The unknown state pulses at --motion-pulse-duration, which reduced motion and motion off set to 0s. Its pale fill, not the solid one, still shows it is not done."
        ],
        "examples": [
          {
            "title": "Upload with a label and percent",
            "code": "<Progress value={40} label=\"Uploading 3 photos\" showValue tone=\"brand\" />"
          },
          {
            "title": "Amount not known yet",
            "code": "<Progress value={null} label=\"Preparing your export\" />"
          },
          {
            "title": "No visible label",
            "code": "<Progress value={72} aria-label=\"Course progress\" size=\"sm\" />"
          },
          {
            "title": "Counting items instead of percent",
            "code": "<Progress\n  value={3}\n  max={10}\n  label=\"Importing contacts\"\n  showValue\n  format={{ style: \"decimal\" }}\n/>"
          },
          {
            "title": "Driven by state",
            "code": "\"use client\";\nimport { useEffect, useState } from \"react\";\n\nfunction Sync() {\n  const [done, setDone] = useState(0);\n  useEffect(() => {\n    if (done >= 100) return;\n    const timer = setTimeout(() => setDone((value) => Math.min(100, value + 10)), 400);\n    return () => clearTimeout(timer);\n  }, [done]);\n  return <Progress value={done} label={done < 100 ? \"Syncing\" : \"Synced\"} showValue tone={done < 100 ? \"brand\" : \"success\"} />;\n}"
          },
          {
            "title": "A gradient bar, full at the moving end",
            "code": "<Progress value={64} tone=\"brand\" label=\"Uploading\" showValue texture=\"gradient\" />"
          }
        ]
      }
    },
    {
      "name": "qr-code",
      "type": "registry:ui",
      "title": "QR Code",
      "description": "A real, scannable QR code made on the device, as ink on a white tile in both themes, with its quiet zone built in.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/qr-code.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A code people scan with a phone: a check-in, a join link, an event pass, a printed poster.",
            "Pass value, usually a link, and aria-label, like 'Check-in code for the Workshop'.",
            "Always pair it with another way in, like a short code to type or check-in by name. Nothing should be camera-only.",
            "Pass className='w-full' for a poster. It scales as vector.",
            "Use encodeQRCode(value) for the modules alone, to draw them some other way."
          ],
          "avoid": [
            "A link people can click on the same screen. Show the link.",
            "Long text, like a whole message. A long value makes a dense code that is hard to scan. Link to the text instead.",
            "Secrets. Anyone who can see the code can read it."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "string",
            "description": "Required. What the code holds, usually a link. It is encoded as UTF-8 bytes."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Required. Names the code for screen readers, like 'Ava Clarke's guest pass'."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "The most it draws: 120, 160, or 240px. Modules snap to whole pixels, so edges stay crisp."
          },
          {
            "name": "level",
            "type": "\"L\" | \"M\" | \"Q\" | \"H\"",
            "default": "\"M\"",
            "description": "The least error correction. It rises for free while the data still fits the same size."
          },
          {
            "name": "margin",
            "type": "number",
            "default": "4",
            "description": "The white quiet zone around the code, in modules. 4 is the standard's minimum."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='w-full max-w-none' fills a poster."
          }
        ],
        "a11y": [
          "Renders an <svg> with role=\"img\", named by aria-label. TypeScript requires it.",
          "The code is ink on a white tile in light and dark, since many phone cameras cannot read a light code on dark.",
          "A value too long for any code shows an empty dashed tile, and its name says so.",
          "Screen readers cannot read the code itself. Put the link or a short code beside it as text."
        ],
        "examples": [
          {
            "title": "A pass, named for screen readers",
            "code": "<QRCode value=\"https://example.com/p/ava-clarke-7q2k\" aria-label=\"Ava Clarke's guest pass\" />"
          },
          {
            "title": "With another way in",
            "code": "<div className=\"flex items-center gap-4\">\n  <QRCode value=\"https://example.com/check-in/4821\" aria-label=\"Check-in code for the Workshop\" size=\"sm\" />\n  <p>Or type the code 4821 at the door.</p>\n</div>"
          },
          {
            "title": "For a poster",
            "code": "<QRCode value=\"https://example.com/join\" aria-label=\"Join the team\" level=\"H\" className=\"w-full max-w-none\" />"
          }
        ]
      }
    },
    {
      "name": "radio-group",
      "type": "registry:ui",
      "title": "Radio Group",
      "description": "A set of radios where people pick exactly one option, with built-in labels, tones, sizes, and arrow-key movement.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/radio-group.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Picking exactly one option from a short list, like a delivery speed or a plan.",
            "Two to six options that all fit on screen. People compare them at a glance.",
            "Pass each option's label as RadioGroupItem children. Clicking the text checks the radio.",
            "Name the group with aria-label, or aria-labelledby pointing at a visible heading or legend.",
            "Set tone and size once, on RadioGroup. Every item follows them."
          ],
          "avoid": [
            "Picking any number of options. Use checkboxes.",
            "Options with a hint, a price, or a consequence. Use SelectionGroup with a SelectionCard for each.",
            "A setting that turns on or off. Use a switch.",
            "Long lists, more than about seven options. Use a select or a combobox.",
            "Starting with nothing checked when one answer is the safe default. Set defaultValue to it."
          ]
        },
        "props": [
          {
            "name": "RadioGroup",
            "type": "RadioGroupProps",
            "description": "Root. Holds the value. Takes value, defaultValue, onValueChange, name, required, readOnly, and disabled."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "RadioGroup. Names the group. TypeScript requires this or aria-labelledby."
          },
          {
            "name": "aria-labelledby",
            "type": "string",
            "description": "RadioGroup. The id of visible text that names the group, like a heading or a fieldset legend."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "RadioGroup. Color of the checked radio, for every item."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "RadioGroup. Radio size and label text size, for every item. Density scales them."
          },
          {
            "name": "value",
            "type": "string",
            "description": "RadioGroup. The checked option, controlled. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "RadioGroup. The option checked at the start when you do not control it."
          },
          {
            "name": "onValueChange",
            "type": "(value: string, details) => void",
            "description": "RadioGroup. Called with the new value when someone picks an option."
          },
          {
            "name": "name",
            "type": "string",
            "description": "RadioGroup. Form field name. The checked value is sent with the form."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "RadioGroup or RadioGroupItem. Blocks picking. On the group it blocks every item."
          },
          {
            "name": "readOnly",
            "type": "boolean",
            "default": "false",
            "description": "RadioGroup. Shows the value, but no one can change it. Items stay focusable."
          },
          {
            "name": "required",
            "type": "boolean",
            "default": "false",
            "description": "RadioGroup. A form will not send until an option is picked."
          },
          {
            "name": "aria-invalid",
            "type": "boolean",
            "description": "RadioGroup. Turns the group to the danger tone. Set it when validation fails, and link the message with aria-describedby."
          },
          {
            "name": "RadioGroupItem",
            "type": "RadioGroupItemProps",
            "description": "Part. One option. Takes value, which is required, and disabled."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "RadioGroupItem. Visible label text. Without it, TypeScript requires aria-label, aria-labelledby, or an id that a label points at."
          },
          {
            "name": "className",
            "type": "string",
            "description": "RadioGroup: classes for the group, so className='flex-row flex-wrap' lays items in a row. RadioGroupItem: classes for the circle. Later classes win."
          }
        ],
        "a11y": [
          "The group has role=\"radiogroup\". Each item renders a span with role=\"radio\" plus a hidden native radio, so the value is sent with the form.",
          "The group is one tab stop. Tab lands on the checked radio, or on the first one when none is checked.",
          "Arrow keys move the choice and check the radio they land on. They skip disabled items. Space checks the focused radio.",
          "TypeScript requires a name for the group, and children, aria-label, aria-labelledby, or an id for each item. It cannot check that a label points at the id.",
          "In server HTML, only aria-label and aria-labelledby name a radio. A label from children or an id is linked once JavaScript runs.",
          "The unchecked border uses --input and the focus ring uses --ring. The theme tests hold both to 3:1 against the page and cards.",
          "The dot uses the tone's foreground on its fill, which the theme tests hold to 4.5:1.",
          "Press scale and the dot's pop-in follow --motion-* tokens. Reduced motion turns both off and keeps the fade."
        ],
        "examples": [
          {
            "title": "Named by a visible heading",
            "code": "<div className=\"grid gap-3\">\n  <p id=\"delivery-label\" className=\"text-sm font-medium\">\n    Delivery speed\n  </p>\n  <RadioGroup aria-labelledby=\"delivery-label\" name=\"delivery\" defaultValue=\"standard\" tone=\"brand\">\n    <RadioGroupItem value=\"standard\">Standard, 3 to 5 days</RadioGroupItem>\n    <RadioGroupItem value=\"express\">Express, next day</RadioGroupItem>\n    <RadioGroupItem value=\"pickup\">Pick up in store</RadioGroupItem>\n  </RadioGroup>\n</div>"
          },
          {
            "title": "A label with a second line",
            "code": "<RadioGroup aria-label=\"Plan\" defaultValue=\"team\">\n  <RadioGroupItem value=\"solo\">\n    <span className=\"block font-medium\">Solo</span>\n    <span className=\"block text-muted-foreground\">One person, three projects.</span>\n  </RadioGroupItem>\n  <RadioGroupItem value=\"team\">\n    <span className=\"block font-medium\">Team</span>\n    <span className=\"block text-muted-foreground\">Up to ten people, every project.</span>\n  </RadioGroupItem>\n</RadioGroup>"
          },
          {
            "title": "In a row",
            "code": "<RadioGroup aria-label=\"Size\" defaultValue=\"m\" className=\"flex-row flex-wrap gap-x-6\">\n  <RadioGroupItem value=\"s\">Small</RadioGroupItem>\n  <RadioGroupItem value=\"m\">Medium</RadioGroupItem>\n  <RadioGroupItem value=\"l\">Large</RadioGroupItem>\n</RadioGroup>"
          },
          {
            "title": "Controlled",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nfunction Theme() {\n  const [theme, setTheme] = useState(\"system\");\n  return (\n    <RadioGroup aria-label=\"Theme\" value={theme} onValueChange={(value) => setTheme(value as string)}>\n      <RadioGroupItem value=\"light\">Light</RadioGroupItem>\n      <RadioGroupItem value=\"dark\">Dark</RadioGroupItem>\n      <RadioGroupItem value=\"system\">Match my device</RadioGroupItem>\n    </RadioGroup>\n  );\n}"
          },
          {
            "title": "Required, in a form, with an error",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nfunction DeliverySpeed() {\n  const [missing, setMissing] = useState(false);\n  return (\n    <form\n      noValidate\n      className=\"grid gap-2\"\n      onSubmit={(event) => {\n        if (!new FormData(event.currentTarget).get(\"speed\")) {\n          event.preventDefault();\n          setMissing(true);\n        }\n      }}\n    >\n      <p id=\"speed-label\" className=\"text-sm font-medium\">\n        Delivery speed\n      </p>\n      <RadioGroup\n        name=\"speed\"\n        required\n        aria-labelledby=\"speed-label\"\n        aria-invalid={missing || undefined}\n        aria-describedby={missing ? \"speed-error\" : undefined}\n        onValueChange={() => setMissing(false)}\n      >\n        <RadioGroupItem value=\"standard\">Standard</RadioGroupItem>\n        <RadioGroupItem value=\"express\">Express</RadioGroupItem>\n      </RadioGroup>\n      {missing && (\n        <p id=\"speed-error\" className=\"text-sm text-destructive-soft-foreground\">\n          Pick a delivery speed.\n        </p>\n      )}\n      <button type=\"submit\">Order</button>\n    </form>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "rail",
      "type": "registry:ui",
      "title": "Rail",
      "description": "The app's left rail, 199px wide: your app's name, Search, places grouped with their counts, one pinned at the bottom, and the workspace you're in.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/badge",
        "@txbrendan/icon",
        "@txbrendan/kbd",
        "@txbrendan/team-flag",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/rail.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The left edge of an app with a handful of places: Home, Messages, each team, Projects.",
            "Group places with a title, like \"Teams\". Give a place a badge for its count, in tone \"warning\" when it needs someone, or \"brand\" for news.",
            "Give each place an href to make it a link, or leave it off and handle onSelect. Pass active for the place you're in.",
            "Pass onSearch to show Search at the top. ⌘K (Ctrl+K) opens it from anywhere but a text field. If the page has a Command Menu, give that shortcut={false}, so one ⌘K opens one search.",
            "Put Settings in footer, so it stays at the bottom however many places there are. Put the workspace you're in, with its colors, in club.",
            "The rail fills its parent's height. Give the parent a height, or pass a className like h-dvh."
          ],
          "avoid": [
            "Phones. A 199px rail leaves too little room. Use a bottom bar or a sheet there.",
            "Places inside one page. Use tabs.",
            "Switching workspaces. Put a switcher in header."
          ]
        },
        "props": [
          {
            "name": "groups",
            "type": "{ title?: string; items: RailItem[] }[]",
            "description": "Required. RailItem is { id, label, icon?, href?, badge?, tone? }. icon is a name from Icon's set or any icon. tone is \"neutral\", \"warning\", or \"brand\"."
          },
          {
            "name": "active",
            "type": "string",
            "description": "The id of the place you're in. Controlled, with onSelect."
          },
          {
            "name": "defaultActive",
            "type": "string",
            "description": "The place you start in, when active is not set."
          },
          {
            "name": "onSelect",
            "type": "(id: string) => void",
            "description": "Runs with the item's id when you press it."
          },
          {
            "name": "onSearch",
            "type": "() => void",
            "description": "Opens search. Shows Search at the top and listens for ⌘K (Ctrl+K)."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Main\"",
            "description": "The navigation's name for screen readers."
          },
          {
            "name": "header",
            "type": "ReactNode",
            "description": "The top of the rail, like your app's name or a switcher."
          },
          {
            "name": "footer",
            "type": "RailItem",
            "description": "One item pinned at the bottom, like Settings."
          },
          {
            "name": "club",
            "type": "{ label: string; colors?: [string, string]; pattern?: \"hoops\" | \"halves\" | \"thirds\" | \"split\" }",
            "description": "The workspace you're in, at the foot, with its colors as a TeamFlag."
          },
          {
            "name": "alerts",
            "type": "number",
            "description": "Its open alerts, as a count beside its name."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the rail. Later classes win."
          }
        ],
        "a11y": [
          "The rail is a navigation landmark named \"Main\", or label. Each group is a list named by its title.",
          "Places are links, or buttons without an href. Each is a Tab stop, top to bottom. The place you're in says aria-current=\"page\".",
          "The place you're in is a raised card and bold. Forced-colors mode draws an outline around it.",
          "Search says its shortcut in aria-keyshortcuts. ⌘K never fires while you type in a field.",
          "The wash follows the pointer and the keyboard. It never shows for touch, and it moves at once with reduced motion.",
          "Headings, Search, and icons hold 4.5:1 on the rail's gray and under the wash, in both themes."
        ],
        "examples": [
          {
            "title": "Grouped places, Search, and a pinned footer",
            "code": "<div className=\"flex h-[580px]\">\n  <Rail\n    header={<span className=\"font-semibold\">Your company</span>}\n    groups={[\n      { items: [{ id: \"home\", label: \"Home\", icon: \"house\", href: \"/\" }, { id: \"messages\", label: \"Messages\", icon: \"message-circle\", href: \"/messages\", badge: 3, tone: \"brand\" }] },\n      { title: \"Teams\", items: [{ id: \"design\", label: \"Design\", icon: \"users\", href: \"/teams/design\" }] },\n    ]}\n    active=\"home\"\n    onSearch={() => console.log(\"search\")}\n    footer={{ id: \"settings\", label: \"Settings\", icon: \"settings\", href: \"/settings\" }}\n    club={{ label: \"Your team\", colors: [\"#1e3a8a\", \"#ffffff\"], pattern: \"hoops\" }}\n    alerts={2}\n  />\n</div>"
          }
        ]
      }
    },
    {
      "name": "read-by",
      "type": "registry:ui",
      "title": "Read By",
      "description": "How many have read a message, out of how many it went to, with a peek that reminds the rest.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/hover-card",
        "@txbrendan/peek-card",
        "@txbrendan/person"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/read-by.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "people",
        "display",
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Under a message, a notice, or a form people were asked to read: \"Read by 241 of 280\".",
            "Pass everyone it went to in people. Mark read: true, or the time, like \"10:12\", once they have.",
            "Its peek lists who has not read it yet, each with Remind, and Remind all for two or more.",
            "Send the reminders in onRemind. It runs once the 8-second undo window ends, never on the press.",
            "Once everyone has read it, it says \"Read by all 23\" as plain text, with no peek."
          ],
          "avoid": [
            "A count with no one behind it, like page views. Use plain text.",
            "Showing who has read it. The peek is for the ones who have not."
          ]
        },
        "props": [
          {
            "name": "people",
            "type": "Array<{ person: string; photo?: string; meta?: string; read?: boolean | string }>",
            "description": "Required. Everyone it went to. read is true, or when they read it, once they have."
          },
          {
            "name": "onRemind",
            "type": "(people: { person: string; photo?: string }[]) => void",
            "description": "Reminds people who have not read it, once the undo window ends. Remind all leaves out anyone already reminded."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Controlled. The peek shows while true. Hover, focus, and Esc only ask, through onOpenChange."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Starts with the peek open, without taking focus."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Hears the peek open and close, or what it asks for when controlled."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the text. Later classes win."
          }
        ],
        "a11y": [
          "With people still to read it, the text is a focusable button that opens a dialog. Its name is the count, like \"Read by 241 of 280\".",
          "The peek is named by its title, like \"39 haven't read it yet\". Enter moves into it, and Esc closes it and puts focus back.",
          "Each Remind says what it did in the page's one polite live region. Undo arms 1.2 seconds after a key press, and ⌘Z or Ctrl+Z takes back the newest reminder.",
          "Once everyone has read it, the text is plain, with no button and no Tab stop."
        ],
        "examples": [
          {
            "title": "Under a message",
            "code": "<ReadBy\n  people={[\n    { person: \"Kate Clarke\", read: \"10:12\" },\n    { person: \"Tom Hale\", read: true },\n    { person: \"Ava Moss\", meta: \"Design team\" },\n    { person: \"Rob Kerr\", meta: \"Support team\" },\n  ]}\n  onRemind={(people) => console.log(\"remind\", people)}\n/>"
          }
        ]
      }
    },
    {
      "name": "receipt",
      "type": "registry:ui",
      "title": "Receipt",
      "description": "What was paid, by whom, when, and how, with the status after the title. A declined payment puts its fixes where it is said.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/person",
        "@txbrendan/status-text"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/receipt.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A payment's record, inside a Card or a Sheet. It draws no box of its own.",
            "status follows the title as colored text: Paid in success, Processing in brand, Refunded in neutral, Declined in danger.",
            "A declined payment: onRetry puts a retry icon right beside Declined, note says why in plain words, and onChangeCard adds Try another card under it.",
            "A refund's note can say where the money went, like \"£15 back to Visa ending 4242 on Wed 30 Sep\".",
            "A discount is a negative amount. It shows a true minus, like −£20, and screen readers hear \"minus £20\".",
            "Amounts show pence only when some amount has them, so the column lines up."
          ],
          "avoid": [
            "A badge or a box for the status. The status is colored words after the title.",
            "A list of payments. Use DataTable or ListRow, and open a Receipt from a row."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "string",
            "description": "Required. What was paid for, like \"October subscription\"."
          },
          {
            "name": "status",
            "type": "\"paid\" | \"pending\" | \"refunded\" | \"declined\"",
            "description": "Shown as Paid, Processing, Refunded, or Declined."
          },
          {
            "name": "note",
            "type": "string",
            "description": "One line under the title, in ink."
          },
          {
            "name": "lines",
            "type": "{ label: string; amount: number; meta?: string }[]",
            "description": "What the total is made of. A negative amount is a discount."
          },
          {
            "name": "total",
            "type": "number",
            "description": "Default the sum of the lines."
          },
          {
            "name": "currency",
            "type": "string",
            "default": "\"£\"",
            "description": "The money sign."
          },
          {
            "name": "by",
            "type": "{ person: string; photo?: string }",
            "description": "Who paid, drawn by Person with their photo."
          },
          {
            "name": "when",
            "type": "string",
            "description": "Like \"Tue 29 Sep, 18:04\"."
          },
          {
            "name": "method",
            "type": "string",
            "description": "Like \"Visa ending 4242\"."
          },
          {
            "name": "reference",
            "type": "string",
            "description": "The payment's reference."
          },
          {
            "name": "onRetry",
            "type": "() => void | Promise<unknown>",
            "description": "Declined: a retry icon beside Declined. Return a promise and it waits, and a failure turns it into Retry."
          },
          {
            "name": "retryLabel",
            "type": "string",
            "default": "\"Try again\"",
            "description": "Names the retry icon, in its tooltip."
          },
          {
            "name": "onChangeCard",
            "type": "() => void",
            "description": "Declined: a quiet text button with a card icon, under the note."
          },
          {
            "name": "changeCardLabel",
            "type": "string",
            "default": "\"Try another card\"",
            "description": "Its words."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Later classes win."
          }
        ],
        "a11y": [
          "The status is words, so it never leans on color alone.",
          "The retry icon is a real button named by retryLabel, in its tooltip too. Try another card is a real button with words.",
          "A discount is read as \"minus £20\", and the true minus sign shows.",
          "The payer, the time, the method, and the reference are a description list, so each is read with its label."
        ],
        "examples": [
          {
            "title": "Paid",
            "code": "<Receipt\n  title=\"October subscription\"\n  status=\"paid\"\n  lines={[{ label: \"Team plan\", meta: \"1 to 31 Oct\", amount: 15 }]}\n  by={{ person: \"Kate Clarke\" }}\n  when=\"Tue 29 Sep, 18:04\"\n  method=\"Visa ending 4242\"\n  reference=\"INV-0042\"\n/>"
          },
          {
            "title": "Declined",
            "code": "<Receipt\n  title=\"October subscription\"\n  status=\"declined\"\n  note=\"Your bank turned it down. No money was taken.\"\n  onRetry={() => retry()}\n  onChangeCard={() => openCards()}\n  lines={[{ label: \"Team plan\", amount: 15 }]}\n/>"
          }
        ]
      }
    },
    {
      "name": "reorder-list",
      "type": "registry:ui",
      "title": "Reorder List",
      "description": "A list you put in order, like an on-call order or a meeting's running order. Drag a row by its handle, or pick it up with Space and move it with the arrows.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/motion-timing",
        "@txbrendan/person",
        "@txbrendan/tool-button",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/reorder-list.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "An on-call order, a meeting's running order, agenda items: one short list, in a Card.",
            "Pass items with an id each. A person shows with their photo. Anything else shows its label and meta, or your renderItem.",
            "Keep the order in state with onReorder. It hands back your own objects, in their new order, after each drop.",
            "Tell people about the new order in onSettle. It runs once the 8-second ⌘Z window ends, so ⌘Z never takes back a message already sent.",
            "Turn on numbered when the rank matters. Ranks and dividers belong to the slots and stay put while rows move.",
            "Set disabled once the order is final, like once the order is sent. The handles go, so it no longer looks as if it could move."
          ],
          "avoid": [
            "Placing people onto a team sheet's positions. That is a slot for each position, like PositionSlot.",
            "Long lists that scroll. A drag does not scroll the page for you.",
            "Your own undo button or timer. Each drop joins the page's ⌘Z stack, and moves made within 8 seconds undo together."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "T[] where T extends { id: string; person?: string; photo?: string; label?: string; meta?: string }",
            "description": "The rows, in order."
          },
          {
            "name": "onReorder",
            "type": "(items: T[]) => void",
            "description": "The items in their new order after each drop, and the order before when ⌘Z puts it back."
          },
          {
            "name": "onSettle",
            "type": "(items: T[]) => void",
            "description": "The order is final: its ⌘Z window ended. Tell people here."
          },
          {
            "name": "renderItem",
            "type": "(item: T, index: number) => ReactNode",
            "description": "Row content. Default a Person for people, otherwise the label and meta."
          },
          {
            "name": "numbered",
            "type": "boolean",
            "default": "false",
            "description": "Shows each rank at the row's left."
          },
          {
            "name": "label",
            "type": "string",
            "description": "Names the list, like \"On call, in order\"."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Locked. The rows show no handles."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "The list is an ordered list named by label, so screen readers say each row's place.",
          "Each row's handle is a button named \"Move\" and the row's name. The handles share one Tab stop, and ↑↓, Home, and End move between them.",
          "Space picks the focused row up, and its handle reads as pressed. ↑↓ move it, Home and End take it to the ends, Space or Enter drops it, and Esc puts it back.",
          "A row that is up owns Esc, even inside a Sheet. The next Esc closes the sheet.",
          "Focus leaving a row that is up drops it where it is.",
          "A polite live region says each step: picked up, each place it passes, and where it dropped or went back.",
          "Every drag has the same moves on the keys. A mouse lifts a row after 4 px, a finger after holding still for 350 ms, and Esc cancels a drag.",
          "Reduced motion turns off the glide. The rows step to their new places."
        ],
        "examples": [
          {
            "title": "A running order",
            "code": "const [plan, setPlan] = useState(PLAN);\n\n<Card className=\"gap-0 py-1.5\">\n  <ReorderList items={plan} label=\"Tuesday's agenda, in order\" numbered onReorder={setPlan} onSettle={save} />\n</Card>"
          },
          {
            "title": "People in ranked order",
            "code": "<ReorderList\n  items={[\n    { id: \"sam\", person: \"Sam Carter\", meta: \"Engineering\" },\n    { id: \"noah\", person: \"Noah Kerr\", meta: \"Engineering\" },\n  ]}\n  label=\"On call, in order\"\n  numbered\n  onReorder={setOnCall}\n/>"
          }
        ]
      }
    },
    {
      "name": "repeat-field",
      "type": "registry:ui",
      "title": "Repeat Field",
      "description": "How a session repeats, one question at a time: once, every week, or every 2 weeks, then the days, then until when. Under it, the series in words with its count, and any sessions that clash with a closure, with a way to skip them.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/button",
        "@txbrendan/choice-chips",
        "@txbrendan/date-picker",
        "@txbrendan/field-frame",
        "@txbrendan/selection-card",
        "@txbrendan/status-text",
        "@txbrendan/tool-button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/repeat-field.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Setting up a class, a team meeting, or any session that repeats through a season.",
            "Pass start, the first session, and time, which the series says back, like \"19:00 to 20:30\".",
            "Pass seasonEnd to offer \"End of season\". Without it, the field asks for a last day.",
            "Pass closed with each closure and why, like a room being repainted. Sessions that fall in one say so, and Skip them leaves them out.",
            "Skip them goes on ⌘Z for 8 seconds, and a Toaster on the page shows it with Undo.",
            "Every 2 weeks counts from the start's own week."
          ],
          "avoid": [
            "A single date. Use a date picker.",
            "Rules a calendar app needs, like the last Friday of each month. This covers the weekly kinds a class or a team uses.",
            "Choosing days with no session to repeat. Use choice chips."
          ]
        },
        "props": [
          {
            "name": "start",
            "type": "string",
            "description": "The first session, \"2026-10-06\". Required."
          },
          {
            "name": "time",
            "type": "string",
            "description": "Said back in the series, like \"19:00 to 20:30\"."
          },
          {
            "name": "seasonEnd",
            "type": "string",
            "description": "The season's last day, \"2027-05-15\". Offers \"End of season\", picked by default."
          },
          {
            "name": "closed",
            "type": "{ from: string; to: string; reason: string }[]",
            "description": "Days the session can't happen, as inclusive ranges with why, like \"Room 2 is closed for repainting\"."
          },
          {
            "name": "value",
            "type": "{ every: \"none\" | \"week\" | \"two-weeks\"; days?: number[]; until?: string | null; skip?: string[] }",
            "description": "The answers. days are weekdays, 0 is Monday. until is a day, \"season\", or null to ask. skip lists days left out. Use with onValueChange to control it."
          },
          {
            "name": "defaultValue",
            "type": "RepeatValue",
            "description": "The answers at first render, when you do not control it. Default once."
          },
          {
            "name": "onValueChange",
            "type": "(value: RepeatValue) => void",
            "description": "Called with the answers on each change, skips too."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "default": "\"Repeats\"",
            "description": "Names the first question."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Text, card, and chip size."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Turns every question off."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-GB\"",
            "description": "A language tag for days and dates, which reads \"Sat 15 May\"."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the outer box."
          }
        ],
        "a11y": [
          "How often is a radio group named by the label. The arrow keys move and choose.",
          "The days are a group of checkboxes named \"On\". Until is a radio group, and its date opens a calendar.",
          "Each clash says its status in words, \"3 sessions clash\", then why. Its dates show on hover, focus, or tap.",
          "Skip them moves focus to the series it changed, and a polite status says how many were skipped.",
          "⌘Z puts them back, and the page says so once."
        ],
        "examples": [
          {
            "title": "Weekly to the season's end, with a closure",
            "code": "<RepeatField\n  start=\"2026-10-06\"\n  time=\"19:00 to 20:30\"\n  seasonEnd=\"2027-05-15\"\n  closed={[{ from: \"2026-10-13\", to: \"2026-10-20\", reason: \"Room 2 is closed for repainting\" }]}\n  defaultValue={{ every: \"week\", days: [1, 3], until: \"season\" }}\n/>"
          },
          {
            "title": "Once, until it repeats",
            "code": "<RepeatField start=\"2026-10-06\" time=\"10:00 to 11:00\" />"
          }
        ]
      }
    },
    {
      "name": "response-button",
      "type": "registry:ui",
      "title": "Response Button",
      "description": "An availability or RSVP answer, like Going, Can't go, Maybe, or Online. Answers sit in a ResponseGroup named for the question, and work like native radios.",
      "dependencies": [
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/response-button.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Answering an event or an invite: two to four answers in a ResponseGroup named for the question, like 'Tuesday workshop'.",
            "Give each answer the tone of its meaning: success for Going, danger for Can't go, warning for Maybe, neutral for Online.",
            "md (32px) on event pages. sm (28px) inline in rows and status strips.",
            "Add an icon before the words, like a check for Going, so the answer reads without its color."
          ],
          "avoid": [
            "Answers that can be picked together. Use checkboxes.",
            "A long list of choices. Use RadioGroup or Select.",
            "A ResponseButton on its own, outside a ResponseGroup. It then has no arrow keys and no single Tab stop."
          ]
        },
        "props": [
          {
            "name": "children",
            "type": "ReactNode",
            "description": "The answer, like 'Going', with an optional icon before it."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color role of the answer. The chosen answer fills with it, and an open one hints it on hover."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "28, 32, or 36px tall at the default density, scaled by density."
          },
          {
            "name": "selected",
            "type": "boolean",
            "default": "false",
            "description": "This answer is the chosen one."
          },
          {
            "name": "onClick",
            "type": "() => void",
            "description": "Runs on a press, and when the arrow keys move onto it. Choose the answer here."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks the answer. The arrow keys skip it."
          },
          {
            "name": "ref",
            "type": "Ref<HTMLButtonElement>",
            "description": "Reaches the button. id, aria-* and data-* land on it too."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the button. Later classes win."
          },
          {
            "name": "ResponseGroup",
            "type": "ComponentProps<\"div\"> & ({ \"aria-label\": string } | { \"aria-labelledby\": string })",
            "description": "Component. The radio group for one question's answers, in a row with a 6px gap. Its name is required."
          }
        ],
        "a11y": [
          "Each answer is a radio, named by its words, with aria-checked for the chosen one.",
          "It finds its group on the page, the closest role='radiogroup'. ResponseGroup is that group, named for the question.",
          "The group is one Tab stop: the chosen answer, or the first one when none is chosen.",
          "The arrow keys move and choose, and wrap. Home and End jump to the ends. Disabled answers are skipped. In right-to-left text, left and right swap.",
          "The chosen answer differs by fill as well as color, so it reads without color."
        ],
        "examples": [
          {
            "title": "Answering an event",
            "code": "import { Check, CircleHelp, Video, X } from \"lucide-react\";\nimport { useState } from \"react\";\n\nfunction Rsvp() {\n  const [answer, setAnswer] = useState<string>();\n  const answers = [\n    { value: \"going\", label: \"Going\", tone: \"success\", icon: <Check /> },\n    { value: \"maybe\", label: \"Maybe\", tone: \"warning\", icon: <CircleHelp /> },\n    { value: \"no\", label: \"Can't go\", tone: \"danger\", icon: <X /> },\n    { value: \"online\", label: \"Online\", tone: \"neutral\", icon: <Video /> },\n  ] as const;\n  return (\n    <ResponseGroup aria-label=\"Tuesday workshop\">\n      {answers.map(({ value, label, tone, icon }) => (\n        <ResponseButton key={value} tone={tone} selected={answer === value} onClick={() => setAnswer(value)}>\n          {icon}\n          {label}\n        </ResponseButton>\n      ))}\n    </ResponseGroup>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "ring-progress",
      "type": "registry:ui",
      "title": "Ring Progress",
      "description": "A small ring that fills toward a goal, with the percent inside. It turns green once the goal is met.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/dither"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/ring-progress.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Progress toward a goal in a small space, like tasks done this week, a profile that is 75% done, or a target in a card.",
            "Pass value as a percent, or value and max, like value={3} max={4}.",
            "Pass aria-label to say what it measures, like 'Tasks'. Screen readers hear 'Tasks: 75%'.",
            "Pass showValue={false} for a small ring beside its own words, like '3 of 4'.",
            "Leave tone out, and the ring turns success once it is full. Pass a tone to keep one color."
          ],
          "avoid": [
            "A task that runs, like an upload. Use Progress.",
            "Parts of a whole, like a budget split. Use DonutChart.",
            "A ranking or many values side by side. Use BreakdownList."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "number",
            "description": "Required. How much is done, from 0 to max. Values outside the range are clamped."
          },
          {
            "name": "max",
            "type": "number",
            "default": "100",
            "description": "The value of a full ring."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the filled part. Left out, the ring is neutral and turns success once it is full. Passed, it stays that tone."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "36, 44, or 66px across at the default density. lg uses larger text."
          },
          {
            "name": "showValue",
            "type": "boolean",
            "default": "true",
            "description": "Shows the percent inside. The percent is read out either way."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "What the ring measures, like 'Tasks'. Screen readers hear it before the percent."
          },
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\" | \"gradient\" | \"grain\"",
            "description": "dither draws the filled part as dots, gradient fades it along the arc to full at its end, and grain speckles it, with a glow on dark pages. Hover fills gradient and grain in. Left out, the ring follows the Texture around it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "Renders role=\"img\", named by aria-label and the percent, like 'Tasks: 75%'. With no aria-label, the name is the percent.",
          "The number inside is hidden from screen readers, since the name already says it. With showValue={false} the name still does.",
          "The filled part uses the tone's color on the track. The track is quiet, so the percent carries the meaning, in text or in the name.",
          "The fill sweeps to a new value over --motion-duration-slow. Reduced motion jumps straight to it."
        ],
        "examples": [
          {
            "title": "A percent, named by its label",
            "code": "<RingProgress value={75} aria-label=\"Tasks\" />"
          },
          {
            "title": "A count toward a target",
            "code": "<RingProgress value={3} max={4} aria-label=\"Sessions this week\" />"
          },
          {
            "title": "Beside its own words",
            "code": "<span className=\"flex items-center gap-2\">\n  <RingProgress value={3} max={4} size=\"sm\" showValue={false} aria-label=\"Forms returned\" />\n  3 of 4 forms returned\n</span>"
          },
          {
            "title": "One color, even when full",
            "code": "<RingProgress value={100} tone=\"brand\" aria-label=\"Ticket sales\" />"
          },
          {
            "title": "A gradient ring that fills in on hover",
            "code": "<RingProgress value={68} tone=\"brand\" texture=\"gradient\" aria-label=\"CPU load\" />"
          }
        ]
      }
    },
    {
      "name": "rolling-number",
      "type": "registry:ui",
      "title": "Rolling Number",
      "description": "A number whose digits roll to each new value, each one the way the whole value moved, so a value that changes during a scrub or a step reads as one number moving.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/rolling-number.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "motion",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A number that changes while a hover, scrub, or step is still going: a chart reading, a calendar tip, a stepper, a live count.",
            "Pass value. Pass format to print it another way, like { style: \"currency\", currency: \"USD\" }.",
            "Set its size and weight with className. It uses tabular digits, so the width holds while it rolls. A digit it gains or drops folds in or out, so the words beside it glide."
          ],
          "avoid": [
            "A number that never changes. Print it as text.",
            "A value people need to read every step of, like a countdown. It shows only the end of each roll.",
            "Long text with digits in it, like a phone number. Only numbers roll."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "number",
            "description": "Required. The number to show. When it changes, each digit rolls to its new value."
          },
          {
            "name": "format",
            "type": "Intl.NumberFormatOptions | ((value: number) => string)",
            "default": "{ maximumFractionDigits: 0 }",
            "description": "How the number prints. Options work from a server component. A function works from a client component only."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for format options. Fixed, so server and browser print the same text."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes, like text size and weight. Later classes win."
          }
        ],
        "a11y": [
          "Screen readers hear the final value only, as plain text. The rolling digits are hidden from them.",
          "It is not a live region. If a change must be announced, say it elsewhere.",
          "Each digit rolls over --motion-duration-slow on --motion-ease-spring. Reduced motion jumps straight to the new value, and so does motion off."
        ],
        "examples": [
          {
            "title": "A count",
            "code": "<RollingNumber value={1284} />"
          },
          {
            "title": "Money",
            "code": "<RollingNumber value={4173.5} format={{ style: \"currency\", currency: \"USD\" }} className=\"text-2xl font-semibold\" />"
          },
          {
            "title": "Following a slider",
            "code": "const [value, setValue] = useState(40);\n\n<RollingNumber value={value / 100} format={{ style: \"percent\" }} />\n<Slider aria-label=\"Share\" value={value} onValueChange={(next) => setValue(next as number)} />"
          }
        ]
      }
    },
    {
      "name": "row-actions",
      "type": "registry:ui",
      "title": "Row Actions",
      "description": "A row's HoverActions and their single-key shortcuts together. While visible, the bar shows and every key its tooltips teach works.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/hover-actions",
        "@txbrendan/action-keys"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/row-actions.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Quick actions on list rows, with keys that work, like R for Reply.",
            "Set visible while the row is hovered or holds focus. The keys work only then, so one key acts on one row.",
            "It takes the same props as HoverActions."
          ],
          "avoid": [
            "Leaving visible at its default true on every row of a list. Each row would then answer every key.",
            "Actions with no keys. Use HoverActions."
          ]
        },
        "props": [
          {
            "name": "actions",
            "type": "HoverAction[]",
            "description": "Required. The actions, in order. Each with a shortcut gets its key."
          },
          {
            "name": "visible",
            "type": "boolean",
            "default": "true",
            "description": "Shows the bar and turns its keys on. Set it while the row is hovered or holds focus."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Actions\"",
            "description": "Names the toolbar."
          },
          {
            "name": "ref",
            "type": "Ref<HTMLDivElement>",
            "description": "Reaches the toolbar."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the toolbar. Later classes win."
          }
        ],
        "a11y": [
          "Everything HoverActions does: one Tab stop, arrow keys between buttons, a name per button, and the key in each tooltip.",
          "A key presses its button, so it shows the same spinner, result, and Undo a click does, and screen readers hear the same message.",
          "Each button with a single-key shortcut sets aria-keyshortcuts.",
          "Keys are ignored while someone types, while a modifier is held, and inside an open dialog or menu."
        ],
        "examples": [
          {
            "title": "On a list row",
            "code": "import { BellRing, Pencil, Reply } from \"lucide-react\";\nimport { useState } from \"react\";\n\nfunction PersonRow({ name }: { name: string }) {\n  const [lit, setLit] = useState(false);\n  return (\n    <div\n      className=\"relative flex h-11 items-center px-4\"\n      onMouseEnter={() => setLit(true)}\n      onMouseLeave={() => setLit(false)}\n      onFocus={() => setLit(true)}\n      onBlur={(event) => !event.currentTarget.contains(event.relatedTarget) && setLit(false)}\n    >\n      <a href=\"#\">{name}</a>\n      <RowActions\n        visible={lit}\n        aria-label={`Actions for ${name}`}\n        className=\"absolute right-2 top-1/2 -translate-y-1/2\"\n        actions={[\n          { icon: <Reply />, label: \"Reply\", shortcut: \"R\" },\n          { icon: <Pencil />, label: \"Edit\", shortcut: \"E\" },\n          { icon: <BellRing />, label: \"Remind\", shortcut: \"M\", result: { label: \"Reminder sent\", doneLabel: \"Reminded\" } },\n        ]}\n      />\n    </div>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "scale-question",
      "type": "registry:ui",
      "title": "Scale Question",
      "description": "A question answered on a short worded scale, for check-ins, like \"Poor, Broken, OK, Good, Great\". One answer, as cards in a row with the dot above each word. All ink, so no end of the scale looks better.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/field-frame",
        "@txbrendan/selection-card"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/scale-question.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Check-ins: sleep, workload, mood, how hard a task felt.",
            "Name the question in label, and say over what time in hint, like \"Last three nights\".",
            "Give up to five steps, lowest first. Words, not numbers: \"None, Light, Some, Busy, Too much\".",
            "The answer is the step's number, 1 to the number of steps.",
            "Keep each word short. Five cards share a phone's width, and a long word wraps."
          ],
          "avoid": [
            "A scale with an end that is good or bad by color. This one stays ink on purpose.",
            "More than five steps, or a number scale like 1 to 10. Use a slider or a number stepper.",
            "Options that need a hint or a price each. Use selection cards."
          ]
        },
        "props": [
          {
            "name": "steps",
            "type": "string[]",
            "description": "The words, lowest first. Up to five."
          },
          {
            "name": "value",
            "type": "number | null",
            "description": "The step's number, 1 to steps.length, or null for no answer. Use with onValueChange to control it."
          },
          {
            "name": "defaultValue",
            "type": "number | null",
            "description": "The step's number at first render, when you do not control it."
          },
          {
            "name": "onValueChange",
            "type": "(value: number) => void",
            "description": "Called with the step's number when someone picks one."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Names the question. TypeScript requires this, aria-label, or aria-labelledby."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "Over what time, like \"Last three nights\". It describes the group."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Replaces the hint, in red, and turns every card's edge red."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Text and dot size."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks choosing."
          },
          {
            "name": "ref",
            "type": "Ref<HTMLDivElement>",
            "description": "Reaches the group. Focusing it focuses the answer, or the first step."
          },
          {
            "name": "id",
            "type": "string",
            "description": "Lands on the group, with aria-* and data-*. Its aria-describedby joins the note's."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the frame around the label, the cards, and the note."
          }
        ],
        "a11y": [
          "A radio group named by the label, with a radio in each card named by its word.",
          "Tab lands on the answer, or the first step. The arrow keys move and choose.",
          "Screen readers hear the word and where it sits, like \"Some, 3 of 5\".",
          "The hint or the error describes the group. An error is announced once when it appears.",
          "A chosen card shows a dot and a doubled edge, so the answer does not rest on color."
        ],
        "examples": [
          {
            "title": "Answered, with a hint",
            "code": "<ScaleQuestion label=\"Sleep\" hint=\"Last three nights\" steps={[\"Poor\", \"Broken\", \"OK\", \"Good\", \"Great\"]} defaultValue={4} />"
          },
          {
            "title": "Unanswered, with a hint",
            "code": "<ScaleQuestion label=\"Workload\" hint=\"This week\" steps={[\"None\", \"Light\", \"Some\", \"Busy\", \"Too much\"]} />"
          }
        ]
      }
    },
    {
      "name": "scene",
      "type": "registry:ui",
      "title": "Scene",
      "description": "A picture drawn from the theme with something light floating on it: tiles, a preview card, people, or a hint.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/badge",
        "@txbrendan/spot-icon"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/scene.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "media",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The top of an empty state that's a first step, a call to action, or a popup in a card. Inset it 6px from the card's edge, like p-1.5.",
            "It shows what's missing or what's next. What people must read goes under it, in the card.",
            "Float one thing on it. SceneTiles: up to three tiles, for what a feature brings together. ScenePreview: the real thing a feature makes, like \"Q4 launch plan\", with cursors for work done together. ScenePeople: up to four faces joined by a link, for sharing or a team. SceneHint: one round icon with a dark hint, for the one thing a step asks.",
            "Pick a backdrop: frost (the default, pale and quiet), alpine, golden, the blue lake, coast, and clubhouse, or the dark dusk and floodlights. Or pass image for a picture of your own.",
            "Use size sm (120px) in a popup."
          ],
          "avoid": [
            "More than one scene on a surface.",
            "A row, a list, or a chart. A scene is for the top of a card.",
            "Words people must read. Put them under the scene: a label only names it for screen readers."
          ]
        },
        "props": [
          {
            "name": "backdrop",
            "type": "\"frost\" | \"alpine\" | \"lake\" | \"coast\" | \"clubhouse\" | \"dusk\" | \"golden\" | \"floodlights\"",
            "default": "\"frost\"",
            "description": "A picture drawn from the theme's colors. dusk and floodlights stay dark in both themes."
          },
          {
            "name": "image",
            "type": "string",
            "description": "A picture of your own instead, as a URL."
          },
          {
            "name": "position",
            "type": "string",
            "description": "Where the picture is anchored, as a background-position: \"center 70%\"."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Scene, SceneTiles, and ScenePeople. A scene is 120px at sm, for a popup, 160px at md, and 200px at lg. Tiles are 48, 64, or 80px. Faces are 28, 36, or 44px."
          },
          {
            "name": "label",
            "type": "string",
            "description": "Says what it shows to screen readers. Without it the scene is hidden from them."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the scene. Later classes win."
          },
          {
            "name": "SceneTiles",
            "type": "{ tiles: SceneTile[]; size?: \"sm\" | \"md\" | \"lg\" }",
            "description": "Part. Up to three tiles rising left to right. SceneTile is { spot?: ReactNode; icon?: ReactNode; src?: string; tag?: string }."
          },
          {
            "name": "ScenePreview",
            "type": "{ title: string; spot?: ReactNode; src?: string; value?: string; lines?: number; cursors?: { name: string; tone?: \"neutral\" | \"brand\" }[] }",
            "description": "Part. A card rising from the bottom, with a second card behind it. value is the change, in the brand color. lines defaults to 2. Two cursors at most: yours first, in tone \"neutral\"."
          },
          {
            "name": "ScenePeople",
            "type": "{ people: { name: string; src?: string }[]; icon?: ReactNode; size?: \"sm\" | \"md\" | \"lg\" }",
            "description": "Part. Up to four faces on an arch, with icon in the middle. Default a link."
          },
          {
            "name": "SceneHint",
            "type": "{ icon: ReactNode; label?: string }",
            "description": "Part. One round icon with a dark hint under it: \"Pair tablet\"."
          }
        ],
        "a11y": [
          "Without label, the whole scene is hidden from screen readers, since the words under it say what it shows.",
          "With label, it is one image with that name, and what floats on it is part of the picture.",
          "The floating parts are cards with a border, so forced-colors mode keeps their edges.",
          "They pop in a beat apart. Reduced motion keeps only the fade."
        ],
        "examples": [
          {
            "title": "At the top of a call to action",
            "code": "import { CalendarDays, Coins, Users } from \"lucide-react\";\n\n<Card className=\"max-w-sm gap-0 p-1.5\">\n  <Scene backdrop=\"lake\">\n    <SceneTiles tiles={[{ spot: <CalendarDays /> }, { spot: <Users /> }, { spot: <Coins />, tag: \"New\" }]} />\n  </Scene>\n  <p className=\"p-3\">Events, people and invoices, in one place.</p>\n</Card>"
          }
        ]
      }
    },
    {
      "name": "scroll-progress",
      "type": "registry:ui",
      "title": "Scroll Progress",
      "description": "A reading-progress pill with a ring, the current section, and an expandable menu for jumping through a page.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/menu"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/scroll-progress.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Keep a reader's position visible in an article or long guide.",
            "Pass sections in reading order, with ids matching mounted elements.",
            "Track the window by default. Pass containerRef or containerId for an independent scroll surface.",
            "Place the indicator outside the scrolling content. Use className='absolute' inside a positioned preview wrapper."
          ],
          "avoid": [
            "Task completion or uploads. Use Progress for work with a known completion value.",
            "Short content with no useful section jumps. Keep the page simple.",
            "Replacing visible headings. The menu supplements the document's structure."
          ]
        },
        "props": [
          {
            "name": "sections",
            "type": "readonly { id: string; label: string }[]",
            "default": "[]",
            "description": "Unique section ids and labels in reading order. An empty list shows a disabled pill."
          },
          {
            "name": "containerRef",
            "type": "RefObject<HTMLElement | null>",
            "description": "A mounted scrolling element. Takes precedence over containerId. Omit both to track the window."
          },
          {
            "name": "containerId",
            "type": "string",
            "description": "The id of a mounted scrolling element, for composition without a client ref. A missing container does not track the window."
          },
          {
            "name": "offset",
            "type": "number",
            "default": "24",
            "description": "Pixels below the scroll surface's top edge at which a section becomes current. Also leaves space above a jump target."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Stops the menu from opening. Reading progress continues to update."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Sections\"",
            "description": "Names the menu. The trigger also includes the current section and the percentage read."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the root, merged last. Fixed at bottom center by default. Override placement for an embedded scroll surface."
          }
        ],
        "a11y": [
          "The trigger exposes its expanded state, current section, and percentage read. Progress is not a live announcement on every scroll.",
          "Enter, Space, or Arrow Down opens the menu. Arrow keys, Home, End, and typing move between sections.",
          "Escape closes the menu and restores trigger focus. Clicking outside also closes it.",
          "The active section carries aria-current='location' and a filled row.",
          "Jump scrolling and transitions follow the theme's motion setting, including reduced motion.",
          "The caller supplies unique ids, meaningful labels, and semantic headings in the reading content."
        ],
        "examples": [
          {
            "title": "Track a page",
            "code": "import { ScrollProgress } from \"@/components/ui/scroll-progress\";\n\nexport default function Guide() {\n  return (\n    <>\n      <ScrollProgress sections={[{ id: \"overview\", label: \"Overview\" }, { id: \"details\", label: \"Details\" }]} />\n      <section id=\"overview\"><h2>Overview</h2><p>Start with the essentials.</p></section>\n      <section id=\"details\"><h2>Details</h2><p>Explore the next steps.</p></section>\n    </>\n  );\n}"
          },
          {
            "title": "Track a scroll container",
            "code": "\"use client\";\nimport { useRef } from \"react\";\nimport { ScrollProgress } from \"@/components/ui/scroll-progress\";\n\nexport default function ReadingPanel() {\n  const containerRef = useRef<HTMLDivElement>(null);\n  return (\n    <div className=\"relative\">\n      <div ref={containerRef} className=\"h-96 overflow-y-auto pb-20\" tabIndex={0} role=\"region\" aria-label=\"Guide\">\n        <section id=\"start\" className=\"min-h-96\"><h2>Getting started</h2></section>\n        <section id=\"next\" className=\"min-h-96\"><h2>Next steps</h2></section>\n      </div>\n      <ScrollProgress className=\"absolute\" containerRef={containerRef} sections={[{ id: \"start\", label: \"Getting started\" }, { id: \"next\", label: \"Next steps\" }]} />\n    </div>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "search-field",
      "type": "registry:ui",
      "title": "Search Field",
      "description": "A search box with the same sizes as Input. A clear button shows once there is text, Esc clears it, and an optional count says how many matched.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/input",
        "@txbrendan/kbd"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/search-field.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Filtering a list or a table on the same page as people type.",
            "Give it a name: aria-label, like \"Search people\", or an id that a visible label's htmlFor points at.",
            "Pass count with the number of matches. It shows in the field, like \"3 people\" or \"No people\", and is announced politely. Set noun to the singular and plural words.",
            "Pass shortcut to focus the field from anywhere on the page: one key, like \"/\", or \"Mod+K\" for ⌘K on a Mac and Ctrl+K elsewhere. It shows as a quiet key at the end of the empty field, and hides once people type, on touch screens, and while disabled.",
            "Esc clears the text. With the field empty, Esc leaves it, and goes on to any dialog around it.",
            "Use onEnter to run a search that waits for Enter, like one that asks a server."
          ],
          "avoid": [
            "Giving two parts on one page the same shortcut. Command Menu and Rail's onSearch listen for ⌘K too, so pass \"Mod+K\" only where neither is on the page.",
            "Picking one value from a list. Use a combobox.",
            "Using the placeholder as the name. It disappears as soon as people type.",
            "A search that opens a results page. A plain input in a form with a submit button works better there."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "string",
            "description": "The typed text. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "The text at first render, when you do not control it."
          },
          {
            "name": "onValueChange",
            "type": "(value: string) => void",
            "description": "Called with the new text on every change, and with \"\" when cleared."
          },
          {
            "name": "onEnter",
            "type": "(value: string) => void",
            "description": "Called with the text when Enter is pressed."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height, padding, and text size. The same heights as Input."
          },
          {
            "name": "count",
            "type": "number | null",
            "description": "How many matched. Shows in the field and is announced. Hidden while the field is empty."
          },
          {
            "name": "noun",
            "type": "[string, string]",
            "default": "[\"match\", \"matches\"]",
            "description": "The singular and plural words for the count."
          },
          {
            "name": "shortcut",
            "type": "string",
            "description": "Focuses the field from anywhere. One key, like \"/\", is ignored while people type in another field. \"Mod+\" and one key, like \"Mod+K\", is ⌘K on a Mac and Ctrl+K elsewhere, and works from another field too, but not in a rich text editor. Shows as a ghost Kbd while the field is empty, with a letter in capitals, as the keyboard prints it."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks typing and hides the clear button."
          },
          {
            "name": "placeholder",
            "type": "string",
            "description": "A hint inside the empty field, like \"Search people\"."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the field. Required unless you pass aria-labelledby, or an id with a visible label."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the field's box. Other props, like name and id, land on the input."
          }
        ],
        "a11y": [
          "A native input with type=\"search\", so screen readers call it a search field.",
          "Its type needs a name: aria-label, aria-labelledby, or an id for a label's htmlFor.",
          "The count sits in a polite live region, so a new count is read out without moving focus.",
          "Esc clears the text first, and marks the field data-owns-escape while it has text. A dialog around it closes only on the next Esc.",
          "The clear button has the name \"Clear search\". It is not a Tab stop, since Esc does the same from the keyboard. It is at least 24px square, and 44px on a coarse pointer.",
          "shortcut sets aria-keyshortcuts, so screen readers can say the key. \"Mod+K\" sets \"Meta+K Control+K\". The printed key is aria-hidden, so it is not read twice.",
          "On touch screens the text is 16px, so iOS Safari does not zoom in when the field gets focus."
        ],
        "examples": [
          {
            "title": "Jump in with ⌘K",
            "code": "<SearchField aria-label=\"Search the app\" placeholder=\"Search\" shortcut=\"Mod+K\" />"
          },
          {
            "title": "Filter a list",
            "code": "const people = [\"Dan Kerr\", \"Maya Patel\", \"Tom Hale\"];\nconst [query, setQuery] = useState(\"\");\nconst shown = people.filter((name) => name.toLowerCase().includes(query.toLowerCase()));\n\n<SearchField\n  aria-label=\"Search people\"\n  placeholder=\"Search people\"\n  value={query}\n  onValueChange={setQuery}\n  count={shown.length}\n  noun={[\"person\", \"people\"]}\n  shortcut=\"/\"\n/>"
          },
          {
            "title": "Small, in a toolbar",
            "code": "<SearchField size=\"sm\" aria-label=\"Search matches\" placeholder=\"Search\" />"
          }
        ]
      }
    },
    {
      "name": "section-header",
      "type": "registry:ui",
      "title": "Section Header",
      "description": "A plain section title on the page, with an optional count and actions at the end of the row, that sits above a card.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/badge"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/section-header.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Title every card from outside it, on the page: the header, then the card 8px under it.",
            "Put the header and its card in one grid with gap-2. Start the next header 16px under the card.",
            "Pass count as a number for a quiet total, like Needs you 5.",
            "Pass your own Badge as count when the number needs a tone, like a warning.",
            "Put the section's actions in children, as ghost Buttons, like See all.",
            "Use size=\"lg\" for the page title, the default md for sections, and sm for sections in a side rail."
          ],
          "avoid": [
            "A title row inside the card. Title it from outside with this.",
            "Long sentences as a title. Keep it to a few words.",
            "Solid buttons in the actions. They pull the eye from the content. Use ghost Buttons.",
            "Skipping heading levels. Pass as=\"h3\" when the header sits inside an h2 section."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "The section's name, like \"Needs you\". Required."
          },
          {
            "name": "count",
            "type": "number | string | ReactElement",
            "description": "A number or string shows in a soft neutral Badge after the title. Pass your own element for a tone."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "lg is a 20px page title, md a 16px section title, and sm a 13px title for a side rail."
          },
          {
            "name": "as",
            "type": "\"h1\" | \"h2\" | \"h3\" | \"h4\" | \"h5\" | \"h6\"",
            "description": "The heading level. Default h1 for lg, and h2 for md and sm."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "Actions at the end of the row, like ghost Buttons."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the row. Later classes win, so className='min-h-0' drops the held height."
          }
        ],
        "a11y": [
          "The title is a real heading, h2 by default, so screen readers list the section in the page outline.",
          "The count sits inside the heading, so the heading reads as \"Needs you 5\".",
          "The actions sit outside the heading, so the heading's name stays short.",
          "Give each action a name that makes sense alone, like \"See all members\", or put the section name in its aria-label.",
          "Title text is the page's foreground color, which the theme tests hold to 4.5:1 in light and dark."
        ],
        "examples": [
          {
            "title": "A section over a card (needs @txbrendan/button and card)",
            "code": "import { Button } from \"@/components/ui/button\";\nimport { Card } from \"@/components/ui/card\";\n\n<div className=\"grid gap-2\">\n  <SectionHeader title=\"Needs you\" count={5}>\n    <Button emphasis=\"ghost\" size=\"sm\">\n      See all\n    </Button>\n  </SectionHeader>\n  <Card>…</Card>\n</div>"
          },
          {
            "title": "A toned count (needs @txbrendan/badge)",
            "code": "import { Badge } from \"@/components/ui/badge\";\n\n<SectionHeader\n  title=\"Needs you\"\n  count={\n    <Badge tone=\"warning\" emphasis=\"soft\" size=\"sm\">\n      3\n    </Badge>\n  }\n/>"
          },
          {
            "title": "The page title",
            "code": "<SectionHeader size=\"lg\" title=\"Schedule\" />"
          },
          {
            "title": "A side rail section",
            "code": "<SectionHeader size=\"sm\" title=\"Coming up\" count={2} />"
          }
        ]
      }
    },
    {
      "name": "select",
      "type": "registry:ui",
      "title": "Select",
      "description": "A button that opens a list to pick one option from, with groups, a check on the picked option, Input's sizes, and form support.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/glide-highlight"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/select.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Picking one option from a short list, about 5 to 15 options, like a country, a role, or a sort order.",
            "One highlight glides from option to option as the pointer moves, and jumps when the arrow keys move it. Reduced motion makes it jump.",
            "Pass items to Select, as [{ value, label }]. SelectValue then shows the picked label, even in server HTML. Without items it shows the raw value.",
            "Set defaultValue to start with an option picked. Leave it out and give SelectValue a placeholder to start empty.",
            "Name it with a SelectLabel inside Select, before SelectTrigger. It needs no id.",
            "When the label must sit outside Select, give Select an id and point a Label's htmlFor at it. With no visible label, put aria-label on SelectTrigger.",
            "Set name on Select to send the value with a form. Select keeps a hidden input in sync.",
            "Put width classes on SelectTrigger, like className='w-48'. It fills its container by default, like Input.",
            "The list opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on SelectPopup so the list keeps its colors. A container that clips its overflow can clip the list."
          ],
          "avoid": [
            "Long lists people would rather search, like every city. Use a combobox.",
            "Two to four options people should see at once. Use a radio group or tabs.",
            "Actions, like Edit or Delete. Use a menu.",
            "Putting className on Select. It lays out as display: contents, so style SelectTrigger or SelectPopup instead."
          ]
        },
        "props": [
          {
            "name": "Select",
            "type": "SelectProps",
            "description": "The root. Holds the value and renders the hidden form input. It adds no box to the layout."
          },
          {
            "name": "SelectLabel",
            "type": "SelectLabelProps",
            "description": "Part. A label inside Select that names the trigger, with no id to set. A click focuses the trigger. It dims when Select is disabled."
          },
          {
            "name": "SelectTrigger",
            "type": "SelectTriggerProps",
            "description": "Part. The button that opens the list, with a chevron. It looks like Input and takes size."
          },
          {
            "name": "SelectValue",
            "type": "SelectValueProps",
            "description": "Part. Shows the picked option's label inside the trigger, or the placeholder."
          },
          {
            "name": "SelectPopup",
            "type": "SelectPopupProps",
            "description": "Part. The list in a portal. It opens below the trigger and flips above it when there is no room."
          },
          {
            "name": "SelectItem",
            "type": "SelectItemProps",
            "description": "Part. One option. Needs a value. Shows a check when it is the picked one."
          },
          {
            "name": "SelectGroup",
            "type": "SelectGroupProps",
            "description": "Part. Groups options under a SelectGroupLabel."
          },
          {
            "name": "SelectGroupLabel",
            "type": "SelectGroupLabelProps",
            "description": "Part. The group's heading. It names the group for screen readers."
          },
          {
            "name": "SelectSeparator",
            "type": "SelectSeparatorProps",
            "description": "Part. A thin line between options or groups. It is a top border, so a border color class recolors it."
          },
          {
            "name": "modal",
            "type": "boolean",
            "default": "false",
            "description": "On Select. False lets a press outside close the list and still reach the page, so one press moves from one open list to the next. True blocks the page and its scroll while the list is open."
          },
          {
            "name": "items",
            "type": "{ value: string; label: ReactNode }[] | Record<string, ReactNode>",
            "description": "On Select. Maps values to labels, so SelectValue shows the label."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "On Select. The option picked at the start, when you do not control it."
          },
          {
            "name": "value",
            "type": "string",
            "description": "Required on SelectItem. On Select, it controls the picked option. Use it with onValueChange."
          },
          {
            "name": "onValueChange",
            "type": "(value, details) => void",
            "description": "On Select. Called with the new value when someone picks an option."
          },
          {
            "name": "name",
            "type": "string",
            "description": "On Select. Form field name. The hidden input sends the value with the form."
          },
          {
            "name": "id",
            "type": "string",
            "description": "On Select. Becomes the trigger's id, so a Label's htmlFor can name it. SelectLabel does not need it."
          },
          {
            "name": "placeholder",
            "type": "ReactNode",
            "description": "On SelectValue. Shown in muted text while nothing is picked."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "On SelectTrigger. The same heights as Input. Density scales them."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "On Select, it blocks opening, removes the trigger from the Tab order, and dims SelectLabel. On SelectItem, it blocks picking that option. The arrow keys still reach it."
          },
          {
            "name": "required",
            "type": "boolean",
            "default": "false",
            "description": "On Select. The form will not submit until an option is picked."
          },
          {
            "name": "side",
            "type": "\"top\" | \"bottom\" | \"left\" | \"right\" | \"inline-start\" | \"inline-end\"",
            "default": "\"bottom\"",
            "description": "On SelectPopup. Where the list opens. It flips when there is no room."
          },
          {
            "name": "align",
            "type": "\"start\" | \"center\" | \"end\"",
            "default": "\"start\"",
            "description": "On SelectPopup. Lines the list up with the trigger's start, center, or end."
          },
          {
            "name": "container",
            "type": "HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null",
            "description": "On SelectPopup. The element the list opens in. By default, the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no list."
          },
          {
            "name": "aria-invalid",
            "type": "boolean",
            "description": "On SelectTrigger. Shows the danger border. Set it when validation fails, and link the message with aria-describedby."
          },
          {
            "name": "effect",
            "type": "\"rise\" | \"grow\"",
            "default": "\"rise\"",
            "description": "On SelectPopup. How the list opens and closes. rise fades in and moves out from the trigger. grow fades in and grows from the trigger, and closes the same way. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on any part except Select. Later classes win, so className='w-48' on SelectTrigger sets its width."
          }
        ],
        "a11y": [
          "The trigger is a button with role=\"combobox\" and aria-expanded. The list has role=\"listbox\", and each option has role=\"option\" with aria-selected.",
          "Enter, Space, or an arrow key opens the list. The arrow keys move through options, Enter picks, and Escape closes.",
          "Typing jumps to a matching option in the open list. On the closed trigger, typing picks the match right away, as a native select does.",
          "A disabled option has aria-disabled. The arrow keys still stop on it, but Enter does not pick it.",
          "Focus goes back to the trigger when the list closes.",
          "SelectLabel renders a native label whose for= matches the trigger's id, so it names the trigger in server HTML too. After hydration, the trigger's aria-labelledby points at it as well.",
          "A click on SelectLabel focuses the trigger and leaves the list closed, as a native select's label does. While Select is disabled, the click does nothing.",
          "A Label whose htmlFor matches the Select's id also names the trigger, in server HTML too, because the trigger is a native button. Clicking that Label opens the list, the same as a click on the trigger.",
          "A group is named by its SelectGroupLabel. The separator has role=\"presentation\", so screen readers skip it.",
          "The trigger border uses --input, which the theme tests hold to 3:1 on the page and on cards. Text in the list uses popover-foreground on popover, held to 4.5:1.",
          "The highlighted option uses accent-foreground on accent, and group labels use muted-foreground on popover. The theme tests hold both to 4.5:1.",
          "The list fades in and moves --motion-distance as it opens. It closes with a quicker fade and a slight shrink toward the trigger. Reduced motion keeps the fades and drops the move and the shrink."
        ],
        "examples": [
          {
            "title": "With a label",
            "code": "const roles = [\n  { value: \"viewer\", label: \"Viewer\" },\n  { value: \"editor\", label: \"Editor\" },\n  { value: \"admin\", label: \"Admin\" },\n];\n\n<div className=\"grid max-w-60 gap-1.5\">\n  <Select name=\"role\" items={roles} defaultValue=\"editor\">\n    <SelectLabel>Role</SelectLabel>\n    <SelectTrigger>\n      <SelectValue />\n    </SelectTrigger>\n    <SelectPopup>\n      {roles.map((role) => (\n        <SelectItem key={role.value} value={role.value}>\n          {role.label}\n        </SelectItem>\n      ))}\n    </SelectPopup>\n  </Select>\n</div>"
          },
          {
            "title": "With a Label outside Select (needs @txbrendan/label)",
            "code": "import { Label } from \"@/components/ui/label\";\n\nconst roles = { viewer: \"Viewer\", editor: \"Editor\", admin: \"Admin\" };\n\n<div className=\"grid max-w-60 gap-1.5\">\n  <Label htmlFor=\"role\">Role</Label>\n  <Select id=\"role\" name=\"role\" items={roles} defaultValue=\"editor\">\n    <SelectTrigger>\n      <SelectValue />\n    </SelectTrigger>\n    <SelectPopup>\n      <SelectItem value=\"viewer\">Viewer</SelectItem>\n      <SelectItem value=\"editor\">Editor</SelectItem>\n      <SelectItem value=\"admin\">Admin</SelectItem>\n    </SelectPopup>\n  </Select>\n</div>"
          },
          {
            "title": "Empty, with a placeholder",
            "code": "const sizes = { s: \"Small\", m: \"Medium\", l: \"Large\" };\n\n<Select items={sizes} name=\"size\" required>\n  <SelectTrigger aria-label=\"Shirt size\" className=\"w-40\">\n    <SelectValue placeholder=\"Pick a size\" />\n  </SelectTrigger>\n  <SelectPopup>\n    <SelectItem value=\"s\">Small</SelectItem>\n    <SelectItem value=\"m\">Medium</SelectItem>\n    <SelectItem value=\"l\">Large</SelectItem>\n  </SelectPopup>\n</Select>"
          },
          {
            "title": "Groups and a separator",
            "code": "const zones = {\n  \"America/New_York\": \"New York\",\n  \"America/Chicago\": \"Chicago\",\n  \"Europe/London\": \"London\",\n  \"Europe/Paris\": \"Paris\",\n};\n\n<Select items={zones} defaultValue=\"Europe/London\">\n  <SelectTrigger aria-label=\"Time zone\">\n    <SelectValue />\n  </SelectTrigger>\n  <SelectPopup>\n    <SelectGroup>\n      <SelectGroupLabel>Americas</SelectGroupLabel>\n      <SelectItem value=\"America/New_York\">New York</SelectItem>\n      <SelectItem value=\"America/Chicago\">Chicago</SelectItem>\n    </SelectGroup>\n    <SelectSeparator />\n    <SelectGroup>\n      <SelectGroupLabel>Europe</SelectGroupLabel>\n      <SelectItem value=\"Europe/London\">London</SelectItem>\n      <SelectItem value=\"Europe/Paris\">Paris</SelectItem>\n    </SelectGroup>\n  </SelectPopup>\n</Select>"
          },
          {
            "title": "Controlled",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nconst sorts = { new: \"Newest first\", old: \"Oldest first\", top: \"Most votes\" };\n\nfunction SortPicker() {\n  const [sort, setSort] = useState(\"new\");\n  return (\n    <Select items={sorts} value={sort} onValueChange={(value) => value && setSort(value)}>\n      <SelectTrigger size=\"sm\" aria-label=\"Sort by\" className=\"w-44\">\n        <SelectValue />\n      </SelectTrigger>\n      <SelectPopup align=\"end\">\n        <SelectItem value=\"new\">Newest first</SelectItem>\n        <SelectItem value=\"old\">Oldest first</SelectItem>\n        <SelectItem value=\"top\">Most votes</SelectItem>\n      </SelectPopup>\n    </Select>\n  );\n}"
          },
          {
            "title": "Grows from its trigger",
            "code": "const sorts = [\n  { value: \"newest\", label: \"Newest first\" },\n  { value: \"oldest\", label: \"Oldest first\" },\n];\n\n<div className=\"grid max-w-60 gap-1.5\">\n  <Select items={sorts} defaultValue=\"newest\">\n    <SelectLabel>Sort</SelectLabel>\n    <SelectTrigger>\n      <SelectValue />\n    </SelectTrigger>\n    <SelectPopup effect=\"grow\">\n      {sorts.map((sort) => (\n        <SelectItem key={sort.value} value={sort.value}>\n          {sort.label}\n        </SelectItem>\n      ))}\n    </SelectPopup>\n  </Select>\n</div>"
          }
        ]
      }
    },
    {
      "name": "selection-card",
      "type": "registry:ui",
      "title": "Selection Card",
      "description": "Options as cards: a dot for one answer, a check for several, with a hint and an aside like a price on each card.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/selection-card.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A question whose options each need a hint, a price, or a consequence, like a plan, a delivery speed, or which teams to tell.",
            "One answer by default. Add multiple for several answers, and give value and defaultValue a string array.",
            "Put the option's name in the children, the explanation in hint, and a short figure like a price in aside.",
            "Name the question with a small field label above the cards and point aria-labelledby at it. Use aria-label when there is no visible label.",
            "Lay the cards out with className on SelectionGroup, like className='grid-cols-2'.",
            "Set tone and size once, on SelectionGroup. Every card follows them."
          ],
          "avoid": [
            "Short, plain options with nothing to explain, in a dense form. Use Radio Group or Checkbox.",
            "A setting that turns on or off. Use a switch.",
            "Long lists, more than about six options. Use a select or a combobox.",
            "A heading above the cards. A small field label names the question.",
            "Starting with nothing chosen when one answer is the safe default. Set defaultValue to it."
          ]
        },
        "props": [
          {
            "name": "SelectionGroup",
            "type": "SelectionGroupProps",
            "description": "Root. Holds the value. Takes value, defaultValue, onValueChange, disabled, and, for one answer, name, required, and readOnly."
          },
          {
            "name": "multiple",
            "type": "boolean",
            "default": "false",
            "description": "SelectionGroup. Several answers. Cards draw a check, and value is a string array."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "SelectionGroup. Names the question. TypeScript requires this or aria-labelledby."
          },
          {
            "name": "aria-labelledby",
            "type": "string",
            "description": "SelectionGroup. The id of the visible field label above the cards."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "SelectionGroup. Color of a chosen card's edge and control."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "SelectionGroup. Text and control size. Density scales them."
          },
          {
            "name": "value",
            "type": "string | string[]",
            "description": "SelectionGroup: the chosen answer, or answers with multiple. SelectionCard: what the group holds when this card is chosen. Required on a card."
          },
          {
            "name": "defaultValue",
            "type": "string | string[]",
            "description": "SelectionGroup. The answer, or answers, chosen at the start when you do not control it."
          },
          {
            "name": "onValueChange",
            "type": "(value, details) => void",
            "description": "SelectionGroup. Called with the new value, a string, or a string array with multiple."
          },
          {
            "name": "aria-invalid",
            "type": "boolean",
            "description": "SelectionGroup. Turns every card's edge to the danger tone. Set it when validation fails, and link the message with aria-describedby."
          },
          {
            "name": "SelectionCard",
            "type": "ComponentProps<\"label\">",
            "description": "Part. One answer. The whole card is the target."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "SelectionCard. The option's name. It names the control."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "SelectionCard. A second line that explains the option. Read as the control's description."
          },
          {
            "name": "aside",
            "type": "ReactNode",
            "description": "SelectionCard. Short text at the end of the card, like a price. Read as part of the description."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "SelectionGroup or SelectionCard. Blocks choosing. On the group it blocks every card."
          },
          {
            "name": "className",
            "type": "string",
            "description": "SelectionGroup: classes for the layout. SelectionCard: classes for the card. Later classes win."
          }
        ],
        "a11y": [
          "One answer renders role=\"radiogroup\" with a role=\"radio\" in each card. Several answers render a group with a role=\"checkbox\" in each card. Each also has a hidden native input, so the value is sent with a form.",
          "The title names the control through aria-labelledby. The hint and the aside describe it through aria-describedby.",
          "With one answer the group is one tab stop, and arrow keys move the answer, skipping disabled cards. With several, each card is a tab stop and Space toggles it.",
          "The control takes focus, and the whole card shows the 2px focus ring. The theme tests hold --ring to 3:1 on the page and cards.",
          "A resting card's edge uses --input, which the theme tests hold to 3:1. A chosen card adds the tone's edge and an inset ring, so the choice does not rest on color alone: the dot or the check shows it too.",
          "TypeScript requires a name for the group and a value for each card."
        ],
        "examples": [
          {
            "title": "One answer, named by a field label",
            "code": "<div className=\"grid gap-1.5\">\n  <p id=\"plan-label\" className=\"text-sm text-muted-foreground\">\n    Plan\n  </p>\n  <SelectionGroup aria-labelledby=\"plan-label\" name=\"plan\" defaultValue=\"team\" tone=\"brand\">\n    <SelectionCard value=\"solo\" hint=\"One person, three projects.\" aside=\"£0\">\n      Solo\n    </SelectionCard>\n    <SelectionCard value=\"team\" hint=\"Up to ten people, every project.\" aside=\"£12 a month\">\n      Team\n    </SelectionCard>\n  </SelectionGroup>\n</div>"
          },
          {
            "title": "Several answers, in two columns",
            "code": "<SelectionGroup multiple aria-label=\"Who to tell\" defaultValue={[\"design\"]} className=\"grid-cols-2\">\n  <SelectionCard value=\"design\" hint=\"19 people get a message.\">\n    Design team\n  </SelectionCard>\n  <SelectionCard value=\"managers\" hint=\"12 managers get a copy.\">\n    Managers\n  </SelectionCard>\n</SelectionGroup>"
          },
          {
            "title": "Controlled",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nfunction Delivery() {\n  const [speed, setSpeed] = useState(\"standard\");\n  return (\n    <SelectionGroup aria-label=\"Delivery speed\" value={speed} onValueChange={(value) => setSpeed(value)}>\n      <SelectionCard value=\"standard\" hint=\"3 to 5 days.\" aside=\"Free\">\n        Standard\n      </SelectionCard>\n      <SelectionCard value=\"express\" hint=\"Next day, if you order by 14:00.\" aside=\"£6\">\n        Express\n      </SelectionCard>\n    </SelectionGroup>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "separator",
      "type": "registry:ui",
      "title": "Separator",
      "description": "A thin line that divides content, horizontal or vertical.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/separator.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Dividing groups that belong to different topics: sections of a settings panel, the account links and the sign-out link in a menu.",
            "Use orientation='vertical' between items in a row, like a toolbar or a list of links.",
            "Leave it announced when the break matters to the reading order. Add aria-hidden when the line only adds visual rhythm."
          ],
          "avoid": [
            "Adding space. Use gap, margin, or padding instead.",
            "A line between every item in a list. Use divide-y on the list instead.",
            "A labeled divider, like 'or' between two sign-in options. A separator cannot hold text. Use two separators with a <span> between them.",
            "Separating items inside a menu, select, or toolbar. Use that component's own separator part."
          ]
        },
        "props": [
          {
            "name": "orientation",
            "type": "\"horizontal\" | \"vertical\"",
            "default": "\"horizontal\"",
            "description": "Which way the line runs. Sets aria-orientation and data-orientation."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='h-4 self-center' gives a vertical line a fixed height."
          },
          {
            "name": "aria-hidden",
            "type": "boolean",
            "description": "Hides a purely decorative line from screen readers."
          },
          {
            "name": "render",
            "type": "ReactElement",
            "description": "Render prop. Swaps the <div> for another element."
          },
          {
            "name": "children",
            "type": "never",
            "description": "Not allowed. TypeScript rejects content inside a separator."
          }
        ],
        "a11y": [
          "Renders a <div> with role=\"separator\" and aria-orientation, so screen readers announce a break between groups.",
          "It is not focusable. Keyboard users move past it.",
          "When the line is only decoration, add aria-hidden. Screen readers then skip it instead of saying \"separator\".",
          "Text inside a separator is not read out, so TypeScript rejects children.",
          "The line uses the border token. It is decoration, so the theme does not hold it to a contrast minimum. Never use it as the only boundary of a control."
        ],
        "examples": [
          {
            "title": "Between two sections",
            "code": "<div className=\"flex flex-col gap-4\">\n  <p>Profile</p>\n  <Separator />\n  <p>Notifications</p>\n</div>"
          },
          {
            "title": "Vertical, between links in a row",
            "code": "<nav aria-label=\"Account\" className=\"flex items-center gap-3 text-sm\">\n  <a href=\"/settings\">Settings</a>\n  <a href=\"/billing\">Billing</a>\n  <Separator orientation=\"vertical\" className=\"h-4 self-center\" />\n  <a href=\"/logout\">Sign out</a>\n</nav>"
          },
          {
            "title": "Decoration only",
            "code": "<div className=\"flex flex-col gap-3\">\n  <h2 className=\"font-semibold\">Usage</h2>\n  <Separator aria-hidden />\n  <p className=\"text-sm text-muted-foreground\">1,204 of 5,000 requests</p>\n</div>"
          },
          {
            "title": "A divider with a label",
            "code": "<div className=\"flex items-center gap-3 text-xs text-muted-foreground\">\n  <Separator className=\"flex-1\" aria-hidden />\n  <span>or</span>\n  <Separator className=\"flex-1\" aria-hidden />\n</div>"
          }
        ]
      }
    },
    {
      "name": "share-link",
      "type": "registry:ui",
      "title": "Share Link",
      "description": "A link to share as a read-only field, with Copy that slips to Copied for a moment and back.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/field-frame",
        "@txbrendan/input"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/share-link.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "people",
        "forms",
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The link in a share sheet, an invite, or a guest pass: people see it, select it, and copy it.",
            "Say what the link lets people do, and until when, in hint: \"View only until Sat 3 Oct, midnight. You can see who opened it.\"",
            "Focusing the link selects it all. Copy puts exactly url on the clipboard, then says Copied for a moment.",
            "If the page can't copy, the link is left selected and the field says so under it.",
            "It keeps its size inside a Sheet. Pass size=\"lg\" there if the sheet's other fields are large."
          ],
          "avoid": [
            "Sending the link. It only copies. Put Send or Share in the sheet's actions.",
            "A link people should edit. Use Input.",
            "A secret, like a password or an API key. Anyone who sees the screen can read the link."
          ]
        },
        "props": [
          {
            "name": "url",
            "type": "string",
            "description": "Required. The link to share. Copy puts exactly this on the clipboard."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "The small field label above, like \"Link\". It names the field."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "What the link lets people do, and until when. It describes the field."
          },
          {
            "name": "onCopy",
            "type": "(url: string) => void",
            "description": "Runs after the link is copied."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height, padding, and text size. The same heights as Input."
          },
          {
            "name": "id",
            "type": "string",
            "description": "The input's id, in place of its own. aria-* and data-* props land on the input too."
          },
          {
            "name": "ref",
            "type": "Ref<HTMLInputElement>",
            "description": "Reaches the input."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the whole field. Later classes win."
          }
        ],
        "a11y": [
          "The link is a read-only text field named by label and described by hint. Give it a label, or an aria-label when there is none.",
          "Copy is a button named \"Copy link\", then \"Link copied\". A polite live region says \"Link copied\" once.",
          "If the copy fails, focus moves to the link, it is selected, and the note under the field says so, read once.",
          "The icon is hidden from screen readers. The focus ring goes around the whole box while the link has focus."
        ],
        "examples": [
          {
            "title": "In a share sheet",
            "code": "<ShareLink\n  label=\"Link\"\n  url=\"https://example.com/s/team/h8tq2m\"\n  hint=\"View only until Sat 3 Oct, midnight. You can see who opened it.\"\n/>"
          }
        ]
      }
    },
    {
      "name": "sheet",
      "type": "registry:ui",
      "title": "Sheet",
      "description": "A large, rounded dialog for one task, with a big heading, an optional preview tray, calm rows, and its actions placed by role.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/action-keys",
        "@txbrendan/button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/sheet.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "One task that needs a few fields or a preview, like inviting members or sharing an event.",
            "Keep it rendered and set open. false plays the close, then removes it. {show && <Sheet />} works too, but closes at once.",
            "onClose runs on Esc and on a press on the backdrop. Set open to false there. The sheet never closes itself.",
            "Pass actions by role. main is the primary, on the right, with its icon after the label. Give it tone=\"brand\" for the primary look. tertiary, like Cancel or Not now, is a ghost just left of it. secondary is a ghost on the left.",
            "footer is for what is not an action, like \"3 of 40 seats used\". It sits on the left.",
            "tray shows a preview across the top, like the card being shared. avatar puts a 32px photo left of the heading.",
            "Fields inside keep their own size. Pass size to them as anywhere else.",
            "inline draws a still picture of it in the page, with no backdrop, for docs and cards."
          ],
          "avoid": [
            "A yes or no question. Use AlertDialog.",
            "A short form with plain actions. Use Dialog.",
            "Content people read beside the page. A sheet hides the page behind it.",
            "A sheet over a sheet. Finish one task, then open the next."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "Required. The heading. It names the sheet."
          },
          {
            "name": "subtitle",
            "type": "ReactNode",
            "description": "A quiet line under the heading. It describes the sheet."
          },
          {
            "name": "avatar",
            "type": "ReactNode",
            "description": "Who the sheet is about, like their photo. It sits left of the heading at 32px, cropped round."
          },
          {
            "name": "tray",
            "type": "ReactNode",
            "description": "A preview on a muted tray across the top."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "The rows."
          },
          {
            "name": "actions",
            "type": "{ main?: SheetAction; tertiary?: SheetAction; secondary?: SheetAction }",
            "description": "The actions by role. Each SheetAction is { label, onClick?, icon?, tone?, loading?, disabled?, type?, form? }."
          },
          {
            "name": "SheetAction.tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The main action's color role. Pass \"brand\" for the primary look. tertiary and secondary stay neutral ghosts."
          },
          {
            "name": "SheetAction.type",
            "type": "\"button\" | \"submit\"",
            "default": "\"button\"",
            "description": "\"submit\" sends the form named by form."
          },
          {
            "name": "footer",
            "type": "ReactNode",
            "description": "A note or a status, on the left of the actions."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "How wide it can grow: sm is 24rem, md is 35rem, lg is 42rem. It never gets wider than the screen."
          },
          {
            "name": "inline",
            "type": "boolean",
            "default": "false",
            "description": "Draws it in the page's flow, with no backdrop and no focus trap."
          },
          {
            "name": "open",
            "type": "boolean",
            "default": "true",
            "description": "false plays the close, then removes it."
          },
          {
            "name": "onClose",
            "type": "() => void",
            "description": "Runs on Esc and on a press on the backdrop."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the sheet. Later classes win, so className='max-w-xl' replaces the width."
          }
        ],
        "a11y": [
          "Over the page it is a modal dialog named by its title and described by its subtitle.",
          "Focus moves onto the sheet itself when it opens, so its title is read first. Tab stays inside, even when pressed fast.",
          "The page behind is hidden from screen readers and does not scroll.",
          "Esc closes only what is on top. A picker, menu, or SmartPick inside takes Esc first.",
          "Focus goes back to what opened it when it closes.",
          "There is no X button. Give it a tertiary action like Cancel, or people on touch screens have only the backdrop to leave by.",
          "inline is a section named by its title, with no focus trap."
        ],
        "examples": [
          {
            "title": "Invite members",
            "code": "\"use client\";\n\nimport { Send } from \"lucide-react\";\nimport { useState } from \"react\";\n\nimport { Button } from \"@/components/ui/button\";\n\nfunction InviteMembers() {\n  const [open, setOpen] = useState(false);\n  return (\n    <>\n      <Button onClick={() => setOpen(true)}>Invite members</Button>\n      <Sheet\n        open={open}\n        onClose={() => setOpen(false)}\n        title=\"Invite members\"\n        subtitle=\"They get an email with a link to join the workspace.\"\n        actions={{\n          main: { label: \"Send invites\", icon: <Send />, tone: \"brand\", onClick: () => setOpen(false) },\n          tertiary: { label: \"Cancel\", onClick: () => setOpen(false) },\n        }}\n      >\n        <p>Add their email addresses.</p>\n      </Sheet>\n    </>\n  );\n}"
          },
          {
            "title": "Send a form",
            "code": "<Sheet\n  open={open}\n  onClose={close}\n  title=\"Edit event\"\n  actions={{ main: { label: \"Save\", tone: \"brand\", type: \"submit\", form: \"event\" }, tertiary: { label: \"Cancel\", onClick: close } }}\n>\n  <form id=\"event\" onSubmit={save}>…</form>\n</Sheet>"
          },
          {
            "title": "A still picture, with a preview",
            "code": "<Sheet inline title=\"Share the event\" tray={<Card className=\"w-56 p-4\">Design review</Card>} actions={{ main: { label: \"Share\", tone: \"brand\" } }} />"
          }
        ]
      }
    },
    {
      "name": "shimmer",
      "type": "registry:ui",
      "title": "Shimmer",
      "description": "Loading text with a slanted band of light that sweeps across it, like \"Thinking\" while an answer streams in. It can also cycle through messages.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/shimmer.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "motion",
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A short line of text that says work is happening, like \"Thinking\" or \"Writing the answer\" while a reply streams in.",
            "Real progress steps. Pass the current step as children and change it as the work moves on.",
            "Put it inside a role=\"status\" element that stays on the page, so screen readers hear each step once.",
            "shine picks the light: gloss, a soft sheen with a bright streak, glint, two streaks like light on glass, or color, a band that runs through the theme's tone colors.",
            "ShimmerStack for a few lines at once, like loading steps. It starts each line a little after the one above, so the light runs down them in order, even when the lines differ in length or size.",
            "ShimmerMessages for filler text that does not report real progress, like \"Thinking\", \"Still thinking\", \"Almost there\". It shows each message for interval milliseconds.",
            "It takes the font size and weight around it. Set them with className, from the theme's type sizes, like text-(length:--type-body) font-(--type-weight-strong)."
          ],
          "avoid": [
            "Content that is loading in a known shape, like a card or a list. Use Skeleton.",
            "An action in progress, like saving. Use loading on the Button.",
            "Solid tone fills, like a brand button. The band reads the card color, so it has no room to show there.",
            "ShimmerMessages for real steps. Screen readers hear only its first message. Use Shimmer and change its children instead.",
            "Long paragraphs. Keep it to one short line. If it wraps, each line gets its own band.",
            "Emoji. The band fills the letters with the text color, so an emoji turns into a flat shape."
          ]
        },
        "props": [
          {
            "name": "children",
            "type": "ReactNode",
            "description": "Shimmer only. The text that shimmers. Required. An icon inside keeps the text's color."
          },
          {
            "name": "shine",
            "type": "\"gloss\" | \"glint\" | \"color\"",
            "default": "\"gloss\"",
            "description": "How the light looks. gloss is a soft sheen with a bright streak. glint is two streaks, a small one and a bright one. color runs through the theme's tone colors."
          },
          {
            "name": "ShimmerStack",
            "type": "component",
            "description": "A column for stacked Shimmer lines. Each child starts its sweep 120ms after the one above, up to the seventh, and every line's band moves at the same speed. A ShimmerMessages line keeps its place in the ripple as its message changes. Set [--shimmer-stagger:200ms] to change the step."
          },
          {
            "name": "messages",
            "type": "readonly string[]",
            "description": "ShimmerMessages only. The messages to show in turn. Screen readers hear only the first one."
          },
          {
            "name": "interval",
            "type": "number",
            "default": "2000",
            "description": "ShimmerMessages only. How long each message shows, in milliseconds."
          },
          {
            "name": "loop",
            "type": "boolean",
            "default": "true",
            "description": "ShimmerMessages only. Start over after the last message. Set false to stop on the last one."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Merged last. 'animate-none' stops the sweep. '[animation-delay:120ms]' starts it later. '[--shimmer-band:32ch]' makes the band wider."
          }
        ],
        "a11y": [
          "Shimmer is real text, so screen readers read it and people can select it. Selected text shows solid, with no band. The sweep is only a look.",
          "It is not a live region. To have screen readers hear it, keep one role=\"status\" element on the page and put the Shimmer inside it while work runs.",
          "Keep that status element on the page before the text arrives. Some screen readers miss a status region that arrives together with its text.",
          "ShimmerMessages gives screen readers its first message only, and hides the rest from them. A status region around it then speaks once, not on every change.",
          "At rest the text is the foreground color in a light theme, and a little dimmer in a dark one, so the band can brighten it. The band passes over a few letters at a time and fades them for a moment. That fade has no contrast check, so never put the only copy of something people must read in a Shimmer.",
          "The sweep runs over 1.3 times --motion-pulse-duration. The theme sets that to 0s for motion off and for reduced motion, so the text then shows plain, with no band.",
          "In forced colors the band is dropped and the text takes the system text color.",
          "In ShimmerMessages, each new message fades in from --motion-distance below while the last one fades out. Reduced motion keeps the fade and drops the movement. Motion off swaps the text at once.",
          "ShimmerMessages holds its width at the longest message, so content beside it does not shift when the message changes.",
          "In right-to-left text, the band sweeps from right to left."
        ],
        "examples": [
          {
            "title": "Thinking",
            "code": "<Shimmer>Thinking</Shimmer>"
          },
          {
            "title": "Each shine",
            "code": "<Shimmer shine=\"gloss\">Thinking</Shimmer>\n<Shimmer shine=\"glint\">Searching the web</Shimmer>\n<Shimmer shine=\"color\">Writing your report</Shimmer>"
          },
          {
            "title": "Stacked lines with the light rippling down",
            "code": "<ShimmerStack>\n  <Shimmer>Thinking</Shimmer>\n  <Shimmer>Searching the web</Shimmer>\n  <Shimmer className=\"text-(length:--type-section)\">Writing your report</Shimmer>\n</ShimmerStack>"
          },
          {
            "title": "Real steps in a status region that stays on the page",
            "code": "function Reply({ step }: { step: string | null }) {\n  return (\n    <p role=\"status\">\n      {step === null ? null : <Shimmer>{step}</Shimmer>}\n    </p>\n  );\n}"
          },
          {
            "title": "Cycling filler messages",
            "code": "<ShimmerMessages messages={[\"Thinking\", \"Still thinking\", \"Almost there\"]} interval={2500} loop={false} />"
          },
          {
            "title": "With an icon (needs lucide-react)",
            "code": "import { Sparkles } from \"lucide-react\";\n\n<Shimmer className=\"inline-flex items-center gap-1.5\">\n  <Sparkles className=\"size-4\" aria-hidden />\n  Thinking\n</Shimmer>"
          }
        ]
      }
    },
    {
      "name": "shirt-number",
      "type": "registry:ui",
      "title": "Shirt Number",
      "description": "A shirt number on a rounded square, never a circle, in ink by default or in a team's kit colors.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/shirt-number.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Beside a player's name in a squad list, a team sheet, or a match log.",
            "Leave colors off. The number is ink, the page's own text color.",
            "Pass colors, [shirt, number], only where the number says which team, as in a match log with both sides."
          ],
          "avoid": [
            "Kit colors to decorate. They are for telling two teams apart.",
            "A circle, or a photo's place. A shirt number is square, so nobody mistakes it for an avatar.",
            "Counts or scores. Use plain text or a badge."
          ]
        },
        "props": [
          {
            "name": "number",
            "type": "number",
            "description": "The number on the shirt."
          },
          {
            "name": "colors",
            "type": "[shirt: string, number: string]",
            "description": "The kit's colors from your data, [shirt, number], as any CSS colors. A club's KitColors from team-flag pass as they are. Pick a pair that reads. Without them, the number is ink."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "sm is 20px, md is 26px, lg is 32px."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "The number is plain text, so screen readers read it with the name beside it.",
          "Ink is the page's text on its background, which the theme holds to 4.5:1 in light and dark.",
          "Kit colors come from your data, so the theme cannot check them. You pick a background and number that reach 4.5:1.",
          "Never let a kit color carry meaning alone. Name the team in text nearby."
        ],
        "examples": [
          {
            "title": "Beside a name",
            "code": "<p className=\"flex items-center gap-2\">\n  <ShirtNumber number={10} />\n  Noah Kerr\n</p>"
          },
          {
            "title": "Sizes",
            "code": "<div className=\"flex items-center gap-2\">\n  <ShirtNumber number={9} size=\"sm\" />\n  <ShirtNumber number={9} />\n  <ShirtNumber number={9} size=\"lg\" />\n</div>"
          },
          {
            "title": "Kit colors telling two teams apart",
            "code": "// home and away come from your data. Each kit is a [shirt, number] pair that reaches 4.5:1.\n<ul className=\"flex flex-col gap-2\">\n  <li className=\"flex items-center gap-2\">\n    <ShirtNumber number={10} colors={home.kit} size=\"sm\" />\n    Try, {home.name}\n  </li>\n  <li className=\"flex items-center gap-2\">\n    <ShirtNumber number={14} colors={away.kit} size=\"sm\" />\n    Penalty, {away.name}\n  </li>\n</ul>"
          }
        ]
      }
    },
    {
      "name": "skeleton",
      "type": "registry:ui",
      "title": "Skeleton",
      "description": "A pulsing placeholder shape that holds the place of content while it loads. Hidden from screen readers.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/skeleton.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Holding the place of content that is loading, at the same size and shape, so the page does not jump when it arrives.",
            "Size and shape it with className: h-4 w-48 for a line of text, size-10 rounded-full for an avatar.",
            "Put aria-busy=\"true\" on the region that is loading. Say it in words in a role=\"status\" element that stays on the page, outside that region."
          ],
          "avoid": [
            "Wrapping real content. A skeleton takes no children. Render it in place of the content, not around it.",
            "An action in progress, like saving. Use loading on the Button.",
            "Loads that finish in well under a second. The skeleton only flashes. Show nothing until the content arrives.",
            "Layouts you cannot predict. If the shape will not match, a short status message is clearer."
          ]
        },
        "props": [
          {
            "name": "className",
            "type": "string",
            "description": "Size and shape. Later classes win, so className='rounded-full' replaces the radius and 'animate-none' stops the pulse."
          },
          {
            "name": "aria-hidden",
            "type": "boolean",
            "default": "true",
            "description": "Set for you, so screen readers skip the shape."
          },
          {
            "name": "children",
            "type": "never",
            "description": "Not allowed. TypeScript rejects content inside a skeleton, because aria-hidden would hide it."
          }
        ],
        "a11y": [
          "Renders a <span> with aria-hidden, so screen readers skip it. Its shape only means something to people who can see it.",
          "Tell screen readers in words. Keep one role=\"status\" element on the page and change its text: \"Loading orders\" while the skeletons show, then \"Orders loaded\".",
          "Put that status element outside the loading region. Screen readers may wait to read changes inside a region marked aria-busy, and some miss a status region that arrives together with its text.",
          "Add aria-busy=\"true\" to the region that is loading, and remove it when the content arrives.",
          "It is a <span> with display block, so it is valid inside a paragraph, a heading, or a button.",
          "It pulses at --motion-pulse-duration. The theme sets that to 0s for motion off and for reduced motion, so the shape stays still then.",
          "The fill is the theme's --color-skeleton, the foreground at 10%, so the shape shows on the page, on cards, and on muted panels. A placeholder is not text, so the theme does not hold it to a contrast minimum."
        ],
        "examples": [
          {
            "title": "A line of text",
            "code": "<Skeleton className=\"h-4 w-48\" />"
          },
          {
            "title": "A profile row, with a status that stays on the page",
            "code": "function Profile({ user }: { user: { name: string; role: string; avatar: string } | null }) {\n  return (\n    <>\n      <div aria-busy={user === null} className=\"flex items-center gap-3\">\n        {user === null ? (\n          <>\n            <Skeleton className=\"size-10 rounded-full\" />\n            <div className=\"flex flex-col gap-2\">\n              <Skeleton className=\"h-4 w-32\" />\n              <Skeleton className=\"h-3 w-24\" />\n            </div>\n          </>\n        ) : (\n          <>\n            <img src={user.avatar} alt=\"\" className=\"size-10 rounded-full\" />\n            <div>\n              <p className=\"font-medium\">{user.name}</p>\n              <p className=\"text-sm text-muted-foreground\">{user.role}</p>\n            </div>\n          </>\n        )}\n      </div>\n      <p role=\"status\" className=\"sr-only\">\n        {user === null ? \"Loading profile\" : \"Profile loaded\"}\n      </p>\n    </>\n  );\n}"
          },
          {
            "title": "A card while it loads (needs @txbrendan/card)",
            "code": "import { Card, CardContent, CardHeader } from \"@/components/ui/card\";\n\n// Pair it with a role=\"status\" element outside the card, as in the profile row.\n<Card aria-busy=\"true\">\n  <CardHeader>\n    <Skeleton className=\"h-5 w-1/2\" />\n    <Skeleton className=\"h-4 w-3/4\" />\n  </CardHeader>\n  <CardContent>\n    <Skeleton className=\"h-24 w-full\" />\n  </CardContent>\n</Card>"
          }
        ]
      }
    },
    {
      "name": "slider",
      "type": "registry:ui",
      "title": "Slider",
      "description": "A track with a thumb people drag or move with arrow keys to pick a number, or two thumbs for a range, with a label, tones, and sizes.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/slider.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Picking a number where the rough amount matters more than the exact one, like volume or brightness.",
            "A range with two thumbs, like a price filter. Pass an array: defaultValue={[20, 80]}.",
            "Give it a label, and add showValue when people need the number. Use format for units, like dollars.",
            "Name each thumb of a range with thumbLabels, like ['Lowest price', 'Highest price']."
          ],
          "avoid": [
            "An exact number, like a quantity or an age. Use an input with type='number'.",
            "A choice from a few named options. Use a radio group.",
            "A setting that is on or off. Use a switch.",
            "A value that only shows progress. Use a progress bar."
          ]
        },
        "props": [
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Visible text above the track. Without it, TypeScript requires aria-label or aria-labelledby."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the slider when there is no visible label. It goes on each thumb."
          },
          {
            "name": "aria-labelledby",
            "type": "string",
            "description": "The id of visible text elsewhere that names the slider. It goes on each thumb."
          },
          {
            "name": "showValue",
            "type": "boolean",
            "default": "false",
            "description": "Shows the value as text at the end of the label row. A range shows both ends, like '20 – 80'."
          },
          {
            "name": "thumbLabels",
            "type": "readonly string[]",
            "description": "A name for each thumb of a range slider, in order. It replaces the label as each thumb's name."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the filled part and the thumb's ring."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Track and thumb size, and label text size. The row is as tall as a button of the same size."
          },
          {
            "name": "value",
            "type": "number | number[]",
            "description": "Controlled value. An array makes a range, with one thumb per value. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "number | number[]",
            "description": "Starting value when you do not control it. An array makes a range."
          },
          {
            "name": "onValueChange",
            "type": "(value: number | number[], details) => void",
            "description": "Called on every move, while dragging too."
          },
          {
            "name": "onValueCommitted",
            "type": "(value: number | number[], details) => void",
            "description": "Called once when a drag or key press ends. Save here, not in onValueChange."
          },
          {
            "name": "min",
            "type": "number",
            "default": "0",
            "description": "The lowest value."
          },
          {
            "name": "max",
            "type": "number",
            "default": "100",
            "description": "The highest value."
          },
          {
            "name": "step",
            "type": "number",
            "default": "1",
            "description": "How far one arrow key press moves. Values snap to it."
          },
          {
            "name": "largeStep",
            "type": "number",
            "default": "10",
            "description": "How far Page Up, Page Down, or Shift with an arrow moves."
          },
          {
            "name": "minStepsBetweenValues",
            "type": "number",
            "default": "0",
            "description": "For a range, the fewest steps the two thumbs can be apart."
          },
          {
            "name": "format",
            "type": "Intl.NumberFormatOptions",
            "description": "How the value reads, as text and to screen readers, like { style: 'currency', currency: 'USD' }."
          },
          {
            "name": "orientation",
            "type": "\"horizontal\" | \"vertical\"",
            "default": "\"horizontal\"",
            "description": "Vertical sliders are 10rem tall. className='h-64' changes it."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks moving it and removes it from the tab order. It dims."
          },
          {
            "name": "name",
            "type": "string",
            "description": "Form field name. The value is sent with the form."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the root. Later classes win, so className='max-w-xs' narrows it."
          }
        ],
        "a11y": [
          "Each thumb holds a native input with type=\"range\", so it has role=\"slider\" and the value is sent with the form.",
          "Arrow keys move by step. Page Up and Page Down, or Shift with an arrow, move by largeStep. Home and End go to min and max.",
          "Each thumb is a tab stop. Tab moves from the first thumb of a range to the second.",
          "TypeScript requires a visible label, aria-label, or aria-labelledby, so each thumb has a name.",
          "In server HTML, only aria-label, aria-labelledby, and thumbLabels name a thumb. The visible label is linked once JavaScript runs.",
          "A range's thumbs read as 'start range' and 'end range' by default. Name them with thumbLabels.",
          "The focus ring sits on the thumb while its input has keyboard focus. It uses --ring, which the theme tests hold to 3:1.",
          "The whole row, as tall as a button, takes clicks and touches, not just the thin track.",
          "The fill uses the tone's color on the muted track. No contrast pair checks the fill or the track, so the label and value carry the meaning."
        ],
        "examples": [
          {
            "title": "With a label and value",
            "code": "<Slider label=\"Volume\" defaultValue={60} showValue tone=\"brand\" />"
          },
          {
            "title": "Price range",
            "code": "<Slider\n  label=\"Price\"\n  defaultValue={[20, 80]}\n  max={200}\n  step={5}\n  minStepsBetweenValues={2}\n  showValue\n  format={{ style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 }}\n  thumbLabels={[\"Lowest price\", \"Highest price\"]}\n/>"
          },
          {
            "title": "No visible label",
            "code": "<Slider aria-label=\"Brightness\" defaultValue={40} size=\"sm\" className=\"max-w-48\" />"
          },
          {
            "title": "Save when the drag ends",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nfunction TextSize({ save }: { save: (size: number) => void }) {\n  const [size, setSize] = useState(16);\n  return (\n    <Slider\n      label=\"Text size\"\n      showValue\n      min={12}\n      max={24}\n      value={size}\n      onValueChange={(value) => setSize(value as number)}\n      onValueCommitted={(value) => save(value as number)}\n    />\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "slideshow",
      "type": "registry:ui",
      "title": "Slideshow",
      "description": "Photos that crossfade one to the next on their own, each with a caption and a bar that fills while it shows.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/dither",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/slideshow.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "media",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A few photos that show what something looks like, like the walks, places, or rooms behind a feature, in a hero or beside a list.",
            "Three to six photos. Give each an alt that says what is in it, and a short title and detail for the caption.",
            "Pass picture for your own element instead of src, like a next/image.",
            "Use photos that fill the frame, with no clear parts. A new photo fades in over the one before, which shows through anything clear until the fade ends.",
            "Set a width. It keeps 4:5. Change the shape with className, like \"aspect-video\".",
            "Keep something beside it in step with onIndexChange.",
            "Change the time each photo shows with className=\"[--slideshow-duration:9s]\". The default is 6s.",
            "Put a small Badge on the top corner with badge."
          ],
          "avoid": [
            "Photos people must compare or study. Use Media Grid, and Lightbox to view one large.",
            "Words or steps people must read in order. Nobody reads a slide that moves on by itself.",
            "Two slideshows on one screen. One moving thing is enough."
          ]
        },
        "props": [
          {
            "name": "slides",
            "type": "Slide[]",
            "description": "Required. The photos, in order. It loops back to the first."
          },
          {
            "name": "Slide",
            "type": "{ src?: string; picture?: ReactNode; alt: string; title?: string; detail?: string; id?: string | number }",
            "description": "Type. src is the photo. picture draws your own element in its place. alt says what is in it. title and detail make the caption."
          },
          {
            "name": "badge",
            "type": "ReactNode",
            "description": "Something small on the top corner, like a Badge."
          },
          {
            "name": "onIndexChange",
            "type": "(index: number) => void",
            "description": "Called with the new photo's index each time it changes."
          },
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\" | \"gradient\" | \"grain\"",
            "description": "dither draws the photos as dots, gradient fades them from the top, and grain speckles them. Hover fills gradient and grain in. Left out, it follows the Texture around it."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Slideshow\"",
            "description": "Names the slideshow for screen readers."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Set the width, the shape, or --slideshow-duration here. Later classes win."
          }
        ],
        "a11y": [
          "The root is a carousel with a name. Each photo is a slide named like \"2 of 4\", and only the one showing reaches screen readers and Tab.",
          "It holds still while the pointer is over it or keyboard focus is inside it.",
          "A pause button stops it for good, on touch screens too. It says it is pressed with aria-pressed. While paused, a new caption is read aloud.",
          "Each bar is a button, like \"Slide 3 of 4\", so every photo can be reached without waiting. The one showing has aria-current.",
          "With prefers-reduced-motion, or data-motion=\"off\", it stands still on the first photo, and the bars still change it. The pause button goes away then.",
          "The caption and controls sit on a dark veil in both themes, so they stay clear on light photos."
        ],
        "examples": [
          {
            "title": "Photos with captions",
            "code": "<Slideshow\n  className=\"max-w-sm\"\n  aria-label=\"Recent photos\"\n  slides={[\n    { src: \"/photos/columns.jpg\", alt: \"Stone columns at dusk\", title: \"Columns at dusk\", detail: \"East gallery · Yesterday\" },\n    { src: \"/photos/lake.jpg\", alt: \"A still lake at sunrise\", title: \"Lake at first light\", detail: \"North shore · 3h ago\" },\n    { src: \"/photos/arcade.jpg\", alt: \"Three arches in a stone wall\", title: \"Arcade at noon\", detail: \"Main square · Today\" },\n  ]}\n/>"
          },
          {
            "title": "A list beside it that follows the photo, with a badge and slower slides",
            "code": "const [index, setIndex] = useState(0);\n\n<div className=\"flex gap-4\">\n  <Slideshow\n    slides={slides}\n    badge={<Badge emphasis=\"soft\" size=\"sm\">New</Badge>}\n    onIndexChange={setIndex}\n    className=\"max-w-xs [--slideshow-duration:9s]\"\n  />\n  <ul>\n    {slides.map((slide, i) => (\n      <li key={slide.src} aria-current={i === index ? \"true\" : undefined}>{slide.title}</li>\n    ))}\n  </ul>\n</div>"
          },
          {
            "title": "Photos drawn as dots",
            "code": "<Slideshow slides={slides} texture=\"dither\" className=\"max-w-sm\" />"
          },
          {
            "title": "Grain photos that clear on hover",
            "code": "<Slideshow slides={slides} texture=\"grain\" className=\"max-w-sm\" />"
          }
        ]
      }
    },
    {
      "name": "smart-pick",
      "type": "registry:ui",
      "title": "Smart Pick",
      "description": "A follow-up question that opens where a change landed, with the app's best guesses first and a search for anyone else.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/button",
        "@txbrendan/input",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/smart-pick.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Ask the one thing a change left open, right where it landed, like who should review a ticket that was just filed.",
            "Rank suggestions with what the app already knows, best first, and say why in reason, like \"Reviewed the last two\".",
            "The 1, 2, and 3 keys pick the first three guesses from anywhere on the page. Each guess's tooltip teaches its key. The keys are never printed.",
            "Pass options, the full list, to add a search field for anyone else.",
            "Show it, and what replaces it once picked, inside <PanelSwitch effect=\"fade\">, so the height glides instead of jumping.",
            "onDismiss runs on Esc and on the close button. Leave the answer empty then, and let people fill it in later.",
            "DeltaBump, from the same file, shows a change like \"+5\" rising from the value it changed. Give it a new key to play it again."
          ],
          "avoid": [
            "A question that must be answered before going on. Use a Dialog.",
            "More than three or four suggestions. Past three, they have no key, and the guess is not a guess.",
            "Opening it on page load. It follows a change the person just made.",
            "Two open at once. Only the newest hears the number keys."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "string",
            "description": "The question, like \"Who should review this?\". It names the group and the list."
          },
          {
            "name": "hint",
            "type": "string",
            "description": "A quiet line under the question, like \"Ticket 482 by Ava Patel\"."
          },
          {
            "name": "suggestions",
            "type": "SmartPickOption[]",
            "description": "Required. The best guesses, best first. Each is { id, label, reason?, leading?, keywords? }. The first three answer to 1, 2, and 3."
          },
          {
            "name": "options",
            "type": "SmartPickOption[]",
            "description": "The full list. With it, a search field finds anyone, matching label and keywords."
          },
          {
            "name": "onPick",
            "type": "(option: SmartPickOption) => void",
            "description": "Runs with the option picked by a press, Enter, or 1 to 3."
          },
          {
            "name": "onDismiss",
            "type": "() => void",
            "description": "Runs on Esc, with no search typed, and on the close button. Leaves the question unanswered."
          },
          {
            "name": "placeholder",
            "type": "string",
            "default": "\"Someone else…\"",
            "description": "The search field's placeholder. Its name drops the ellipsis."
          },
          {
            "name": "SmartPickOption.leading",
            "type": "ReactNode",
            "description": "Before the name, like an avatar or initials."
          },
          {
            "name": "DeltaBump",
            "type": "{ value: string; tone?: \"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\" }",
            "description": "Part. \"+5\" rising from the value it changed, then gone. Put it in a relative box around the value."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "On DeltaBump. The change's color."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the box. Later classes win, so className='p-2' replaces the padding."
          }
        ],
        "a11y": [
          "It is a group named by its title. The guesses are a listbox of options, and each keyed guess carries aria-keyshortcuts.",
          "It opens with focus left where it was. 1 to 3 and Esc reach it from anywhere on the page, but not from a field that types text, or a menu or dialog opened above it, or while it is inert.",
          "With a search field, the field is a combobox: ↑ and ↓ move the highlight, Enter picks, and aria-activedescendant points at the highlight. Without one, the list is the Tab stop and does the same, with Home and End too.",
          "The pointer and the keys share one highlight. A finger does not highlight.",
          "Esc clears a typed search first, then leaves the question unanswered. It stops there, so a Sheet around it stays open.",
          "\"No one matches\" is said through a status message.",
          "DeltaBump is hidden from screen readers. Say the change where the value is.",
          "Option text is accent-foreground on the accent fill when highlighted, a pair the theme tests hold to 4.5:1 in light and dark."
        ],
        "examples": [
          {
            "title": "Pick, then show the answer in place (needs @txbrendan/panel-switch)",
            "code": "\"use client\";\n\nimport { useState } from \"react\";\n\nimport { PanelSwitch } from \"@/components/ui/panel-switch\";\n\nfunction Reviewer({ people }: { people: SmartPickOption[] }) {\n  const [reviewer, setReviewer] = useState<SmartPickOption | null>(null);\n  const [open, setOpen] = useState(true);\n  return (\n    <PanelSwitch effect=\"fade\" value={open ? \"pick\" : \"done\"}>\n      {open ? (\n        <SmartPick\n          title=\"Who should review this?\"\n          hint=\"Ticket 482 by Ava Patel\"\n          suggestions={people.slice(0, 3)}\n          options={people}\n          onPick={(option) => {\n            setReviewer(option);\n            setOpen(false);\n          }}\n          onDismiss={() => setOpen(false)}\n        />\n      ) : (\n        <p>Reviewer: {reviewer?.label ?? \"Not set\"}</p>\n      )}\n    </PanelSwitch>\n  );\n}"
          },
          {
            "title": "Guesses only, with no search",
            "code": "<SmartPick\n  title=\"Who owns this incident?\"\n  suggestions={[\n    { id: \"tom\", label: \"Tom Hale\", reason: \"On call this week\" },\n    { id: \"jo\", label: \"Jo Okafor\" },\n  ]}\n  onPick={(option) => save(option.id)}\n/>"
          },
          {
            "title": "A value that bumps by +2",
            "code": "<span className=\"relative\">\n  19\n  <DeltaBump key={bumps} value=\"+2\" tone=\"success\" />\n</span>"
          }
        ]
      }
    },
    {
      "name": "solid-icon",
      "type": "registry:ui",
      "title": "Solid Icon",
      "description": "24 original solid icons with softly squared silhouettes and transparent cutouts, in 16, 20, and 24px sizes.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/solid-icon.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "media"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Import an individual component, such as SolidFolder or SolidCalendar, for a button, menu, or sidebar.",
            "Use SolidIcon with a name when the choice comes from data. This includes the whole collection.",
            "Use sm at 16px, md at 20px, or lg at 24px. Color comes from the surrounding text or a theme token class.",
            "Browse Solid Icon Library to search, copy SVG or React usage, and download an individual SVG.",
            "getSolidIconSvg(name, fill?) returns standalone SVG markup. Pass a 3- or 6-digit hex value to keep a chosen color in the file; omit it for currentColor. Inline SVG inherits text color; an image file does not inherit its parent page's color."
          ],
          "avoid": [
            "Mixing solid and outline families within the same group of controls unless the difference conveys state.",
            "Detailed illustrations: use Pixel Icon for retro artwork.",
            "Using an icon alone for an unfamiliar action. Keep a visible label."
          ]
        },
        "props": [
          {
            "name": "name",
            "type": "SolidIconName",
            "default": "\"home\"",
            "description": "For SolidIcon only: a name from solidIconCatalog. Named exports do not need this prop."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "16, 20, or 24px. Shared by SolidIcon and every named export."
          },
          {
            "name": "title",
            "type": "string",
            "description": "Accessible name for a meaningful standalone icon. Omit when adjacent text or its control provides the name."
          },
          {
            "name": "fill",
            "type": "string",
            "default": "\"currentColor\"",
            "description": "Native SVG fill. Use a chosen artwork color, or keep currentColor to inherit text color."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes; later classes win. Use a theme text token to set color."
          }
        ],
        "a11y": [
          "Decorative icons are hidden from screen readers. With title they expose an image with that name.",
          "Icons do not take keyboard focus. An icon-only button still needs its own aria-label.",
          "Transparent cutouts reveal the actual surface underneath. Color inherits from text."
        ],
        "examples": [
          {
            "title": "Individual React export",
            "code": "import { SolidFolder } from \"@/components/ui/solid-icon\";\n\n<span className=\"flex items-center gap-2\"><SolidFolder size=\"sm\" />Project files</span>"
          },
          {
            "title": "From data",
            "code": "<SolidIcon name=\"calendar\" title=\"Calendar\" />"
          },
          {
            "title": "Standalone SVG",
            "code": "import { getSolidIconSvg } from \"@/components/ui/solid-icon\";\n\nconst svg = getSolidIconSvg(\"folder\");"
          }
        ]
      }
    },
    {
      "name": "sparkline",
      "type": "registry:ui",
      "title": "Sparkline",
      "description": "A tiny trend line, inline with text or in a table cell. Plain SVG, with no Recharts.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/chart",
        "@txbrendan/skeleton",
        "@txbrendan/dither"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/sparkline.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing the shape of a trend next to a number, in a stat card, or in a table row.",
            "Name it with aria-label, like \"Revenue, last 30 days\". Screen readers hear the name with the first and last values.",
            "Use tone for meaning: success for a good trend, danger for a bad one, neutral when it is neither.",
            "Pass the numbers oldest first."
          ],
          "avoid": [
            "When people need to read exact values or dates. Use LineChart, which has axes and a tooltip.",
            "Comparing two or more series. Use LineChart.",
            "As the only place a number appears. Show the current value in text next to it."
          ]
        },
        "props": [
          {
            "name": "data",
            "type": "number[]",
            "description": "The numbers to draw, oldest first. Values that are not finite numbers are skipped."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Required. Names the trend. Screen readers hear it with the first and last values."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The line's color role."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height and width, from --control-h-sm, so density scales it. Small fits a line of text."
          },
          {
            "name": "area",
            "type": "boolean",
            "default": "false",
            "description": "Fills the space under the line with the tone at 15%."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows a skeleton at the same size."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "description": "Formats the first and last values that screen readers hear."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for those numbers."
          },
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\" | \"gradient\" | \"grain\"",
            "description": "dither draws the area as dots, gradient fades it from the top, and grain speckles it. The line glows on dark pages. Hover fills gradient and grain in. Left out, the line follows the Texture around it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='w-full' stretches it."
          }
        ],
        "a11y": [
          "Renders a <span role=\"img\">. Its name is your aria-label plus the first and last values, like \"Revenue: 12.4 to 23.9\". TypeScript requires aria-label.",
          "The words it adds, \"to\", \"loading\", and \"no data\", are English.",
          "The SVG inside is hidden from screen readers, so they hear the name once.",
          "The line takes the tone's solid color, which the theme tests hold to 3:1 against the page and cards, in light and dark.",
          "It has no tab stop, because it has nothing to interact with. Put exact values in text nearby.",
          "It draws on the server and never animates, so its space and shape are in the static HTML."
        ],
        "examples": [
          {
            "title": "Next to a number",
            "code": "<p className=\"flex items-center gap-2\">\n  $23.9K\n  <Sparkline data={[12.4, 14.1, 13.2, 16.8, 18.7, 23.9]} tone=\"success\" aria-label=\"Revenue, last 6 weeks\" />\n</p>"
          },
          {
            "title": "In a table cell, filled",
            "code": "<td>\n  <Sparkline data={row.history} area tone=\"brand\" aria-label={`${row.name}, last 30 days`} />\n</td>"
          },
          {
            "title": "While it loads",
            "code": "<Sparkline data={data ?? []} loading={isLoading} aria-label=\"Revenue, last 6 weeks\" />"
          },
          {
            "title": "A grain area that fills in on hover",
            "code": "<Sparkline area tone=\"success\" data={[4, 6, 5, 8, 7, 9]} texture=\"grain\" aria-label=\"Revenue, last 6 weeks\" />"
          }
        ]
      }
    },
    {
      "name": "spinner",
      "type": "registry:ui",
      "title": "Spinner",
      "description": "A turning ring beside the control a person used, while their request runs, with a required label for screen readers.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/spinner.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A spinner only marks a wait the person started, at the control they used. It never stands in for content. Content waits with Skeleton.",
            "Put it beside the control that started the wait, like a search field fetching results or a switch saving its new value.",
            "Give it a label that says what is happening, like \"Searching orders\". TypeScript requires one.",
            "It takes the text color around it. Set the color with a text token, like className='text-muted-foreground'.",
            "Use size to match what is beside it: sm beside text-sm or a small control, md beside a medium control, lg beside a large one."
          ],
          "avoid": [
            "A button that is saving. Use loading on the Button. It keeps the width and blocks repeat clicks.",
            "Content that is loading, like a list, a panel, or a page, even when you do not know its shape. Use Skeleton in its place.",
            "Progress you can measure. Show how far along it is instead.",
            "Waits that finish in well under a second. The spinner only flashes. Show nothing until the result arrives.",
            "Several spinners at once for one wait. Use one, at the control the person used."
          ]
        },
        "props": [
          {
            "name": "label",
            "type": "string",
            "description": "Required. What is happening, in words, like \"Searching orders\". Rendered as visually hidden text inside the status."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Diameter: half, three quarters, or all of a small control's height. Density scales it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='size-12' replaces the size and 'text-primary' sets the color."
          },
          {
            "name": "children",
            "type": "never",
            "description": "Not allowed. The label is the content."
          }
        ],
        "a11y": [
          "Renders a <span role=\"status\">, a polite live region. The label is its text, in a visually hidden span.",
          "TypeScript requires label, so a spinner always has words. A live region is announced by its text, so the label is text, not aria-label.",
          "Some screen readers miss a status region that arrives together with its text, and some hold back changes inside a region marked aria-busy. When people must hear that loading started, keep one role=\"status\" element on the page, outside any busy region, and change its text, as the Skeleton docs show.",
          "The ring is an aria-hidden <svg>. It is not focusable and adds no tab stop.",
          "It turns once per 0.4 × --motion-pulse-duration. The theme sets that to 0s for motion off and for reduced motion, so the spinner stays still then. A theme without the variable also stays still.",
          "The arc is currentColor, and the track is the same color at 25%. Put it on text the theme already checks, like muted-foreground, which is held to 4.5:1 on the page."
        ],
        "examples": [
          {
            "title": "A spinner with a label",
            "code": "<Spinner label=\"Searching orders\" />"
          },
          {
            "title": "Beside a search field, with Skeleton for the results",
            "code": "function OrderSearch({ results }: { results: string[] | null }) {\n  return (\n    <div className=\"grid gap-3\">\n      <div className=\"flex items-center gap-2\">\n        <Input type=\"search\" aria-label=\"Search orders\" />\n        {results === null && <Spinner label=\"Searching orders\" className=\"text-muted-foreground\" />}\n      </div>\n      {results === null ? (\n        <Skeleton className=\"h-20 w-full\" />\n      ) : (\n        <ul>\n          {results.map((result) => (\n            <li key={result}>{result}</li>\n          ))}\n        </ul>\n      )}\n    </div>\n  );\n}"
          },
          {
            "title": "Beside a switch that saves",
            "code": "<div className=\"flex items-center gap-2\">\n  <Switch defaultChecked>Auto-save</Switch>\n  <Spinner label=\"Saving\" className=\"text-muted-foreground\" />\n</div>"
          }
        ]
      }
    },
    {
      "name": "spot-icon",
      "type": "registry:ui",
      "title": "Spot Icon",
      "description": "A brand-colored sticker around an icon that names a piece of content, like a feature, an empty state, or a card's header.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/spot-icon.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "media",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Naming a piece of content: a feature in a list, an empty state, a hub link, or a card's header.",
            "Use size lg over an empty state, md in a hub link or card header, and sm in a dense feature list.",
            "Use one spot per item. Pass any icon element, like <Calendar /> from lucide-react.",
            "Its side margins hang outside its box, so the sticker's edge lines up with the text above or below it."
          ],
          "avoid": [
            "Anything you press, and rows, menus, charts, and statuses. Use a plain icon there.",
            "Saying something with its color. Artwork colors are decorative; use a visible label for meaning.",
            "Sizes under 24px. Use a plain icon instead."
          ]
        },
        "props": [
          {
            "name": "ink",
            "type": "string",
            "description": "Optional artwork foreground color. Omit to follow the brand theme."
          },
          {
            "name": "paper",
            "type": "string",
            "description": "Optional sticker background color. Omit to follow the brand theme."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "The icon on the sticker, like <Calendar /> from lucide-react. It is sized for you."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "sm is 24px, md is 32px, lg is 40px."
          },
          {
            "name": "title",
            "type": "string",
            "description": "Names the spot for screen readers. Without it the spot is hidden from them."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "Without title the spot has aria-hidden, since the text beside it names the same thing.",
          "With title it is role=\"img\" with that name.",
          "The default brand pair is checked at 4.5:1. Custom ink and paper colors are not contrast-checked."
        ],
        "examples": [
          {
            "title": "Over an empty state",
            "code": "import { CalendarDays } from \"lucide-react\";\n\n<div className=\"flex flex-col items-center gap-3 text-center\">\n  <SpotIcon icon={<CalendarDays />} size=\"lg\" />\n  <p className=\"font-semibold\">No events this week</p>\n</div>"
          },
          {
            "title": "In a feature list",
            "code": "import { Bell, Users } from \"lucide-react\";\n\n<ul className=\"flex flex-col gap-4\">\n  <li className=\"flex items-start gap-3\">\n    <SpotIcon icon={<Users />} />\n    <p>Invite the whole team in one go.</p>\n  </li>\n  <li className=\"flex items-start gap-3\">\n    <SpotIcon icon={<Bell />} />\n    <p>Reminders go out the day before.</p>\n  </li>\n</ul>"
          },
          {
            "title": "Standing alone, with a name",
            "code": "import { Trophy } from \"lucide-react\";\n\n<SpotIcon icon={<Trophy />} title=\"Award\" />"
          }
        ]
      }
    },
    {
      "name": "stagger",
      "type": "registry:ui",
      "title": "Stagger",
      "description": "Shows its children fading up one after another as they first appear, for lists, card grids, and sections on page load.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/stagger.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "motion",
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A list, a grid of cards, or the sections of a page that arrive together, so the eye reads them in order.",
            "Wrap the items once: <Stagger className=\"grid gap-4\">{cards}</Stagger>. Each child becomes one item.",
            "Put the layout classes on Stagger. Each child is wrapped in a <div>, so the wrappers are the grid or flex items.",
            "Pass as=\"ul\" or as=\"ol\" for a list. Each child is then wrapped in an <li>, so pass the content, not <li> elements.",
            "Only the first eight items step, 35ms apart. The rest arrive with the eighth, so a long list is whole in under half a second."
          ],
          "avoid": [
            "Menus, selects, and other popups. Their items appear at once, in one piece.",
            "Content that updates often, like a live feed. Only new items fade in, and the wait between them reads as lag.",
            "Wrapping a single element. There is nothing to put in order.",
            "Nesting Staggers. The inner items wait on top of the outer wait."
          ]
        },
        "props": [
          {
            "name": "children",
            "type": "ReactNode",
            "description": "The items. Each child fades up in turn, wrapped in a <div>, or an <li> in a list."
          },
          {
            "name": "step",
            "type": "number",
            "default": "35",
            "description": "Milliseconds between items in the default feel. Calm and Playful stretch it with their durations, and motion off makes it 0."
          },
          {
            "name": "delay",
            "type": "number",
            "default": "0",
            "description": "Milliseconds before the first item, scaled the same way as step."
          },
          {
            "name": "as",
            "type": "\"div\" | \"section\" | \"ul\" | \"ol\"",
            "default": "\"div\"",
            "description": "The element that holds the items. With ul or ol, each item is an <li>."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes on the outer element, like a grid or a gap."
          }
        ],
        "a11y": [
          "Nothing is hidden from screen readers while it fades. Every item is in the page from the first render.",
          "Reduced motion takes out the rise, so items only fade in place. With motion off, every item shows at once.",
          "It uses CSS @starting-style, so it needs no script. Browsers without it show every item at once.",
          "With as=\"ul\" or as=\"ol\", it is a real list, so screen readers say how many items there are."
        ],
        "examples": [
          {
            "title": "A grid of cards (needs @txbrendan/card)",
            "code": "import { Card } from \"@/components/ui/card\";\n\n<Stagger className=\"grid gap-4 sm:grid-cols-3\">\n  {teams.map((team) => (\n    <Card key={team.id} className=\"h-full px-4\">\n      {team.name}\n    </Card>\n  ))}\n</Stagger>"
          },
          {
            "title": "A list",
            "code": "<Stagger as=\"ul\" className=\"flex flex-col gap-2\">\n  {members.map((member) => (\n    <span key={member.id}>{member.name}</span>\n  ))}\n</Stagger>"
          },
          {
            "title": "Slower, after the page title",
            "code": "<Stagger step={60} delay={120} className=\"flex flex-col gap-6\">\n  <section>…</section>\n  <section>…</section>\n</Stagger>"
          }
        ]
      }
    },
    {
      "name": "standings",
      "type": "registry:ui",
      "title": "Standings",
      "description": "A league table or a tournament pool, on DataTable: the place, the team and its kit, the counts, points difference, points, and form, with zones.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/data-table",
        "@txbrendan/form-guide",
        "@txbrendan/team-flag"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/standings.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A league table or a tournament pool. Put it in a Card with className=\"px-0\", and name it with the card's title through aria-labelledby.",
            "The app works out the numbers and the order. The table only shows them.",
            "layout league shows P W D L PD BP Pts. pool shows P W D L PF PA PD Pts.",
            "zones mark places that mean something: promotion from the top in success, relegation from the bottom in danger. Give each a note, so its peek says what it means.",
            "you marks your team: a blue bar, its own wash, and its name in the strong weight.",
            "In a narrow card the counts give way, PF and PA first, then form, then D and BP, and under 20rem W and L, so the team keeps its room."
          ],
          "avoid": [
            "A row inserted between zones. A zone's edge is its 2px rule.",
            "Real club colors in a demo. Pass theme tokens, like var(--chart-1).",
            "Ranking people. Use Leaderboard."
          ]
        },
        "props": [
          {
            "name": "rows",
            "type": "StandingsRow[]",
            "description": "Required, in table order. Each: { id, team: { name, colors?: KitColors, pattern? }, position?, played, won, drawn, lost, pointsFor?, pointsAgainst?, diff?, bonus?, points, form?: FormResult[] }."
          },
          {
            "name": "layout",
            "type": "\"league\" | \"pool\"",
            "default": "\"league\"",
            "description": "Which counts show."
          },
          {
            "name": "you",
            "type": "string",
            "description": "Your team's id."
          },
          {
            "name": "zones",
            "type": "{ label: string; top?: number; bottom?: number; tone?: \"success\" | \"danger\" | \"warning\" | \"brand\"; note?: string }[]",
            "description": "Places counted from the top or the bottom. tone defaults to success from the top and danger from the bottom."
          },
          {
            "name": "form",
            "type": "boolean",
            "default": "true",
            "description": "The form column, when any row has form."
          },
          {
            "name": "caption",
            "type": "ReactNode",
            "description": "Names the table, under it."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the table when it has no caption."
          },
          {
            "name": "aria-labelledby",
            "type": "string",
            "description": "Names the table by another element, like the Card's title."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Holds the space with placeholder rows."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the wrapper. Later classes win."
          }
        ],
        "a11y": [
          "A real <table>. Each team's name is the row header, so every count is read with it.",
          "Short headers, like P, are read as their full words, like Played, and show them on a mouse rest.",
          "A zone's place number is read with its label, like \"1, Promotion\". Its peek says what the zone means.",
          "Points difference uses a true minus sign, which screen readers read as minus.",
          "A zone's edges and your team's bar are borders, so they stay in forced colors."
        ],
        "examples": [
          {
            "title": "League with promotion and relegation zones",
            "code": "<Standings\n  aria-label=\"League\"\n  you=\"rov\"\n  zones={[\n    { label: \"Promotion\", top: 1, note: \"The top team goes up.\" },\n    { label: \"Relegation\", bottom: 1, note: \"The bottom team goes down.\" },\n  ]}\n  rows={[\n    { id: \"lio\", team: { name: \"Lions\", colors: [\"var(--chart-1)\", \"var(--background)\"] }, played: 10, won: 8, drawn: 0, lost: 2, diff: 96, bonus: 6, points: 38 },\n    { id: \"rov\", team: { name: \"Rovers\", colors: [\"var(--foreground)\", \"var(--chart-3)\"], pattern: \"hoops\" }, played: 10, won: 6, drawn: 1, lost: 3, diff: 41, bonus: 5, points: 31 },\n    { id: \"fal\", team: { name: \"Falcons\" }, played: 10, won: 1, drawn: 1, lost: 8, diff: -120, bonus: 2, points: 8 },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "status-strip",
      "type": "registry:ui",
      "title": "Status Strip",
      "description": "Joined status pills for the top of a home page: a label with an optional status, the value under it, and controls at the end.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/status-strip.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The few facts a home page leads with, top right: next event, your status, an invoice due.",
            "Each segment has a label on line 1 and a value on line 2. aside puts a status right after the label, like a StatusText.",
            "trailing is for controls only, like Yes and No buttons, centered across both lines.",
            "It hugs its content. In a narrow column its segments wrap onto more rows and stay joined."
          ],
          "avoid": [
            "More than four segments. Put the rest on the page.",
            "Long values. Keep each to a few words, like a date and a team.",
            "Links or text in trailing. Put a link in the value's page instead."
          ]
        },
        "props": [
          {
            "name": "segments",
            "type": "{ label: string; value: string; aside?: ReactNode; trailing?: ReactNode }[]",
            "description": "Required. The pills, in order."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the strip. Later classes win."
          }
        ],
        "a11y": [
          "A description list: each label is a <dt>, and its status, value, and controls are <dd>s, so screen readers read each pair together.",
          "The 1px lines between segments are gaps that show the border color, so they stay in forced colors.",
          "Controls in trailing need their own names, like \"Yes, I'm going\"."
        ],
        "examples": [
          {
            "title": "Two facts for a home page",
            "code": "<StatusStrip\n  segments={[\n    { label: \"Next event\", value: \"Sat 3 Oct · Demo day\" },\n    { label: \"Invoice\", value: \"£15 due Fri\" },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "status-text",
      "type": "registry:ui",
      "title": "Status Text",
      "description": "Status as colored text with no background, with an optional quickest fix and a peek behind it.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/hover-card",
        "@txbrendan/peek-card",
        "@txbrendan/tool-button",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/status-text.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Drawing a status anywhere: rows, tables, receipts, files, and sync lines. Status is colored text, not a chip.",
            "Map your own statuses to a tone, like ok → success, warn → warning, bad → danger, info → brand, muted → neutral, overdue → danger. Overdue shares danger's red, so its word, Overdue, must always show.",
            "fix ends the status with its quickest fix as an icon in the same color, named in its tooltip: Couldn't save, then a retry arrow.",
            "Give fix a result, like { label: 'Reminded' }. The icon slips to a check and its tooltip says so. With onUndo, coming back shows Undo for 8 seconds, and ⌘Z undoes it too. Send the change in onCommit.",
            "Return a promise from onClick, and give the result failed words. The fix waits while it runs, and turns into a red Retry arrow if it fails.",
            "detail opens the story behind the status on hover, focus, or tap. Pass PeekCard props, or a node.",
            "Set stream when the words change in place, like Reply to Sent, so the new words slip in."
          ],
          "avoid": [
            "A chip or badge for a status. Use StatusText.",
            "An icon for a fix that does not say the action on sight. Leave fix out, and put the action in the row.",
            "Color alone. The words must say the status, since color is only a hint."
          ]
        },
        "props": [
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color role. neutral is muted."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "An icon before the words, in their color."
          },
          {
            "name": "fix",
            "type": "{ label: string; icon?: ReactNode; onClick?; result?: ActionResult }",
            "description": "The quickest fix: an icon after the words, named by label in its tooltip. The default icon is a turn arrow, for retry."
          },
          {
            "name": "stream",
            "type": "boolean",
            "default": "false",
            "description": "When the words change, the new ones slip in. The first words just show."
          },
          {
            "name": "detail",
            "type": "PeekCardProps | ReactNode",
            "description": "The story behind the status, in a peek on hover, focus, or tap. The words get a dotted underline."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "The words, like \"Going\" or \"Couldn't save\". Required, since tones share colors: Overdue and Couldn't save are both danger red."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the status. Later classes win."
          }
        ],
        "a11y": [
          "The words carry the status, so color is never the only signal.",
          "A fix is a 24px button named by its label, which shows in its tooltip. Once pressed, its name follows its state: the result, with \"Press to undo.\" while a press undoes, then the record. A fix that failed is named \"Retry:\" and what failed.",
          "A fix says its result once, through the page's one polite live region, and its tooltip shows it for a moment after the press.",
          "With detail, the words are a button that opens the peek, named by its PeekCard title, or else by the words.",
          "stream only moves the words. To have a change read out, put role=\"status\" on it."
        ],
        "examples": [
          {
            "title": "Each tone, in words",
            "code": "<StatusText tone=\"success\">Going</StatusText>\n<StatusText tone=\"warning\">Maybe</StatusText>\n<StatusText>No reply</StatusText>"
          },
          {
            "title": "With its quickest fix",
            "code": "<StatusText tone=\"danger\" fix={{ label: \"Retry\", onClick: () => save() }}>\n  Couldn't save\n</StatusText>"
          },
          {
            "title": "With a peek behind it",
            "code": "<StatusText\n  tone=\"danger\"\n  detail={{\n    title: \"Overdue\",\n    facts: [[\"Due\", \"Sat 17 Oct · INV-0042\"]],\n  }}\n>\n  Overdue\n</StatusText>"
          }
        ]
      }
    },
    {
      "name": "step-dots",
      "type": "registry:ui",
      "title": "Step Dots",
      "description": "A row of dots that shows progress through steps, like an onboarding flow, with each step named for screen readers.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/step-dots.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing where someone is in a short flow with a few steps: onboarding, a setup wizard, a checkout.",
            "Pass one name per step. Sighted people see dots, and screen readers read each name, its place, and whether it is done.",
            "Set jump='back' to let people click a done step to go back to it. Set jump='any' when every step can be done in any order.",
            "Set current to steps.length when the flow is finished, so every dot shows as done.",
            "For another language, pass aria-label, stepText, and stateText."
          ],
          "avoid": [
            "Flows where people need to read the step names. Dots show no text, so use a list or tabs with visible labels.",
            "More than about eight steps. The dots get hard to count. Show 'Step 3 of 12' as text instead.",
            "Paging through a carousel of slides. Dots here mean progress, and done steps fill in.",
            "Moving focus for the app. A jump only changes the current step. Your app shows that step and moves focus to it."
          ]
        },
        "props": [
          {
            "name": "steps",
            "type": "readonly string[]",
            "description": "One name per step, in order. Screen readers read each name with 'Step 2 of 4' and its state."
          },
          {
            "name": "current",
            "type": "number",
            "description": "The current step, counted from 0. Steps before it are done. Set it to steps.length when all are done. Use it with onCurrentChange."
          },
          {
            "name": "defaultCurrent",
            "type": "number",
            "default": "0",
            "description": "The first current step when you do not control it."
          },
          {
            "name": "onCurrentChange",
            "type": "(index: number) => void",
            "description": "Called with the step someone clicked. Clicking the current step does not call it."
          },
          {
            "name": "jump",
            "type": "\"none\" | \"back\" | \"any\"",
            "default": "\"none\"",
            "description": "Which dots are buttons. back: done steps and the current one. any: every step. none: no buttons."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the done and current dots."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Progress\"",
            "description": "Names the list. Pass a better name, like 'Setup progress'. With aria-labelledby set, the default is dropped."
          },
          {
            "name": "stepText",
            "type": "string",
            "default": "\"Step {step} of {total}: {name}, {state}\"",
            "description": "What screen readers hear for each step. {step} is its place, {total} the number of steps, {name} its name, and {state} a word from stateText."
          },
          {
            "name": "stateText",
            "type": "{ done: string; current: string; upcoming: string }",
            "default": "{ done: \"done\", current: \"current\", upcoming: \"upcoming\" }",
            "description": "The words for {state} in stepText."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes for the list. Later classes win, so className='[--step-dots-size:6px]' makes the dots smaller."
          }
        ],
        "a11y": [
          "Renders an <ol>, so screen readers say how many steps there are.",
          "Each step has hidden text, like 'Step 2 of 4: Profile, current'. stepText and stateText change the words. The dots themselves are hidden from screen readers.",
          "The current step has aria-current=\"step\".",
          "With jump on, a clickable step is a native <button>, so Tab, Enter, and Space work. Steps people cannot reach are not buttons.",
          "The current step stays a button while jump is on, so focus stays on the dot someone pressed when it becomes current.",
          "Each dot sits in a cell at least 24px square, the smallest click target WCAG 2.2 allows.",
          "The current dot is a wider pill, so it is not marked by color alone. Done dots use the tone fill and upcoming dots use --input. The theme tests hold both to 3:1 on the page and on cards.",
          "The pill grows and shrinks over --motion-duration-base. data-motion=\"off\" makes it instant. Reduced motion keeps the color change and drops the change of width."
        ],
        "examples": [
          {
            "title": "Onboarding progress",
            "code": "<StepDots\n  aria-label=\"Setup progress\"\n  steps={[\"Account\", \"Profile\", \"Team\", \"Invite\"]}\n  current={1}\n/>"
          },
          {
            "title": "Go back to a done step",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nconst steps = [\"Cart\", \"Shipping\", \"Payment\", \"Review\"];\n\nfunction CheckoutSteps() {\n  const [step, setStep] = useState(2);\n  return (\n    <StepDots\n      aria-label=\"Checkout progress\"\n      steps={steps}\n      current={step}\n      onCurrentChange={setStep}\n      jump=\"back\"\n      tone=\"brand\"\n    />\n  );\n}"
          },
          {
            "title": "Every step done",
            "code": "const steps = [\"Account\", \"Profile\", \"Team\"];\n\n<StepDots aria-label=\"Setup progress\" steps={steps} current={steps.length} tone=\"success\" />"
          },
          {
            "title": "In another language",
            "code": "<StepDots\n  aria-label=\"Fortschritt der Einrichtung\"\n  steps={[\"Konto\", \"Profil\", \"Team\", \"Einladen\"]}\n  current={1}\n  stepText=\"Schritt {step} von {total}: {name}, {state}\"\n  stateText={{ done: \"erledigt\", current: \"aktuell\", upcoming: \"offen\" }}\n/>"
          }
        ]
      }
    },
    {
      "name": "suggestion",
      "type": "registry:ui",
      "title": "Suggestion",
      "description": "An AI or rule-based suggestion shown in place and never applied on its own: a quiet chip on a row, or ghost words that would continue a field. Taken, it says so in place, with Undo.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/tooltip",
        "@txbrendan/kbd",
        "@txbrendan/tool-button",
        "@txbrendan/action-keys"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/suggestion.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "ai",
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Suggestion: a chip on a row with a next step, like 'Ask Cal Nash' or 'Start as reviewer'. Give it a reason, so hover shows why.",
            "SuggestionText: ghost words over a field, after what is typed, like ' at 13:05'. Tab takes them while keys is on.",
            "Say the ghost words in the field's description, like 'Suggested: 13:05. Press Tab to take it.' They are hidden from screen readers.",
            "Turn keys on for the focused row or field only, so one suggestion answers at a time.",
            "Give it onUndo when taking it changes something, so its record offers Undo for 8 seconds.",
            "When where it lands is off screen, show your own toast from onAccept. Suggestion shows no toast."
          ],
          "avoid": [
            "Applying a suggestion on its own. It waits for a press, a tap, its key, or Tab.",
            "A dashed edge around it. Dashes mark a place where something can be dropped.",
            "A chip for the row's main action. Use a Button.",
            "Ghost text in a field that already has a placeholder showing. Keep the mark on in an empty field, so the two can't be mixed up."
          ]
        },
        "props": [
          {
            "name": "children",
            "type": "ReactNode",
            "description": "Required. Suggestion: its words. SuggestionText: the ghost words, a string. Spaces show as typed."
          },
          {
            "name": "reason",
            "type": "string",
            "description": "Suggestion only. Why it was suggested. The tooltip shows it, and it is the button's description."
          },
          {
            "name": "onAccept",
            "type": "() => void | Promise<unknown>",
            "description": "Takes it. Return a promise to read busy until it lands. A failure turns it into Retry. Show a toast here when where it lands is off screen."
          },
          {
            "name": "doneLabel",
            "type": "string",
            "description": "What it reads as once taken, and the record it leaves, like 'Asked Cal Nash'. Default its own words."
          },
          {
            "name": "onUndo",
            "type": "() => void",
            "description": "Takes it back, like clearing a prefill. With it, the record offers Undo while the window lasts."
          },
          {
            "name": "onCommit",
            "type": "() => void | Promise<unknown>",
            "description": "The acceptance is final: the window ended, or it left the page mid-window. Return a promise, and a failure turns it into Retry."
          },
          {
            "name": "say",
            "type": "string",
            "description": "What screen readers hear as it's taken. Default its doneLabel, or 'Accepted: ' and its words."
          },
          {
            "name": "failed",
            "type": "string",
            "description": "What it says when taking it didn't go through, like \"Couldn't ask Mark Clarke\". It stays on offer as Retry."
          },
          {
            "name": "toast",
            "type": "string",
            "description": "What a toast says once it's taken, only for what the suggestion can't show, like \"Cal Nash added to Saturday\". Needs a Toaster that called setToastSlot from tool-button. If the suggestion leaves mid-window, the toast keeps its Undo."
          },
          {
            "name": "onDismiss",
            "type": "() => void",
            "description": "Turns it down. A chip shows its × button while on offer, and Esc calls it while keys is on."
          },
          {
            "name": "keys",
            "type": "boolean",
            "default": "false",
            "description": "Listens for its keys: a chip's shortcut, Tab for ghost text in its field, and Esc for both. Turn it on for the focused row or field only."
          },
          {
            "name": "shortcut",
            "type": "string",
            "description": "Suggestion only. One key that takes it while keys is on, like 'S'. The tooltip teaches it. Never Tab."
          },
          {
            "name": "dismissLabel",
            "type": "string",
            "default": "\"Dismiss suggestion\"",
            "description": "Suggestion only. Names the × button."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Suggestion only. Blocks taking it."
          },
          {
            "name": "mark",
            "type": "boolean",
            "default": "true",
            "description": "SuggestionText only. Ends the words in the sparkles mark, a button that takes it. Keep it while the field is empty."
          },
          {
            "name": "ref",
            "type": "Ref<HTMLSpanElement>",
            "description": "Reaches the outer span."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer span. Later classes win."
          }
        ],
        "a11y": [
          "A chip is a button named by its words. Its reason is the description. Tab moves past it, and Enter or Space takes it.",
          "Ghost words are hidden from screen readers, so the field's description must say them. The mark is a button named 'Accept suggestion:' and the words.",
          "Taken, the button is named by its record. While Undo is shown it says '… Press to undo.' After the window it stays focusable but can't be pressed.",
          "What happened is said once through the page's one polite live region: the record, 'Undone', or what failed. So it is still heard after the suggestion leaves its row or field.",
          "Keys are ignored while a modifier is held. A chip's keys are also ignored while someone types, or inside an open dialog or menu.",
          "Every suggestion carries the sparkles mark, so it never passes for the app's own words or a placeholder."
        ],
        "examples": [
          {
            "title": "A chip on a row",
            "code": "import { useState } from \"react\";\n\nfunction NextStep() {\n  const [shown, setShown] = useState(true);\n  if (!shown) return null;\n  return (\n    <Suggestion\n      reason=\"He came to the last three syncs\"\n      doneLabel=\"Asked Cal Nash\"\n      onAccept={() => fetch(\"/api/ask\", { method: \"POST\" })}\n      onUndo={() => {}}\n      onDismiss={() => setShown(false)}\n    >\n      Ask Cal Nash\n    </Suggestion>\n  );\n}"
          },
          {
            "title": "Ghost text in an empty field",
            "code": "import { useState } from \"react\";\n\nfunction MeetAt() {\n  const [value, setValue] = useState(\"\");\n  const [focused, setFocused] = useState(false);\n  return (\n    <div className=\"relative\">\n      <input\n        aria-label=\"Meet at\"\n        aria-describedby=\"meet-hint\"\n        value={value}\n        onChange={(event) => setValue(event.target.value)}\n        onFocus={() => setFocused(true)}\n        onBlur={() => setFocused(false)}\n        className=\"h-9 w-full rounded-md border border-input bg-card px-3 text-sm\"\n      />\n      {value === \"\" ? (\n        <div className=\"pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm\">\n          <SuggestionText keys={focused} onAccept={() => setValue(\"13:05\")}>\n            13:05\n          </SuggestionText>\n        </div>\n      ) : null}\n      <p id=\"meet-hint\" className=\"sr-only\">Suggested: 13:05. Press Tab to take it.</p>\n    </div>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "suspension-row",
      "type": "registry:ui",
      "title": "Suspension Row",
      "description": "A player in the sin bin, sent off, or off for a temporary replacement, as a row in a card, counting down on the same clock value as MatchClock.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/button",
        "@txbrendan/match-clock",
        "@txbrendan/rolling-number",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/suspension-row.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "One row per player off, in a Card, beside the MatchClock on a scorer's or timekeeper's screen.",
            "Pass the match's clock value and format, the same ones the MatchClock gets. A card counts down on playing time: it stops when the clock stops, carries over half-time, and counts the time played after time is up.",
            "Record at as matchPlayed(clock) and time as matchClockDisplay(clock, format) when the card is shown.",
            "yellow uses the format's sin bin: 10 minutes in 15s, 2 in sevens and 10s, 5 to 7 in the age grades. Pass minutes when a competition sets its own.",
            "red reads Sent off for the rest of the match. red20 is elite rugby's 20-minute red card: after 20 minutes of playing time a replacement may come on.",
            "blood and hia are temporary replacements on the wall clock, from since: 15 minutes for blood, 12 for an HIA, or minutes={17} for both.",
            "Pass onReturn. A person button beside Can return, or Back within, brings them back on.",
            "Remove the row once the player is back on. A team's players on the field is its format's players less its rows."
          ],
          "avoid": [
            "A row for a suspected concussion in community or age-grade rugby. Recognise and Remove takes them off for good, which is a permanent replacement, not a row.",
            "Your own countdown timer. The row works out the time from the clock value, so every device agrees.",
            "Telling the cards apart by the mark's color alone. The words under the name say which card it is."
          ]
        },
        "props": [
          {
            "name": "player",
            "type": "{ person: string; photo?: string; number?: number }",
            "description": "The player, with their photo and shirt number."
          },
          {
            "name": "team",
            "type": "string",
            "description": "The player's team, in the line under the name."
          },
          {
            "name": "reason",
            "type": "\"yellow\" | \"red\" | \"red20\" | \"blood\" | \"hia\"",
            "description": "Why the player is off: a card, or a temporary replacement."
          },
          {
            "name": "clock",
            "type": "MatchClockValue | null",
            "description": "The match's MatchClock value."
          },
          {
            "name": "format",
            "type": "MatchFormatId | MatchFormat",
            "default": "\"15s\"",
            "description": "The same format as the MatchClock. It sets the sin bin."
          },
          {
            "name": "at",
            "type": "number",
            "default": "0",
            "description": "matchPlayed(clock) when the card was shown. Cards only."
          },
          {
            "name": "time",
            "type": "string",
            "description": "What the clock read when it was shown, like \"8:25\"."
          },
          {
            "name": "minutes",
            "type": "number",
            "description": "Minutes off, when a competition sets its own sin bin, or 17 for blood and an HIA together."
          },
          {
            "name": "since",
            "type": "number",
            "description": "blood and hia: when the player left, in ms since the epoch."
          },
          {
            "name": "onReturn",
            "type": "() => void",
            "description": "Brings them back on, or a replacement on. A person button shows once it can."
          },
          {
            "name": "index",
            "type": "number",
            "description": "Place in a list. Rows fade up one after another as they first show."
          },
          {
            "name": "divider",
            "type": "boolean",
            "default": "false",
            "description": "A hairline above the row, inset from the card's edges."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "The row reads as one line: the number, the name, and the status, like \"#3 Owen Price · Back in 4:12\".",
          "Which card it is, or why they are off, is in words in the line under the name. The card mark before those words is hidden from screen readers.",
          "The return button is a native button named for what it does, like \"Bring Owen Price back on\", with the same words in its tooltip.",
          "A polite live region says once when the time is served, like \"Owen Price: Can return\". The countdown itself is not read out each second.",
          "Can return, Sent off, and Replacement stays are words in the tone's soft text, which the theme holds to 4.5:1. A card mark keeps a border in forced-colors mode."
        ],
        "examples": [
          {
            "title": "A yellow card counting down beside the clock",
            "code": "// clock is the match's MatchClock value. at and time were recorded when the card was shown.\n<Card className=\"gap-0 px-0\">\n  <SuspensionRow\n    player={{ person: \"Owen Price\", number: 3 }}\n    team=\"Rovers\"\n    reason=\"yellow\"\n    at={card.at}\n    time={card.time}\n    clock={clock}\n    format=\"15s\"\n    onReturn={() => bringBack(\"owen\")}\n  />\n</Card>"
          },
          {
            "title": "A blood replacement on the wall clock",
            "code": "<SuspensionRow\n  player={{ person: \"Will Hardy\", number: 1 }}\n  team=\"Saints\"\n  reason=\"blood\"\n  since={leftAt}\n  clock={clock}\n  onReturn={() => bringBack(\"will\")}\n  divider\n/>"
          }
        ]
      }
    },
    {
      "name": "switch",
      "type": "registry:ui",
      "title": "Switch",
      "description": "An on and off control for a setting that takes effect right away, with a built-in label, tones, and sizes.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/switch.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Turning a setting on or off when the change applies at once, like email alerts or auto-save.",
            "Pass the label text as children. Name the setting, like 'Email alerts', not the action, like 'Turn on alerts'.",
            "A settings row with the text first: give the switch an id and point a label's htmlFor at it.",
            "Use tone to color the track when it is on. Keep neutral unless 'on' needs to stand out."
          ],
          "avoid": [
            "A choice that only applies after the person presses Save. Use a checkbox.",
            "Picking options from a list. Use checkboxes or a radio group.",
            "An id with no label pointing at it. TypeScript accepts the id, but the switch then has no name."
          ]
        },
        "props": [
          {
            "name": "children",
            "type": "ReactNode",
            "description": "Visible label text. Without it, TypeScript requires aria-label, aria-labelledby, or an id."
          },
          {
            "name": "id",
            "type": "string",
            "description": "Links a separate label. A label whose htmlFor matches names the switch, and clicking it flips the switch."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the track when it is on."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Track size and label text size. Density scales them."
          },
          {
            "name": "checked",
            "type": "boolean",
            "description": "Controlled state. Use it with onCheckedChange."
          },
          {
            "name": "defaultChecked",
            "type": "boolean",
            "default": "false",
            "description": "Starting state when you do not control it."
          },
          {
            "name": "onCheckedChange",
            "type": "(checked: boolean, details) => void",
            "description": "Called with the new state when someone flips the switch."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks flipping and removes the switch from the tab order. The label text dims too."
          },
          {
            "name": "name",
            "type": "string",
            "description": "Form field name. The switch sends its value with the form when it is on."
          },
          {
            "name": "aria-invalid",
            "type": "boolean",
            "description": "Shows the danger border. Set it when validation fails, and link the message with aria-describedby."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the track. Later classes win, so className='[--switch-h:2rem]' makes it bigger."
          }
        ],
        "a11y": [
          "Renders a span with role=\"switch\" and aria-checked plus a hidden native checkbox, so the value is sent with the form.",
          "Space and Enter both flip it.",
          "TypeScript requires visible children, aria-label, aria-labelledby, or an id. It cannot check that a label points at the id.",
          "In server HTML, only aria-label and aria-labelledby name the switch. A label from children or an id is linked once JavaScript runs.",
          "Clicking the label text also flips it, so the target is bigger than the track.",
          "The off track uses --input, which the theme tests hold to 3:1 against the page, so the off state stays visible.",
          "The thumb on the on track uses the tone's foreground, which the theme tests hold to 4.5:1 against the tone.",
          "The focus ring uses --ring, which the theme tests hold to 3:1 against the page and cards.",
          "In forced colors the thumb fills with the system text color, and an on track fills with the system highlight, so on and off still look different.",
          "The thumb slides with --motion-* tokens. Reduced motion keeps the color change and drops the slide."
        ],
        "examples": [
          {
            "title": "With a label",
            "code": "<Switch name=\"alerts\" defaultChecked>\n  Email alerts\n</Switch>"
          },
          {
            "title": "Controlled",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nfunction AutoSave() {\n  const [on, setOn] = useState(true);\n  return (\n    <Switch checked={on} onCheckedChange={setOn} tone=\"brand\">\n      Auto-save drafts\n    </Switch>\n  );\n}"
          },
          {
            "title": "Settings row with the text first",
            "code": "<div className=\"flex items-center justify-between gap-4\">\n  <label htmlFor=\"sync\" className=\"text-sm\">\n    Sync over mobile data\n  </label>\n  <Switch id=\"sync\" />\n</div>"
          },
          {
            "title": "No visible text, in a toolbar",
            "code": "<Switch size=\"sm\" aria-label=\"Show weekends\" />"
          }
        ]
      }
    },
    {
      "name": "sync-status",
      "type": "registry:ui",
      "title": "Sync Status",
      "description": "Where a save stands, beside what changed: saving, saved, waiting for a signal, or failed, with a retry icon.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/sync-status.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Beside what changed: a card header, next to a form's buttons, under a total that just changed.",
            "Put it in a field's status slot, like FieldFrame's or an editor's, with size sm. It sits on the label line, 16px tall.",
            "offline counts what waits to send: Offline · 3 changes will send when you're back online. Apply changes on screen at once anyway. This line is the promise they'll send.",
            "failed says why in plain words with reason, like No signal, and onRetry ends the line with a retry icon.",
            "saved is quiet. Pass at, like 18:04, to say when."
          ],
          "avoid": [
            "A toast for a save. The status sits beside what changed.",
            "A status on a row or in a table. Use StatusText.",
            "Saying Saved after every keystroke. Show saving while a save runs, then saved."
          ]
        },
        "props": [
          {
            "name": "state",
            "type": "\"saving\" | \"saved\" | \"offline\" | \"failed\"",
            "description": "Required. Where the save stands."
          },
          {
            "name": "count",
            "type": "number",
            "description": "offline: how many changes wait to send."
          },
          {
            "name": "at",
            "type": "string",
            "description": "saved: when, like \"18:04\"."
          },
          {
            "name": "reason",
            "type": "string",
            "description": "failed: why, in plain words, like \"No signal\"."
          },
          {
            "name": "onRetry",
            "type": "() => void",
            "description": "failed: a retry icon ends the line."
          },
          {
            "name": "retryLabel",
            "type": "string",
            "default": "\"Try again\"",
            "description": "Names the retry icon, in its tooltip and to screen readers."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "sm sits on a field's label line: meta type, 16px tall. md is body type, and lg is section type."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the line. Later classes win."
          }
        ],
        "a11y": [
          "The words sit in a polite status region, so a change is said once, without taking focus.",
          "The state is said in words: Saving, Saved, Offline, Couldn't save. Color only marks it.",
          "The retry icon is a button named by retryLabel, which also shows in its tooltip. It sits outside the status region, so its name is not said with every change.",
          "The icons are hidden from screen readers, since the words say the same."
        ],
        "examples": [
          {
            "title": "Saving",
            "code": "<SyncStatus state=\"saving\" />"
          },
          {
            "title": "Offline",
            "code": "<SyncStatus state=\"offline\" count={3} />"
          },
          {
            "title": "Failed, with a retry",
            "code": "<SyncStatus state=\"failed\" reason=\"No signal\" onRetry={() => save()} />"
          }
        ]
      }
    },
    {
      "name": "table",
      "type": "registry:ui",
      "title": "Table",
      "description": "A data table built from native table elements, with a caption, row sizes, and a container that scrolls sideways on narrow screens.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/table.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Showing records people compare across columns: invoices, members, orders, results.",
            "Name every table with a TableCaption, as the first part inside Table.",
            "Put the column names in a TableHeader row of TableHead cells. Use TableHead with scope=\"row\" for a cell that names its row.",
            "Pass scrollLabel when the table can be wider than its container, like on a phone. It makes the container a named region keyboard users can scroll.",
            "Use size to set row height: sm for dense data, md by default, lg for short tables with room to breathe.",
            "Right-align numbers with className=\"text-right\" on their TableHead and TableCell, so digits line up.",
            "For no rows, render one row with a single TableCell whose colSpan covers every column, and say why it is empty."
          ],
          "avoid": [
            "Laying out a page or a form. Use grid or flex. A table tells screen readers the content is data.",
            "One column of items. Use a list.",
            "Sorting, filtering, selecting rows, or editing cells. This table only displays. Add those with real buttons and inputs in the cells.",
            "Very long text in a cell. Cells do not wrap, so a paragraph makes the table very wide. Pass className=\"whitespace-normal\" to that cell."
          ]
        },
        "props": [
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "On Table. Row height and cell padding: a one-line row is as tall as a control of the same size. Density scales it."
          },
          {
            "name": "scrollLabel",
            "type": "string",
            "description": "On Table. Makes the scroll container a focusable region with this name, so keyboard users can scroll a wide table. Use the caption's words."
          },
          {
            "name": "Table",
            "type": "Omit<ComponentProps<\"table\">, \"color\"> & { size?, scrollLabel? }",
            "description": "A <table> inside a container that scrolls sideways. className and other props go on the <table>."
          },
          {
            "name": "TableCaption",
            "type": "Omit<ComponentProps<\"caption\">, \"color\">",
            "description": "Part. The table's name. Put it first inside Table. It shows below the table, left-aligned."
          },
          {
            "name": "TableHeader",
            "type": "Omit<ComponentProps<\"thead\">, \"color\">",
            "description": "Part. The header row group. Its rows take no hover fill."
          },
          {
            "name": "TableBody",
            "type": "Omit<ComponentProps<\"tbody\">, \"color\">",
            "description": "Part. The body row group."
          },
          {
            "name": "TableFooter",
            "type": "Omit<ComponentProps<\"tfoot\">, \"color\">",
            "description": "Part. A footer row group for totals, on a muted fill."
          },
          {
            "name": "TableRow",
            "type": "Omit<ComponentProps<\"tr\">, \"color\">",
            "description": "Part. One row, with a bottom border and a muted fill on hover."
          },
          {
            "name": "TableHead",
            "type": "Omit<ComponentProps<\"th\">, \"color\">",
            "description": "Part. A header cell, in muted-foreground. Does not wrap."
          },
          {
            "name": "TableCell",
            "type": "Omit<ComponentProps<\"td\">, \"color\">",
            "description": "Part. A data cell. Does not wrap. Pass className=\"whitespace-normal\" to let it wrap."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on any part. Later classes win, so className=\"py-4\" on a cell replaces its padding."
          }
        ],
        "a11y": [
          "Every part is the native element: <table>, <caption>, <thead>, <tbody>, <tfoot>, <tr>, <th>, and <td>. Screen readers can move by row and column and read each cell's header.",
          "TableCaption names the table. Screen readers read it when they reach the table.",
          "A <caption> must be the first child of its <table>, so put TableCaption first.",
          "scrollLabel makes the container a role=\"region\" with that name and tabIndex=0, so keyboard users can Tab to it and scroll with the arrow keys. It shows the shared focus ring.",
          "Without scrollLabel, the container adds no tab stop and no name. Some browsers let keyboard users focus a container that scrolls. Do not count on it.",
          "Header cells use muted-foreground and body text uses the page's foreground. The theme tests hold both to 4.5:1 on the page and on cards.",
          "The row hover fill and the footer fill are muted at 50% over the page. Text on them sits between two pairs the theme tests hold to 4.5:1."
        ],
        "examples": [
          {
            "title": "Invoices with a caption and a total",
            "code": "<Table>\n  <TableCaption>Recent invoices</TableCaption>\n  <TableHeader>\n    <TableRow>\n      <TableHead>Invoice</TableHead>\n      <TableHead>Status</TableHead>\n      <TableHead className=\"text-right\">Amount</TableHead>\n    </TableRow>\n  </TableHeader>\n  <TableBody>\n    <TableRow>\n      <TableCell className=\"font-medium\">INV-001</TableCell>\n      <TableCell>Paid</TableCell>\n      <TableCell className=\"text-right\">$250.00</TableCell>\n    </TableRow>\n    <TableRow>\n      <TableCell className=\"font-medium\">INV-002</TableCell>\n      <TableCell>Pending</TableCell>\n      <TableCell className=\"text-right\">$150.00</TableCell>\n    </TableRow>\n  </TableBody>\n  <TableFooter>\n    <TableRow>\n      <TableCell colSpan={2}>Total</TableCell>\n      <TableCell className=\"text-right\">$400.00</TableCell>\n    </TableRow>\n  </TableFooter>\n</Table>"
          },
          {
            "title": "A wide table on a phone",
            "code": "<Table scrollLabel=\"Team members\" size=\"sm\">\n  <TableCaption>Team members</TableCaption>\n  <TableHeader>\n    <TableRow>\n      <TableHead>Name</TableHead>\n      <TableHead>Email</TableHead>\n      <TableHead>Role</TableHead>\n      <TableHead>Last active</TableHead>\n    </TableRow>\n  </TableHeader>\n  <TableBody>\n    <TableRow>\n      <TableCell>Ada Lovelace</TableCell>\n      <TableCell>ada@example.com</TableCell>\n      <TableCell>Owner</TableCell>\n      <TableCell>2 minutes ago</TableCell>\n    </TableRow>\n  </TableBody>\n</Table>"
          },
          {
            "title": "Row headers",
            "code": "<Table>\n  <TableCaption>Plan limits</TableCaption>\n  <TableHeader>\n    <TableRow>\n      <TableHead>Plan</TableHead>\n      <TableHead className=\"text-right\">Seats</TableHead>\n    </TableRow>\n  </TableHeader>\n  <TableBody>\n    <TableRow>\n      <TableHead scope=\"row\">Free</TableHead>\n      <TableCell className=\"text-right\">3</TableCell>\n    </TableRow>\n    <TableRow>\n      <TableHead scope=\"row\">Pro</TableHead>\n      <TableCell className=\"text-right\">25</TableCell>\n    </TableRow>\n  </TableBody>\n</Table>"
          },
          {
            "title": "No rows yet",
            "code": "<Table>\n  <TableCaption>Orders</TableCaption>\n  <TableHeader>\n    <TableRow>\n      <TableHead>Order</TableHead>\n      <TableHead>Customer</TableHead>\n      <TableHead className=\"text-right\">Total</TableHead>\n    </TableRow>\n  </TableHeader>\n  <TableBody>\n    <TableRow>\n      <TableCell colSpan={3} className=\"h-24 text-center text-muted-foreground\">\n        No orders yet.\n      </TableCell>\n    </TableRow>\n  </TableBody>\n</Table>"
          }
        ]
      }
    },
    {
      "name": "tabs",
      "type": "registry:ui",
      "title": "Tabs",
      "description": "A row of tabs that switches between panels in one place, with a sliding indicator, a filled or underlined look, tones, and sizes.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/tabs.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Splitting one page's content into views people switch between, like Overview, Activity, and Settings.",
            "Give each TabsTab and its TabsPanel the same value. Put each TabsTab directly inside TabsList.",
            "Always set defaultValue on Tabs to an enabled tab. The server HTML then shows the right tab and panel.",
            "Use the default solid look for a compact switcher. Use emphasis='ghost' for an underlined row across the top of a section.",
            "A row too long for its space scrolls sideways. Each edge with more tabs past it fades out, so people can tell.",
            "Opening a tab in a long row scrolls the row so that tab sits clear of the fades. A click, a new value, and a defaultValue far along the row all do this.",
            "Set orientation='vertical' on Tabs for a list beside the panels. The arrow keys then move up and down.",
            "The arrow keys open each tab they reach. Set activateOnFocus={false} on TabsList when a panel is slow to show, such as one that loads data, so people can pass tabs without opening each one.",
            "Give TabsList an aria-label when a page has more than one tab list."
          ],
          "avoid": [
            "Moving between pages. Use links in a nav, because tabs do not change the URL.",
            "Steps people must finish in order. Show one step at a time with its own buttons.",
            "Picking a value that a form sends. Use a radio group or a select.",
            "Wrapping a TabsTab in another element. Its look comes from TabsList, its direct parent."
          ]
        },
        "props": [
          {
            "name": "Tabs",
            "type": "TabsProps",
            "description": "The root. Holds the open tab's value and stacks the list above the panels."
          },
          {
            "name": "TabsList",
            "type": "TabsListProps",
            "description": "Part. The row of tabs, with the sliding indicator built in. Takes tone, emphasis, and size."
          },
          {
            "name": "TabsTab",
            "type": "TabsTabProps",
            "description": "Part. One tab button. Needs a value that matches a panel."
          },
          {
            "name": "TabsPanel",
            "type": "TabsPanelProps",
            "description": "Part. The content for the tab with the same value. Only the open panel is in the page, unless keepMounted is set."
          },
          {
            "name": "defaultValue",
            "type": "string | number",
            "description": "On Tabs. The tab that starts open. Set it, or the server HTML may show no open tab."
          },
          {
            "name": "value",
            "type": "string | number",
            "description": "Required on TabsTab and TabsPanel, where matching values link a tab to its panel. On Tabs, it controls the open tab. Use it with onValueChange."
          },
          {
            "name": "onValueChange",
            "type": "(value, details) => void",
            "description": "On Tabs. Called with the new value when someone opens a tab."
          },
          {
            "name": "orientation",
            "type": "\"horizontal\" | \"vertical\"",
            "default": "\"horizontal\"",
            "description": "On Tabs. Vertical puts the list beside the panels, and the arrow keys move up and down."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\"",
            "default": "\"neutral\"",
            "description": "On TabsList. Color of the active tab's pill or line."
          },
          {
            "name": "emphasis",
            "type": "\"solid\" | \"ghost\"",
            "default": "\"solid\"",
            "description": "On TabsList. Solid puts the active tab on a filled pill over a muted track. Ghost underlines it."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "On TabsList. The list is as tall as a control of the same size. Density scales it."
          },
          {
            "name": "activateOnFocus",
            "type": "boolean",
            "default": "true",
            "description": "On TabsList. Opens a tab as soon as an arrow key reaches it. Set it to false for panels that are slow to show, and Enter or Space then opens the focused tab."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "On TabsTab. Blocks opening that tab, and dims it. The arrow keys still reach it."
          },
          {
            "name": "keepMounted",
            "type": "boolean",
            "default": "false",
            "description": "On TabsPanel. Keeps the panel in the page while it is hidden, so its state survives a switch."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on any part. Later classes win, so className='[--tabs-radius:0px]' on TabsList squares the corners, and '[--tabs-fade:0px]' turns off the edge fades."
          }
        ],
        "a11y": [
          "TabsList has role=\"tablist\", each TabsTab has role=\"tab\" with aria-selected, and each panel has role=\"tabpanel\" named by its tab.",
          "The arrow keys move between tabs, open each tab they reach, and wrap at the ends. Home and End jump to the first and last tab. With activateOnFocus={false}, the keys only move focus, and Enter or Space opens the focused tab.",
          "A disabled tab has aria-disabled. The arrow keys still stop on it, so screen reader users hear that it exists, but it does not open.",
          "Only the active tab is in the Tab order, and the list itself is not, even when it scrolls. Tab then moves into the open panel.",
          "Inactive labels use muted-foreground, which the theme tests hold to 4.5:1 on the muted track and on the page.",
          "The solid look's active label uses the tone's foreground on the tone, and the ghost look's uses the tone's soft foreground on the page. The theme tests hold both to 4.5:1.",
          "The indicator slides with --motion-duration-base and --motion-ease-out. It still slides under reduced motion.",
          "A small inline script places the indicator before React hydrates. Without JavaScript, the solid look's active tab fills itself, so its label stays readable.",
          "In a row that scrolls, the arrow keys bring the focused tab and its ring fully clear of the edge fades. Forced-colors mode turns the fades off.",
          "A click or a new value brings the open tab clear of the fades too, and tabs that come or go keep it clear while the row stays where it last scrolled to the open tab. Once the row has been hidden and shown again, that no longer holds: a tab added past the open tab can leave it partly under the right fade. The row scrolls at once, with no animation, and the page does not move.",
          "A press opens its tab once it lifts on that tab, so a tab never opens or scrolls away under a held button. A press dragged off the tab opens nothing.",
          "The fades and that scroll start once React runs. Until then, and without JavaScript, a long row shows its first tabs, even when the open tab is further along. Its edges are cut off with no fade."
        ],
        "examples": [
          {
            "title": "Settings sections",
            "code": "<Tabs defaultValue=\"account\">\n  <TabsList aria-label=\"Settings\">\n    <TabsTab value=\"account\">Account</TabsTab>\n    <TabsTab value=\"password\">Password</TabsTab>\n    <TabsTab value=\"billing\">Billing</TabsTab>\n  </TabsList>\n  <TabsPanel value=\"account\">Change your name and email.</TabsPanel>\n  <TabsPanel value=\"password\">Change your password.</TabsPanel>\n  <TabsPanel value=\"billing\">See your plan and invoices.</TabsPanel>\n</Tabs>"
          },
          {
            "title": "Underlined, in the brand color",
            "code": "<Tabs defaultValue=\"overview\">\n  <TabsList emphasis=\"ghost\" tone=\"brand\" aria-label=\"Project\">\n    <TabsTab value=\"overview\">Overview</TabsTab>\n    <TabsTab value=\"activity\">Activity</TabsTab>\n    <TabsTab value=\"settings\">Settings</TabsTab>\n  </TabsList>\n  <TabsPanel value=\"overview\">Overview content</TabsPanel>\n  <TabsPanel value=\"activity\">Activity content</TabsPanel>\n  <TabsPanel value=\"settings\">Settings content</TabsPanel>\n</Tabs>"
          },
          {
            "title": "Beside the panels",
            "code": "<Tabs defaultValue=\"general\" orientation=\"vertical\">\n  <TabsList emphasis=\"ghost\" aria-label=\"Preferences\">\n    <TabsTab value=\"general\">General</TabsTab>\n    <TabsTab value=\"alerts\">Alerts</TabsTab>\n    <TabsTab value=\"privacy\">Privacy</TabsTab>\n  </TabsList>\n  <TabsPanel value=\"general\">General settings</TabsPanel>\n  <TabsPanel value=\"alerts\">Alert settings</TabsPanel>\n  <TabsPanel value=\"privacy\">Privacy settings</TabsPanel>\n</Tabs>"
          },
          {
            "title": "Controlled",
            "code": "\"use client\";\nimport { useState } from \"react\";\n\nfunction ReportTabs() {\n  const [tab, setTab] = useState(\"week\");\n  return (\n    <Tabs value={tab} onValueChange={setTab}>\n      <TabsList size=\"sm\" aria-label=\"Report range\">\n        <TabsTab value=\"week\">Week</TabsTab>\n        <TabsTab value=\"month\">Month</TabsTab>\n        <TabsTab value=\"year\">Year</TabsTab>\n      </TabsList>\n      <TabsPanel value={tab}>Showing the last {tab}.</TabsPanel>\n    </Tabs>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "team-flag",
      "type": "registry:ui",
      "title": "Team Flag",
      "description": "A small swatch of a team's two kit colors, in hoops, halves, thirds, or split, standing in for a club crest.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/team-flag.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "sport",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Beside a team's name in a fixture, a bracket, a standings table, or a club switcher.",
            "Pass the kit's two colors from your data, and the pattern the kit uses.",
            "Leave colors off when you do not know them. It shows a plain neutral swatch.",
            "Use sm in dense rows, md beside body text, and lg in a header."
          ],
          "avoid": [
            "Drawing a club's crest or logo. A flag stands in for it, so no brand art is ever drawn.",
            "Telling teams apart by color alone. Always show the team's name in text beside it.",
            "A default club color. Without colors, the swatch is neutral, since no club's colors are anyone's default."
          ]
        },
        "props": [
          {
            "name": "colors",
            "type": "KitColors",
            "description": "The kit's two colors from your data, [main, second], as any CSS colors. The KitColors type is exported for other parts that take a kit."
          },
          {
            "name": "pattern",
            "type": "\"hoops\" | \"halves\" | \"thirds\" | \"split\"",
            "default": "\"halves\"",
            "description": "How the two colors share the swatch."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "sm is 16×12px, md is 24×18px, lg is 32×24px."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names it, like \"Rovers colors\", when no text beside it names the team."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "Hidden from screen readers by default, since the team's name sits beside it.",
          "With aria-label it is role=\"img\" with that name.",
          "A hairline in the border color keeps its edge when a kit color matches the page.",
          "Kit colors come from your data, so the theme cannot check them, and picking colors that read is your job. The name in text carries the meaning."
        ],
        "examples": [
          {
            "title": "Beside a team's name",
            "code": "// club comes from your data: { name, kit: KitColors, pattern }.\n<p className=\"flex items-center gap-2\">\n  <TeamFlag colors={club.kit} pattern={club.pattern} />\n  {club.name}\n</p>"
          },
          {
            "title": "Patterns",
            "code": "<div className=\"flex items-center gap-2\">\n  <TeamFlag colors={[\"var(--chart-1)\", \"var(--background)\"]} pattern=\"hoops\" />\n  <TeamFlag colors={[\"var(--chart-1)\", \"var(--background)\"]} pattern=\"halves\" />\n  <TeamFlag colors={[\"var(--chart-1)\", \"var(--background)\"]} pattern=\"thirds\" />\n  <TeamFlag colors={[\"var(--chart-1)\", \"var(--background)\"]} pattern=\"split\" />\n</div>"
          },
          {
            "title": "Colors not known yet",
            "code": "<TeamFlag size=\"sm\" />"
          }
        ]
      }
    },
    {
      "name": "textarea",
      "type": "registry:ui",
      "title": "Textarea",
      "description": "A multi-line text field that grows with its text, with sizes and an error state.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/textarea.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Text that can run past one line: a comment, a bio, a bug report.",
            "Name it with a visible label. Give the textarea an id and point the label's htmlFor at it.",
            "Let it grow with the text. For a fixed height, add className='field-sizing-fixed' and set rows.",
            "Show errors with aria-invalid plus a message under it, linked with aria-describedby."
          ],
          "avoid": [
            "One-line values like a name or an email address. Use an input.",
            "Text that needs bold, lists, or links. Use a rich text editor.",
            "Turning Enter into submit. People expect Enter to start a new line here.",
            "Using placeholder as the label. It disappears as soon as someone types."
          ]
        },
        "props": [
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Minimum height, padding, and text size. Density scales them."
          },
          {
            "name": "rows",
            "type": "number",
            "description": "Visible lines. It only applies with className='field-sizing-fixed', because the default grows with the text."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks typing and removes the textarea from the tab order."
          },
          {
            "name": "aria-invalid",
            "type": "boolean",
            "description": "Shows the danger border. Set it when validation fails, and link the message with aria-describedby."
          },
          {
            "name": "onValueChange",
            "type": "(value: string, details) => void",
            "description": "Called with the new text on every change. Use it with value for a controlled textarea."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win, so className='field-sizing-fixed' stops it from growing."
          }
        ],
        "a11y": [
          "Renders a native <textarea>, so Enter adds a new line and typing, selection, and autofill work as usual.",
          "It has no name on its own. Pair it with a <label htmlFor>, or wrap it in a label.",
          "aria-invalid=\"true\" tells screen readers the value is wrong. Put the reason in text next to it, not only in the red border.",
          "The border uses --input, which the theme tests hold to 3:1 against the page and cards.",
          "The focus ring uses --ring, which the theme tests hold to 3:1 against the page and cards.",
          "On touch screens the text is 16px, so iOS Safari does not zoom in when the textarea gets focus."
        ],
        "examples": [
          {
            "title": "With a label",
            "code": "<div className=\"grid gap-2\">\n  <label htmlFor=\"bio\" className=\"text-sm font-medium\">\n    Bio\n  </label>\n  <Textarea id=\"bio\" name=\"bio\" placeholder=\"A few words about you\" />\n</div>"
          },
          {
            "title": "Error message the textarea points to",
            "code": "<div className=\"grid gap-2\">\n  <label htmlFor=\"reason\" className=\"text-sm font-medium\">\n    Reason for the refund\n  </label>\n  <Textarea id=\"reason\" aria-invalid aria-describedby=\"reason-error\" />\n  <p id=\"reason-error\" className=\"text-sm text-destructive-soft-foreground\">\n    Tell us what went wrong so we can fix it.\n  </p>\n</div>"
          },
          {
            "title": "Fixed height",
            "code": "<Textarea aria-label=\"Release notes\" className=\"field-sizing-fixed\" rows={8} />"
          }
        ]
      }
    },
    {
      "name": "thinking-state",
      "type": "registry:ui",
      "title": "Thinking State",
      "description": "What an agent is doing, step by step. The list grows while it works, then folds into one line, like Thought for 34s, 5 steps.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/spinner",
        "@txbrendan/timeline"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/thinking-state.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "ai",
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Above an agent's answer, to show the steps it took: Read 12 files, Ran the tests.",
            "Pass loading while the agent works. Add each step to the end of steps as it starts. The last one spins.",
            "Set loading to false when it is done. The list folds into one line that opens again.",
            "Pass duration, like 34s, and keep it current while loading for a live timer. The folded line uses it.",
            "Give each step an id, so a new one fades in and the rest stay still.",
            "Put the files or sources a step used in detail, as a quiet second line."
          ],
          "avoid": [
            "A single wait with no steps. Use Spinner.",
            "One tool call with its inputs and result. Use ToolCallCard.",
            "Progress with a known end, like 3 of 5 files sent. Use Progress."
          ]
        },
        "props": [
          {
            "name": "steps",
            "type": "{ id?: string; label: ReactNode; detail?: ReactNode; time?: string }[]",
            "description": "Required. The steps so far, oldest first. While loading, the last one is under way."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "The agent is still working. The list stays open and grows. When it turns false, the list folds into one line."
          },
          {
            "name": "duration",
            "type": "string",
            "description": "How long the agent has worked, or worked in all, like \"34s\"."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "default": "\"Thinking\"",
            "description": "The line while loading. It pulses slowly."
          },
          {
            "name": "summary",
            "type": "ReactNode",
            "description": "The folded line once done. Default \"Thought for 34s, 5 steps\", from duration and the step count."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "description": "Starts open or folded. Default: open while loading, folded when done."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Controls whether it is open. Pair it with onOpenChange. Then it no longer folds itself."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Called when it opens or folds."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Later classes win."
          }
        ],
        "a11y": [
          "The line is one button that opens and folds the steps, and it tells screen readers if it is open.",
          "The steps are an ordered list named Steps. The one under way says (working) to screen readers.",
          "Each new step is said politely in a hidden status region, and so is the summary when the agent is done.",
          "The spinners and the pulse stop when the theme turns motion off, and for reduced motion. The list then opens and folds at once."
        ],
        "examples": [
          {
            "title": "While working",
            "code": "<ThinkingState\n  loading\n  duration=\"12s\"\n  steps={[\n    { id: \"read\", label: \"Read 12 files\", detail: \"app/, lib/, registry/\" },\n    { id: \"test\", label: \"Ran the tests\" },\n  ]}\n/>"
          },
          {
            "title": "Done",
            "code": "<ThinkingState\n  duration=\"34s\"\n  steps={[\n    { id: \"read\", label: \"Read 12 files\" },\n    { id: \"test\", label: \"Ran the tests\" },\n    { id: \"fix\", label: \"Fixed the failing test\" },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "time-field",
      "type": "registry:ui",
      "title": "Time Field",
      "description": "A 24-hour time, typed or picked from a list under the field. \"19\", \"1930\", \"19.30\", and \"7pm\" all settle as 19:00-style times, and an end time shows how long it makes the event.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/field-frame",
        "@txbrendan/input"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/time-field.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "dates",
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A start or end time for a meeting, a session, or a booking. Pair it with a DatePicker for a date and a time.",
            "Give it a label, like \"Starts\". Without one, pass aria-label.",
            "The value is \"HH:MM\" on a 24-hour clock, or \"\" for none. onValueChange only ever gets a good time.",
            "Typed text settles when the field is left or Enter is pressed. Text it cannot read says so in red, and the last good time stays.",
            "For an end time, pass the start as from. The list begins after it, each time says how long that makes it, and the field shows the length.",
            "step sets the minutes between listed times. Any minute can still be typed. min and max default to 06:00 and 23:00."
          ],
          "avoid": [
            "A length of time, like 90 minutes. Use a NumberStepper with a duration UnitSwitch.",
            "A date. Use a DatePicker.",
            "Times that cross midnight. The value has no date, so 23:30 to 00:30 cannot be told from a backwards range."
          ]
        },
        "props": [
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Names the field, above it."
          },
          {
            "name": "value",
            "type": "string",
            "description": "The time, \"HH:MM\", or \"\" for none. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "default": "\"\"",
            "description": "The time at first render, when you do not control it."
          },
          {
            "name": "onValueChange",
            "type": "(time: string) => void",
            "description": "Called with a good \"HH:MM\" once the field settles, or \"\" when cleared."
          },
          {
            "name": "from",
            "type": "string",
            "description": "The start, for an end time. The list begins after it and says each length."
          },
          {
            "name": "step",
            "type": "number",
            "default": "15",
            "description": "Minutes between listed times."
          },
          {
            "name": "min",
            "type": "string",
            "default": "\"06:00\"",
            "description": "The earliest time allowed."
          },
          {
            "name": "max",
            "type": "string",
            "default": "\"23:00\"",
            "description": "The latest time allowed."
          },
          {
            "name": "placeholder",
            "type": "string",
            "default": "\"--:--\"",
            "description": "Shown while the field is empty."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "A short note under the field."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Replaces the hint, in red. The field's own reading problems show first."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height and text size of the input, the label, and the list."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks typing and the list."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Opens the list on first render, for docs and screenshots."
          },
          {
            "name": "name",
            "type": "string",
            "description": "Form field name. Sends the settled time."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the frame. Later classes win."
          }
        ],
        "a11y": [
          "The input is a combobox. It says it has a list, and whether the list is open.",
          "Focus stays in the input. The highlighted time is its active descendant, so screen readers read it as the keys move.",
          "Typing, a click, or the down arrow opens the list at the nearest time. The arrows move a step, and Page Up, Page Down, or Shift with an arrow move an hour.",
          "Enter takes the highlighted time once the keys have moved it. Otherwise it takes the typed one. Home and End stay in the text.",
          "The first Esc closes the list, and the next puts back the last good time. With nothing to undo, Esc goes on to the page.",
          "A problem reading the time is shown in red under the field and announced once. The input is marked invalid.",
          "For an end time, the length after the time describes the input."
        ],
        "examples": [
          {
            "title": "A start and an end",
            "code": "const [start, setStart] = useState(\"19:00\");\nconst [end, setEnd] = useState(\"20:30\");\n\n<TimeField label=\"Starts\" value={start} onValueChange={setStart} />\n<TimeField label=\"Ends\" value={end} onValueChange={setEnd} from={start} />"
          },
          {
            "title": "Every 30 minutes, in the evening",
            "code": "<TimeField label=\"Workshop\" step={30} min=\"17:00\" max=\"21:00\" />"
          }
        ]
      }
    },
    {
      "name": "time-grid",
      "type": "registry:ui",
      "title": "Time Grid",
      "description": "A day of rooms or other resources: one row per room, time across, each booking a soft block in its event type's color. Book free time, and drag a booking to another time or room.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/badge",
        "@txbrendan/hover-card",
        "@txbrendan/motion-timing",
        "@txbrendan/peek-card",
        "@txbrendan/tool-button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/time-grid.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Room, desk, and equipment bookings: a day of rooms or other resources, in a Card.",
            "Pass resources, one per row, and bookings, each with its resource's id and 24-hour times.",
            "Set type for the event's color: training blue, match green, tournament teal, social violet, meeting slate.",
            "Pass now on today's day. A blue line marks it, the day opens on it, and bookings that have ended go quiet.",
            "Give a booking detail, PeekCard props, for its peek. Put the booking's own actions there, like Open, Move, and Cancel.",
            "Set closed on a room that cannot be booked, with the reason.",
            "Keep bookings in state. onMove hands back a booking's new room and times after each drop, and where it was when ⌘Z puts it back.",
            "Hand back a function from onCreate that takes the new booking away. ⌘Z calls it for 8 seconds.",
            "Tell the people booked in onSettle. It runs once the 8-second ⌘Z window ends, so ⌘Z never takes back a message already sent.",
            "Leave out onMove and onCreate for a day people only read."
          ],
          "avoid": [
            "Telling the event type by color alone. Each booking's name and peek say the type in words.",
            "Weeks or months. This is one day.",
            "Your own undo button or timer. Moves and new bookings join the page's ⌘Z stack.",
            "Days much wider than the screen that people rearrange a lot. A drag does not scroll the day for you."
          ]
        },
        "props": [
          {
            "name": "resources",
            "type": "{ id: string; name: string; meta?: string; closed?: string }[]",
            "description": "The rooms or other resources, one row each. closed is why it is closed."
          },
          {
            "name": "bookings",
            "type": "{ id: string; resource: string; start: string; end: string; title: string; type?: \"training\" | \"match\" | \"tournament\" | \"social\" | \"meeting\"; meta?: string; detail?: PeekCardProps | ReactNode }[]",
            "description": "The day's bookings. resource is a resource's id, and times are 24-hour."
          },
          {
            "name": "start",
            "type": "string",
            "default": "\"09:00\"",
            "description": "The day's first time, 24-hour."
          },
          {
            "name": "end",
            "type": "string",
            "default": "\"18:00\"",
            "description": "The day's last time, 24-hour."
          },
          {
            "name": "step",
            "type": "number",
            "default": "30",
            "description": "Minutes the slot and a moved booking go by. Drags snap to it."
          },
          {
            "name": "length",
            "type": "number",
            "default": "60",
            "description": "Minutes a booking made with a click or Enter lasts. It stops short at the next booking."
          },
          {
            "name": "now",
            "type": "string",
            "description": "The time now, like \"14:20\", when the day shown is today."
          },
          {
            "name": "hourWidth",
            "type": "number",
            "default": "72",
            "description": "Pixels per hour before the day scrolls sideways."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Bookings\"",
            "description": "Names the day for screen readers."
          },
          {
            "name": "onCreate",
            "type": "(slot: { resource: string; start: string; end: string }) => void | (() => void)",
            "description": "Free time was booked: by a drag, a click, a second tap, or Enter. Hand back a function that takes it away, and ⌘Z calls it for 8 seconds."
          },
          {
            "name": "onMove",
            "type": "(booking: { id: string; resource: string; start: string; end: string }) => void",
            "description": "A booking was dragged, or moved with the keys, to free time: its new room and times. ⌘Z moves it back, calling this with where it was."
          },
          {
            "name": "onSettle",
            "type": "(booking: { id?: string; resource: string; start: string; end: string }, change: \"move\" | \"create\") => void",
            "description": "A move, or a booking made here that onCreate can take away, is final: its ⌘Z window ended. A new booking has no id."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "The day is a group named by label.",
          "Each booking reads its type, title, times, and room, like \"Meeting, Design review, 11:00–12:30, Room 1\". Ended bookings add \"ended\".",
          "The day is one Tab stop. ←→ move by step, ↑↓ go to the room above or below at the same time and pass closed ones, and Home and End go to the day's ends.",
          "On free time the stop is a button named like \"Book Room 1, 10:30 to 11:30\", and Enter books it. On a booking the stop is the booking. Enter goes into its peek, or says \"11:00 is booked\".",
          "A room's Clash is a Tab stop of its own, before the day. So is each booking on a closed room, since the keys pass closed rooms.",
          "Space picks a booking up. ←→ move it by step, Shift with ←→ changes its end, ↑↓ take it to the next open room, Space or Enter drops it, and Esc puts it back.",
          "A booking that is up, the slot while it shows, and a drag all own Esc, even inside a Sheet. The next Esc closes the sheet.",
          "Focus leaving a booking that is up drops it where it is, or puts it back from booked time.",
          "A polite live region says each step: picked up, each place it passes, where it dropped or went back, and why, like Taken or Closed.",
          "Every drag has the same moves on the keys. A mouse lifts a booking after 4 px, a finger after holding still for 350 ms, and a finger moving scrolls the day.",
          "⌘Z (Ctrl+Z) puts a move back, or takes a new booking away, for 8 seconds. Screen readers hear what went back.",
          "The type is in words as well as color, and the peek's subtitle says it too.",
          "Booking text is the theme's foreground and muted text on a tint of 14% or less, so it keeps the card's contrast.",
          "The now line's time is text, read as \"Now 14:20\".",
          "Clash is a peek trigger. Its peek names the bookings that overlap.",
          "A closed room says Closed and why in words, not only with its shade.",
          "Forced colors keep a border on every booking and on the ended dot, and the rings on a lifted or landed booking become outlines.",
          "Reduced motion turns off the glide. Bookings step to their new places."
        ],
        "examples": [
          {
            "title": "Book free time and drag bookings",
            "code": "const [bookings, setBookings] = useState(DAY);\n\n<Card className=\"gap-0 py-2\">\n  <TimeGrid\n    label=\"Room bookings\"\n    resources={ROOMS}\n    bookings={bookings}\n    now=\"14:20\"\n    onCreate={(slot) => {\n      const id = crypto.randomUUID();\n      setBookings((all) => [...all, { id, ...slot, title: \"New booking\" }]);\n      return () => setBookings((all) => all.filter((booking) => booking.id !== id));\n    }}\n    onMove={(moved) => setBookings((all) => all.map((booking) => (booking.id === moved.id ? { ...booking, ...moved } : booking)))}\n    onSettle={tellPeopleBooked}\n  />\n</Card>"
          },
          {
            "title": "Read only, with nothing to book or move",
            "code": "<Card className=\"gap-0 py-2\">\n  <TimeGrid label=\"Evening bookings\" start=\"17:00\" end=\"21:00\" hourWidth={120} resources={ROOMS} bookings={EVENING} />\n</Card>"
          }
        ]
      }
    },
    {
      "name": "timeline",
      "type": "registry:ui",
      "title": "Timeline",
      "description": "Things in time order on one rail: who did what, newest first, or the day's running order with a line at now.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/timeline.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "layout=\"activity\" for a feed of who did what, newest first. Pass person and avatar, and the name leads the line.",
            "layout=\"schedule\" for the day's running order, with a time column. Pass now as \"HH:MM\" and keep it current.",
            "With now, done items go quiet, the one under way is bold, and a red line marks the moment between it and the next.",
            "Pass limit for a long feed. It shows that many, then a \"Show N more\" button that shows the rest in place.",
            "Give each item an id, and pass fresh on new ones. They fade up where they land, and the rest keep their place.",
            "Pass an icon for an item with no person, like an alert or a system change, and a tone to color its marker.",
            "Name the list with aria-label, like \"Event day\"."
          ],
          "avoid": [
            "Steps people must finish in order. Use StepDots or a list of steps.",
            "Events on a calendar. Use Calendar.",
            "Items that are not in time order. Use a plain list.",
            "Long text in each item. Keep it to one line, with meta for a second."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "TimelineItem[]",
            "description": "Required. Each is { id?, text?, title?, meta?, time?, person?, avatar?, icon?, tone?, fresh? }. activity reads person and text. schedule reads title."
          },
          {
            "name": "layout",
            "type": "\"activity\" | \"schedule\"",
            "default": "\"activity\"",
            "description": "activity: markers are photos or icons, and the time sits at the end. schedule: a time column on the left."
          },
          {
            "name": "now",
            "type": "string",
            "description": "schedule: the current time as \"HH:MM\". It sets which items are done, which one is under way, and where the red line sits."
          },
          {
            "name": "limit",
            "type": "number",
            "description": "Show this many, then a \"Show N more\" button. Pressing it shows the rest and moves focus to the first new one."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the list for screen readers, like \"Event day\"."
          },
          {
            "name": "TimelineItem.tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The marker's color: the dot's ring, or the icon's soft fill."
          },
          {
            "name": "TimelineItem.avatar",
            "type": "ReactNode",
            "description": "activity: the person's photo, as the marker. Pass an avatar 28px across."
          },
          {
            "name": "TimelineItem.icon",
            "type": "ReactNode",
            "description": "The marker when there is no avatar, like a lucide icon."
          },
          {
            "name": "TimelineItem.fresh",
            "type": "boolean",
            "description": "Just arrived. It fades up where it lands, once."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer box. Later classes win."
          }
        ],
        "a11y": [
          "The items are an <ol>, so screen readers say how many there are and where each one is.",
          "Name the list with aria-label. The rail and the markers are hidden from screen readers.",
          "In a schedule with now, the item under way carries aria-current=\"time\", and done items end with a hidden \"(done)\".",
          "The red now line is drawn only. aria-current carries the same news for screen readers.",
          "\"Show N more\" is a real button. Once pressed, it goes away and focus moves to the first item it showed.",
          "Text is the foreground or muted-foreground color, which the theme tests hold to 4.5:1 on the page and on cards, in light and dark."
        ],
        "examples": [
          {
            "title": "Who did what",
            "code": "<Timeline\n  aria-label=\"Recent activity\"\n  items={[\n    { id: \"a3\", person: \"Sam Lee\", text: \"moved Planning to Room 3\", time: \"18:20\" },\n    { id: \"a2\", person: \"Ava Patel\", text: \"added 4 people to the Design team\", time: \"17:05\" },\n    { id: \"a1\", text: \"Invoice INV-0042 is due\", time: \"Mon\", tone: \"warning\" },\n  ]}\n/>"
          },
          {
            "title": "The day's running order",
            "code": "<Timeline\n  layout=\"schedule\"\n  now=\"14:40\"\n  aria-label=\"Event day\"\n  items={[\n    { time: \"13:00\", title: \"Welcome\", meta: \"Room 2\" },\n    { time: \"14:00\", title: \"Keynote\" },\n    { time: \"15:00\", title: \"Workshop\" },\n    { time: \"16:30\", title: \"Awards\", meta: \"Boardroom\" },\n  ]}\n/>"
          },
          {
            "title": "A long feed",
            "code": "<Timeline aria-label=\"Team news\" limit={5} items={news} />"
          }
        ]
      }
    },
    {
      "name": "toast",
      "type": "registry:ui",
      "title": "Toast",
      "description": "A short note in the corner after something happened. An undo toast offers Undo for 8 seconds, then commits the change. Tones say how it went, and a promise toast follows work to its end.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/toast.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Place one <Toaster /> near the end of the root layout. Then call toast() from any client code, like an event handler.",
            "Apply a change that can be undone at once, then call toast.undoable() with onUndo to put it back. onCommit runs when the 8 seconds end.",
            "Use toast.promise() for work that lands somewhere else, like an import or a send. It shows a spinner, then success or danger.",
            "Use toast() for news that nothing on screen shows, like a reminder that went out. Give it one action that goes to where the news lives.",
            "Say what happened in the title, in a few words, like 'Archived Design review'. Add a description only when the title needs it.",
            "Use tone 'danger' and timeout: 0 for a failure people must see, like a send that did not go, and give it a Retry action."
          ],
          "avoid": [
            "Asking before an action that can be undone. Do it, and offer Undo in a toast.",
            "An action that cannot be undone, like a payment or a removal. Use Alert Dialog.",
            "A problem with one field or row. Say it under the thing with Notice.",
            "A success toast for what the screen already shows, like a saved field. Show the result in place.",
            "More than one Toaster on a page. Each one draws every toast, so each would show twice.",
            "Rendering <Toast> to pop a toast. It is a still picture for docs. Call toast()."
          ]
        },
        "props": [
          {
            "name": "Toaster",
            "type": "{ limit?, timeout?, className?, aria-label? }",
            "description": "Where toasts show, in the bottom right corner. Place one on the page."
          },
          {
            "name": "limit",
            "type": "number",
            "default": "1",
            "description": "Toaster. How many toasts show at once. A newer one replaces the oldest."
          },
          {
            "name": "timeout",
            "type": "number",
            "default": "5000",
            "description": "How long a toast stays, in milliseconds: on Toaster for every toast, or in toast() for one. 0 keeps it until it is dismissed. An undo toast always stays 8 seconds."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Toaster. Classes for the corner the toasts sit in, like 'right-auto left-4'. Later classes win."
          },
          {
            "name": "toast",
            "type": "(title | { title, description?, tone?, action?: { label, onClick }, timeout?, priority?, id?, onClose? }) => string",
            "description": "Shows a toast and returns its id. Pressing the action closes the toast, then runs onClick. Showing again with the same id replaces it in place."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "In toast(). Colors and picks the icon. Neutral has none."
          },
          {
            "name": "priority",
            "type": "\"low\" | \"high\"",
            "description": "In toast(). 'high' reads it out at once. Default 'high' for danger and 'low' for the rest."
          },
          {
            "name": "toast.promise",
            "type": "(work, { loading, success, error }) => Promise",
            "description": "Shows loading with a spinner, then success or danger. Each is a title or toast options, and success and error can be functions of the result. Returns the same promise, which still rejects."
          },
          {
            "name": "toast.undoable",
            "type": "({ title, description?, onUndo, onCommit?, undoLabel?, priority? }) => string",
            "description": "Offers Undo for 8 seconds. Undo or ⌘Z runs onUndo once. Dismissing it, or the window ending, runs onCommit once. With no Toaster on the page, it commits at once."
          },
          {
            "name": "toast.close",
            "type": "(id?) => void",
            "description": "Closes one toast by id, or every toast with no id."
          },
          {
            "name": "UNDO_WINDOW_MS",
            "type": "8000",
            "description": "How long an undo toast stays, in milliseconds."
          },
          {
            "name": "Toast",
            "type": "{ title?, description?, tone?, loading?, action? }",
            "description": "A still picture of a toast, for docs and galleries. It takes no clicks, and a server file can render it. action is the button's text."
          },
          {
            "name": "toastVariants",
            "type": "({ tone? }) => string",
            "description": "The toast's look. toast.tsx is a client file, so call it from a client file."
          }
        ],
        "a11y": [
          "Toasts are read out by a live region. The default priority waits until the screen reader is quiet. 'high', the default for danger, reads at once.",
          "Each tone has its own icon, so the tone does not rest on color alone. A loading toast sets aria-busy.",
          "F6 moves focus to the toast, so keyboard users can reach its action and Dismiss.",
          "The window holds while the pointer is on the toast, and while focus is in it.",
          "⌘Z or Ctrl+Z undoes the newest change while its window is open. It leaves text fields alone, so their own undo still works.",
          "The Dismiss button is named for screen readers. It stays hidden from them until the toast is pointed at or focused.",
          "The toast uses the popover colors. The theme tests hold its muted text and each tone's icon color to 4.5:1 on popovers."
        ],
        "examples": [
          {
            "title": "Archive at once, and offer Undo",
            "code": "function EventRow({ id, title }: { id: string; title: string }) {\n  const [hidden, setHidden] = useState(false);\n  if (hidden) return null;\n  return (\n    <div className=\"flex items-center justify-between gap-3\">\n      {title}\n      <Button\n        emphasis=\"ghost\"\n        onClick={() => {\n          setHidden(true);\n          toast.undoable({\n            title: `Archived ${title}`,\n            onUndo: () => setHidden(false),\n            onCommit: () => fetch(`/api/events/${id}/archive`, { method: \"POST\" }),\n          });\n        }}\n      >\n        Archive\n      </Button>\n    </div>\n  );\n}"
          },
          {
            "title": "Place one Toaster in the root layout",
            "code": "export default function RootLayout({ children }: { children: React.ReactNode }) {\n  return (\n    <html lang=\"en\">\n      <body>\n        {children}\n        <Toaster />\n      </body>\n    </html>\n  );\n}"
          },
          {
            "title": "Follow an import to its end",
            "code": "toast.promise(importMembers(file), {\n  loading: \"Importing members…\",\n  success: (added) => ({\n    title: \"Import finished\",\n    description: `${added} members added to the workspace.`,\n    action: { label: \"Open\", onClick: () => router.push(\"/members\") },\n  }),\n  error: \"Could not import\",\n});"
          },
          {
            "title": "A failure that stays until it is dismissed",
            "code": "toast({\n  title: \"Reminder not sent\",\n  description: \"Check your connection, then try again.\",\n  tone: \"danger\",\n  action: { label: \"Retry\", onClick: sendReminder },\n  timeout: 0,\n});"
          }
        ]
      }
    },
    {
      "name": "token-field",
      "type": "registry:ui",
      "title": "Token Field",
      "description": "A field for entries that each resolve to one real thing, like emails and people. A finished entry becomes a soft token, a known person shows with their photo, and a bad email turns red.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/chip-glide",
        "@txbrendan/field-frame",
        "@txbrendan/input",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/token-field.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Inviting people by email, or adding known people to a team or a message.",
            "An entry is finished by a comma, Enter, Tab, a paste, or leaving the field. Backspace in the empty field removes the last token.",
            "Pass people with { name, email, photo }. An entry that matches a person's email or name shows as them. Without a photo it shows their initials.",
            "An entry with an @ that is not a valid email shows red, and says so in its tooltip and to screen readers. Say what to fix in error.",
            "Text that is not an email and matches no person stays as text. It never becomes a token.",
            "value is the list of entries as typed. Check each one on your server too."
          ],
          "avoid": [
            "Free tags or labels people make up. This field only takes entries that resolve to someone or something real.",
            "Picking from a fixed list. Use a combobox with multiple.",
            "One email address. Use an input with type=\"email\"."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "string[]",
            "description": "The entries. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string[]",
            "description": "The entries at first render, when you do not control them."
          },
          {
            "name": "onValueChange",
            "type": "(tokens: string[]) => void",
            "description": "Called with the new list each time an entry is added or removed."
          },
          {
            "name": "people",
            "type": "{ name: string; email?: string; photo?: string }[]",
            "description": "Known people. A matching entry shows as them, with their photo or initials."
          },
          {
            "name": "placeholder",
            "type": "string",
            "description": "Shown while the field is empty. Also names the field when there is no label."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "Names the field, above it."
          },
          {
            "name": "hint",
            "type": "ReactNode",
            "description": "A short note under the field."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Replaces the hint, in red, and is announced."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height, padding, and text size. The same heights as Input."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks typing, and hides the remove buttons."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the frame. Other props, like name and aria-*, land on the input."
          }
        ],
        "a11y": [
          "The text field is a native input, named by the label, or by the placeholder when there is no label.",
          "Each token reads as its name. A bad email adds \", not a valid email address\" for screen readers, and a tooltip for the mouse.",
          "Each token has a remove button named \"Remove\" plus the token, with a tooltip that says the same. It is not a Tab stop, since Backspace removes from the keyboard.",
          "A polite live region says what was added or removed.",
          "The × shows on hover with a mouse, and always on touch screens, where nothing hovers.",
          "On touch screens the text is 16px, so iOS Safari does not zoom in when the field gets focus."
        ],
        "examples": [
          {
            "title": "Invite by email",
            "code": "<TokenField\n  label=\"Invite\"\n  placeholder=\"Add email addresses\"\n  defaultValue={[\"dan.kerr@mail.com\", \"tom.hale@mail.com\"]}\n  hint=\"Separate them with commas.\"\n/>"
          },
          {
            "title": "Known people",
            "code": "<TokenField\n  label=\"People\"\n  placeholder=\"Add people by name or email\"\n  people={[\n    { name: \"Maya Patel\", email: \"maya@mail.com\" },\n    { name: \"Tom Hale\", email: \"tom.hale@mail.com\" },\n  ]}\n  defaultValue={[\"maya@mail.com\"]}\n/>"
          }
        ]
      }
    },
    {
      "name": "tool-button",
      "type": "registry:ui",
      "title": "Tool Button",
      "description": "A small button in a glass toolbar, with its label and key taught in a tooltip. A press can leave its result in place, with Undo and Retry.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/tooltip",
        "@txbrendan/kbd"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/tool-button.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "actions"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Small toolbars: the quick actions on a row, an editor's formatting bar, a bar of bulk actions.",
            "Put ToolButtons and ToolDividers inside a Toolbar. The toolbar is one Tab stop, and the arrow keys move between its buttons.",
            "A ToolButton alone, outside a Toolbar, is a single quiet button with the same tooltip and result, like Copy on a code block.",
            "Give every button a label. It names the button and fills its tooltip, so an icon button is never unnamed.",
            "Give shortcut the key the app listens for, like R. The tooltip teaches it. ActionKeys wires single keys for you.",
            "Give result to a press that changes something, like a reminder. The button says what happened, offers Undo with onUndo, and commits when the window ends.",
            "Return a promise from onClick to show a spinner until it settles. A failure turns the button into Retry.",
            "A change with no button, like a dragged row, calls undoable(result) to join the page's ⌘Z stack. A Toaster calls setToastSlot, and then takes the Undo or Retry of a button that leaves.",
            "Use pressed for a toggle, like Bold, and keepFocus so a press leaves the text selection alone."
          ],
          "avoid": [
            "A page's main action, like Save. Use Button.",
            "A toast that repeats what the button already says. The button carries the result.",
            "More than a handful of buttons in a row's quick actions. Keep two to four, and put the rest in a Menu."
          ]
        },
        "props": [
          {
            "name": "Toolbar",
            "type": "ComponentProps<\"div\"> & { orientation?: \"horizontal\" | \"vertical\"; \"aria-label\"?: string }",
            "description": "Root. The glass bar. role=toolbar, one Tab stop, and arrow keys between its buttons. Name it with aria-label."
          },
          {
            "name": "ToolButton",
            "type": "component",
            "description": "Part. One button in the toolbar."
          },
          {
            "name": "ToolDivider",
            "type": "component",
            "description": "Part. The thin line between groups of buttons."
          },
          {
            "name": "label",
            "type": "string",
            "description": "ToolButton. Required. What it does, like Reply. It names the button and fills its tooltip."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "ToolButton. The icon, like a lucide icon. With no icon, the label shows as text."
          },
          {
            "name": "text",
            "type": "boolean",
            "default": "false",
            "description": "ToolButton. Shows the label beside the icon."
          },
          {
            "name": "shortcut",
            "type": "string",
            "description": "ToolButton. The key as the tooltip teaches it, like R or ⌘B. A single key also sets aria-keyshortcuts and data-shortcut, which ActionKeys reads."
          },
          {
            "name": "pressed",
            "type": "boolean",
            "description": "ToolButton. Makes it a toggle. Sets aria-pressed."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"danger\"",
            "default": "\"neutral\"",
            "description": "ToolButton. Color role. Use danger for a destructive action."
          },
          {
            "name": "keepFocus",
            "type": "boolean",
            "default": "false",
            "description": "ToolButton. A press leaves focus and the text selection where they are, as an editor's toolbar needs."
          },
          {
            "name": "tooltip",
            "type": "boolean",
            "default": "true",
            "description": "ToolButton. Shows the tooltip."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "ToolButton. Shows a spinner and blocks presses. The button stays focusable."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "ToolButton. Blocks presses. The button stays focusable, so its tooltip still shows."
          },
          {
            "name": "onClick",
            "type": "(event?: MouseEvent) => void | Promise<unknown>",
            "description": "ToolButton. Runs on press. Return a promise to show a spinner until it settles. A rejection turns the button into Retry."
          },
          {
            "name": "result",
            "type": "ActionResult",
            "description": "ToolButton. What the press leaves behind. The button shows result.label with a check, offers Undo while the window lasts if onUndo is set, then commits and stays as result.doneLabel, a record that can't be pressed."
          },
          {
            "name": "ActionResult",
            "type": "{ label: string; doneLabel?: string; say?: string; failed?: string; onUndo?: () => void; undone?: string; onCommit?: () => void | Promise<unknown>; seconds?: number; toast?: string }",
            "description": "Type. seconds is the Undo window, default 8. onCommit runs when it ends, or when the button leaves the page mid-window. A rejected onCommit turns the button into Retry. toast goes to the toast slot, only for what the button can't show."
          },
          {
            "name": "useActionResult",
            "type": "({ label, onPress, result, onSlip?, live? }) => { phase, armed, undoShown, telling, choice, failText, press, handlers, message }",
            "description": "Hook. The same press-to-result flow for a control of your own, on the same ⌘Z stack. Call press from its click, spread handlers on it, and render message in a polite live region. Pass live: 'page' for a control that may leave before it is heard, like one in a peek, and the page's one region says it instead."
          },
          {
            "name": "undoable",
            "type": "(result: ActionResult) => { undo, restart }",
            "description": "Puts a change with no control, like a saved inline edit, on the page's ⌘Z stack for its window. restart gives it the whole window again."
          },
          {
            "name": "setToastSlot",
            "type": "(slot: ((toast: ResultToast) => () => void) | null) => void",
            "description": "For a Toaster. A button that leaves mid-window hands it its Undo, and one that leaves showing Retry hands it Retry. With no slot, it commits as before."
          },
          {
            "name": "toolbarVariants",
            "type": "() => string",
            "description": "The toolbar's glass look, for a bar of your own."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Toolbar, ToolButton, or ToolDivider. Extra classes. Later classes win."
          }
        ],
        "a11y": [
          "Toolbar has role=toolbar. It is one Tab stop, and the arrow keys move between its buttons, with Home and End to the ends. Give it an aria-label.",
          "Every ToolButton is named by its label, as aria-label. The type requires a label.",
          "A single-key shortcut sets aria-keyshortcuts. The tooltip shows the key, but only to sighted mouse and keyboard users.",
          "The button's name follows its state: the result once pressed, '… Press to undo.' while Undo is on offer, the record once done, and 'Retry: …' after a failure.",
          "The page's one polite live region says the result, Undone, or what failed, once. So it is still heard after a hover bar hides the button or its row leaves, and while a modal dialog is open.",
          "After a mouse press, Undo arms only once the pointer leaves. After a key or touch press it arms in 1.2 seconds, and a touch press then shows Undo. So a double press never undoes. The window holds while an armed button is pointed at or has keyboard focus.",
          "Disabled, loading, and done buttons stay focusable, so their tooltips still show.",
          "⌘Z or Ctrl+Z undoes the newest change still in its window, whatever shows it: a button, a peek, or undoable(). In a text field it stays the field's own undo."
        ],
        "examples": [
          {
            "title": "A formatting toolbar",
            "code": "import { Bold, Italic, Link } from \"lucide-react\";\nimport { useState } from \"react\";\n\nfunction FormatBar() {\n  const [bold, setBold] = useState(false);\n  return (\n    <Toolbar aria-label=\"Formatting\">\n      <ToolButton icon={<Bold />} label=\"Bold\" shortcut=\"⌘B\" aria-keyshortcuts=\"Meta+B\" pressed={bold} keepFocus onClick={() => setBold(!bold)} />\n      <ToolButton icon={<Italic />} label=\"Italic\" shortcut=\"⌘I\" aria-keyshortcuts=\"Meta+I\" keepFocus />\n      <ToolDivider />\n      <ToolButton icon={<Link />} label=\"Add link\" keepFocus />\n    </Toolbar>\n  );\n}"
          },
          {
            "title": "A press that leaves its result, with Undo",
            "code": "import { BellRing } from \"lucide-react\";\n\nfunction RemindButton({ send, cancel }: { send: () => Promise<void>; cancel: () => void }) {\n  return (\n    <Toolbar aria-label=\"Person\">\n      <ToolButton\n        icon={<BellRing />}\n        label=\"Remind\"\n        text\n        shortcut=\"R\"\n        result={{ label: \"Sent\", doneLabel: \"Reminded\", say: \"Reminder sent to Tom Hale\", onUndo: cancel, onCommit: send }}\n      />\n    </Toolbar>\n  );\n}"
          }
        ]
      }
    },
    {
      "name": "tool-call-card",
      "type": "registry:ui",
      "title": "Tool Call Card",
      "description": "One tool call an agent made, on one line: its status, the tool, its inputs in a few words, and how long it took. It opens to the inputs and the result.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/card",
        "@txbrendan/spinner"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/tool-call-card.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "ai",
        "feedback"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "In an agent's chat or run log, one card per tool call: what it called, with what, and what came back.",
            "Pass status running while the call runs, then done or failed. running spins, done is a green check, and failed is red.",
            "Keep summary to a few words, like the search query or the file name. The full inputs go in input.",
            "Pass input and result as the objects the tool took and gave. They show as JSON when the card opens. Pass a node to show them your own way.",
            "On failed, pass error in plain words, like Timed out after 30s. It shows under the line, so no one has to open the card to see it.",
            "Stack several in a flex column with a small gap for a run of calls."
          ],
          "avoid": [
            "The agent's own words or reasoning. Use ThinkingState for its steps.",
            "A step that needs a person to say yes before it runs. Ask for approval first, then show the call.",
            "A status on a row or in a table. Use StatusText."
          ]
        },
        "props": [
          {
            "name": "name",
            "type": "string",
            "description": "Required. The tool's name, like \"search_docs\". Shown in mono type."
          },
          {
            "name": "status",
            "type": "\"running\" | \"done\" | \"failed\"",
            "description": "Required. Where the call stands. running spins in the brand tone, done is a success check, and failed is danger red."
          },
          {
            "name": "summary",
            "type": "ReactNode",
            "description": "The inputs in a few words, like \"collapsible panel height\". Shown on the closed line, cut short with an ellipsis."
          },
          {
            "name": "input",
            "type": "ReactNode | Record<string, unknown> | unknown[]",
            "description": "The full inputs, shown when the card opens. An object or array shows as JSON."
          },
          {
            "name": "result",
            "type": "ReactNode | Record<string, unknown> | unknown[]",
            "description": "What came back, shown when the card opens. An object or array shows as JSON. While running, the card says it is waiting."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "failed: what went wrong, in plain words. It shows under the line, open or closed."
          },
          {
            "name": "duration",
            "type": "string",
            "description": "How long the call took, or has run so far, like \"1.5s\". Update it while running for a live timer."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Starts open."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Controls whether it is open. Pair it with onOpenChange."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Called when the card opens or closes."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "The line can't be opened."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the card. Later classes win."
          }
        ],
        "a11y": [
          "The line is one button. It says the status in words, the tool, the summary, and the time, and it tells screen readers if it is open.",
          "Enter or Space opens and closes it.",
          "Status is shown three ways: words for screen readers, an icon shape (a spinner, a check, or a circle with a mark), and a color.",
          "A change of status is said once, politely, in a hidden status region: search_docs: Done.",
          "The error is plain text under the line, so it is read without opening the card.",
          "The spinner stops when the theme turns motion off, and for reduced motion. The panel then opens at once."
        ],
        "examples": [
          {
            "title": "Running",
            "code": "<ToolCallCard name=\"search_docs\" status=\"running\" summary=\"collapsible panel height\" duration=\"0.8s\" />"
          },
          {
            "title": "Done, with input and result",
            "code": "<ToolCallCard\n  name=\"search_docs\"\n  status=\"done\"\n  summary=\"collapsible panel height\"\n  duration=\"1.2s\"\n  input={{ query: \"collapsible panel height\", limit: 3 }}\n  result={{ hits: 2, top: [\"docs/collapsible.md\", \"docs/accordion.md\"] }}\n/>"
          },
          {
            "title": "Failed",
            "code": "<ToolCallCard name=\"run_tests\" status=\"failed\" summary=\"card.test.tsx\" duration=\"4.2s\" error=\"2 of 9 tests failed.\" />"
          }
        ]
      }
    },
    {
      "name": "tooltip",
      "type": "registry:ui",
      "title": "Tooltip",
      "description": "A short text label that shows on hover or keyboard focus, to name an icon button or show a cut-off label in full.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/tooltip.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "overlays"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Naming an icon-only button for sighted mouse and keyboard users, like Bold in a toolbar.",
            "Showing the full text of a label that is cut off with an ellipsis.",
            "Wrap a row of tooltips in one TooltipProvider. After the first one opens, the next one opens at once.",
            "The tooltip opens at the end of the page body. In a box with its own theme, like a dark preview on a light page, pass that box as container on TooltipPopup so the tooltip keeps its colors. A container that clips its overflow can clip the tooltip.",
            "Teaching a keyboard shortcut. Pass the key to TooltipPopup's shortcut, and put the same key in aria-keyshortcuts on the trigger. Your app binds the key.",
            "On a text button, the tooltip can be the key alone: <TooltipPopup shortcut=\"⌘S\" />. The button already says what it does.",
            "While a menu, list, popover, or peek is open, a tip that opens outside it stays out of sight until it closes, so two popups never show at once. A tip inside the open popup shows as usual."
          ],
          "avoid": [
            "Anything people must see to use the page. Touch screens never show tooltips, and screen readers do not read them. Put it in visible text.",
            "Links, buttons, or fields inside it. A tooltip closes when the pointer leaves. Use a popover.",
            "An info icon that opens help text. Use a popover, so touch and screen reader users get the text too.",
            "More than a few words. Use a popover for longer text.",
            "An error for a form field. Show it next to the field."
          ]
        },
        "props": [
          {
            "name": "Tooltip",
            "type": "{ open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean, details) => void }",
            "description": "Root. Holds one tooltip's open state and renders no element. Takes open, defaultOpen, and onOpenChange."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Tooltip. Whether it shows, controlled. Use it with onOpenChange."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Tooltip. Whether it shows at the start, when you do not control it."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean, details) => void",
            "description": "Tooltip. Called with the new open state when it opens or closes."
          },
          {
            "name": "TooltipProvider",
            "type": "{ closeDelay?: number; timeout?: number }",
            "description": "Wrap a group of tooltips, like a toolbar. After the first one opens, the next one opens at once. Each trigger's delay sets the first wait."
          },
          {
            "name": "TooltipTrigger",
            "type": "ComponentProps<\"button\"> & { render?: ReactElement }",
            "description": "Part. The element the tooltip names. A <button> by default. Pass render={<Button />} to use your own button."
          },
          {
            "name": "TooltipPopup",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The tooltip text. It renders in a portal, above the page, while open."
          },
          {
            "name": "side",
            "type": "\"top\" | \"right\" | \"bottom\" | \"left\" | \"inline-start\" | \"inline-end\"",
            "default": "\"top\"",
            "description": "TooltipPopup. Which side of the trigger it opens on. It flips when there is no room."
          },
          {
            "name": "align",
            "type": "\"start\" | \"center\" | \"end\"",
            "default": "\"center\"",
            "description": "TooltipPopup. How it lines up with the trigger."
          },
          {
            "name": "sideOffset",
            "type": "number",
            "default": "6",
            "description": "TooltipPopup. Gap between the trigger and the tooltip, in pixels."
          },
          {
            "name": "shortcut",
            "type": "ReactNode",
            "description": "TooltipPopup. The key that runs the trigger's action, like \"⌘B\". It shows after the text in the muted color. It only shows the key. Your app binds it."
          },
          {
            "name": "collisionPadding",
            "type": "number | { top?: number; right?: number; bottom?: number; left?: number }",
            "default": "8",
            "description": "TooltipPopup. Least gap between the tooltip and the screen edge, in pixels. It keeps the tooltip off the edge on phones."
          },
          {
            "name": "container",
            "type": "HTMLElement | ShadowRoot | RefObject<HTMLElement | ShadowRoot | null> | null",
            "description": "TooltipPopup. The element the tooltip opens in. By default, the end of the page body, so no parent can clip it. Pass an element, or a ref itself (not ref.current), to open it inside, for example to keep a parent's theme. Only client code has elements and refs. A null container shows no tooltip."
          },
          {
            "name": "effect",
            "type": "\"rise\" | \"grow\" | \"instant\"",
            "default": "\"rise\"",
            "description": "TooltipPopup. How the tooltip shows and hides. rise fades in and moves out from the trigger. grow also grows from the trigger, and closes the same way. instant shows and hides at once, with no motion. Left out, it follows an effect-<name> class on <body>, where popups open, and rises with none."
          },
          {
            "name": "delay",
            "type": "number",
            "default": "150",
            "description": "TooltipTrigger. How long the pointer must rest on the trigger before the tooltip opens, in milliseconds."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Tooltip or TooltipTrigger. Stops the tooltip from opening. It does not disable the trigger itself."
          },
          {
            "name": "className",
            "type": "string",
            "description": "TooltipPopup. Extra classes. Later classes win, so className='rounded-none' replaces the radius."
          }
        ],
        "a11y": [
          "Tooltips are visual only. The tooltip is not linked to its trigger for screen readers, so the trigger needs its own name.",
          "For an icon-only button, give it an aria-label that says the same as the tooltip. Button's iconOnly type makes aria-label required.",
          "For a cut-off label, the trigger's own text is the full label, so screen readers read all of it.",
          "It opens after a short hover, or at once on keyboard focus. Escape closes it.",
          "A tap does not open it. A tooltip opens only on mouse hover or keyboard focus, so touch screen users never see it.",
          "To show a tooltip on a disabled button, give the Button focusableWhenDisabled. It can then still get focus and hover.",
          "The theme tests hold tooltip text to 4.5:1 on the popover color, in light and dark. The shortcut uses the muted text color, which they hold to 4.5:1 on the popover color too.",
          "A shortcut in the tooltip is not read out, since the tooltip is visual only. Put the key in aria-keyshortcuts on the trigger, like aria-keyshortcuts=\"Meta+B\", so screen readers say it."
        ],
        "examples": [
          {
            "title": "Icon buttons in a toolbar (needs @txbrendan/button)",
            "code": "import { Bold, Italic } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\n\n<TooltipProvider>\n  <Tooltip>\n    <TooltipTrigger render={<Button iconOnly emphasis=\"ghost\" aria-label=\"Bold\" />}>\n      <Bold />\n    </TooltipTrigger>\n    <TooltipPopup>Bold</TooltipPopup>\n  </Tooltip>\n  <Tooltip>\n    <TooltipTrigger render={<Button iconOnly emphasis=\"ghost\" aria-label=\"Italic\" />}>\n      <Italic />\n    </TooltipTrigger>\n    <TooltipPopup>Italic</TooltipPopup>\n  </Tooltip>\n</TooltipProvider>"
          },
          {
            "title": "Teach a key in the tooltip (needs @txbrendan/button)",
            "code": "import { Bold } from \"lucide-react\";\n\nimport { Button } from \"@/components/ui/button\";\n\n// Your app listens for ⌘B and runs the same action. The tooltip only teaches the key.\n<div className=\"flex gap-2\">\n  <Tooltip>\n    <TooltipTrigger render={<Button iconOnly emphasis=\"ghost\" aria-label=\"Bold\" aria-keyshortcuts=\"Meta+B\" />}>\n      <Bold />\n    </TooltipTrigger>\n    <TooltipPopup shortcut=\"⌘B\">Bold</TooltipPopup>\n  </Tooltip>\n  <Tooltip>\n    <TooltipTrigger render={<Button emphasis=\"outline\" aria-keyshortcuts=\"Meta+S\" />}>Save draft</TooltipTrigger>\n    <TooltipPopup shortcut=\"⌘S\" />\n  </Tooltip>\n</div>"
          },
          {
            "title": "The full text of a cut-off label",
            "code": "function FileName({ name }: { name: string }) {\n  return (\n    <Tooltip>\n      <TooltipTrigger className=\"max-w-48 truncate\">{name}</TooltipTrigger>\n      <TooltipPopup>{name}</TooltipPopup>\n    </Tooltip>\n  );\n}"
          },
          {
            "title": "Below the trigger (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Tooltip>\n  <TooltipTrigger render={<Button emphasis=\"outline\" />}>Share</TooltipTrigger>\n  <TooltipPopup side=\"bottom\">Copy a link to this page</TooltipPopup>\n</Tooltip>"
          },
          {
            "title": "Shows at once, with no motion (needs @txbrendan/button)",
            "code": "import { Button } from \"@/components/ui/button\";\n\n<Tooltip>\n  <TooltipTrigger render={<Button emphasis=\"outline\" />}>Search</TooltipTrigger>\n  <TooltipPopup effect=\"instant\" shortcut=\"⌘K\" />\n</Tooltip>"
          }
        ]
      }
    },
    {
      "name": "unit-switch",
      "type": "registry:ui",
      "title": "Unit Switch",
      "description": "The unit beside a value, like £, min, or km, that you can change in place. Click it for a menu of units. On a change the unit crossfades to the new one, and a note can say what changed.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion",
        "@txbrendan/field-frame",
        "@txbrendan/menu"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/unit-switch.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "forms"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A value whose unit people may want to change: a fee in pounds or dollars, a length in minutes or hours, a distance in km or miles.",
            "Pass a set name as units: \"currency\", \"duration\", \"distance\", or \"weight\". Or pass your own list of ids and options.",
            "A currency's id is its code, like \"GBP\", so you can pass it straight to Intl.NumberFormat.",
            "Put it in a NumberStepper's suffix for a count with a unit. Beside plain text, put it before or after the value, as its place says.",
            "Give announce to say what changed, like { text: \"Pounds changed to dollars\", key: 3 }. Change the key to say it again.",
            "matchUnit reads a typed value, like \"2 hours\", and returns the unit in it. Use it to switch the unit when someone types one."
          ],
          "avoid": [
            "A unit that never changes. Write it as text, or use NumberStepper's suffix.",
            "Picking anything that is not a unit. Use a select or a menu."
          ]
        },
        "props": [
          {
            "name": "units",
            "type": "\"currency\" | \"duration\" | \"distance\" | \"weight\" | Array<string | UnitOption>",
            "default": "\"currency\"",
            "description": "A built-in set, or your own list. A string in a list is a built-in id, like \"mins\", or a plain unit."
          },
          {
            "name": "value",
            "type": "string",
            "description": "The unit's id. Use it with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "The unit at first render. Defaults to the first one."
          },
          {
            "name": "onValueChange",
            "type": "(id: string) => void",
            "description": "Called with the new unit's id when someone picks one."
          },
          {
            "name": "announce",
            "type": "{ text: string; key: number | string } | null",
            "description": "Shows a note above the unit for a moment, and says it to screen readers."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Text size and padding, to match the field it sits in."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Blocks the menu."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the button. Defaults to the unit's name, like \"Pounds\". A unit that shows letters puts them first, like \"min, Minutes\"."
          },
          {
            "name": "title",
            "type": "string",
            "description": "The tooltip. Defaults to \"Change currency\", or \"Change unit\"."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the button. Later classes win."
          }
        ],
        "a11y": [
          "The unit is a menu button: Enter, Space, or the down arrow opens the menu, and focus starts on the current unit.",
          "With a mouse it opens on the press. Let go on a unit to pick it, or click to leave the menu open.",
          "The button's name is the unit's name, like \"Pounds\", and its tooltip, \"Change currency\", describes it.",
          "A unit that shows letters puts them first in its name, like \"min, Minutes\", so people who speak commands can say what they see.",
          "Each unit is a radio item named by its name, so screen readers hear which one is checked.",
          "Picking a unit closes the menu and puts focus back on the button.",
          "The note is said once through a polite live region. The visible copy is hidden from screen readers, so it is not read twice.",
          "The label uses the shared MotionSwap fade and layout tokens. Motion off changes it immediately; reduced motion keeps only the fade."
        ],
        "examples": [
          {
            "title": "Currency beside a price",
            "code": "<p className=\"flex items-center gap-1\">\n  <UnitSwitch units=\"currency\" defaultValue=\"GBP\" />\n  <span>6.00</span>\n</p>"
          },
          {
            "title": "Duration in a stepper",
            "code": "<NumberStepper\n  aria-label=\"Meeting length\"\n  defaultValue={90}\n  suffix={<UnitSwitch units=\"duration\" defaultValue=\"mins\" />}\n/>"
          },
          {
            "title": "Switch it from typed text",
            "code": "const unit = matchUnit(\"2 hours\", \"duration\"); // { id: \"hours\", ... }"
          }
        ]
      }
    },
    {
      "name": "usage-meter",
      "type": "registry:ui",
      "title": "Usage Meter",
      "description": "One number for a whole, split into parts with a legend, like 412 GB of 1 TB or $84K of a $100K goal. Its digits roll and its parts slide when values change. Against a limit it warns, and against a goal it turns success.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/badge",
        "@txbrendan/button",
        "@txbrendan/card",
        "@txbrendan/notice",
        "@txbrendan/rolling-number",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/usage-meter.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "data",
        "display"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Show how much of a limit is used, like storage, a budget, seats, or an API quota, and what fills it.",
            "Show progress to a goal, like sales or money raised, split by where it came from. Pass goal.",
            "Show how a total splits with no limit, like visits by source. Leave max out.",
            "Pass parts in the order the bar shows them. Keep to five. Each takes the next chart color. Group small ones into Other.",
            "Pass unit for a word after each number, like \"GB\" or \"seats\". Pass valueFormat for money or percents, like { style: \"currency\", currency: \"USD\" }.",
            "A limit turns warning at 80% of max and danger once full. Pass warnAt to warn earlier, like 0.6. A goal never warns and turns success once reached.",
            "Pass the parts again with new values, and the digits roll and the bar slides.",
            "Give a part its own action, like { label: \"Clean up\" } on Photos. Pass action for the card's next step, like an Upgrade button.",
            "Pass legend={false} and size=\"sm\" for a small space."
          ],
          "avoid": [
            "A task that runs, like an upload. Use Progress.",
            "One plain share with no parts and no card, like 72% done. Use Progress or Ring Progress.",
            "Many items compared side by side, or a ranking. Use Breakdown List.",
            "A total over time. Use a chart, like Area Chart.",
            "Tokens in a model's context. Use Context Window."
          ]
        },
        "props": [
          {
            "name": "parts",
            "type": "{ id?: string; label: string; value: number; action?: UsageMeterPartAction }[]",
            "description": "Required. What makes up the total, in the order the bar shows them. Five at most."
          },
          {
            "name": "UsageMeterPartAction",
            "type": "{ label: string; onClick?: () => void; loading?: boolean; disabled?: boolean }",
            "description": "Type. A quiet button at the end of the part's legend row. Pass functions from a client component."
          },
          {
            "name": "max",
            "type": "number",
            "description": "The limit or the goal, like 1000. Left out, the bar splits the total, with no percent and no warning."
          },
          {
            "name": "goal",
            "type": "boolean",
            "default": "false",
            "description": "max is a goal to reach, not a limit. It never warns, says how much is to go, and turns success once reached."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "default": "\"Usage\"",
            "description": "The card's title. It names the meter."
          },
          {
            "name": "badge",
            "type": "ReactNode",
            "description": "A small tag beside the title, like the plan's name."
          },
          {
            "name": "unit",
            "type": "string",
            "description": "A word after each number, like \"GB\" or \"seats\". Leave it out when valueFormat prints the unit."
          },
          {
            "name": "valueFormat",
            "type": "Intl.NumberFormatOptions",
            "default": "{ notation: \"compact\", maximumFractionDigits: 1 }",
            "description": "Formats every number. Merged over the default, so { style: \"currency\", currency: \"USD\" } prints $84K. Pass { notation: \"standard\" } for full numbers."
          },
          {
            "name": "warnAt",
            "type": "number",
            "default": "0.8",
            "description": "For a limit, the share of max, from 0 to 1, where it turns warning. It turns danger once full."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Colors the percent and the last line. Left out, it follows the share of max. Passed, it stays that tone."
          },
          {
            "name": "legend",
            "type": "boolean",
            "default": "true",
            "description": "Shows each part's name, value, and action under the bar."
          },
          {
            "name": "action",
            "type": "ReactNode",
            "description": "A button in the last row, like Upgrade or Raise limit."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholders while the numbers load."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\"",
            "default": "\"md\"",
            "description": "Bar height and the number's size."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for valueFormat."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes for the card. Later classes win."
          }
        ],
        "a11y": [
          "The card is a group named by the title. With max, the bar is a meter with the same name, and reads like \"412 GB of 1,000 GB, 41%\".",
          "With no max, the bar is hidden from screen readers, and the legend says each part in words.",
          "The bar's colored parts are hidden from screen readers. The legend says each part's name and value in text.",
          "The big number's rolling digits are hidden. Screen readers hear the final value only.",
          "A part's action is named with the part, like \"Clean up Photos\", so each button says what it acts on.",
          "The last line says Almost full, Full, or Goal reached in words, so the state does not rest on color alone.",
          "The bar slides and the digits roll on the motion variables, so reduced motion jumps straight to the new values.",
          "In forced-colors mode the bar draws a border and its parts fill with the text color."
        ],
        "examples": [
          {
            "title": "Storage",
            "code": "<UsageMeter\n  title=\"Storage\"\n  badge=\"Pro plan\"\n  max={1000}\n  unit=\"GB\"\n  valueFormat={{ notation: \"standard\" }}\n  parts={[\n    { label: \"Photos\", value: 212 },\n    { label: \"Videos\", value: 128 },\n    { label: \"Documents\", value: 46 },\n    { label: \"Other\", value: 26 },\n  ]}\n/>"
          },
          {
            "title": "A budget near its limit",
            "code": "import { Button } from \"@/components/ui/button\";\nimport { UsageMeter } from \"@/components/ui/usage-meter\";\n\nexport function MarketingBudget() {\n  return (\n    <UsageMeter\n      title=\"Q3 budget\"\n      max={50000}\n      valueFormat={{ style: \"currency\", currency: \"USD\" }}\n      parts={[\n        { label: \"Ads\", value: 26400 },\n        { label: \"Events\", value: 12100 },\n        { label: \"Tools\", value: 4300 },\n      ]}\n      action={\n        <Button size=\"sm\" emphasis=\"outline\">\n          Raise limit\n        </Button>\n      }\n    />\n  );\n}"
          },
          {
            "title": "A goal",
            "code": "<UsageMeter\n  title=\"Spring fundraiser\"\n  goal\n  max={100000}\n  valueFormat={{ style: \"currency\", currency: \"USD\" }}\n  parts={[\n    { label: \"Online\", value: 48200 },\n    { label: \"Events\", value: 21500 },\n    { label: \"Sponsors\", value: 14000 },\n  ]}\n/>"
          },
          {
            "title": "A total with no limit",
            "code": "<UsageMeter\n  title=\"Visits this week\"\n  unit=\"visits\"\n  parts={[\n    { label: \"Search\", value: 6400 },\n    { label: \"Direct\", value: 3100 },\n    { label: \"Social\", value: 1900 },\n    { label: \"Email\", value: 1000 },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "view-morph",
      "type": "registry:ui",
      "title": "View morph",
      "description": "Moves the same shells from one layout to another. The shell moves, then both faces crossfade together.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/view-morph.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Two or more layouts of the same records, such as a board and a list, when each record must keep its element and its focus.",
            "Put one ViewMorphItem per record, with the same id in every layout, and swap the slot inside it.",
            "Put a ViewMorphGroup around each cluster. A header item uses layer=\"header\". Its text is the same in every layout, and it does not crossfade.",
            "Mark the text that should fade with data-flip-slot. Leave the header's text unmarked."
          ],
          "avoid": [
            "A one-time entrance. The first paint stores positions and does not move.",
            "Scaling a card to its next size. The shell animates its box, so a radius and a shadow are not stretched.",
            "Records that appear and disappear between layouts. A shell with no previous box does not fly in."
          ]
        },
        "props": [
          {
            "name": "layout",
            "type": "string",
            "default": "\"default\"",
            "description": "Which layout is showing. Changing it moves the shells."
          },
          {
            "name": "contentLayouts",
            "type": "string[]",
            "default": "[]",
            "description": "When both sides of a change are in this list, each slot stays fully opaque and its children crossfade. Every other change crossfades the slot."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes on the root. Later classes win."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "Groups and shells."
          },
          {
            "name": "Part. ViewMorphGroup",
            "type": "{ id: string; as?: \"div\" | \"ul\" | \"tbody\"; className?: string; children?: ReactNode }",
            "description": "A group of shells. id is its stable key and is not copied onto the element's id. as picks \"div\", \"ul\", or \"tbody\"."
          },
          {
            "name": "Part. ViewMorphItem",
            "type": "{ id: string; layer?: \"item\" | \"header\"; as?: \"div\" | \"li\" | \"tr\"; className?: string; children?: ReactNode }",
            "description": "One shell. id is its stable key and is not copied onto the element's id. The shell is not a tab stop. layer \"header\" stays above the others and is not crossfaded, so its text must be the same in every layout."
          }
        ],
        "a11y": [
          "The root has no role of its own. The page that uses it gives it one, such as a tab panel.",
          "A shell is not in the tab order. Put the control that should take focus inside the shell.",
          "The shell moves with transform only, on --motion-duration-slow and --motion-ease-out, then rests. The ease never exceeds 1, so a frame-stepped strip shows no overshoot. Width and height do not animate.",
          "The fade follows --motion-duration-fast, capped at 120ms, and does not follow --motion-distance. A shell that moves waits until --motion-crossfade-delay. A shell that stays, and a reduced-motion switch, dissolve with no delay. That delay is the shared start. It is not a gap, and there is no row stagger.",
          "A header is not crossfaded. Its text has to be byte-identical across layouts.",
          "Motion off sets the durations to 0, and that drops the fade. Reduced motion sets --motion-distance to 0 and keeps the fade."
        ],
        "examples": [
          {
            "title": "Two layouts of the same shells",
            "code": "<ViewMorph layout={layout}>\n  <ViewMorphGroup id=\"open\">\n    <ViewMorphItem id=\"open-label\" layer=\"header\">\n      <div>Open</div>\n    </ViewMorphItem>\n    <ViewMorphItem id=\"ledger\">\n      <div data-flip-slot>{layout === \"board\" ? \"Ledgerly payouts\" : \"Ledgerly payouts, list\"}</div>\n    </ViewMorphItem>\n  </ViewMorphGroup>\n</ViewMorph>"
          }
        ]
      }
    },
    {
      "name": "view-switch",
      "type": "registry:ui",
      "title": "View switch",
      "description": "A row of views. The active one slides to the view you pick.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/tabs"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/view-switch.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "navigation"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Choosing one layout of the same records, like Board, List, and Timeline.",
            "Pass options when the views are not those three. Each option needs a value and a label.",
            "Hold the value yourself when another part, such as a view morph, must follow it."
          ],
          "avoid": [
            "A setting that is not a view of the same things. Use Tabs and put a panel under each one.",
            "More than a handful of views. The row scrolls, but a menu is easier to scan."
          ]
        },
        "props": [
          {
            "name": "options",
            "type": "{ value: string; label: string; id?: string; controls?: string }[]",
            "default": "[{ value: \"board\", label: \"Board\" }, { value: \"list\", label: \"List\" }, { value: \"timeline\", label: \"Timeline\" }]",
            "description": "The views, in order."
          },
          {
            "name": "value",
            "type": "string",
            "description": "The selected view. Use with onValueChange."
          },
          {
            "name": "defaultValue",
            "type": "string",
            "description": "The view selected on first paint, when value is not set. Defaults to the first option."
          },
          {
            "name": "onValueChange",
            "type": "(value: string) => void",
            "description": "Called with the view that was just chosen."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\"",
            "default": "\"neutral\"",
            "description": "Color of the active pill or line."
          },
          {
            "name": "emphasis",
            "type": "\"solid\" | \"ghost\"",
            "default": "\"solid\"",
            "description": "\"solid\" puts the active view on a filled pill. \"ghost\" underlines it."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height, padding, and text size."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"View\"",
            "description": "Names the group, like \"Layout\"."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes on the root. Later classes win."
          }
        ],
        "a11y": [
          "The row is a tab list. Each view is a tab. Arrow keys move between them and select the one they reach.",
          "Home and End jump to the first and last view.",
          "The active view's pill or line slides. Reduced motion jumps there instead."
        ],
        "examples": [
          {
            "title": "Board, list, and timeline",
            "code": "<ViewSwitch value={layout} onValueChange={setLayout} aria-label=\"Layout\" />"
          },
          {
            "title": "Your own views",
            "code": "<ViewSwitch\n  aria-label=\"Layout\"\n  options={[{ value: \"cards\", label: \"Cards\" }, { value: \"rows\", label: \"Rows\" }]}\n  defaultValue=\"cards\"\n/>"
          }
        ]
      }
    },
    {
      "name": "zoom-pan",
      "type": "registry:ui",
      "title": "Zoom Pan",
      "description": "A frame for anything bigger than its card, like a bracket: drag to move, ⌘ or Ctrl and scroll to zoom, with −, +, and fit buttons.",
      "dependencies": [
        "@base-ui/react@^1.9.0",
        "class-variance-authority@^0.7.1",
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/ui/zoom-pan.tsx",
          "type": "registry:ui"
        }
      ],
      "categories": [
        "layout"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Show one big thing that will not fit its card, like a bracket or a floor plan, and let people move around it.",
            "Give it a height. The content keeps its own size, and the frame starts it at 100%, centered where it fits.",
            "Drag to move. ⌘ or Ctrl with the wheel zooms around the pointer, and so does a trackpad pinch. Two fingers zoom on a phone.",
            "A plain scroll still scrolls the page. A hint in the middle says how to zoom until the scrolling stops.",
            "−, +, and fit sit at the bottom right. Fit shows it all and never zooms past 100% to do it.",
            "Name it with aria-label, like \"Floor plan\"."
          ],
          "avoid": [
            "A long list or a wide table. Let the page scroll, or use Table.",
            "Content people must read closely at every size. Zoomed out, small text is hard to read.",
            "A frame taller than the screen. Its drags and the page's scroll would fight."
          ]
        },
        "props": [
          {
            "name": "children",
            "type": "ReactNode",
            "description": "What to move and zoom. It keeps its own size."
          },
          {
            "name": "height",
            "type": "number",
            "default": "320",
            "description": "The frame's height in pixels."
          },
          {
            "name": "min",
            "type": "number",
            "default": "0.25",
            "description": "The least zoom, as a fraction. 0.25 is 25%."
          },
          {
            "name": "max",
            "type": "number",
            "default": "2",
            "description": "The most zoom. 2 is 200%."
          },
          {
            "name": "step",
            "type": "number",
            "default": "0.25",
            "description": "How much − and + change the zoom."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Zoom and pan\"",
            "description": "Names the frame for screen readers."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the frame. Later classes win, so className='bg-card' replaces the muted fill."
          }
        ],
        "a11y": [
          "The frame is a named group and a Tab stop. Its description says how to use the keys.",
          "With the frame focused, the arrow keys move, + and − zoom, and 0 fits it all in view.",
          "Keys pressed on controls inside the content stay with those controls.",
          "A zoom from a button or a key is said through a polite live message, like \"Zoom 150%\".",
          "The −, +, and fit buttons have names. − and + stay focusable at the limit, so focus does not jump away.",
          "A press that does not move still clicks what is under it. A drag never clicks.",
          "The zoom hint is hidden from screen readers. The frame's description already says how to zoom.",
          "With reduced motion, the buttons and keys land the view at once."
        ],
        "examples": [
          {
            "title": "A bracket bigger than its card",
            "code": "<ZoomPan height={300} aria-label=\"Vote bracket\">\n  <div className=\"flex gap-10 p-4\">\n    <Card className=\"w-56 p-4\">Dark mode 19 – 17 Shortcuts</Card>\n    <Card className=\"w-56 p-4\">Top pick: Dark mode</Card>\n  </div>\n</ZoomPan>"
          },
          {
            "title": "Zoom in to 400%, in steps of 50%",
            "code": "<ZoomPan height={400} max={4} step={0.5} aria-label=\"Floor plan\">\n  <img src=\"/floor-plan.svg\" alt=\"Floor plan\" width={1200} height={800} />\n</ZoomPan>"
          }
        ]
      }
    },
    {
      "name": "agent-inbox",
      "type": "registry:block",
      "title": "Agent Inbox",
      "description": "One calm list of background agent tasks, grouped into Needs you, Running, and Done, with filter tabs and live counts. A row opens to its last steps and the action its state needs.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/accordion",
        "@txbrendan/badge",
        "@txbrendan/button",
        "@txbrendan/card",
        "@txbrendan/motion-timing",
        "@txbrendan/skeleton",
        "@txbrendan/spinner",
        "@txbrendan/status-text",
        "@txbrendan/tabs",
        "@txbrendan/timeline"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/agent-inbox/agent-inbox.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/agent-inbox/agent-inbox-row.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "agents",
        "ai",
        "tasks",
        "inbox"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Show people the work their agents do while they are away, sorted by who has to act next.",
            "Each task is one row: what it is, where it stands, which model runs it, and how long it has taken.",
            "Tasks that wait for a yes or no and tasks that stopped both sit under Needs you, where people look first.",
            "Give a failed task a short reason, like \"API key expired\", so people can tell whether Retry will help.",
            "Pass elapsed in seconds. Running and waiting rows count on from there, once a second.",
            "Without handlers, Approve, Reject, and Retry change the task inside the block. Pass a handler to own the change in your own state."
          ],
          "avoid": [
            "A queue of more than a few dozen tasks. Use a DataTable with paging and search.",
            "A task's full log. Link to it with href, and show the log on its own page.",
            "Tasks that finish in a second or two. Show the result where people asked for it."
          ]
        },
        "props": [
          {
            "name": "tasks",
            "type": "{ id: string; title: string; summary?: string; model: string; status: \"waiting\" | \"running\" | \"done\" | \"failed\"; elapsed?: number; reason?: string; steps?: { label: string; time?: string }[]; href?: string }[]",
            "default": "[]",
            "description": "One row per task, in order inside each group. ids must be unique. steps are oldest first, and a row shows the last four. href puts an Open link on running and done tasks. A new list, by value, replaces changes made inside the block."
          },
          {
            "name": "onApprove",
            "type": "(task: AgentTask) => void",
            "description": "Approve on a waiting task. Without it, the task moves to Running."
          },
          {
            "name": "onReject",
            "type": "(task: AgentTask) => void",
            "description": "Reject on a waiting task. Without it, the task fails with \"Rejected\" as its reason."
          },
          {
            "name": "onRetry",
            "type": "(task: AgentTask) => void",
            "description": "Retry on a failed task. Without it, the task moves to Running and its clock starts over."
          },
          {
            "name": "onOpen",
            "type": "(task: AgentTask) => void",
            "description": "Shows Open on running and done tasks that have no href."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholder rows and hides the counts."
          },
          {
            "name": "loadingText",
            "type": "string",
            "default": "\"Loading tasks\"",
            "description": "What the status line tells screen readers while loading."
          },
          {
            "name": "emptyText",
            "type": "string",
            "default": "\"No agent tasks yet.\"",
            "description": "Shown when there are no tasks and nothing is loading."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Agent tasks\"",
            "description": "Names the filter tabs for screen readers."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer div, which holds the container query."
          }
        ],
        "a11y": [
          "The filters are tabs. Arrow keys move between them, and each tab says its count.",
          "Each row is a button inside a heading, with aria-expanded. Enter or Space opens it to the last steps and the actions.",
          "Each group is a labeled group in the All tab, so screen readers say Needs you, Running, or Done.",
          "Status is words with a color, like Waiting for you or Failed: and a reason. Color never carries it alone.",
          "After Approve, Reject, or Retry, focus moves to the row in its new place. When the open filter hides it, focus goes to the tab.",
          "One status line outside the lists reads out each change: which task, what happened, and the new counts. The clocks never speak.",
          "With reduced motion, rows take their new place at once, and the running spinner stays still."
        ],
        "examples": [
          {
            "title": "Background tasks",
            "code": "<AgentInbox\n  tasks={[\n    {\n      id: \"invoices\",\n      title: \"Send March invoices\",\n      summary: \"Drafted 38 invoices. Ready to email them to clients.\",\n      model: \"Opus 5.5\",\n      status: \"waiting\",\n      elapsed: 412,\n      steps: [\n        { label: \"Drafted 38 invoices\", time: \"4m ago\" },\n        { label: \"Wants to email 38 clients\", time: \"1m ago\" },\n      ],\n    },\n    { id: \"tests\", title: \"Find the flaky checkout test\", model: \"Sonnet 5.5\", status: \"running\", elapsed: 221, href: \"/runs/tests\" },\n    { id: \"index\", title: \"Rebuild the search index\", model: \"Haiku 5.5\", status: \"failed\", reason: \"docs server timed out\", elapsed: 534 },\n  ]}\n/>"
          },
          {
            "title": "Your own state owns each change",
            "code": "\"use client\";\n\nfunction TaskInbox() {\n  const { data: tasks } = useTasks();\n  return (\n    <AgentInbox\n      tasks={tasks ?? []}\n      loading={tasks === undefined}\n      onApprove={(task) => approveTask(task.id)}\n      onReject={(task) => rejectTask(task.id)}\n      onRetry={(task) => retryTask(task.id)}\n      onOpen={(task) => openTask(task.id)}\n    />\n  );\n}"
          },
          {
            "title": "With nothing to show yet",
            "code": "<AgentInbox tasks={[]} emptyText=\"Tasks you hand to an agent show up here.\" />"
          }
        ]
      }
    },
    {
      "name": "choropleth-explorer",
      "type": "registry:block",
      "title": "Choropleth Explorer",
      "description": "A world map of one measure. Choose a country to see its states, provinces, or regions, with a breadcrumb back, a country search, a ranked list, and a tab per measure.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/breadcrumb",
        "@txbrendan/breakdown-list",
        "@txbrendan/card",
        "@txbrendan/chart",
        "@txbrendan/choropleth-map",
        "@txbrendan/combobox",
        "@txbrendan/tabs"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/choropleth-explorer/choropleth-explorer.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "dashboard",
        "map",
        "geography",
        "charts"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A measure by place, where people start with the world and look closer at one country, like members, sales, or sign-ups.",
            "Pass metrics, each with values for countries and their regions together, like { USA: 340, 'US-TX': 31 }. Countries use ISO 3166-1 alpha-3 codes, and regions ISO 3166-2 codes, where the place has one.",
            "The maps load from src, '/maps' by default: world.json, then admin1/<ID>.json for the country chosen. This site serves them. In your app, copy the files into public/maps, or pass loadShape.",
            "The line under the map says how many regions there are, how many have data, and that states or provinces are as deep as the maps go.",
            "Pass country and onCountryChange to keep the chosen country in the URL."
          ],
          "avoid": [
            "Cities, counties, or streets. These maps stop at states and provinces.",
            "One country only. Use ChoroplethMap with that country's shape.",
            "Exact comparisons across many places. Use a table, sorted by the measure."
          ]
        },
        "props": [
          {
            "name": "metrics",
            "type": "{ id: string; label: string; values: Record<string, number | null>; valueFormat?: Intl.NumberFormatOptions }[]",
            "description": "One tab per metric. Values hold countries and their regions together, by id."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "The block's heading. It also names the section."
          },
          {
            "name": "src",
            "type": "string",
            "default": "\"/maps\"",
            "description": "Where world.json and admin1/<ID>.json are."
          },
          {
            "name": "loadShape",
            "type": "(id: string) => Promise<ExplorerShape>",
            "description": "Loads a map yourself, by 'world' or a country id, in place of src. Define it outside the component, so each map loads once."
          },
          {
            "name": "country",
            "type": "string | null",
            "description": "The country shown, by id. null shows the world. Use with onCountryChange."
          },
          {
            "name": "defaultCountry",
            "type": "string | null",
            "default": "null",
            "description": "The country shown first, when you do not control it."
          },
          {
            "name": "onCountryChange",
            "type": "(country: string | null) => void",
            "description": "Called when someone chooses a country or goes back to the world."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholders while the metrics load."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No data to map yet.\"",
            "description": "Shown when there are no metrics."
          },
          {
            "name": "top",
            "type": "number",
            "default": "8",
            "description": "How many places the ranked list shows."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the section, which holds the container query."
          }
        ],
        "a11y": [
          "The section is named by its title. The breadcrumb is a nav named 'Map level', and World is a button while a country shows.",
          "Every country has its own option in the search, so a country too small to point at is still one choice away.",
          "The map is one Tab stop, and arrow keys move between places. On the world map, Enter opens the country. In a country, Enter marks a region.",
          "A status line says each new map as it loads, like 'Members, Japan: 47 prefectures'.",
          "The ranked list repeats the top values as text, so nobody has to read them from shades."
        ],
        "examples": [
          {
            "title": "Members by country and region",
            "code": "<ChoroplethExplorer\n  title=\"Members\"\n  metrics={[\n    { id: \"members\", label: \"Members\", values: { USA: 340, \"US-TX\": 31, JPN: 120, \"JP-13\": 40 } },\n    { id: \"growth\", label: \"Growth\", values: growth, valueFormat: { style: \"percent\" } },\n  ]}\n/>"
          },
          {
            "title": "Keep the country in the URL",
            "code": "<ChoroplethExplorer metrics={metrics} country={params.country ?? null} onCountryChange={setCountry} />"
          }
        ]
      }
    },
    {
      "name": "empty-state",
      "type": "registry:block",
      "title": "Empty State",
      "description": "What a list, table, or page shows when it has nothing in it yet: an icon, a title, a short description, and one or two actions.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/empty-state/empty-state.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "empty",
        "zero state",
        "no results",
        "placeholder"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A list, table, or page that has nothing in it yet. Say what will show up here and how to add the first one.",
            "A search or filter that found nothing. Say so, and offer a way to clear the filter.",
            "A section that failed to load. Set tone=\"danger\" and offer a Retry button.",
            "Pass one or two Buttons in actions, the main one first. With size=\"sm\", pass size=\"sm\" Buttons too.",
            "Make every action an emphasis=\"ghost\" Button, so the next step stays quiet. With two actions, give the main one tone=\"brand\" so it stands apart.",
            "Write the title so it makes sense without the icon, since screen readers skip the icon."
          ],
          "avoid": [
            "Content that is still loading. Show a skeleton, then the empty state once you know there is nothing.",
            "A setup checklist for someone's first visit. Use First Use instead.",
            "A message about the whole app, like an outage or a billing problem. Use Alert.",
            "More than two actions. Pick the one that matters, and link to the rest from the description."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "What is missing, in a few words, like \"No projects yet\". Required."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "One or two short sentences: what will show up here, and how to start."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "An icon element, like <FolderIcon />. It sits in a round tile above the title."
          },
          {
            "name": "actions",
            "type": "ReactNode",
            "description": "One or two Buttons or links. Put the main one first. Below 18rem wide they stack."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the icon tile. Use \"danger\" when something failed to load."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "sm inside a list or card, md for a section, lg for a whole page. Scales the space, icon, and text."
          },
          {
            "name": "headingLevel",
            "type": "1 | 2 | 3 | 4 | 5 | 6",
            "default": "2",
            "description": "The title's heading level, so it fits under the heading above it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer div, which holds the container query. Add a border here to frame it."
          }
        ],
        "a11y": [
          "The title is a real heading, an h2 by default. Set headingLevel to one below the heading the empty state sits under.",
          "The icon tile is hidden from screen readers, so the title has to say what is missing on its own.",
          "The block adds nothing that takes focus. Only the actions you pass are in the tab order, in the order you pass them.",
          "Other props, like role or aria-label, go on the outer div."
        ],
        "examples": [
          {
            "title": "Nothing here yet",
            "code": "<EmptyState\n  icon={<FolderPlus />}\n  title=\"No projects yet\"\n  description=\"Projects keep your tasks, files, and people in one place.\"\n  actions={\n    <>\n      <Button tone=\"brand\" emphasis=\"ghost\">New project</Button>\n      <Button emphasis=\"ghost\">Import</Button>\n    </>\n  }\n/>"
          },
          {
            "title": "A search with no results",
            "code": "<EmptyState\n  size=\"sm\"\n  headingLevel={3}\n  icon={<SearchX />}\n  title=\"No matches\"\n  description=\"Nothing matches those filters.\"\n  actions={<Button size=\"sm\" emphasis=\"ghost\">Clear filters</Button>}\n/>"
          },
          {
            "title": "Failed to load",
            "code": "<EmptyState\n  tone=\"danger\"\n  icon={<CircleAlert />}\n  title=\"Could not load invoices\"\n  description=\"Check your connection, then try again.\"\n  actions={<Button emphasis=\"ghost\">Try again</Button>}\n/>"
          }
        ]
      }
    },
    {
      "name": "feedback-banner",
      "type": "registry:block",
      "title": "Feedback Banner",
      "description": "A banner that asks one question with a few answer buttons, says thanks after an answer, and can be dismissed.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/card",
        "@txbrendan/button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/feedback-banner/feedback-banner.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "feedback",
        "survey",
        "banner",
        "rating"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Asking how a new feature is going, right where people use it.",
            "A yes or no question at the end of a help article: \"Did this answer your question?\".",
            "Ask one short question with two or three answers. Send the answer in onValueChange.",
            "Save the answer or the dismissal, and stop showing the banner to that person.",
            "Write a question that the default Yes and No answer, or pass options with your own labels."
          ],
          "avoid": [
            "Long surveys or free-text answers. Link to a form instead.",
            "Errors, warnings, or news. Use Alert for a whole page, or Notice under the thing it is about.",
            "Asking before people have used the thing you ask about.",
            "Showing it on every visit. Ask once, then respect the answer or the dismissal."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "The question, like \"Is the new editor working for you?\". It also names the answer buttons. Required."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "One short line under the question: why you ask, or how long it takes."
          },
          {
            "name": "options",
            "type": "{ value: string; label: ReactNode; icon?: ReactNode }[]",
            "default": "[{ value: \"yes\", label: \"Yes\" }, { value: \"no\", label: \"No\" }]",
            "description": "The answers, one button each, all the same weight. Each value must be unique."
          },
          {
            "name": "onValueChange",
            "type": "(value: string) => void",
            "description": "Called with the option's value when people pick one. Send it to your feedback store here."
          },
          {
            "name": "value",
            "type": "string | null",
            "description": "The picked option's value, when you control it. null shows the question."
          },
          {
            "name": "defaultValue",
            "type": "string | null",
            "default": "null",
            "description": "The picked option's value to start with. Set it to start on the thank-you message."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "An icon element, like <MessageCircle />. It sits in a round tile and turns into a check after an answer."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the icon tile."
          },
          {
            "name": "thanksTitle",
            "type": "ReactNode",
            "default": "\"Thanks for your feedback\"",
            "description": "Shown in place of the question after an answer."
          },
          {
            "name": "thanksDescription",
            "type": "ReactNode",
            "description": "One line under thanksTitle, like what happens next."
          },
          {
            "name": "dismissible",
            "type": "boolean",
            "default": "true",
            "description": "Shows the dismiss button."
          },
          {
            "name": "dismissLabel",
            "type": "string",
            "default": "\"Dismiss\"",
            "description": "The dismiss button's name for screen readers."
          },
          {
            "name": "onDismiss",
            "type": "() => void",
            "description": "Called when people dismiss the banner, after it hides. Save this, so you do not ask again."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer div, which holds the container query."
          }
        ],
        "a11y": [
          "The answer buttons sit in a group named by the question, so each one reads with the question.",
          "After an answer, the thank-you message takes the place of the question, and focus moves to it, so screen readers read it.",
          "The icon tile is hidden from screen readers. Write a question that makes sense without it.",
          "The dismiss button is named by dismissLabel and described by the question or the thanks. Dismissing removes the banner, so focus falls back to the page. If that matters, move focus in onDismiss."
        ],
        "examples": [
          {
            "title": "Ask about a new feature",
            "code": "<FeedbackBanner\n  icon={<MessageCircle />}\n  tone=\"brand\"\n  title=\"How is the new editor?\"\n  description=\"One click. It shapes what we fix next.\"\n  options={[\n    { value: \"good\", label: \"Love it\", icon: <ThumbsUp /> },\n    { value: \"bad\", label: \"Needs work\", icon: <ThumbsDown /> },\n  ]}\n  onValueChange={(answer) => sendFeedback(\"editor\", answer)}\n  onDismiss={() => saveSetting(\"editorFeedbackHidden\", true)}\n/>"
          },
          {
            "title": "Yes or no, at the end of an article",
            "code": "<FeedbackBanner\n  title=\"Did this answer your question?\"\n  thanksDescription=\"We read every answer.\"\n  onValueChange={(answer) => sendFeedback(articleId, answer)}\n  dismissible={false}\n/>"
          }
        ]
      }
    },
    {
      "name": "first-use",
      "type": "registry:block",
      "title": "First Use",
      "description": "A setup checklist for someone's first visit: steps with their actions, progress dots with a count, and a dismiss button.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/card",
        "@txbrendan/button",
        "@txbrendan/skeleton",
        "@txbrendan/step-dots"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/first-use/first-use.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/first-use/first-use-step.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "onboarding",
        "checklist",
        "setup",
        "getting started",
        "progress"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The top of a new account's home page: three to six steps that get someone to a working setup.",
            "Mark a step done from real data, like a project that exists, not from a click on its button.",
            "Give each step one action that takes people straight to it. The checklist shows it until the step is done.",
            "Save the dismissal in onDismiss, so the card stays hidden on the next visit.",
            "Set loading while you find out which steps are done, so the card does not flash the wrong ones."
          ],
          "avoid": [
            "A tour of the interface. Put help where people need it, next to the thing it explains.",
            "A list or page with nothing in it yet. Use Empty State.",
            "Steps people must finish before they can use the app. Put those in the sign-up flow.",
            "More than six steps. Cut it down, or split it into stages."
          ]
        },
        "props": [
          {
            "name": "steps",
            "type": "{ id: string; title: ReactNode; description?: ReactNode; done?: boolean; action?: ReactNode }[]",
            "description": "The checklist, in order. Each id must be unique. The first step that is not done is the current step. Steps can be done in any order. The dots fill in one per done step, from the left, so they show how many are done, not which. Past eight steps, only the count shows."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "default": "\"Set up your workspace\"",
            "description": "The card's heading."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "One line under the title: what finishing the steps gets people."
          },
          {
            "name": "headingLevel",
            "type": "1 | 2 | 3 | 4 | 5 | 6",
            "default": "2",
            "description": "The title's heading level, so it fits under the heading above it."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the done marks, the current step's ring, and the progress dots."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholder steps while you find out which are done."
          },
          {
            "name": "loadingText",
            "type": "string",
            "default": "\"Loading setup steps\"",
            "description": "What the status line tells screen readers while loading."
          },
          {
            "name": "dismissible",
            "type": "boolean",
            "default": "true",
            "description": "Shows the dismiss button."
          },
          {
            "name": "dismissLabel",
            "type": "string",
            "default": "\"Dismiss\"",
            "description": "The dismiss button's name for screen readers."
          },
          {
            "name": "onDismiss",
            "type": "() => void",
            "description": "Called when people dismiss the card, after it hides. Save this, so it stays hidden next time."
          },
          {
            "name": "completeTitle",
            "type": "ReactNode",
            "default": "\"You're all set\"",
            "description": "Shown in place of the steps once every step is done, or when there are no steps."
          },
          {
            "name": "completeDescription",
            "type": "ReactNode",
            "default": "\"You finished every step.\"",
            "description": "One line under completeTitle."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer section, which holds the container query."
          }
        ],
        "a11y": [
          "The card is a section named by its title, which is a real heading, an h2 by default. Set headingLevel to fit your page.",
          "The steps are an ordered list. The current step has aria-current=\"step\", and every step's text ends with (done) or (not done) for screen readers.",
          "The check marks and rings are hidden from screen readers, since the text already says each step's state.",
          "A status line says \"2 of 4 done\", and loadingText while loading. It stays on the page, so screen readers hear each new count.",
          "The dots and the count beside them are hidden from screen readers, since the status line says the same count. The dots are not buttons, so nothing focusable is hidden.",
          "Forced colors mode drops fills. There the dots hide and the count shows alone, done marks keep their circle as a border, and loading shows loadingText as words.",
          "The dismiss button is named by dismissLabel and described by the title. Dismissing removes the card, so focus falls back to the page. If that matters, move focus in onDismiss."
        ],
        "examples": [
          {
            "title": "A new workspace",
            "code": "<FirstUse\n  title=\"Set up your workspace\"\n  description=\"Four steps, about five minutes.\"\n  steps={[\n    { id: \"profile\", title: \"Add your name and photo\", done: true },\n    { id: \"team\", title: \"Invite your team\", done: true },\n    {\n      id: \"calendar\",\n      title: \"Connect your calendar\",\n      description: \"Meetings show up next to your tasks.\",\n      action: <Button size=\"sm\">Connect</Button>,\n    },\n    {\n      id: \"project\",\n      title: \"Create your first project\",\n      action: <Button size=\"sm\" emphasis=\"outline\">New project</Button>,\n    },\n  ]}\n  onDismiss={() => saveSetting(\"setupGuideHidden\", true)}\n/>"
          },
          {
            "title": "While you check which steps are done",
            "code": "<FirstUse steps={steps ?? []} loading={steps === undefined} />"
          },
          {
            "title": "Keep it until the steps are done",
            "code": "<FirstUse steps={steps} dismissible={false} tone=\"brand\" />"
          }
        ]
      }
    },
    {
      "name": "interface-icon-library",
      "type": "registry:block",
      "title": "Interface Icon Library",
      "description": "Browse outline, dither, and spot icons. Choose artwork colors and copy or download the result.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/panel-switch",
        "@txbrendan/toast",
        "@txbrendan/icon",
        "@txbrendan/dither-icon",
        "@txbrendan/spot-icon",
        "@txbrendan/icon-color-picker",
        "@txbrendan/button",
        "@txbrendan/search-field",
        "@txbrendan/select"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/interface-icon-library/interface-icon-library.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "icons",
        "assets"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Place one Toaster in your app layout to show brief copy confirmations.",
            "Compare outline, still dither, and spot collections with editable colors. Dither previews, SVG downloads, and copied React examples use still artwork.",
            "Spot icons have separate foreground and background controls. SVG exports resolve the current artwork colors; copied React code retains chosen hex values.",
            "Colors stay selected when switching collections. Use theme color restores the default."
          ],
          "avoid": [
            "Solid, pixel, or sport collections: use their dedicated browsers."
          ]
        },
        "props": [
          {
            "name": "className",
            "type": "string",
            "description": "Classes on the container; later classes win."
          }
        ],
        "a11y": [
          "Search, collection, and color controls are labeled. Selection uses aria-pressed and a visible border.",
          "Copy success uses a toast; failures and download feedback use a status line. All icons have visible names.",
          "Custom artwork colors are not contrast-checked."
        ],
        "examples": [
          {
            "title": "Browse",
            "code": "<InterfaceIconLibrary />"
          }
        ]
      }
    },
    {
      "name": "payments-globe",
      "type": "registry:block",
      "title": "Payments Globe",
      "description": "A dotted globe you can spin, with payments flying home from their cities as arcs, a rolling total, a top-cities list to pick from, a day you can replay, and a live mode.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/badge",
        "@txbrendan/button",
        "@txbrendan/card",
        "@txbrendan/motion-timing",
        "@txbrendan/notice",
        "@txbrendan/rolling-number",
        "@txbrendan/skeleton",
        "@txbrendan/slider"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/payments-globe/payments-globe.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/payments-globe/payments-globe-land.ts",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/payments-globe/payments-globe-sphere.ts",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/payments-globe/payments-globe-time.ts",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/payments-globe/payments-globe-view.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "dashboard",
        "revenue",
        "payments",
        "map",
        "globe",
        "live"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The hero of a revenue or sales dashboard, where seeing money arrive from around the world is the point.",
            "Pass one home city, the cities payments come from, and each payment's city, time, and amount. The block works out every total.",
            "For live mode, set live and add each new payment to payments as it arrives. The block never fetches anything itself.",
            "Pass now from your server when the page must render the same on the server and in the browser, like a demo or a test.",
            "Set timeZone to where your day starts. The clock, the hours, and the day all follow it.",
            "Pass format as number format options, like { style: \"currency\", currency: \"EUR\" }, so a server component can render the block."
          ],
          "avoid": [
            "Exact places, like store locations or delivery routes. The land is drawn by hand, so it is rough. Use a real map.",
            "A plain total, or a total over time. Use Stats Cards or a chart. They are smaller and easier to read.",
            "More than about 60 cities. Dots crowd together and the list gets long. Group small places into their nearest big city."
          ]
        },
        "props": [
          {
            "name": "home",
            "type": "{ name: string; lat: number; lon: number }",
            "description": "The city every payment flies to, like your headquarters. The globe starts facing it."
          },
          {
            "name": "cities",
            "type": "{ id: string; name: string; lat: number; lon: number }[]",
            "description": "Where payments come from. Ids must be unique. Cities with no payments that day are left off."
          },
          {
            "name": "payments",
            "type": "{ cityId: string; at: number; amount: number }[]",
            "description": "Each payment: its city's id, its time in ms, and its amount. Any order. Payments outside the day are ignored."
          },
          {
            "name": "format",
            "type": "Intl.NumberFormatOptions | ((value: number) => string)",
            "default": "{ style: \"currency\", currency: \"USD\", maximumFractionDigits: 0 }",
            "description": "How amounts print. Options work from a server component. A function works from a client component only."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for format options."
          },
          {
            "name": "timeZone",
            "type": "string",
            "default": "\"UTC\"",
            "description": "Where the day starts and how the clock reads, like \"America/New_York\"."
          },
          {
            "name": "live",
            "type": "boolean",
            "default": "false",
            "description": "Follows the current time and shows a Live badge. Moving the scrubber leaves it until Back to live."
          },
          {
            "name": "now",
            "type": "number",
            "description": "The current time in ms. When it is not set, the block reads the clock after it mounts."
          },
          {
            "name": "day",
            "type": "number",
            "description": "Any time on the day to show. Default the day of now, or of the newest payment."
          },
          {
            "name": "selectedCityId",
            "type": "string | null",
            "description": "The picked city's id. Leave it out to let the block keep track."
          },
          {
            "name": "onSelectedCityChange",
            "type": "(id: string | null) => void",
            "description": "Called with a city's id when someone picks it, or null when they clear it."
          },
          {
            "name": "title",
            "type": "string",
            "default": "\"Revenue today\"",
            "description": "The label over the total. A picked city replaces it with its own name."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholders at the same size while the data loads."
          },
          {
            "name": "loadingText",
            "type": "string",
            "default": "\"Loading payments\"",
            "description": "What the status line tells screen readers while loading."
          },
          {
            "name": "error",
            "type": "ReactNode",
            "description": "Shows this message in place of the payments. The globe stays, with no arcs."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No payments yet today.\"",
            "description": "Shown when the day has no payments."
          },
          {
            "name": "visibleCities",
            "type": "number",
            "default": "5",
            "description": "How many cities the list shows before Show all."
          },
          {
            "name": "aria-label",
            "type": "string",
            "default": "\"Payments by city\"",
            "description": "Names the globe for screen readers. The block adds how many cities and the total so far."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer div, which holds the container query."
          }
        ],
        "a11y": [
          "The globe is a focusable group named by aria-label, with the number of cities and the total so far. Arrow keys turn it, and Home turns it back to the home city.",
          "Turn west and Turn east buttons turn the globe too, so nobody has to drag. A tap on a city dot picks it, but the list is the main way.",
          "A touch drag only spins the globe sideways. Dragging up and down still scrolls the page.",
          "The city list carries the real numbers. Each city is a button with aria-pressed. Picking one turns the globe to it and shows its own total.",
          "At rest the globe sways slowly so home stays in sight. Pause motion stops the sway and the arcs. The motion feel turns them off on its own at 0ms, and the arcs then show still.",
          "A status line speaks when someone picks a city, or a replay starts or ends. It never speaks for each payment.",
          "The scrubber's name reads the time of day, like \"Time of day 16:42:14 UTC\". Its value is in minutes.",
          "Arcs, dots, and the home city keep system colors in forced-colors mode."
        ],
        "examples": [
          {
            "title": "Today's payments",
            "code": "<PaymentsGlobe\n  home={{ name: \"San Francisco\", lat: 37.77, lon: -122.42 }}\n  cities={[\n    { id: \"nyc\", name: \"New York\", lat: 40.71, lon: -74.01 },\n    { id: \"lon\", name: \"London\", lat: 51.51, lon: -0.13 },\n    { id: \"tyo\", name: \"Tokyo\", lat: 35.68, lon: 139.69 },\n  ]}\n  payments={payments}\n/>"
          },
          {
            "title": "Live, in your own time zone",
            "code": "<PaymentsGlobe home={home} cities={cities} payments={payments} live timeZone=\"America/Los_Angeles\" />"
          },
          {
            "title": "In euros",
            "code": "<PaymentsGlobe home={home} cities={cities} payments={payments} format={{ style: \"currency\", currency: \"EUR\", maximumFractionDigits: 0 }} locale=\"de-DE\" />"
          },
          {
            "title": "While the payments load",
            "code": "<PaymentsGlobe home={home} cities={[]} payments={[]} loading />"
          }
        ]
      }
    },
    {
      "name": "pixel-icon-library",
      "type": "registry:block",
      "title": "Pixel Icon Library",
      "description": "Browse 300 retro pixel icons, filter by category, copy SVG or JSX, and download individual files or the complete ZIP.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/accordion",
        "@txbrendan/toast",
        "@txbrendan/icon-color-picker",
        "@txbrendan/pixel-icon",
        "@txbrendan/button",
        "@txbrendan/search-field",
        "@txbrendan/select"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/pixel-icon-library/pixel-icon-library.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/pixel-icon-library/pixel-icon-library-archive.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "icons",
        "assets",
        "downloads"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Place one Toaster in your app layout to show brief copy confirmations.",
            "Edit the primary color or expand More palette colors for individual inks. Previews, SVG/JSX copies, individual downloads, and the full ZIP retain the overrides. Reset palette restores the theme.",
            "An asset browser for the bundled 300-icon collection.",
            "Standalone SVG downloads keep the current scoped theme colors. ZIP includes 300 SVGs, a category catalog, and usage notes.",
            "The collection is local and static; search and downloads need no service or API key."
          ],
          "avoid": [
            "A single illustration: install Pixel Icon alone.",
            "An upload manager or remote asset picker. This browser contains only the bundled collection."
          ]
        },
        "props": [
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the container. Later classes win."
          }
        ],
        "a11y": [
          "Search and category controls are labeled. Icon buttons have names and expose selection with aria-pressed.",
          "Search counts, copy results, errors, and download preparation results are announced.",
          "Clipboard access needs browser permission and a secure context. When it fails, a message directs people to the SVG download.",
          "The grid follows its container width. All actions are keyboard buttons."
        ],
        "examples": [
          {
            "title": "Complete icon browser",
            "code": "<PixelIconLibrary />"
          }
        ]
      }
    },
    {
      "name": "project-views",
      "type": "registry:block",
      "title": "Project Views",
      "description": "Shows one set of projects as a Board, a List, or a Timeline, switched by tabs, with search, filters, and a sheet for each project. The name column stays still between List and Timeline, and only the dates side changes.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar-stack",
        "@txbrendan/badge",
        "@txbrendan/board",
        "@txbrendan/button",
        "@txbrendan/gantt-chart",
        "@txbrendan/glide-highlight",
        "@txbrendan/input",
        "@txbrendan/menu",
        "@txbrendan/motion-timing",
        "@txbrendan/notice",
        "@txbrendan/progress",
        "@txbrendan/rolling-number",
        "@txbrendan/search-field",
        "@txbrendan/sheet",
        "@txbrendan/skeleton",
        "@txbrendan/tabs",
        "@txbrendan/toast",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/project-views/project-views.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/project-views/project-views-board.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/project-views/project-views-data.ts",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/project-views/project-views-menu.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/project-views/project-views-sheet.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/project-views/project-views-switch.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/project-views/project-views-table.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/project-views/project-views-tip.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "projects",
        "board",
        "kanban",
        "list",
        "timeline",
        "gantt",
        "tasks"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Work that moves through stages, like projects, when people want a board, a list, and dates on one screen.",
            "Pass defaultProjects for the block to own the list, or projects with onProjectsChange to own it yourself.",
            "Give each project a stage id from stages. The default stages are To do, In progress, In review, and Done.",
            "Pass today as an ISO day from the server. It marks the timeline and dates new projects, and keeps the first render the same on the server and in the browser.",
            "Dates are ISO days, like \"2026-11-06\". start and due draw the timeline bar.",
            "Move and Remove apply at once and offer Undo in a toast for 8 seconds, so place one Toaster on the page.",
            "Set loading while the projects load, and error with onRetry when they fail."
          ],
          "avoid": [
            "A single list with no stages. Use Table or List Row.",
            "Only a timeline, with no board or list. Use Gantt Chart.",
            "Dragging cards between lanes. Cards do not drag. Each card's menu moves it."
          ]
        },
        "props": [
          {
            "name": "projects",
            "type": "{ id: string; name: string; kind?: string; stage: string; description?: string; priority?: \"high\" | \"medium\" | \"low\"; urgent?: boolean; tasks?: { done: number; total: number }; people?: string[]; comments?: number; files?: number; start?: string; due?: string }[]",
            "description": "The projects, in order inside each stage. Use with onProjectsChange to own them. Each id is unique, and stage is a stage's id."
          },
          {
            "name": "defaultProjects",
            "type": "Project[]",
            "default": "[]",
            "description": "The projects to start with, when the block owns them."
          },
          {
            "name": "onProjectsChange",
            "type": "(projects: Project[]) => void",
            "description": "Called with the whole new list after an add, a move, a remove, or an undo."
          },
          {
            "name": "view",
            "type": "\"board\" | \"list\" | \"timeline\"",
            "description": "Which view shows. Use with onViewChange."
          },
          {
            "name": "defaultView",
            "type": "\"board\" | \"list\" | \"timeline\"",
            "default": "\"board\"",
            "description": "The view to start on."
          },
          {
            "name": "onViewChange",
            "type": "(view: ProjectView) => void",
            "description": "Called when a tab picks another view."
          },
          {
            "name": "defaultSearch",
            "type": "string",
            "default": "\"\"",
            "description": "The search to start with."
          },
          {
            "name": "stages",
            "type": "{ id: string; title: string; tone?: \"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\" }[]",
            "default": "To do, In progress, In review, Done",
            "description": "The stages, in order. Each is a lane on the board and a group in the list."
          },
          {
            "name": "today",
            "type": "string",
            "description": "Today as an ISO day, passed from the server."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The locale for dates."
          },
          {
            "name": "timeZone",
            "type": "string",
            "default": "\"UTC\"",
            "description": "The time zone the timeline reads days in."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Each view holds its space with placeholders, and the count waits."
          },
          {
            "name": "error",
            "type": "string",
            "description": "Says what went wrong in place of the views."
          },
          {
            "name": "onRetry",
            "type": "() => void",
            "description": "Shows Retry beside the error."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer section, which holds the container query."
          }
        ],
        "a11y": [
          "The block is a section named by its heading. On a page with more than one, give each an aria-label, which then names it instead. The views are tabs, and the arrow keys move between them.",
          "Each card and each row has one button named by the project, which opens its sheet. The rest of a row opens it with a pointer.",
          "Each project's \"…\" button is named \"More for\" and the project, and opens Open, Move to, and Remove.",
          "After a move or a remove, focus goes to the project in its new place, or to the one beside where it was.",
          "List and Timeline are one table, with column headers, a row header for each project, and a row group for each stage.",
          "Each lane is a group named by its stage and count. Each \"+\" button is named for its stage, and its tooltip says Add project.",
          "The view that leaves is hidden from screen readers and from Tab while it fades.",
          "The count line and each stage's count roll to new numbers. Reduced motion takes out the slide, and motion off shows each view at once."
        ],
        "examples": [
          {
            "title": "Projects the block keeps",
            "code": "<ProjectViews\n  today=\"2026-10-30\"\n  defaultProjects={[\n    { id: \"payments\", name: \"Payments page\", kind: \"Web\", stage: \"doing\", priority: \"high\", urgent: true, tasks: { done: 9, total: 20 }, people: [\"AK\", \"BL\"], start: \"2026-10-19\", due: \"2026-11-06\" },\n    { id: \"help\", name: \"Help center\", kind: \"Web\", stage: \"review\", tasks: { done: 18, total: 20 }, start: \"2026-10-12\", due: \"2026-10-31\" },\n  ]}\n/>"
          },
          {
            "title": "Your own state owns the projects and the view",
            "code": "\"use client\";\n\nfunction TeamProjects({ initial, today }: { initial: Project[]; today: string }) {\n  const [projects, setProjects] = useState(initial);\n  const [view, setView] = useState<ProjectView>(\"list\");\n  return (\n    <ProjectViews\n      projects={projects}\n      onProjectsChange={(next) => {\n        setProjects(next);\n        saveProjects(next);\n      }}\n      view={view}\n      onViewChange={setView}\n      today={today}\n    />\n  );\n}"
          },
          {
            "title": "Your own stages",
            "code": "<ProjectViews\n  today=\"2026-10-30\"\n  stages={[\n    { id: \"ideas\", title: \"Ideas\" },\n    { id: \"building\", title: \"Building\", tone: \"brand\" },\n    { id: \"shipped\", title: \"Shipped\", tone: \"success\" },\n  ]}\n  defaultProjects={[{ id: \"search\", name: \"Search\", stage: \"ideas\" }]}\n/>"
          },
          {
            "title": "While loading, and when loading fails",
            "code": "<ProjectViews\n  projects={data ?? []}\n  loading={isLoading}\n  error={failed ? \"Projects could not load.\" : undefined}\n  onRetry={refetch}\n/>"
          }
        ]
      }
    },
    {
      "name": "scoreboard",
      "type": "registry:block",
      "title": "Scoreboard",
      "description": "A ballpark scoreboard on a dark green board: the line score across the top, then the count, both batting orders, the umpires, and other games around the league.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/line-score",
        "@txbrendan/ball-count",
        "@txbrendan/batting-order",
        "@txbrendan/league-scores",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/scoreboard/scoreboard.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "sports",
        "baseball",
        "scores",
        "live"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A live baseball game page, a stadium screen, or a fan site's game center.",
            "Pass away and home with their runs by inning, hits, errors, and lineup. Then the rest as you have it: count, batter, umpires, and leagues.",
            "Pass inning while the game is on. Its team's lineup marks the batter, by batter.number.",
            "Leave inning and count out once the game is over, and pass status, like \"Final\".",
            "Each panel shows only when it has data, so a board with just a line score works too.",
            "The board stays dark green in light mode and dark. It scopes the theme's dark tokens to itself with the dark class."
          ],
          "avoid": [
            "A board that follows the page's light and dark mode. Use LineScore and the other parts on your own card.",
            "Games with no innings. Use MatchCard."
          ]
        },
        "props": [
          {
            "name": "away",
            "type": "ScoreboardTeam",
            "description": "The visitors: a LineScoreTeam plus lineup?, their batting order. With home, the game on the board."
          },
          {
            "name": "home",
            "type": "ScoreboardTeam",
            "description": "The home side, the same shape."
          },
          {
            "name": "innings",
            "type": "number",
            "default": "9",
            "description": "How many innings the game is scheduled for."
          },
          {
            "name": "inning",
            "type": "{ number: number; half: \"top\" | \"bottom\" }",
            "description": "The half-inning being played now."
          },
          {
            "name": "status",
            "type": "string",
            "description": "A word for the game's state when no inning is in play, like \"Final\"."
          },
          {
            "name": "count",
            "type": "{ balls: number; strikes: number; outs: number }",
            "description": "The count now. Leave it out to hide the count."
          },
          {
            "name": "batter",
            "type": "{ number: number; average?: string }",
            "description": "Who is at the plate."
          },
          {
            "name": "leagues",
            "type": "{ title: string; games: LeagueScoresGame[]; note?: ReactNode }[]",
            "default": "[]",
            "description": "Other games, one table per group."
          },
          {
            "name": "umpires",
            "type": "{ label: string; number: number }[]",
            "default": "[]",
            "description": "The umpires by base, like { label: \"Plate\", number: 17 }."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholder tiles while the game loads."
          },
          {
            "name": "loadingText",
            "type": "string",
            "default": "\"Loading the scoreboard\"",
            "description": "What the status line tells screen readers while loading."
          },
          {
            "name": "emptyText",
            "type": "string",
            "default": "\"No game on the board.\"",
            "description": "Shown when there is no game and nothing is loading."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the board, which holds the container query."
          }
        ],
        "a11y": [
          "The board is a section named \"Scoreboard\". Pass aria-label to name it after the game.",
          "Each part keeps its own table or list, so screen readers can move by table and by row.",
          "The line score says each scoring change once in a polite line. The count does not, since it changes every pitch.",
          "On the dark green board, white text is 12.6:1, the lit amber is 9.6:1, and the muted labels are 5.5:1.",
          "While loading, the board has aria-busy=\"true\" and a status line says loadingText."
        ],
        "examples": [
          {
            "title": "A game in progress",
            "code": "<Scoreboard\n  away={{ name: \"Harbor\", short: \"HAR\", runs: [0, 1, 0, 0], hits: 4, errors: 1, lineup: harborLineup }}\n  home={{ name: \"Rivers\", short: \"RIV\", runs: [1, 0, 2], hits: 3, lineup: riversLineup }}\n  inning={{ number: 4, half: \"bottom\" }}\n  count={{ balls: 1, strikes: 2, outs: 1 }}\n  batter={{ number: 24, average: \".267\" }}\n/>"
          },
          {
            "title": "A final score with nothing else",
            "code": "<Scoreboard away={{ name: \"Harbor\", runs: [0, 1, 0, 0, 2, 0, 0, 1, 0], hits: 9 }} home={{ name: \"Rivers\", runs: [1, 0, 2, 1, 0, 0, 1, 0, \"X\"], hits: 8 }} status=\"Final\" />"
          },
          {
            "title": "While the game loads",
            "code": "<Scoreboard loading />"
          }
        ]
      }
    },
    {
      "name": "settings-panel",
      "type": "registry:block",
      "title": "Settings Panel",
      "description": "A card of labeled settings rows, with switches, text fields, and selects, and a Save button that shows saving, saved, and errors.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/card",
        "@txbrendan/button",
        "@txbrendan/input",
        "@txbrendan/label",
        "@txbrendan/select",
        "@txbrendan/skeleton",
        "@txbrendan/switch",
        "@txbrendan/textarea"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/settings-panel/settings-panel.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/settings-panel/settings-panel-row.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "settings",
        "preferences",
        "form",
        "account"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A group of related settings saved together, like notifications, a profile, or a workspace.",
            "Switches for on or off choices, inputs for short text, textareas for longer text, and selects for one option from a short list.",
            "Know the cost: the panel always brings in Select, about 41 KB gzip, even with no select field. An app that already uses Select, a popover, a menu, or a combobox shares most of it.",
            "Return a promise from onSave. The panel shows saving until it settles, then saved, or the error if it rejects.",
            "Pass loading while the values load, and the fields with no defaults if you know them. The placeholder rows take their shapes.",
            "Pass the saved values as each field's defaultValue. The panel reads a default until people change that field or save.",
            "Set layout to \"title-outside\" or \"actions-outside\" for a nested card: the title or the Save bar sits on a muted shell around a white card of rows. Only one part goes outside."
          ],
          "avoid": [
            "Settings that should apply the moment people flip them. Use a Switch on its own and save on change.",
            "Picking from a long list people would rather search, like every city. The panel's select has no search.",
            "Long forms with validation messages under each field. Build those with your form library.",
            "Many groups in one card. Use one panel per group, each with its own title."
          ]
        },
        "props": [
          {
            "name": "fields",
            "type": "({ type: \"switch\"; defaultValue?: boolean } | { type: \"input\"; defaultValue?: string; inputType?: \"text\" | \"email\" | \"url\" | \"tel\" | \"number\"; placeholder?: string; autoComplete?: string } | { type: \"textarea\"; defaultValue?: string; placeholder?: string } | { type: \"select\"; options: { value: string; label: ReactNode }[]; defaultValue?: string; placeholder?: ReactNode })[], each with { name: string; label: ReactNode; description?: ReactNode; disabled?: boolean }",
            "description": "The rows, in order. name is the key for the field's value in onSave, so each name must be unique. A select gives the picked option's value, or \"\" while nothing is picked."
          },
          {
            "name": "onSave",
            "type": "(values: Record<string, string | boolean>) => void | Promise<void>",
            "description": "Called with every field's value, keyed by name, when people click Save or press Enter in an input. Return a promise to show saving until it settles."
          },
          {
            "name": "saveStatus",
            "type": "\"idle\" | \"saving\" | \"saved\" | \"error\"",
            "description": "Sets the save state yourself, like from a mutation hook. The panel still calls onSave. Unsaved changes still show over \"saved\"."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "default": "\"Settings\"",
            "description": "The card's heading. It also names the form."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "One line under the title: what these settings change."
          },
          {
            "name": "headingLevel",
            "type": "1 | 2 | 3 | 4 | 5 | 6",
            "default": "2",
            "description": "The title's heading level, so it fits under the heading above it."
          },
          {
            "name": "layout",
            "type": "\"flat\" | \"title-outside\" | \"actions-outside\"",
            "default": "\"flat\"",
            "description": "\"title-outside\" puts the title and description on a muted shell, above a white card of rows. The title still names the form. \"actions-outside\" puts the status line and Save button on the shell, below the rows."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "Color of the Save button and the switches."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholder rows while the values load: one per field, or three when there are no fields yet."
          },
          {
            "name": "disabled",
            "type": "boolean",
            "default": "false",
            "description": "Locks every field and the Save button, like for people who can only view."
          },
          {
            "name": "saveLabel",
            "type": "ReactNode",
            "default": "\"Save changes\"",
            "description": "The Save button's text."
          },
          {
            "name": "savingText",
            "type": "ReactNode",
            "default": "\"Saving…\"",
            "description": "The status line while saving."
          },
          {
            "name": "savedText",
            "type": "ReactNode",
            "default": "\"Changes saved\"",
            "description": "The status line after a save."
          },
          {
            "name": "unsavedText",
            "type": "ReactNode",
            "default": "\"Unsaved changes\"",
            "description": "The status line once people change something."
          },
          {
            "name": "errorText",
            "type": "ReactNode",
            "default": "\"Could not save. Try again.\"",
            "description": "The status line when onSave throws or rejects."
          },
          {
            "name": "loadingText",
            "type": "string",
            "default": "\"Loading settings\"",
            "description": "What the status line tells screen readers while loading."
          },
          {
            "name": "emptyText",
            "type": "ReactNode",
            "default": "\"No settings here yet.\"",
            "description": "Shown when there are no fields and nothing is loading."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the form, which holds the container query."
          }
        ],
        "a11y": [
          "The panel is a form named by its title, so screen readers can jump to it.",
          "Each label is linked to its control, and each description is read with it. Clicking a switch's label flips it, and clicking a select's label opens its list.",
          "Every control is named by its label in server HTML too, before the page hydrates. A select shows its picked option's label there, not its value.",
          "One status line stays on the page under the rows. It reads out unsaved changes, saving, saved, and errors.",
          "While saving, the Save button keeps focus and shows a spinner. A second click or Enter does not save twice.",
          "While loading, the placeholder rows are hidden from screen readers, the form has aria-busy, and the status line says loadingText.",
          "Forced colors mode drops fills, so the placeholder rows do not show there. The status line shows loadingText as words instead.",
          "disabled sets disabled on every control and its label. A field's own disabled locks only that field."
        ],
        "examples": [
          {
            "title": "Email settings",
            "code": "<SettingsPanel\n  title=\"Email notifications\"\n  description=\"Choose what we email you about.\"\n  tone=\"brand\"\n  fields={[\n    { type: \"input\", name: \"email\", label: \"Send to\", inputType: \"email\", autoComplete: \"email\", defaultValue: user.email },\n    { type: \"switch\", name: \"comments\", label: \"Comments\", description: \"When someone replies to you.\", defaultValue: prefs.comments },\n    {\n      type: \"select\",\n      name: \"summary\",\n      label: \"Summary\",\n      options: [\n        { value: \"daily\", label: \"Every day\" },\n        { value: \"weekly\", label: \"Every Monday\" },\n        { value: \"never\", label: \"Never\" },\n      ],\n      defaultValue: prefs.summary,\n    },\n  ]}\n  onSave={(values) => saveEmailSettings(values)}\n/>"
          },
          {
            "title": "Nested card",
            "code": "<SettingsPanel\n  title=\"Team settings\"\n  description=\"These apply to everyone in the workspace.\"\n  layout=\"title-outside\"\n  fields={[\n    { type: \"switch\", name: \"private\", label: \"New projects are private\", defaultValue: true },\n    { type: \"input\", name: \"session\", label: \"Sign-in length in days\", inputType: \"number\", defaultValue: \"30\" },\n  ]}\n  onSave={saveTeamSettings}\n/>"
          },
          {
            "title": "While the values load",
            "code": "<SettingsPanel\n  title=\"Profile\"\n  loading={profile === undefined}\n  fields={[\n    { type: \"input\", name: \"name\", label: \"Display name\", defaultValue: profile?.name },\n    { type: \"textarea\", name: \"bio\", label: \"Bio\", defaultValue: profile?.bio },\n  ]}\n  onSave={updateProfile}\n/>"
          }
        ]
      }
    },
    {
      "name": "solid-icon-library",
      "type": "registry:block",
      "title": "Solid Icon Library",
      "description": "Browse 24 solid icons, choose a color, copy SVG or React usage, and download individual SVGs.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/panel-switch",
        "@txbrendan/accordion",
        "@txbrendan/toast",
        "@txbrendan/solid-icon",
        "@txbrendan/button",
        "@txbrendan/search-field",
        "@txbrendan/icon-color-picker"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/solid-icon-library/solid-icon-library.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "icons",
        "assets",
        "downloads"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Place one Toaster in your app layout to show brief copy confirmations.",
            "A searchable browser for the original solid icon collection.",
            "Search by name or category. Selecting an icon shows its sizes, SVG download, and individual React import.",
            "Choose an icon color with the picker or a 3- or 6-digit hex value. The color applies to previews, downloads, and copied SVG or React code, and stays selected while browsing. Use text color restores inheritance.",
            "Downloads are generated locally from the same paths as the React icons. No hosted assets or API keys are required."
          ],
          "avoid": [
            "A single icon in a control: install Solid Icon instead.",
            "A general asset manager. This browser contains only the bundled solid icons."
          ]
        },
        "props": [
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the container; later classes win."
          }
        ],
        "a11y": [
          "The picker and hex field are labeled. Invalid hex keeps the last valid color and shows an associated message. Custom artwork colors are not contrast-checked; labels and controls keep their theme colors.",
          "Search and selection buttons are named. Selection is exposed through aria-pressed.",
          "Copy success uses a toast. Counts and clipboard failures are announced in a status line.",
          "Clipboard access requires a secure context and permission. Download and selectable React code remain available when copying fails."
        ],
        "examples": [
          {
            "title": "Browse the collection",
            "code": "<SolidIconLibrary />"
          }
        ]
      }
    },
    {
      "name": "stats-cards",
      "type": "registry:block",
      "title": "Stats Cards",
      "description": "A row of stat cards, each with a label, a value, and an optional change badge, with loading and empty states.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/card",
        "@txbrendan/badge",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/stats-cards/stats-cards.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/stats-cards/stats-cards-card.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "dashboard",
        "stats",
        "cards"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The top of a dashboard: two to four numbers people check first.",
            "Any place that shows a few key numbers side by side, like a report summary or an account page.",
            "Format each value and change yourself, so numbers, money, and dates read the way your app writes them.",
            "Pick each change's tone from what it means. Fewer errors is good news, so a drop there is success."
          ],
          "avoid": [
            "More than eight numbers. Use a table, so people can sort and compare.",
            "Numbers over time. Use a chart, and put the latest value in a stat card beside it.",
            "A single number in running text. Write it in the sentence."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "{ label: string; value: string; change?: string; tone?: \"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"; description?: string }[]",
            "description": "One card per item, in order. Labels must be unique, since each one is a key."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholder cards: one per item, or four when there are no items yet."
          },
          {
            "name": "loadingText",
            "type": "string",
            "default": "\"Loading stats\"",
            "description": "What the status line tells screen readers while loading."
          },
          {
            "name": "emptyText",
            "type": "string",
            "default": "\"No stats yet.\"",
            "description": "Shown when there are no items and nothing is loading."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer div, which holds the container query."
          }
        ],
        "a11y": [
          "The cards are a list, so screen readers say how many stats there are. Each card reads its label first, then its value.",
          "The change is text in a badge. Its color adds meaning but never carries it alone.",
          "While loading, the list has aria-busy=\"true\" and a status line says loadingText. That line stays on the page, outside the list, so screen readers hear it.",
          "The cards have no headings. If people should jump between them, put the block under a heading of your own.",
          "Nothing in the block takes focus. Put links and buttons near it, not inside a card."
        ],
        "examples": [
          {
            "title": "Four stats",
            "code": "<StatsCards\n  items={[\n    { label: \"Active users\", value: \"12,480\", change: \"+12%\", tone: \"success\", description: \"since last week\" },\n    { label: \"Revenue\", value: \"$48.2k\", change: \"+4%\", tone: \"success\", description: \"since last month\" },\n    { label: \"Errors\", value: \"37\", change: \"+9\", tone: \"danger\", description: \"in the last hour\" },\n    { label: \"Uptime\", value: \"99.98%\", description: \"this quarter\" },\n  ]}\n/>"
          },
          {
            "title": "While the numbers load",
            "code": "<StatsCards items={stats ?? []} loading={stats === undefined} loadingText=\"Loading account stats\" />"
          },
          {
            "title": "With nothing to show yet",
            "code": "<StatsCards items={[]} emptyText=\"Stats show up after your first sale.\" />"
          }
        ]
      }
    },
    {
      "name": "svg-icon-library",
      "type": "registry:block",
      "title": "SVG Icon Library",
      "description": "Search and filter an SVG collection, preview icons, copy their markup, and download individual files or a prepared ZIP.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/accordion",
        "@txbrendan/toast",
        "@txbrendan/icon-color-picker",
        "@txbrendan/button",
        "@txbrendan/search-field",
        "@txbrendan/select",
        "@txbrendan/skeleton"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/svg-icon-library/svg-icon-library.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/svg-icon-library/svg-icon-library-archive.ts",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/svg-icon-library/svg-icon-library-paint.ts",
          "type": "registry:component"
        }
      ],
      "categories": [
        "icons",
        "assets",
        "downloads"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Place one Toaster in your app layout to show brief copy confirmations.",
            "A downloadable icon collection with names and categories.",
            "Host trusted SVG files and the optional ZIP on your own origin. Copy needs fetch access; browser downloads work with same-origin URLs.",
            "Supply your own assets. The block does not install the gallery's example artwork."
          ],
          "avoid": [
            "A single icon inside a control: use Icon.",
            "Untrusted uploads. This block does not sanitize or host files."
          ]
        },
        "props": [
          {
            "name": "palette",
            "type": "readonly { label: string; value: string }[]",
            "description": "Editable original hex paints. Overrides apply to SVG paint attributes in previews, copies, downloads, and ZIPs; geometry and transparency stay intact."
          },
          {
            "name": "items",
            "type": "readonly SvgIconAsset[]",
            "default": "[]",
            "description": "Unique name, visible title, category, and SVG src URL for each icon."
          },
          {
            "name": "downloadAllHref",
            "type": "string",
            "description": "Optional same-origin ZIP for original artwork. With custom colors, a new ZIP of the whole collection is generated locally. Filters never reduce the collection."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholders and hides stale artwork and download actions."
          },
          {
            "name": "emptyText",
            "type": "string",
            "default": "\"No icons yet.\"",
            "description": "Message when the collection has no assets."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the container. Later classes win."
          }
        ],
        "a11y": [
          "Search and category controls have accessible names. Each icon is a named button with aria-pressed selection.",
          "Copy success uses a toast. Failures, counts, and loading are announced in a status line.",
          "Copy requires a secure context and clipboard permission. A failed copy keeps the download link available.",
          "Filtering keeps the preview and download link on a visible result. No matches hides both."
        ],
        "examples": [
          {
            "title": "An asset collection",
            "code": "<SvgIconLibrary items={[{ name: \"ball\", title: \"Rugby ball\", category: \"Sport\", src: \"/icons/ball.svg\" }]} downloadAllHref=\"/icons/collection.zip\" />"
          }
        ]
      }
    },
    {
      "name": "task-inbox",
      "type": "registry:block",
      "title": "Task Inbox",
      "description": "One calm list of tasks, grouped into Needs you, Running, and Done, with filter tabs and live counts. Use it for approvals, requests, and background jobs. A row opens to its last steps and the action its state needs.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/accordion",
        "@txbrendan/badge",
        "@txbrendan/button",
        "@txbrendan/card",
        "@txbrendan/motion-timing",
        "@txbrendan/skeleton",
        "@txbrendan/spinner",
        "@txbrendan/status-text",
        "@txbrendan/tabs",
        "@txbrendan/timeline"
      ],
      "files": [
        {
          "path": "registry/txbrendan/blocks/task-inbox/task-inbox.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/blocks/task-inbox/task-inbox-row.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "tasks",
        "inbox",
        "approvals",
        "jobs"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Show people the work that waits on them or runs for them, sorted by who has to act next: expense reports, refunds, time off, imports, exports.",
            "Each task is one row: what it is, where it stands, how long it has taken, and a small badge, like who asked or the team.",
            "Tasks that wait for a yes or no and tasks that stopped both sit under Needs you, where people look first.",
            "Give a failed task a short reason, like \"card declined\", so people can tell whether Retry will help.",
            "Pass approveLabel and rejectLabel when Approve and Reject are not the right words, like Sign off and Send back.",
            "Pass approveTo=\"done\" when a yes finishes the task, like a sign-off. Left out, an approved task starts running.",
            "Pass elapsed in seconds. Running and waiting rows count on from there, once a second.",
            "Without handlers, Approve, Reject, and Retry change the task inside the block. Pass a handler to own the change in your own state."
          ],
          "avoid": [
            "A queue of more than a few dozen tasks. Use a DataTable with paging and search.",
            "A task's full history. Link to it with href, and show it on its own page.",
            "One job with its details, on its own. Use JobCard.",
            "Agent tasks with a model on each row. Use the Agent Inbox block."
          ]
        },
        "props": [
          {
            "name": "tasks",
            "type": "{ id: string; title: string; summary?: string; badge?: string; status: \"waiting\" | \"running\" | \"done\" | \"failed\"; elapsed?: number; reason?: string; steps?: { label: string; time?: string }[]; href?: string }[]",
            "default": "[]",
            "description": "One row per task, in order inside each group. ids must be unique. steps are oldest first, and a row shows the last four. href puts an Open link on running and done tasks. A new list, by value, replaces changes made inside the block."
          },
          {
            "name": "onApprove",
            "type": "(task: InboxTask) => void",
            "description": "Approve on a waiting task. Without it, the task moves to Running."
          },
          {
            "name": "onReject",
            "type": "(task: InboxTask) => void",
            "description": "Reject on a waiting task. Without it, the task fails with \"Rejected\" as its reason."
          },
          {
            "name": "onRetry",
            "type": "(task: InboxTask) => void",
            "description": "Retry on a failed task. Without it, the task moves to Running and its clock starts over."
          },
          {
            "name": "onOpen",
            "type": "(task: InboxTask) => void",
            "description": "Shows Open on running and done tasks that have no href."
          },
          {
            "name": "approveTo",
            "type": "\"running\" | \"done\"",
            "default": "\"running\"",
            "description": "Where an approved task goes without onApprove. \"running\" for work that starts on a yes, like an import. \"done\" for a sign-off, like an expense report."
          },
          {
            "name": "approveLabel",
            "type": "string",
            "default": "\"Approve\"",
            "description": "The yes button on a waiting task, like Accept or Sign off."
          },
          {
            "name": "rejectLabel",
            "type": "string",
            "default": "\"Reject\"",
            "description": "The no button on a waiting task, like Decline or Send back."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows placeholder rows and hides the counts."
          },
          {
            "name": "loadingText",
            "type": "string",
            "default": "\"Loading tasks\"",
            "description": "What the status line tells screen readers while loading."
          },
          {
            "name": "emptyText",
            "type": "string",
            "default": "\"No tasks yet.\"",
            "description": "Shown when there are no tasks and nothing is loading."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Tasks\"",
            "description": "Names the filter tabs for screen readers."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the outer div, which holds the container query."
          }
        ],
        "a11y": [
          "The filters are tabs. Arrow keys move between them, and each tab says its count.",
          "Each row is a button inside a heading, with aria-expanded. Enter or Space opens it to the last steps and the actions.",
          "Each group is a labeled group in the All tab, so screen readers say Needs you, Running, or Done.",
          "Status is words with a color, like Waiting for you or Failed: and a reason. Color never carries it alone.",
          "After Approve, Reject, or Retry, focus moves to the row in its new place. When the open filter hides it, focus goes to the tab.",
          "One status line outside the lists reads out each change: which task, what happened, and the new counts. The clocks never speak.",
          "With reduced motion, rows take their new place at once, and the running spinner stays still."
        ],
        "examples": [
          {
            "title": "Approvals and jobs",
            "code": "<TaskInbox\n  tasks={[\n    {\n      id: \"expenses\",\n      title: \"Approve Ana's expense report\",\n      summary: \"Client trip to Lisbon, $1,284 across 9 receipts.\",\n      badge: \"Ana Ruiz\",\n      status: \"waiting\",\n      elapsed: 5400,\n      steps: [\n        { label: \"Submitted 9 receipts\", time: \"2h ago\" },\n        { label: \"Waiting for your sign-off\", time: \"1h ago\" },\n      ],\n    },\n    { id: \"import\", title: \"Import March orders\", badge: \"Shop\", status: \"running\", elapsed: 221, href: \"/jobs/import\" },\n    { id: \"refund\", title: \"Refund order #4821\", badge: \"Support\", status: \"failed\", reason: \"card declined\", elapsed: 34 },\n  ]}\n/>"
          },
          {
            "title": "Your own state owns each change",
            "code": "\"use client\";\n\nfunction RequestInbox() {\n  const { data: tasks } = useRequests();\n  return (\n    <TaskInbox\n      tasks={tasks ?? []}\n      loading={tasks === undefined}\n      approveLabel=\"Sign off\"\n      rejectLabel=\"Send back\"\n      onApprove={(task) => signOff(task.id)}\n      onReject={(task) => sendBack(task.id)}\n      onRetry={(task) => retry(task.id)}\n      onOpen={(task) => openRequest(task.id)}\n    />\n  );\n}"
          },
          {
            "title": "With nothing to show yet",
            "code": "<TaskInbox tasks={[]} emptyText=\"Requests sent to you show up here.\" />"
          }
        ]
      }
    },
    {
      "name": "announcement-dialog",
      "type": "registry:block",
      "title": "Announcement Dialog",
      "description": "A launch popup with feature tiles and one button, plus an optional review step that takes people from the news to checkout.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/agreement",
        "@txbrendan/avatar",
        "@txbrendan/button",
        "@txbrendan/dialog",
        "@txbrendan/switch"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/announcement-dialog/announcement-dialog.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/website/announcement-dialog/announcement-dialog-review.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "announcement",
        "launch",
        "modal",
        "checkout",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Recommended: three features, each with a `visual` drawn from parts and a short `title`. Three fills the row on wide screens and stacks into rows on phones.",
            "Recommended: pass a `trigger`, like a What's new button, so people can open it again after they close it.",
            "Recommended for a launch: add `showOnce` with a key named for this launch, like \"plus-launch\". It opens once per browser and not again after it closes. Use a new key for the next launch.",
            "Recommended when the news leads to a purchase: pass `review={<AnnouncementReview />}`. The main button moves to it, and its Back button comes back.",
            "Recommended: leave `animated` on. Tiles lift on hover, and reduced motion keeps them still.",
            "News with no checkout: give `action` an `href`, and the button links on. Pass `className=\"max-w-lg\"` for one feature.",
            "AnnouncementReview on its own page: pass `onBack` to show Back. Send people to checkout in `onProceed`, which runs once every required term is agreed.",
            "Pass your router's link through `renderLink`, like next/link, for the note's link, the button's href, and the review's links.",
            "AnnouncementIntro alone draws a still picture of the popup, for a page or a preview.",
            "The headline is set in the theme's `font-headline`. Load Geist Serif as `--font-geist-serif`, like `geist/font/serif/variable` in Next.js, or it falls back to the system serif."
          ],
          "avoid": [
            "Opening it on every visit. Use `showOnce`, or open it yourself only when there is news.",
            "Showing it before people have seen the page, like on a first visit from an ad. Let them land first, and offer a trigger.",
            "More than four features. Link to a page that tells more.",
            "Checkboxes for terms. Each term is an Agreement, an \"I agree\" button that records who and when, so nothing is ticked by accident.",
            "Starting email updates on. The switch starts off, so nobody is signed up without choosing it.",
            "Hiding what costs extra. Put charges on top of the price last in the plan's `perks`, and as a term to agree to.",
            "A small yes or no question. Use Alert Dialog.",
            "A banner that stays on the page. Use Alert or Notice."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "Required. What is new, like \"Introducing Plus\". It names the dialog."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "One line under the title that says what it means for people."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "description": "A short label between two lines over the features, like \"Everything in one plan\"."
          },
          {
            "name": "features",
            "type": "{ title: ReactNode; visual?: ReactNode; badge?: ReactNode }[]",
            "description": "Two to four. Recommended: three. Each visual fills a 4:3 tile. A badge is a sticker on the tile, shown when the tiles sit in a row, from 36rem wide."
          },
          {
            "name": "note",
            "type": "ReactNode",
            "description": "One line in a soft box under the features, like a limit or a promise."
          },
          {
            "name": "link",
            "type": "{ label: ReactNode; href: string }",
            "description": "A link after the note, like See the guide."
          },
          {
            "name": "action",
            "type": "{ label?: ReactNode; href?: string; onClick?: () => void }",
            "default": "{ label: \"Try it\" }",
            "description": "The one main button. With a review step it moves to it. With no review and no href, it closes the dialog."
          },
          {
            "name": "animated",
            "type": "boolean",
            "default": "true",
            "description": "Recommended: true. Feature tiles lift on hover. Reduced motion keeps them still."
          },
          {
            "name": "renderLink",
            "type": "(props: AnnouncementLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws links. Spread the props onto your router's link: (props) => <Link {...props} />."
          },
          {
            "name": "trigger",
            "type": "ReactElement",
            "description": "Recommended. The button that opens it, like <Button emphasis=\"outline\">What's new</Button>."
          },
          {
            "name": "showOnce",
            "type": "string",
            "description": "Recommended for a launch. A storage key. It opens by itself once per browser, after the page loads, and not again after it closes. When storage is blocked it never opens by itself."
          },
          {
            "name": "open",
            "type": "boolean",
            "description": "Open it yourself. Use it with onOpenChange."
          },
          {
            "name": "defaultOpen",
            "type": "boolean",
            "default": "false",
            "description": "Open on first render, when you do not control it."
          },
          {
            "name": "onOpenChange",
            "type": "(open: boolean) => void",
            "description": "Called when it opens or closes."
          },
          {
            "name": "review",
            "type": "ReactNode",
            "description": "Recommended when the news leads to a purchase. A second step, like <AnnouncementReview />."
          },
          {
            "name": "container",
            "type": "HTMLElement | RefObject<HTMLElement>",
            "description": "Where the dialog opens. By default, at the end of the page body."
          },
          {
            "name": "className",
            "type": "string",
            "description": "On AnnouncementDialog, classes for the popup, like max-w-lg. On AnnouncementIntro, classes for its root."
          },
          {
            "name": "AnnouncementIntro",
            "type": "AnnouncementIntroProps",
            "description": "Part. What the popup says, with no dialog: a still picture for a page or a preview. Takes the props from title to animated."
          },
          {
            "name": "AnnouncementReview",
            "type": "AnnouncementReviewProps",
            "description": "Part. The last step before checkout: account, terms, an email switch, and the plan as a card that turns over. Its props follow."
          },
          {
            "name": "account",
            "type": "{ name: string; email: string; photo?: string; note?: ReactNode }",
            "description": "AnnouncementReview. Required. Who is signing up. Their name goes on each agreement's record."
          },
          {
            "name": "plan",
            "type": "AnnouncementPlan",
            "description": "AnnouncementReview. Required. { name, price?, period?, note?, perks?, memberSince?, tagline?, marks? }. The front shows the price and perks. The back is a member card."
          },
          {
            "name": "terms",
            "type": "{ id: string; title: ReactNode; text?: ReactNode; required?: boolean }[]",
            "description": "AnnouncementReview. Terms to agree to, each with an \"I agree\" button. Required by default. Recommended: three or fewer."
          },
          {
            "name": "updates",
            "type": "ReactNode | false",
            "default": "\"Email me about new features.\"",
            "description": "AnnouncementReview. A switch for email updates. It always starts off. Pass false to leave it out."
          },
          {
            "name": "step",
            "type": "ReactNode",
            "description": "AnnouncementReview. Where they are, like \"Step 2 of 2\"."
          },
          {
            "name": "links",
            "type": "{ label: ReactNode; href: string }[]",
            "description": "AnnouncementReview. Links under the card, like See the guide and Get help."
          },
          {
            "name": "onBack",
            "type": "() => void",
            "description": "AnnouncementReview. Shows Back. Inside AnnouncementDialog, Back returns to the announcement by itself."
          },
          {
            "name": "onProceed",
            "type": "(result: { agreed: Record<string, AgreementRecord>; updates: boolean }) => void",
            "description": "AnnouncementReview. Runs once every required term is agreed. Before that, a press shows an error on each one left."
          },
          {
            "name": "proceedLabel",
            "type": "ReactNode",
            "default": "\"Proceed to checkout\"",
            "description": "AnnouncementReview. The main button's words."
          },
          {
            "name": "termError",
            "type": "ReactNode",
            "default": "\"Agree to this to go on.\"",
            "description": "AnnouncementReview. Under each required term left unagreed."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "AnnouncementReview. The proceed button shows a spinner, and the terms and switch are blocked."
          }
        ],
        "a11y": [
          "The title names the dialog and the description describes it. Focus starts on the Close button, at the top.",
          "Escape, the Close button, and a press on the backdrop close it. Tab stays inside while it is open.",
          "Moving to the review step and back puts focus on the new step's title, so it never drops to the page.",
          "Feature pictures and badges are aria-hidden. The feature's title says what it is.",
          "With showOnce, it opens after the page loads, never during the first render, and only once.",
          "Each term is an Agreement: a group named by its title. A press on proceed with terms left moves focus to the first one, whose error is read with it.",
          "The turn button says what it does and is pressed while the back shows. The hidden face is inert, so screen readers and Tab skip it.",
          "In forced colors, tiles, the note, and the card's back get borders, so their edges still show."
        ],
        "examples": [
          {
            "title": "Recommended: a launch with a review step",
            "code": "<AnnouncementDialog\n  title=\"Introducing Plus\"\n  description=\"One plan for the whole team: Schedule, Chat, and Stats.\"\n  label=\"Everything in one plan\"\n  features={[\n    { title: \"Schedule, with who's coming\", visual: <ScheduleMock />, badge: <Badge>24 going</Badge> },\n    { title: \"Chat and voice notes, by team\", visual: <ChatMock /> },\n    { title: \"Stats and trends, all year\", visual: <StatsMock /> },\n  ]}\n  note=\"Generous limits, shared across all three apps.\"\n  link={{ label: \"See the guide\", href: \"/plus\" }}\n  trigger={<Button emphasis=\"outline\">What's new</Button>}\n  showOnce=\"plus-launch\"\n  review={\n    <AnnouncementReview\n      step=\"Step 2 of 2\"\n      account={{ name: user.name, email: user.email }}\n      terms={[{ id: \"permanent\", title: \"Linking is permanent\", text: \"The linked apps cannot be split apart again.\" }]}\n      plan={{ name: \"Plus\", price: \"$12\", period: \"/month\", perks: [{ title: \"One sign-in for all three apps\" }] }}\n      onProceed={({ updates }) => startCheckout({ updates })}\n    />\n  }\n/>"
          },
          {
            "title": "News with no checkout",
            "code": "<AnnouncementDialog\n  title=\"Voice notes are here\"\n  description=\"Hold the mic in any team chat to send one.\"\n  features={[{ title: \"Chat and voice notes, by team\", visual: <ChatMock /> }]}\n  action={{ label: \"Try it now\", href: \"/chat\" }}\n  showOnce=\"voice-notes\"\n  className=\"max-w-lg\"\n/>"
          }
        ]
      }
    },
    {
      "name": "article",
      "type": "registry:block",
      "title": "Article",
      "description": "The reading layout of one blog post: a back link, tag, title, lead, author, date, and cover, then the post as plain HTML, with a list of its sections on wide screens.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/badge",
        "@txbrendan/button",
        "@txbrendan/glide-highlight"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/article/article.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/website/article/article-contents.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/website/article/article-copy-link.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "blog",
        "post",
        "reading",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The page for one blog post, guide, or announcement, under the site's Navbar.",
            "Pass the post as children, as plain HTML or the output of your Markdown: h2, h3, p, a, ul, ol, blockquote, code, pre, figure, img, and hr. The Article styles them.",
            "Give each section an h2. With two or more, a list of them sits beside the body on wide screens, and marks the one being read.",
            "Pass `back` to link to the list of posts, and your router's link through `renderLink`, like next/link.",
            "Pass `date` as a plain date, like \"2026-10-12\". It prints the same on the server and in the browser.",
            "Pass `cover` as your own image or a drawing. It fills a 16:9 frame at every width, and an image is cropped to fit.",
            "The title and section headings are set in the theme's `font-headline`. Load Geist Serif as `--font-geist-serif`, or it falls back to the system serif.",
            "A pinned navbar covers the top of the page. Set `--article-offset` on the Article to its height plus a gap, like className=\"[--article-offset:5rem]\". It sets where the list sticks and where a jump to a section lands. The default is 6rem."
          ],
          "avoid": [
            "A list of posts. Use a grid of cards that link here.",
            "Docs with a sidebar of pages. The sections list covers one page only.",
            "A Hero above it. The title is the page's h1.",
            "Headings in the body other than h2 and h3. Use h2 for each section and h3 inside one."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "The post's title. It is the page's h1, in the serif."
          },
          {
            "name": "lead",
            "type": "ReactNode",
            "description": "One or two sentences under the title that say what the post covers."
          },
          {
            "name": "tag",
            "type": "ReactNode",
            "description": "A short tag over the title, like \"Guides\"."
          },
          {
            "name": "back",
            "type": "{ href: string; label: string }",
            "description": "The link back to the list of posts, like { href: \"/blog\", label: \"All posts\" }."
          },
          {
            "name": "author",
            "type": "{ name: string; role?: ReactNode; avatar?: string }",
            "description": "Who wrote it. The name gives the avatar its initials and tint, and avatar is a photo URL."
          },
          {
            "name": "date",
            "type": "string",
            "description": "When it was published, as a date like \"2026-10-12\". A string that is not a date shows as it is."
          },
          {
            "name": "readingTime",
            "type": "ReactNode",
            "description": "How long it takes to read, like \"6 min read\"."
          },
          {
            "name": "cover",
            "type": "ReactNode",
            "description": "A picture under the header, like an <img> or a drawing, in a 16:9 frame. It spans the body and the sections list."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "The post itself, as plain HTML."
          },
          {
            "name": "renderLink",
            "type": "(props: ArticleLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws the back link. Spread the props onto your router's link: (props) => <Link {...props} />. The renderLink a site passes its Navbar fits too."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "Formats the date. Fixed, so the server and the browser print the same words."
          },
          {
            "name": "contentsLabel",
            "type": "string",
            "default": "\"On this page\"",
            "description": "The heading over the list of sections, on wide screens."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the article, which holds the container query."
          }
        ],
        "a11y": [
          "The root is an article. The title is its h1, so the page should have no other.",
          "The date is a time element with the date you passed as its datetime.",
          "The author's name is written out, so screen readers skip the avatar.",
          "The sections list is a nav named by its heading. The link for the section being read has aria-current=\"location\".",
          "A press on a section scrolls to it, smoothly unless reduced motion is on, updates the address, and moves focus to its heading, so Tab carries on from there.",
          "Copy link keeps its name. Screen readers hear \"Link copied\", or that it could not copy, from a polite live region.",
          "A code block that is wider than the column scrolls inside itself, and takes focus so keys can scroll it.",
          "Links in the body are underlined, so they never rely on color alone."
        ],
        "examples": [
          {
            "title": "A blog post",
            "code": "<Article\n  back={{ href: \"/blog\", label: \"All posts\" }}\n  tag=\"Guides\"\n  date=\"2026-10-12\"\n  readingTime=\"6 min read\"\n  title=\"How to plan a release\"\n  lead=\"The steps a team can follow from the first list of changes to the day it ships.\"\n  author={{ name: \"Priya Raman\", role: \"Product lead\" }}\n>\n  <h2>Write down the scope</h2>\n  <p>Start with a short list of what the release changes.</p>\n  <h2>Set the dates</h2>\n  <p>Pick the day it ships first, then work back from it.</p>\n</Article>"
          },
          {
            "title": "With next/link, a cover, and another locale",
            "code": "<Article\n  title=\"Planifier une version\"\n  date=\"2026-10-12\"\n  locale=\"fr-FR\"\n  contentsLabel=\"Sur cette page\"\n  back={{ href: \"/blog\", label: \"Tous les articles\" }}\n  cover={<img src=\"/blog/release.png\" alt=\"\" width={1600} height={800} />}\n  renderLink={(props) => <Link {...props} />}\n>\n  <p>Commencez par une courte liste.</p>\n</Article>"
          }
        ]
      }
    },
    {
      "name": "bento-grid",
      "type": "registry:block",
      "title": "Bento Grid",
      "description": "A grid of soft gray cards that show off features, each with a live preview, an icon, or a link.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/bento-grid/bento-grid.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "features",
        "bento",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The feature sections of a landing page, under a Headline.",
            "A showcase: three cards, each with a `label`, a `preview` built from real parts, and a `description` at the bottom.",
            "Feature cards: an `icon`, a `title`, and a `description`, with a `preview` above them if you have one. Use size lg for three big ones.",
            "A feature list: up to nine cards with only an icon, title, and description.",
            "Give a card an `href` when a page tells more. The whole card becomes the link, and an arrow shows.",
            "Make one card wider with className=\"@3xl:col-span-2\"."
          ],
          "avoid": [
            "Numbers people check, like a dashboard's key stats. Use Stats Cards.",
            "Screenshots as previews. Build them from parts, so they stay sharp and follow the theme.",
            "More than nine cards in one grid. Split them under two Headlines."
          ]
        },
        "props": [
          {
            "name": "label",
            "type": "string",
            "description": "BentoCard. A small label at the top."
          },
          {
            "name": "preview",
            "type": "ReactNode",
            "description": "BentoCard. A live mock of the feature. With a title under it, its bottom fades out."
          },
          {
            "name": "icon",
            "type": "ReactNode",
            "description": "BentoCard. An icon over the title, in the brand color, drawn at 28px."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "BentoCard. What the feature is. It is an h3, and it names the link."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "BentoCard. What it does. Without a title, it sits at the bottom of the card."
          },
          {
            "name": "href",
            "type": "string",
            "description": "BentoCard. Where the card leads. Needs a title, which becomes the link text."
          },
          {
            "name": "renderLink",
            "type": "(props: BentoLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "BentoCard. Draws the link. Spread the props onto your router's link: (props) => <Link {...props} />."
          },
          {
            "name": "pattern",
            "type": "boolean",
            "default": "false",
            "description": "BentoCard. A field of dots fading down from the top edge, in the card's tone."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "BentoCard. The color of the dots."
          },
          {
            "name": "size",
            "type": "\"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "BentoCard. Text size: 13px for a grid of many cards, 16px for a few big ones."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. On BentoGrid it holds the container query."
          },
          {
            "name": "BentoGrid",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The grid: one column when narrow, three from 48rem of its own width. Put BentoCards inside."
          }
        ],
        "a11y": [
          "Each title is an h3, so put the grid under an h2, like a Headline.",
          "With an href, the link is the title, stretched over the card. Screen readers hear one link with the title as its name.",
          "A focused link outlines the whole card.",
          "Icons, arrows, and the dot pattern are aria-hidden.",
          "A preview is a picture of the product. Keep it free of real buttons, so nothing inside it takes focus.",
          "In forced colors the preview's fade is turned off, so nothing is hidden.",
          "In forced colors each card gets a border, so its edge still shows when its fill is painted away."
        ],
        "examples": [
          {
            "title": "Three showcase cards",
            "code": "<BentoGrid>\n  <BentoCard label=\"Everyone answers\" preview={<AnswersMock />} pattern tone=\"success\" description=\"Every event has an answer-by date.\" />\n  <BentoCard label=\"Watch the count\" preview={<CountsMock />} pattern tone=\"danger\" description=\"The number going sits on the event.\" />\n  <BentoCard label=\"Set the team up\" preview={<RolesMock />} pattern tone=\"brand\" description=\"Leads plan, everyone answers.\" />\n</BentoGrid>"
          },
          {
            "title": "A feature list with links",
            "code": "<BentoGrid>\n  <BentoCard icon={<Calendar />} title=\"Calendar feed\" description=\"Add it once and it stays up to date.\" href=\"/features/calendar\" />\n  <BentoCard icon={<Mail />} title=\"Reminder emails\" description=\"Chase the people who haven't answered.\" href=\"/features/reminders\" />\n  <BentoCard icon={<Download />} title=\"Download your data\" description=\"Anyone can take their data with them.\" />\n</BentoGrid>"
          }
        ]
      }
    },
    {
      "name": "call-to-action-band",
      "type": "registry:block",
      "title": "Call to Action Band",
      "description": "The closing band of a marketing page, above the footer: a big two-tone headline, one short line, and a main action with an optional second one.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/button-variants"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/call-to-action-band/call-to-action-band.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "cta",
        "section",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The last section of a landing page or a page of a site, right above the Footer.",
            "Put the claim in `title` and the rest in `muted`, so the eye reads the claim first.",
            "Give one main `action`, like Create a workspace, and at most one quieter `secondaryAction`, like See pricing.",
            "An action with `href` is a link. Pass your router's link through `renderLink`, like next/link, so it stays client-side. An action with `onClick` is a button.",
            "Use `layout=\"split\"` for a band with the words on the start side and the actions on the end side, and the default center for a band down the middle.",
            "Pass a Dot Field as `art` for a louder band. The band fades it out behind the words.",
            "The headline is set in the theme's `font-headline`. Load Geist Serif as `--font-geist-serif`, like `geist/font/serif/variable` in Next.js, or it falls back to the system serif."
          ],
          "avoid": [
            "The top of a page. Use Hero, which holds the page's h1.",
            "A section headline with no actions. Use Headline.",
            "A next step inside an app's own screen, like inviting a team. Use Call To Action, the card.",
            "Three or more actions. Keep one main action and one quieter one at most.",
            "A long description. Keep it to one line, and put the rest in the page above."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "The words that land first, in full ink. It is an h2."
          },
          {
            "name": "muted",
            "type": "ReactNode",
            "description": "The words that follow in gray, on the same line."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "One short line under the headline."
          },
          {
            "name": "action",
            "type": "{ label: string; href?: string; onClick?: () => void }",
            "description": "The main action, a large brand button. With `href` it is a link drawn by `renderLink`. It comes last in the row."
          },
          {
            "name": "secondaryAction",
            "type": "{ label: string; href?: string; onClick?: () => void }",
            "description": "A quieter second action, a large ghost button just before the main one."
          },
          {
            "name": "layout",
            "type": "\"center\" | \"split\"",
            "default": "\"center\"",
            "description": "\"center\" stacks everything down the middle. \"split\" puts the words on the start side and the actions on the end side once the band is wide, and stacks them when it is narrow."
          },
          {
            "name": "art",
            "type": "ReactNode",
            "description": "Your art behind the band, like a Dot Field. The band adds room around the words and fades the art out behind them. It comes up to full strength on hover."
          },
          {
            "name": "renderLink",
            "type": "(props: CallToActionBandLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws each action that has an `href`. It gets href, className, children, and data-slot. Spread them onto your router's link."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the section, which holds the container query."
          }
        ],
        "a11y": [
          "The headline is an h2, and the gray part is inside it, so screen readers hear one sentence.",
          "The gray is muted-foreground, which the theme checks at 4.5:1 on the page.",
          "An action with `href` is a real link, styled as a button. One with `onClick` is a real button.",
          "The main action comes last, so Tab reaches the quieter one first and the main one at the end of the band.",
          "The art is aria-hidden, since it is decoration, and it never takes a press.",
          "Both buttons are 44px tall at the default density, so they fit a thumb."
        ],
        "examples": [
          {
            "title": "Two links at the end of a page",
            "code": "<CallToActionBand\n  title=\"Plan the week with your team,\"\n  muted=\"all in one place.\"\n  description=\"Invite people, share the calendar, and see who is coming.\"\n  action={{ label: \"Create a workspace\", href: \"/signup\" }}\n  secondaryAction={{ label: \"See pricing\", href: \"/pricing\" }}\n  renderLink={(props) => <Link {...props} />}\n/>"
          },
          {
            "title": "Split, with a button that opens a form",
            "code": "<CallToActionBand\n  layout=\"split\"\n  title=\"Questions about a plan?\"\n  muted=\"Send us a message.\"\n  action={{ label: \"Write to us\", onClick: () => setOpen(true) }}\n/>"
          },
          {
            "title": "Over a Dot Field",
            "code": "<CallToActionBand\n  title=\"Plan the week with your team,\"\n  muted=\"all in one place.\"\n  action={{ label: \"Create a workspace\", href: \"/signup\" }}\n  art={<DotField bands={[\"var(--chart-1)\", \"var(--chart-2)\"]} />}\n/>"
          }
        ]
      }
    },
    {
      "name": "changelog",
      "type": "registry:block",
      "title": "Changelog",
      "description": "A list of product releases for a changelog page, newest first: each with its date and version, a title, a short summary, an optional cover, and its changes marked New, Improved, Fixed, or Removed.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/badge",
        "@txbrendan/button",
        "@txbrendan/glide-highlight",
        "@txbrendan/panel-switch",
        "@txbrendan/tabs"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/changelog/changelog.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "changelog",
        "releases",
        "updates",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The body of a changelog page, under a Headline as the page's h1. Each release title is an h2.",
            "Pass `releases` newest first. Write each `date` as an ISO date, like \"2026-10-28\". It shows in words, like \"October 28, 2026\", the same on the server and in every browser.",
            "Give a release a `version`, like \"2.4\", and it gets the id \"2-4\", so a link to /changelog#2-4 lands on it. With no version, pass an `id` of your own. Keep every id unique on the page.",
            "A link to a release that is hidden, past the limit or out of the filter, still lands: the list shows it at once, then scrolls to it.",
            "Mark each change with a `type`: \"new\" is green, \"removed\" is red, and \"improved\" and \"fixed\" stay gray, so the news stands out.",
            "Set `limit` for a long list. It shows that many, then a Show older releases button that shows the rest in place and moves focus to the first one it showed.",
            "Set `filter` to add tabs for each type of change in your releases. A tab shows only the releases with that type, and only those changes.",
            "Pass a `cover` for a release worth a picture, like a drawing from our illustrations or a screenshot. It fills a 16:9 frame.",
            "The dates stick below a floating navbar as you read, and a link scrolls each release to the same place. Both read `--changelog-offset`, 6rem by default. Set it in `className`, like `[--changelog-offset:1rem]`, when nothing floats over the page."
          ],
          "avoid": [
            "An in-app feed of who did what, or the day's running order. Use Timeline, which keeps each item to one line on a rail.",
            "Live updates or alerts inside an app. Use a Toast or an Alert.",
            "Every commit or ticket. Group them into releases people can read, a few changes each.",
            "Long change lines. Keep each to one sentence, and put the detail in the summary or a page of its own."
          ]
        },
        "props": [
          {
            "name": "releases",
            "type": "{ date: string; version?: string; id?: string; title: ReactNode; summary?: ReactNode; cover?: ReactNode; changes?: { type: \"new\" | \"improved\" | \"fixed\" | \"removed\"; text: ReactNode }[] }[]",
            "default": "[]",
            "description": "The releases, newest first. date is an ISO date. version also gives the release its id, \"2.4\" as \"2-4\", unless you pass id. cover fills a 16:9 frame. Each change has a type and its words. With none, it says there are no releases yet."
          },
          {
            "name": "limit",
            "type": "number",
            "description": "Shows this many first, then a Show older releases button that shows the rest. With none, every release shows."
          },
          {
            "name": "filter",
            "type": "boolean",
            "default": "false",
            "description": "Adds tabs over the list, All and one per type of change your releases have. It needs two types or more."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The language the dates are written in, like \"en-GB\" or \"de-DE\"."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query. The date column sits beside each release from 48rem of its own width, and above it when narrower. Set --changelog-offset here."
          },
          {
            "name": "ChangelogRelease",
            "type": "{ date: string; version?: string; id?: string; title: ReactNode; summary?: ReactNode; cover?: ReactNode; changes?: ChangelogChange[] }",
            "description": "Type. One release."
          },
          {
            "name": "ChangelogChange",
            "type": "{ type: \"new\" | \"improved\" | \"fixed\" | \"removed\"; text: ReactNode }",
            "description": "Type. One change in a release."
          }
        ],
        "a11y": [
          "The releases are an ordered list, newest first. Each is an article named by its title, an h2, so put the list under the page's h1.",
          "Each date is a <time> with the ISO date in dateTime.",
          "Each change's type is written on its badge, so it never rests on color alone.",
          "The tabs are named \"Type of change\". Arrow keys move between them. When the list changes, screen readers hear how many releases are left.",
          "Show older releases is a real button. Once pressed, it goes away and focus moves to the first release it showed, which sits where the button was.",
          "The hover fill is for the pointer only. Nothing in a release takes focus by itself.",
          "Reduced motion keeps the fades and drops the slides and the height glide.",
          "In forced colors each cover keeps a border, since its fill is painted away."
        ],
        "examples": [
          {
            "title": "Releases with versions, covers, and changes",
            "code": "<Changelog\n  releases={[\n    {\n      date: \"2026-10-28\",\n      version: \"2.4\",\n      title: \"Shared views\",\n      summary: \"Save a filtered view of any list and share it with your team.\",\n      cover: <ThemeLayers />,\n      changes: [\n        { type: \"new\", text: \"Save any filter as a view.\" },\n        { type: \"improved\", text: \"Column widths stay put when you switch views.\" },\n        { type: \"fixed\", text: \"Sorting by date no longer puts empty dates first.\" },\n      ],\n    },\n    {\n      date: \"2026-10-14\",\n      version: \"2.3\",\n      title: \"Faster search\",\n      changes: [{ type: \"improved\", text: \"Results show as you type.\" }],\n    },\n  ]}\n/>"
          },
          {
            "title": "A long list that starts short, with tabs for each type of change",
            "code": "<Changelog releases={releases} limit={5} filter />"
          },
          {
            "title": "A changelog page under a floating navbar",
            "code": "<Navbar floating {...navbar} />\n<main className=\"mx-auto flex max-w-7xl flex-col gap-16 px-4 pt-12\">\n  <Headline as=\"h1\" align=\"start\" title=\"Changelog\" description=\"New features, improvements, and fixes.\" />\n  <Changelog releases={releases} limit={5} filter />\n</main>"
          },
          {
            "title": "Dates in British English, with nothing floating over the page",
            "code": "<Changelog releases={releases} locale=\"en-GB\" className=\"[--changelog-offset:1rem]\" />"
          }
        ]
      }
    },
    {
      "name": "comparison",
      "type": "registry:block",
      "title": "Comparison",
      "description": "A table that compares your product with other ways of doing the job, with a dot, a ring, or a dash for included, manual, and missing.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/comparison/comparison.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "comparison",
        "table",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A landing or pricing page, to show what people get with you and what they do by hand without you.",
            "Two to four columns. Set `highlight` on your own, so it sits on a white panel.",
            "Name the jobs people do in each row, not your feature names: \"Reminders to whoever hasn't answered\".",
            "Explain what partial means in `note`."
          ],
          "avoid": [
            "Naming a competitor's product unless your claims about it are true and current. Compare ways of working instead.",
            "Prices or numbers in cells. Use a Table, or Pricing.",
            "More than about twelve rows. Keep the ones that decide the choice."
          ]
        },
        "props": [
          {
            "name": "columns",
            "type": "{ label: string; icon?: ReactNode; highlight?: boolean }[]",
            "description": "The products or ways of working, left to right. Labels must be unique, since each one is a key."
          },
          {
            "name": "rows",
            "type": "{ label: string; icon?: ReactNode; values: (\"yes\" | \"partial\" | \"no\")[] }[]",
            "description": "The jobs, top to bottom, with one value per column. A missing value reads as no. Labels must be unique."
          },
          {
            "name": "rowsLabel",
            "type": "string",
            "default": "\"Feature\"",
            "description": "The heading over the row labels."
          },
          {
            "name": "legend",
            "type": "{ yes: string; partial: string; no: string }",
            "default": "{ yes: \"Included\", partial: \"Manual work\", no: \"Not included\" }",
            "description": "The words for each mark, in the legend and for screen readers."
          },
          {
            "name": "note",
            "type": "ReactNode",
            "description": "A small line under the table."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Comparison\"",
            "description": "The name screen readers give the table's box. Give each table on a page its own."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          }
        ],
        "a11y": [
          "It is a real table, with column and row headers, so screen readers announce each cell's row and column.",
          "Each mark is aria-hidden, and its legend word sits beside it as screen reader text.",
          "The marks differ by shape as well as color: a filled dot, a ring, and a red dash.",
          "The marks use each tone's text color, which every theme must pass at 4.5:1 on muted and card.",
          "On a narrow screen the table scrolls sideways. Its box is a named region that takes focus, so the keyboard can scroll it.",
          "In forced colors the gray panel gets a border, so its edge still shows when its fill is painted away.",
          "In forced colors the dot and dash take the text color, and your highlighted column is outlined instead of sitting on a white panel."
        ],
        "examples": [
          {
            "title": "You against doing it by hand",
            "code": "<Comparison\n  rowsLabel=\"Team task\"\n  columns={[\n    { label: \"Workspace\", highlight: true },\n    { label: \"Shared table\" },\n    { label: \"Group chat\" },\n  ]}\n  rows={[\n    { label: \"Who's coming\", values: [\"yes\", \"partial\", \"partial\"] },\n    { label: \"Reminder emails\", values: [\"yes\", \"no\", \"no\"] },\n  ]}\n  note=\"Manual work means the team keeps the record itself.\"\n/>"
          }
        ]
      }
    },
    {
      "name": "dot-field",
      "type": "registry:block",
      "title": "Dot Field",
      "description": "A wide field of dithered dots, with bands of theme color that wind across it, as art behind a hero or a section title.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/dot-field/dot-field.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "art",
        "background",
        "hero",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Art for a landing page, like the band behind a hero, a section title, or a call to action.",
            "Put a short Headline inside it as children. It sits in the middle of the field.",
            "Pass `bands` to pick the colors, as theme colors like `var(--chart-1)`. Later bands draw on top.",
            "Pass `seed` for another shape. The same seed always draws the same field.",
            "Pair it with Dither, so the charts and buttons on the page share its dotted look."
          ],
          "avoid": [
            "Long text over the field. The dots behind it make it hard to read. Keep it to a short headline.",
            "Raw colors in `bands`. Use theme colors, so the field follows light and dark.",
            "Information. The field is art, so it says nothing a screen reader needs, unless you name it."
          ]
        },
        "props": [
          {
            "name": "bands",
            "type": "readonly string[]",
            "default": "[\"var(--chart-4)\", \"var(--chart-2)\"]",
            "description": "Each band's color, as a theme color. Later bands draw over earlier ones."
          },
          {
            "name": "seed",
            "type": "number",
            "default": "1",
            "description": "Picks the shape of the bands."
          },
          {
            "name": "size",
            "type": "\"sm\" | \"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "Height: 5, 8, or 12 times --control-h-md, so density scales it."
          },
          {
            "name": "aria-label",
            "type": "string",
            "description": "Names the field as an image. Left out, screen readers skip it."
          },
          {
            "name": "children",
            "type": "ReactNode",
            "description": "Content over the field, like a Headline. It sits in the middle."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. Later classes win, so className='h-64' sets the height."
          }
        ],
        "a11y": [
          "The field is decoration, hidden from screen readers. Pass aria-label to name it as an image instead.",
          "The name sits on the canvas, so a Headline in children stays a real heading.",
          "When the field first comes on screen, its dots grow in one at a time, sweeping in from the left, over 60% of --motion-pulse-duration. After that, nothing moves.",
          "With reduced motion or motion off, the pulse is 0s, so every dot shows at once. A print shows every dot."
        ],
        "examples": [
          {
            "title": "Art on its own",
            "code": "<DotField />"
          },
          {
            "title": "Behind a headline",
            "code": "<DotField size=\"lg\" bands={[\"var(--chart-1)\", \"var(--chart-2)\"]}>\n  <Headline texture=\"dither\" title=\"Signal through\" muted=\"the noise.\" />\n</DotField>"
          }
        ]
      }
    },
    {
      "name": "faq",
      "type": "registry:block",
      "title": "FAQ",
      "description": "Questions and answers in one soft gray column, each answer opening in place.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/accordion"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/faq/faq.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "faq",
        "questions",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Near the bottom of a landing or pricing page, under a Headline like \"Questions people ask.\"",
            "Five to eight questions people really ask before they buy or sign up.",
            "Write each question the way a person would ask it, and answer it in two or three sentences."
          ],
          "avoid": [
            "Long docs or setup steps. Link to a docs page from the answer instead.",
            "Sections inside an app, like settings. Use Accordion.",
            "Hiding prices, limits, or terms only in an answer. Put them on the page too."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "{ question: string; answer: ReactNode }[]",
            "description": "The questions, in the order people ask them. Each question must be unique, since it is the key."
          },
          {
            "name": "multiple",
            "type": "boolean",
            "default": "false",
            "description": "Lets more than one answer be open at once."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          }
        ],
        "a11y": [
          "Built on Accordion: each question is a button inside an h3 that says whether its answer is open.",
          "Put it under an h2, like a Headline, so the question headings sit in order.",
          "Enter and Space open and close an answer. Closed answers are hidden from screen readers.",
          "In forced colors the column gets a border, so its edge still shows when its fill is painted away."
        ],
        "examples": [
          {
            "title": "Under a headline",
            "code": "<Headline title=\"Questions people ask.\" />\n<Faq\n  items={[\n    { question: \"Can people change their answer?\", answer: \"Yes, until the answer-by date.\" },\n    { question: \"Does the calendar stay in sync?\", answer: \"Calendar apps pick up changes on their next refresh.\" },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "feature-grid",
      "type": "registry:block",
      "title": "Feature Grid",
      "description": "A grid of feature cells split by thin lines, each with a title, a description, and a picture above or below.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/feature-grid/feature-grid.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "features",
        "grid",
        "marketing",
        "illustrations"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The feature section of a landing page, under a Headline: three cells, then two wider ones.",
            "Give each cell an `illustration`. The Illustrations, like Stacked Blocks or Code Key, are made for it. Add each one by its name, like `npx shadcn@latest add @txbrendan/stacked-blocks`, since this install does not bring them.",
            "Set `span=\"half\"` on two cells for a row of two. Cells are a third wide by default.",
            "Set `illustrationFirst` when the picture should sit above the words, like a wide cell in the second row.",
            "Keep each description to two lines at 1280px. The pictures carry the rest."
          ],
          "avoid": [
            "Soft cards with gaps between them. Use Bento Grid.",
            "Screenshots as pictures. Build them from parts, so they stay sharp and follow the theme.",
            "More than six cells. Split them under two Headlines."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "FeatureGridItem. What the feature is. It is an h3."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "FeatureGridItem. What it does, in a sentence or two."
          },
          {
            "name": "illustration",
            "type": "ReactNode",
            "description": "FeatureGridItem. A picture of the feature. It fills the room the words leave."
          },
          {
            "name": "illustrationFirst",
            "type": "boolean",
            "default": "false",
            "description": "FeatureGridItem. Put the picture above the words."
          },
          {
            "name": "span",
            "type": "\"third\" | \"half\" | \"full\"",
            "default": "\"third\"",
            "description": "FeatureGridItem. How much of a row the cell takes, from 48rem of the grid's width."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. On FeatureGrid it holds the container query."
          },
          {
            "name": "FeatureGrid",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The grid: one column when narrow, six tracks from 48rem of its own width. Put FeatureGridItems inside."
          }
        ],
        "a11y": [
          "Each title is an h3, so put the grid under an h2, like a Headline.",
          "The Illustrations are each one image with a label, so screen readers hear a sentence per picture.",
          "Keep pictures free of real buttons, so nothing inside them takes focus.",
          "The lines between cells are outlines, so they still show in forced colors."
        ],
        "examples": [
          {
            "title": "Three cells, then two, after adding the five Illustrations",
            "code": "<FeatureGrid>\n  <FeatureGridItem title=\"Each part explains itself\" description=\"Notes on when to use each part.\" illustration={<StackedBlocks />} />\n  <FeatureGridItem title=\"Checked in light and dark\" description=\"Color pairs are tested for contrast.\" illustration={<ContrastOrb />} />\n  <FeatureGridItem title=\"One theme for every part\" description=\"Color, type, and motion from one set of tokens.\" illustration={<ThemeLayers />} />\n  <FeatureGridItem span=\"half\" illustrationFirst title=\"A size budget for each part\" description=\"The check fails when one goes over.\" illustration={<SpeedGauge />} />\n  <FeatureGridItem span=\"half\" illustrationFirst title=\"The code is yours\" description=\"Change anything you like.\" illustration={<CodeKey />} />\n</FeatureGrid>"
          },
          {
            "title": "Words only",
            "code": "<FeatureGrid>\n  <FeatureGridItem span=\"half\" title=\"Checked\" description=\"Color pairs are tested for contrast.\" />\n  <FeatureGridItem span=\"half\" title=\"Yours\" description=\"Plain source files you can change.\" />\n</FeatureGrid>"
          }
        ]
      }
    },
    {
      "name": "footer",
      "type": "registry:block",
      "title": "Footer",
      "description": "The bottom of a site: brand, a line about you, social links, columns of links, the legal line, and drawn ridges.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/footer/footer.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "footer",
        "navigation",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The bottom of every page on a marketing site.",
            "Two to five columns of links, each with a short title like Product or Legal.",
            "Put your copyright in `legal`, and social icon links, each with a name, in `social`.",
            "Pass your router's link through `renderLink`, like next/link.",
            "Leave `art` off to get the drawn ridges. Pass an empty element to show none."
          ],
          "avoid": [
            "The main way to get around. Put the important links in the Navbar too.",
            "More than about eight links in a column. Split it, or link to a site map.",
            "Footers inside an app's own screens. Keep app chrome in the app shell."
          ]
        },
        "props": [
          {
            "name": "brand",
            "type": "ReactNode",
            "description": "Your logo or name. It links to brandHref."
          },
          {
            "name": "brandHref",
            "type": "string",
            "default": "\"/\"",
            "description": "Where the brand links."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "A line or two about what you do, under the brand."
          },
          {
            "name": "social",
            "type": "ReactNode",
            "description": "Social links under the description. Give each one an aria-label."
          },
          {
            "name": "columns",
            "type": "{ title: string; links: { href: string; label: string }[] }[]",
            "description": "Columns of links, left to right. Titles must be unique, and so must each column's hrefs."
          },
          {
            "name": "legal",
            "type": "ReactNode",
            "description": "The bottom line, like the copyright."
          },
          {
            "name": "renderLink",
            "type": "(props: FooterLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws the brand and each link. Spread the props onto your router's link: (props) => <Link {...props} />."
          },
          {
            "name": "art",
            "type": "ReactNode",
            "default": "drawn ridges",
            "description": "Your own art in the strip along the bottom. It fills the strip."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Footer\"",
            "description": "The name screen readers give the link columns' nav."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the footer, which holds the container query."
          }
        ],
        "a11y": [
          "It renders a footer landmark, with the link columns in a nav named by `label`.",
          "Each column title is an h2, so screen readers can jump between them.",
          "The art has its own strip under the text, so no text sits on it. The drawn ridges are aria-hidden.",
          "If you pass your own `art`, give it empty alt text, since it is decoration."
        ],
        "examples": [
          {
            "title": "A site footer",
            "code": "<Footer\n  brand=\"Workspace\"\n  description=\"Plan the week together.\"\n  columns={[\n    { title: \"Product\", links: [{ href: \"/features\", label: \"Features\" }, { href: \"/pricing\", label: \"Pricing\" }] },\n    { title: \"Legal\", links: [{ href: \"/privacy\", label: \"Privacy\" }, { href: \"/terms\", label: \"Terms\" }] },\n  ]}\n  legal=\"© 2026 Workspace\"\n  renderLink={(props) => <Link {...props} />}\n/>"
          }
        ]
      }
    },
    {
      "name": "headline",
      "type": "registry:block",
      "title": "Headline",
      "description": "A section's big two-tone headline: the claim in full ink, then the rest in gray.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/dither"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/headline/headline.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "heading",
        "section",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Above each section of a landing page, like a bento grid, pricing, or a FAQ.",
            "Put the claim in `title` and the payoff in `muted`, so the eye reads the claim first.",
            "Keep it to one sentence that fits on two lines at 1280px.",
            "Use `as=\"h1\"` only when it is the top of the page and there is no Hero.",
            "Pass texture=\"dither\" for a short, loud title, like two or three words over a dark section.",
            "The headline is set in the theme's `font-headline`. Load Geist Serif as `--font-geist-serif`, like `geist/font/serif/variable` in Next.js, or it falls back to the system serif."
          ],
          "avoid": [
            "The top of a page with a product preview. Use Hero, which has the bigger size.",
            "Headings inside an app's own screens. Use Section Header.",
            "A long paragraph in `description`. Keep it to two lines, and put the rest in the section."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "The words that land first, in full ink."
          },
          {
            "name": "muted",
            "type": "ReactNode",
            "description": "The words that follow in gray, on the same line."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "A line or two under the headline."
          },
          {
            "name": "align",
            "type": "\"center\" | \"start\"",
            "default": "\"center\"",
            "description": "Where the text sits."
          },
          {
            "name": "as",
            "type": "\"h1\" | \"h2\" | \"h3\"",
            "default": "\"h2\"",
            "description": "The heading level. A page has one h1, usually the hero's."
          },
          {
            "name": "size",
            "type": "\"md\" | \"lg\"",
            "default": "\"md\"",
            "description": "\"lg\" sets the title a step bigger, from 36 to 48px, for a page's own title over its section headlines."
          },
          {
            "name": "texture",
            "type": "\"flat\" | \"dither\"",
            "default": "\"flat\"",
            "description": "\"dither\" draws the title as dots in the first chart color, with a glow on dark pages. Only this prop turns it on, never a Dither around it."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          }
        ],
        "a11y": [
          "It renders a real heading, h2 by default. Pick `as` so the page's heading levels go in order.",
          "The gray part is inside the same heading, so screen readers hear one sentence.",
          "The gray is muted-foreground, which the theme checks at 4.5:1 on the page.",
          "A dithered title keeps its text, so screen readers hear the same sentence. Its dots are the first chart color, which the theme checks at 3:1 on the page, and only a big title should use it."
        ],
        "examples": [
          {
            "title": "Above a section",
            "code": "<Headline title=\"Start with your people,\" muted=\"then organize the week.\" />"
          },
          {
            "title": "A page's own title, a step bigger than its section headlines",
            "code": "<Headline as=\"h1\" size=\"lg\" align=\"start\" title=\"The blog.\" muted=\"What we ship, and how we work.\" />"
          },
          {
            "title": "Left-aligned, with a line under it",
            "code": "<Headline\n  align=\"start\"\n  title=\"Questions people ask.\"\n  description=\"Can't find yours? Email us and we answer within a day.\"\n/>"
          }
        ]
      }
    },
    {
      "name": "hero",
      "type": "registry:block",
      "title": "Hero",
      "description": "The top of a landing page: a big claim, a lead with a call to action, customer logos, and a framed product preview.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/hero/hero.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/website/hero/hero-window.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "hero",
        "header",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The first section of a landing page, right under the Navbar.",
            "Split the claim: the setup in `title`, the payoff in `accent`, which draws in the brand color.",
            "Put one main call to action in `actions`, like a brand Button. Add a quiet second one at most.",
            "Show the product in `preview` with HeroWindow, and build the screen inside it from real parts.",
            "Leave `art` off to get the drawn pattern. Pass your own image there only if it is yours to use.",
            "The headline is set in the theme's `font-headline`. Load Geist Serif as `--font-geist-serif`, like `geist/font/serif/variable` in Next.js, or it falls back to the system serif."
          ],
          "avoid": [
            "More than one Hero on a page. It renders the page's h1.",
            "A screenshot as the preview. Build it from parts, so it stays sharp and follows the theme.",
            "A headline for a lower section. Use Headline."
          ]
        },
        "props": [
          {
            "name": "title",
            "type": "ReactNode",
            "description": "The page's one big claim, as the h1. 36 to 48px, growing with the hero's width."
          },
          {
            "name": "accent",
            "type": "ReactNode",
            "description": "The end of the claim, in the brand color, on the same line."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "The lead, in a sentence or three. It sits to the right on wide screens."
          },
          {
            "name": "note",
            "type": "ReactNode",
            "description": "A small line under the lead, like a log in link."
          },
          {
            "name": "actions",
            "type": "ReactNode",
            "description": "The call to action. It sits at the end of the lead, aligned right."
          },
          {
            "name": "logos",
            "type": "ReactNode",
            "description": "Customer logos under the claim, shown in gray. Give each one alt text."
          },
          {
            "name": "logosLabel",
            "type": "string",
            "default": "\"Trusted by\"",
            "description": "The small label over the logos."
          },
          {
            "name": "preview",
            "type": "ReactNode",
            "description": "A product preview under the text, on drawn art. Usually a HeroWindow."
          },
          {
            "name": "art",
            "type": "ReactNode",
            "default": "a brand-colored pattern",
            "description": "Your own art behind the preview, like an <img> that fills the frame."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the section, which holds the container query."
          },
          {
            "name": "HeroWindow",
            "type": "{ title: ReactNode; subtitle?: ReactNode; icon?: ReactNode; badge?: ReactNode } & ComponentProps<\"div\">",
            "description": "Part. A framed app window for the preview: a top bar with the app's name, a dark badge, and your mock screen as children."
          }
        ],
        "a11y": [
          "The title is the page's h1, and the accent is inside it, so screen readers hear one sentence.",
          "The accent is large text in the brand fill color. The theme checks that fill at 3:1 on the page, which is the WCAG level for large text.",
          "The drawn art is aria-hidden. If you pass your own `art`, give it empty alt text, since it is decoration.",
          "Logos are shown in gray. Give each one alt text with the customer's name.",
          "A HeroWindow is a picture of the product. Keep its mock screen free of real buttons, or label them as a preview.",
          "In forced colors the preview panel gets a border, so its edge still shows when its fill is painted away."
        ],
        "examples": [
          {
            "title": "A landing page hero",
            "code": "<Hero\n  title=\"The app your team\"\n  accent=\"is missing.\"\n  description=\"One app the whole team is in.\"\n  actions={<Button tone=\"brand\" size=\"lg\">Get early access</Button>}\n  preview={\n    <HeroWindow title=\"Workspace\" subtitle=\"Reports\" badge=\"Overview\">\n      <StatsCards items={[{ label: \"Active\", value: \"1,204\" }]} />\n    </HeroWindow>\n  }\n/>"
          },
          {
            "title": "Text only",
            "code": "<Hero title=\"Ship the page,\" accent=\"not the plumbing.\" description=\"Every section is a part you own.\" />"
          }
        ]
      }
    },
    {
      "name": "logo-marquee",
      "type": "registry:block",
      "title": "Logo Marquee",
      "description": "A \"Used by teams at\" row of logos that scrolls slowly in a loop and stands still for reduced motion.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/logo-marquee/logo-marquee.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "logos",
        "social proof",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Under a Hero, to show who already uses the product.",
            "Six to twelve logos. Fewer than six leaves the loop looking thin.",
            "Draw each logo in currentColor, so they all take the same muted gray and follow the theme.",
            "Give each logo the company's `name`. Screen readers say it, and it is the key.",
            "Give a logo an `href` when you have a case study. Pass your router's link through `renderLink`.",
            "Slow it down or speed it up with className=\"[--logo-marquee-duration:60s]\"."
          ],
          "avoid": [
            "Logos you have no right to show. Ask each customer first.",
            "Two marquees stacked on one page. One moving row is enough.",
            "Content people must read or press, like plans or news. Use a static grid, or Bento Grid.",
            "Fewer than four logos. Put them in a plain row instead."
          ]
        },
        "props": [
          {
            "name": "logos",
            "type": "{ name: string; logo: ReactNode; href?: string }[]",
            "description": "The logos, in order. Each name must be unique, since it is the key. An SVG or <img> as the direct child of `logo` fills its height: 28px, or 32px from 48rem."
          },
          {
            "name": "label",
            "type": "ReactNode",
            "default": "\"Used by teams at\"",
            "description": "The line above the logos."
          },
          {
            "name": "pauseButton",
            "type": "boolean",
            "default": "true",
            "description": "Shows a small button by the label that stops and starts the row. It hides when the row stands still."
          },
          {
            "name": "renderLink",
            "type": "(props: LogoMarqueeLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws each logo's link. Spread the props onto your router's link: (props) => <Link {...props} />."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query. Set --logo-marquee-duration here to change the speed."
          }
        ],
        "a11y": [
          "The logos are a list. Each one is named by its `name`, and the drawn logo is hidden from screen readers.",
          "The row repeats once to fill the loop. The copy is aria-hidden and inert, so screen readers and Tab meet each logo once.",
          "The row stops while the pointer is over it, or while focus is inside it. Tab to a linked logo and the row goes back to its start with no fade, then scrolls to show that logo whole.",
          "A pause button by the label stops it for good, on touch screens too. It says it is pressed with aria-pressed.",
          "With prefers-reduced-motion, or data-motion=\"off\", the row stands still and wraps, so every logo shows. The pause button and the edge fade go away then.",
          "In forced colors the edge fade goes away, so a logo at either edge is cut off instead of faded."
        ],
        "examples": [
          {
            "title": "Under a hero",
            "code": "<LogoMarquee\n  logos={[\n    { name: \"Studio\", logo: <img src=\"/logos/studio.svg\" alt=\"\" /> },\n    { name: \"Clinic\", logo: <img src=\"/logos/clinic.svg\" alt=\"\" /> },\n    { name: \"Library\", logo: <img src=\"/logos/library.svg\" alt=\"\" /> },\n    { name: \"Bakery\", logo: <img src=\"/logos/bakery.svg\" alt=\"\" /> },\n    { name: \"Agency\", logo: <img src=\"/logos/agency.svg\" alt=\"\" /> },\n    { name: \"Workshop\", logo: <img src=\"/logos/workshop.svg\" alt=\"\" /> },\n  ]}\n/>"
          },
          {
            "title": "Links to case studies, slower",
            "code": "<LogoMarquee\n  label=\"Read how they use it\"\n  className=\"[--logo-marquee-duration:60s]\"\n  logos={[\n    { name: \"Studio\", logo: <img src=\"/logos/studio.svg\" alt=\"\" />, href: \"/customers/studio\" },\n    { name: \"Clinic\", logo: <img src=\"/logos/clinic.svg\" alt=\"\" />, href: \"/customers/clinic\" },\n  ]}\n  renderLink={(props) => <Link {...props} />}\n/>"
          }
        ]
      }
    },
    {
      "name": "morph-navbar",
      "type": "registry:block",
      "title": "Morph Navbar",
      "description": "A navbar whose menus share one floating box. Moving from menu to menu, the box glides to the new menu and changes size to fit it, while the links slide across.",
      "dependencies": [
        "@base-ui/react@^1.9.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/accordion",
        "@txbrendan/button",
        "@txbrendan/glide-highlight"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/morph-navbar/morph-navbar.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "navigation",
        "header",
        "marketing"
      ],
      "meta": {
        "status": "stable",
        "usage": {
          "use": [
            "The top of a product site with three to five menus, where people move along the bar to compare them.",
            "Give each item a short `description` when its name alone does not say enough.",
            "Give each item in a menu an `icon` to help people scan it. Give every item in that menu one, or none.",
            "Give each item in one menu a `preview`, a small picture drawn in code, to show the page before people open it. Hovering or focusing an item switches the picture.",
            "Mark the page you are on with `current: true`.",
            "Pass your router's link through `renderLink`, like next/link, so links stay client-side.",
            "Put a quiet button and a main call to action in `actions`. On narrow screens they move to the end of the menu.",
            "Set `collapseBelow` to the smallest width your links and actions fit in."
          ],
          "avoid": [
            "One or two plain menus. Use Navbar, whose menus open small lists under each link.",
            "Pictures in more than one menu. One menu with pictures draws the eye. More of them crowd the bar.",
            "More than about eight items in one menu. Split it, or link to a page that lists them all.",
            "A menu inside a menu. Each menu is one level of links, and on narrow screens one level of sections.",
            "Links inside an app's own screens. Use Tabs, or a sidebar."
          ]
        },
        "props": [
          {
            "name": "brand",
            "type": "ReactNode",
            "description": "Your logo or name. It links to brandHref."
          },
          {
            "name": "brandHref",
            "type": "string",
            "default": "\"/\"",
            "description": "Where the brand links."
          },
          {
            "name": "links",
            "type": "(MorphNavbarLink | MorphNavbarMenu)[]",
            "description": "The main links, in order. Each href and each menu label must be unique, since they are keys."
          },
          {
            "name": "MorphNavbarLink",
            "type": "{ href: string; label: string; current?: boolean }",
            "description": "Type. A link in the bar. current marks the page you are on, with aria-current=\"page\"."
          },
          {
            "name": "MorphNavbarMenu",
            "type": "{ label: string; items: { href: string; label: string; description?: ReactNode; icon?: ReactNode; preview?: ReactNode; current?: boolean }[] }",
            "description": "Type. A menu in the bar. It opens on hover after a short pause, or on a click or Enter. An item's description shows under its label, and its icon before the label, in the muted color until the row is hovered or focused. When any item has a preview, the menu shows the picture of the row under the pointer or focus beside the list."
          },
          {
            "name": "actions",
            "type": "ReactNode",
            "description": "Buttons on the right, like Talk to sales and Create an account. On narrow screens they sit at the end of the menu."
          },
          {
            "name": "renderLink",
            "type": "(props: MorphNavbarLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws the brand and each link. Spread every prop onto your router's link: (props) => <Link {...props} />. It is a function, so pass it from a client component, not a server page."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Main\"",
            "description": "The name screen readers give the nav."
          },
          {
            "name": "menuLabel",
            "type": "string",
            "default": "\"Menu\"",
            "description": "The name of the menu button on narrow screens."
          },
          {
            "name": "collapseBelow",
            "type": "\"sm\" | \"md\" | \"lg\" | \"xl\"",
            "default": "\"lg\"",
            "description": "The navbar's own width below which links fold into the menu: 36, 42, 48, or 56rem. Pick the smallest your links and actions fit in."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the header, which holds the container query. Add sticky top-0 z-40 here to pin it."
          }
        ],
        "a11y": [
          "The links sit in a nav landmark named by `label`. Only one copy of the links is on screen at a time, so screen readers hear one nav.",
          "A menu's trigger is a button with aria-expanded. Enter, Space, Arrow Down, or a click opens it. Tab moves into the open menu, and the left and right arrow keys move along the bar.",
          "Hover opens a menu too, but never only hover.",
          "Escape closes the menu and puts focus back on its trigger. Picking a link, by click or Enter, closes it too.",
          "The current link has aria-current=\"page\".",
          "Pictures and icons are hidden from screen readers. Each link's label and description say where it goes.",
          "With reduced motion the menus fade from one to the next without sliding.",
          "On narrow screens a menu button opens the links, and says whether it is open with aria-expanded. Each menu is a section that opens in place. Escape or a press outside closes it.",
          "Links in the narrow menu are at least 44px tall for touch.",
          "On narrow screens `actions` render a second time, inside the menu. Do not give them an id."
        ],
        "examples": [
          {
            "title": "Menus that share one box",
            "code": "<MorphNavbar\n  brand=\"Workspace\"\n  links={[\n    {\n      label: \"Product\",\n      items: [\n        { href: \"/chat\", label: \"Chat\", description: \"Ask questions and get answers from your files.\" },\n        { href: \"/docs\", label: \"Docs\", description: \"Write, share, and comment on pages together.\" },\n      ],\n    },\n    {\n      label: \"Company\",\n      items: [\n        { href: \"/about\", label: \"About\" },\n        { href: \"/careers\", label: \"Careers\" },\n      ],\n    },\n    { href: \"/pricing\", label: \"Pricing\" },\n  ]}\n  actions={<Button size=\"sm\">Create an account</Button>}\n/>"
          },
          {
            "title": "A menu that shows a picture of each page",
            "code": "<MorphNavbar\n  brand=\"Workspace\"\n  links={[\n    {\n      label: \"Product\",\n      items: [\n        { href: \"/chat\", label: \"Chat\", description: \"Ask questions.\", preview: <ChatPicture /> },\n        { href: \"/boards\", label: \"Boards\", description: \"Track work in columns.\", preview: <BoardsPicture /> },\n      ],\n    },\n  ]}\n/>"
          },
          {
            "title": "A menu with an icon on each item",
            "code": "<MorphNavbar\n  brand=\"Workspace\"\n  links={[\n    {\n      label: \"Developers\",\n      items: [\n        { href: \"/api\", label: \"API\", icon: <Braces />, description: \"Build on your workspace's data.\" },\n        { href: \"/guides\", label: \"Guides\", icon: <BookOpen />, description: \"Step-by-step help for common tasks.\" },\n        { href: \"/status\", label: \"Status\", icon: <Activity />, description: \"Uptime and incidents, live.\" },\n      ],\n    },\n  ]}\n/>"
          },
          {
            "title": "With next/link, pinned to the top",
            "code": "<MorphNavbar\n  className=\"sticky top-0 z-40 border-b\"\n  brand=\"Workspace\"\n  links={[{ href: \"/\", label: \"Home\", current: true }, { href: \"/blog\", label: \"Blog\" }]}\n  renderLink={(props) => <Link {...props} />}\n/>"
          }
        ]
      }
    },
    {
      "name": "navbar",
      "type": "registry:block",
      "title": "Navbar",
      "description": "The bar across the top of a site: brand, links, and actions, with a menu on narrow screens.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button",
        "@txbrendan/popover"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/navbar/navbar.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "navigation",
        "header",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The top of a marketing site, docs site, or app, with up to six main links.",
            "Group related pages into a menu, with `items`, like Product or Who it's for. Give its items a short `description` when the names alone don't say enough.",
            "Mark the page you are on with `current: true`, so it gets a soft fill and aria-current.",
            "Pass your router's link through `renderLink`, like next/link, so links stay client-side.",
            "Put a sign-in and a main call to action in `actions`. On narrow screens they move into the menu.",
            "Set `collapseBelow` to the smallest width your links and actions fit in. More links need a bigger one.",
            "Set `floating` on a landing page, so the bar stays in reach as a pill while people scroll."
          ],
          "avoid": [
            "More than six entries in the bar. Group the rest into a menu, or move them into the footer.",
            "A menu with one item, or more than about eight. Link straight to the one page, or split the list.",
            "A menu inside a menu. On narrow screens each menu is a heading with its links under it, one level deep.",
            "Links inside an app's own screens, like settings sections. Use Tabs, or a sidebar.",
            "A trail of where you are. Use Breadcrumb."
          ]
        },
        "props": [
          {
            "name": "brand",
            "type": "ReactNode",
            "description": "Your logo or name. It links to brandHref."
          },
          {
            "name": "brandHref",
            "type": "string",
            "default": "\"/\"",
            "description": "Where the brand links."
          },
          {
            "name": "links",
            "type": "({ href: string; label: string; current?: boolean } | NavbarMenu)[]",
            "description": "The main links, in order. Each href must be unique, since it is the key. A menu, with `items`, opens a list of links instead."
          },
          {
            "name": "NavbarMenu",
            "type": "{ label: string; items: { href: string; label: string; description?: ReactNode; icon?: ReactNode; current?: boolean }[] }",
            "description": "Type. A menu in the bar. It opens on hover after a short pause, or on a click or Enter. Items with a description show it under the label, two columns from four items. An icon needs a description. Labels must be unique."
          },
          {
            "name": "actions",
            "type": "ReactNode",
            "description": "Buttons on the right, like Sign in and Create a team. On narrow screens they sit at the bottom of the menu."
          },
          {
            "name": "renderLink",
            "type": "(props: NavbarLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws the brand and each link. Spread the props onto your router's link: (props) => <Link {...props} />."
          },
          {
            "name": "label",
            "type": "string",
            "default": "\"Main\"",
            "description": "The name screen readers give the nav."
          },
          {
            "name": "menuLabel",
            "type": "string",
            "default": "\"Menu\"",
            "description": "The name of the menu button on narrow screens."
          },
          {
            "name": "collapseBelow",
            "type": "\"sm\" | \"md\" | \"lg\" | \"xl\"",
            "default": "\"lg\"",
            "description": "The navbar's own width below which links fold into the menu: 36, 42, 48, or 56rem. Pick the smallest your links and actions fit in."
          },
          {
            "name": "floating",
            "type": "boolean",
            "default": "false",
            "description": "Pins the navbar to the top. Once the page scrolls, the bar lifts into a rounded pill with a shadow, and the menu opens as a rounded panel."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the header, which holds the container query. Add sticky top-0 z-40 here to pin it without the pill."
          }
        ],
        "a11y": [
          "The links sit in a nav landmark named by `label`. Only one copy of the links is on screen at a time, so screen readers hear one nav.",
          "The current link has aria-current=\"page\".",
          "On narrow screens a menu button opens the links. It says whether it is open with aria-expanded.",
          "Escape closes the menu and puts focus back on its button. A press outside the bar closes it too.",
          "Links in the menu are at least 44px tall for touch.",
          "On narrow screens `actions` render a second time, inside the menu. Do not give them an id.",
          "A menu's trigger is a button with aria-expanded. Its list opens as a non-modal popup named by the menu's label, so Tab moves on past it and closes it.",
          "Escape closes a menu and puts focus back on its trigger. Picking an item closes it too.",
          "The trigger also opens on hover, but never only on hover: a click, Enter, or Space opens it as well.",
          "In the narrow menu each menu's links sit in a list named by its heading, so nothing nests."
        ],
        "examples": [
          {
            "title": "A marketing site",
            "code": "<Navbar\n  brand={<span className=\"font-semibold\">Workspace</span>}\n  links={[\n    { href: \"/features\", label: \"Features\" },\n    { href: \"/pricing\", label: \"Pricing\", current: true },\n    { href: \"/docs\", label: \"Docs\" },\n  ]}\n  actions={\n    <>\n      <Button emphasis=\"ghost\" size=\"sm\">Sign in</Button>\n      <Button tone=\"brand\" size=\"sm\">Create a team</Button>\n    </>\n  }\n/>"
          },
          {
            "title": "A floating pill on a landing page",
            "code": "<Navbar\n  floating\n  brand=\"Workspace\"\n  links={[{ href: \"/features\", label: \"Features\" }, { href: \"/pricing\", label: \"Pricing\" }]}\n  actions={<Button tone=\"brand\" emphasis=\"outline\" size=\"sm\">Request access</Button>}\n/>"
          },
          {
            "title": "With menus",
            "code": "<Navbar\n  brand=\"Workspace\"\n  links={[\n    {\n      label: \"Product\",\n      items: [\n        { href: \"/plan\", label: \"Planning\", description: \"Set up the week, with who is coming.\" },\n        { href: \"/agendas\", label: \"Agendas\", description: \"Run saved agendas and track goals.\" },\n      ],\n    },\n    { href: \"/pricing\", label: \"Pricing\" },\n    { label: \"More\", items: [{ href: \"/blog\", label: \"Blog\" }, { href: \"/about\", label: \"About\" }] },\n  ]}\n/>"
          },
          {
            "title": "With next/link, pinned to the top",
            "code": "<Navbar\n  className=\"sticky top-0 z-40 border-b\"\n  brand=\"Workspace\"\n  links={[{ href: \"/\", label: \"Home\", current: true }, { href: \"/blog\", label: \"Blog\" }]}\n  renderLink={(props) => <Link {...props} />}\n/>"
          }
        ]
      }
    },
    {
      "name": "post-grid",
      "type": "registry:block",
      "title": "Post Grid",
      "description": "A grid of blog posts, each one link with a cover, a tag, a title, an excerpt, a date, and its author, with an optional row of tags to narrow them.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/button",
        "@txbrendan/panel-switch",
        "@txbrendan/tabs"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/post-grid/post-grid.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "blog",
        "posts",
        "articles",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A blog's index page: every post, newest first, with `featured` to draw the newest wide across the top and `filter` to narrow by tag.",
            "The end of a post: three more posts, no filter, under a Headline like \"More posts\".",
            "Give each post a `cover`, like an <img>. Without one it gets a soft field of dots in its `tone`, so the grid still looks finished.",
            "On the post's own page, pass `<PostGridCover post={post} />` to Article's `cover`, so the reader sees the same cover they pressed.",
            "Pass your router's link through `renderLink`, like next/link, so posts open client-side.",
            "Set `headingLevel` to 2 when the grid sits right under the page's h1, and keep 3 under an h2.",
            "Control the tag with `value` and `onValueChange` when the address holds it, like /blog?tag=design."
          ],
          "avoid": [
            "Feature sections. Bento Grid shows what a product does, with live previews, and its cards need no date or author. Post Grid lists dated posts by people.",
            "Quotes from customers. Use Testimonial Wall.",
            "Release notes by version. Use a changelog.",
            "More than one tag per post. The filter shows one tag per post.",
            "Hundreds of posts on one page. Split them into pages with Pagination under the grid."
          ]
        },
        "props": [
          {
            "name": "posts",
            "type": "Post[]",
            "description": "The posts, newest first. Each href must be unique, since it is the key. A post with a tag joins the filter under that tag."
          },
          {
            "name": "Post",
            "type": "{ href: string; title: ReactNode; excerpt?: ReactNode; tag?: string; date: string; readingTime?: string; author?: { name: string; avatar?: string }; cover?: ReactNode; tone?: \"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\" }",
            "description": "Type. One post. `date` is an ISO date, like \"2026-03-04\", shown as \"Mar 4, 2026\". `readingTime` is words, like \"6 min read\". `tone` colors the default cover, default \"neutral\"."
          },
          {
            "name": "featured",
            "type": "boolean",
            "default": "false",
            "description": "Draws the first post wide across the top, with its cover beside the words from 48rem, while every post shows."
          },
          {
            "name": "filter",
            "type": "boolean",
            "default": "false",
            "description": "Shows a row of tags over the grid, All first, then each tag in the order the posts bring them. The grid slides to the picked tag's posts."
          },
          {
            "name": "value",
            "type": "string | null",
            "description": "The tag shown, or null for every post. Pass it to control the filter."
          },
          {
            "name": "defaultValue",
            "type": "string | null",
            "default": "null",
            "description": "The tag shown first, when the filter is not controlled."
          },
          {
            "name": "onValueChange",
            "type": "(value: string | null) => void",
            "description": "Called with the tag picked, or null for All."
          },
          {
            "name": "renderLink",
            "type": "(props: PostGridLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws each post's link. Spread the props onto your router's link: (props) => <Link {...props} />."
          },
          {
            "name": "headingLevel",
            "type": "2 | 3 | 4",
            "default": "3",
            "description": "The level of each post's title, so it fits under the heading above it."
          },
          {
            "name": "locale",
            "type": "string",
            "default": "\"en-US\"",
            "description": "The language the dates are written in."
          },
          {
            "name": "allLabel",
            "type": "string",
            "default": "\"All\"",
            "description": "The first tag, which shows every post."
          },
          {
            "name": "filterLabel",
            "type": "string",
            "default": "\"Filter posts by tag\"",
            "description": "The name screen readers give the row of tags."
          },
          {
            "name": "emptyLabel",
            "type": "ReactNode",
            "default": "\"No posts yet.\"",
            "description": "What shows when no post is left to show."
          },
          {
            "name": "showAllLabel",
            "type": "string",
            "default": "\"Show all\"",
            "description": "The button under the empty line while a tag is picked. It goes back to every post."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          }
        ],
        "a11y": [
          "The posts are a list, and each one is an article with its title as a heading at `headingLevel`.",
          "Each post is one link: the title, stretched over the whole post. Screen readers hear one link per post, named by its title.",
          "A focused post outlines the whole card.",
          "The date is a <time> with the ISO date, and the avatar is hidden, since the name is written beside it.",
          "The default cover is hidden from screen readers. Give an <img> cover an empty alt, since the title already says what the post is.",
          "The row of tags is a tablist named by `filterLabel`. The arrow keys move between tags, and the grid is its panel.",
          "While the grid slides, the posts leaving are inert, so nothing in them takes focus or a press.",
          "For a moment after a switch, a press on a post that just slid in under the pointer opens nothing, so a double click never opens a post by mistake.",
          "In forced colors each cover gets a border, so its edge still shows when its fill is painted away."
        ],
        "examples": [
          {
            "title": "The same cover on the post's own page",
            "code": "<Article\n  title=\"Shared folders are here\"\n  date=\"2026-10-28\"\n  tag=\"Product\"\n  cover={<PostGridCover post={{ href: \"/blog/shared-folders\", tag: \"Product\", tone: \"brand\" }} />}\n>\n  <p>Put files in one folder that the whole team can open.</p>\n</Article>"
          },
          {
            "title": "A blog index with a featured post and a tag filter",
            "code": "<PostGrid\n  featured\n  filter\n  headingLevel={2}\n  posts={[\n    {\n      href: \"/blog/shared-folders\",\n      title: \"Shared folders are here\",\n      excerpt: \"Put files in one folder that the whole team can open.\",\n      tag: \"Product\",\n      date: \"2026-10-28\",\n      readingTime: \"4 min read\",\n      author: { name: \"Priya Raman\" },\n      tone: \"brand\",\n    },\n    {\n      href: \"/blog/review-designs\",\n      title: \"How we review designs each week\",\n      tag: \"Design\",\n      date: \"2026-10-21\",\n      author: { name: \"Elin Sato\", avatar: \"/people/elin.jpg\" },\n      cover: <img src=\"/covers/review.jpg\" alt=\"\" />,\n    },\n  ]}\n/>"
          },
          {
            "title": "More posts at the end of a post, with next/link",
            "code": "<PostGrid\n  posts={morePosts.slice(0, 3)}\n  renderLink={(props) => <Link {...props} />}\n/>"
          },
          {
            "title": "A filter that follows the page's address",
            "code": "const [tag, setTag] = useState<string | null>(searchParams.get(\"tag\"));\n\n<PostGrid\n  filter\n  posts={posts}\n  value={tag}\n  onValueChange={(next) => {\n    setTag(next);\n    router.replace(next ? `/blog?tag=${encodeURIComponent(next)}` : \"/blog\", { scroll: false });\n  }}\n/>"
          },
          {
            "title": "Dates in German",
            "code": "<PostGrid posts={posts} locale=\"de-DE\" allLabel=\"Alle\" filter filterLabel=\"Beiträge nach Thema filtern\" />"
          }
        ]
      }
    },
    {
      "name": "pricing",
      "type": "registry:block",
      "title": "Pricing",
      "description": "A row of plan cards, light and dark, with mono labels, label and value rows, what is included, and a call to action.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/choice-chips",
        "@txbrendan/morph-label",
        "@txbrendan/rolling-number"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/pricing/pricing.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "pricing",
        "plans",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A pricing page, or the closing section of a landing page, under a Headline.",
            "Two to four plans. Make the one you want people to pick solid, the dark card, and the rest soft.",
            "Use `details` for short facts, like seats or projects, and `features` for what is included.",
            "Put one Button in each card's `action`. It fills the card's width.",
            "A contact card works too: leave out the price, and add a `description` and a `footnote` with your email.",
            "For monthly and yearly prices, pass `billing` to Pricing, and give each card's `price` and `period` one entry per period id. A plain string stays the same on every period.",
            "Put a saving, like \"Save 20% yearly\", in `billing.note`, or in the yearly `period`, like \"a month, billed yearly\". Keep the switch's labels to the period's name.",
            "The headline is set in the theme's `font-headline`. Load Geist Serif as `--font-geist-serif`, like `geist/font/serif/variable` in Next.js, or it falls back to the system serif."
          ],
          "avoid": [
            "More than four plans. People stop comparing past that. Link to a full table, or use Comparison.",
            "Prices that change by region or seat count in the card itself. Show the starting price, and explain the rest.",
            "A plan comparison of many features. Use Comparison.",
            "A billing switch with more than three periods, or with one. Show a single period as a plain price."
          ]
        },
        "props": [
          {
            "name": "eyebrow",
            "type": "string",
            "description": "PricingCard. A small mono label at the top left."
          },
          {
            "name": "tag",
            "type": "string",
            "description": "PricingCard. A small mono label at the top right."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "PricingCard. The plan's name, large. It is an h3."
          },
          {
            "name": "price",
            "type": "string | Record<string, string>",
            "description": "PricingCard. The price, if you show one. Under a billing switch, one entry per period id: { monthly: \"$24\", yearly: \"$240\" }."
          },
          {
            "name": "period",
            "type": "string | Record<string, string>",
            "description": "PricingCard. What the price covers, after it: once, a year. Under a billing switch, it can be one entry per period id too."
          },
          {
            "name": "details",
            "type": "{ label: string; value: string }[]",
            "description": "PricingCard. Short facts as rows under a dashed line, values in the brand color. Labels must be unique."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "PricingCard. A paragraph, like why the plan exists."
          },
          {
            "name": "features",
            "type": "string[]",
            "description": "PricingCard. What is included, one line each, with a plus. Each must be unique."
          },
          {
            "name": "action",
            "type": "ReactNode",
            "description": "PricingCard. The call to action, like a Button. It fills the card's width."
          },
          {
            "name": "footnote",
            "type": "ReactNode",
            "description": "PricingCard. A small line under the action."
          },
          {
            "name": "emphasis",
            "type": "\"solid\" | \"soft\"",
            "default": "\"solid\"",
            "description": "PricingCard. solid is a dark card that stands out, in both modes. soft is a light gray one."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root. On Pricing it holds the container query."
          },
          {
            "name": "Pricing",
            "type": "ComponentProps<\"div\">",
            "description": "Part. The row: cards stack when narrow, and sit side by side at equal widths from 42rem of its own width."
          },
          {
            "name": "billing",
            "type": "{ options: { id: string; label: string }[]; defaultValue?: string; value?: string; onValueChange?: (value: string) => void; label?: string; note?: ReactNode; announce?: (option: { id: string; label: string }) => string }",
            "description": "Pricing. A switch above the cards between billing periods. It starts on defaultValue, or the first option. label names it for screen readers, default \"Billing\". note is a short line beside it. announce returns what screen readers hear after a change, default \"Showing yearly prices\" from the option's label; pass it for another language."
          }
        ],
        "a11y": [
          "Each plan name is an h3, so put the row under an h2, like a Headline.",
          "Details are a description list, so screen readers pair each label with its value.",
          "A solid card uses the theme's own dark colors, so every pair in it is one the theme checks in dark mode.",
          "In forced colors a soft card gets a border, so its edge still shows when its fill is painted away.",
          "Brand values on the soft card use tone text on muted, which the theme checks at 4.5:1.",
          "Plus marks are aria-hidden. Each feature reads as a plain list item.",
          "The billing switch is a radio group named by its label, so screen readers say which period is on. The arrow keys move between periods.",
          "A period is always picked: pressing the picked one keeps it.",
          "The prices change in place. A hidden polite status beside the switch then says so, like \"Showing yearly prices\". It is silent on page load, and speaks after a press, an arrow key, or a controlled value change.",
          "The card keeps room for the longest period, so it does not jump when the period changes. The other periods' prices are hidden from screen readers."
        ],
        "examples": [
          {
            "title": "Two plans",
            "code": "<Pricing>\n  <PricingCard emphasis=\"soft\" eyebrow=\"Yearly\" title=\"All-Access\" price=\"$129\" period=\"a year\"\n    features={[\"Every block and template\"]} action={<Button emphasis=\"outline\">Choose yearly</Button>} />\n  <PricingCard eyebrow=\"Lifetime\" tag=\"Best value\" title=\"All-Access\" price=\"$199\" period=\"once\"\n    features={[\"Every block and template\", \"Updates forever\"]} action={<Button tone=\"brand\">Choose lifetime</Button>} />\n</Pricing>"
          },
          {
            "title": "Monthly or yearly",
            "code": "<Pricing\n  billing={{\n    options: [{ id: \"monthly\", label: \"Monthly\" }, { id: \"yearly\", label: \"Yearly\" }],\n    note: \"Save 20% yearly\",\n  }}\n>\n  <PricingCard emphasis=\"soft\" title=\"Starter\" price=\"$0\" period=\"forever\" action={<Button emphasis=\"outline\">Start free</Button>} />\n  <PricingCard\n    title=\"Team\"\n    price={{ monthly: \"$24\", yearly: \"$19\" }}\n    period={{ monthly: \"a month\", yearly: \"a month, billed yearly\" }}\n    action={<Button tone=\"brand\">Start a trial</Button>}\n  />\n</Pricing>"
          }
        ]
      }
    },
    {
      "name": "stat-band",
      "type": "registry:block",
      "title": "Stat Band",
      "description": "A row of two to four big figures, each with a short label and an optional note, for an About page or a landing page.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/rolling-number",
        "@txbrendan/motion-timing",
        "@txbrendan/glide-highlight"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/stat-band/stat-band.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "stats",
        "numbers",
        "marketing",
        "about"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "Facts about a company or product on a marketing page, like people, offices, and the year it started. Put a Headline above it.",
            "Write each `value` as it should read, like \"40\", \"1,800\", \"$4.2M\", or \"24/7\". Its digits roll in once, when the band scrolls into view. Text with no digits just shows.",
            "Keep each `label` to a few words, and the `note` to one short line.",
            "Two figures sit side by side at any width. Three stack until there is room for a row. Four go two by two, then four in a row from 40rem of the band's width.",
            "Keep figures short, like \"1,800\" or \"12k\". One wider than that, like \"$4.2M\" or \"1,250,000\", makes the whole band stack on phones and wait for more room before it goes to a row."
          ],
          "avoid": [
            "Numbers in an app's dashboard, with a change, a loading state, or an empty state. Use Stats Cards, which shows each one in a card.",
            "Figures you cannot back up, like an uptime or a customer count. Say only what is true.",
            "More than four figures. Pick the four that matter most.",
            "Long words as a value. A value is a figure. Put the words in the label."
          ]
        },
        "props": [
          {
            "name": "items",
            "type": "{ value: string; label: ReactNode; note?: ReactNode }[]",
            "default": "[]",
            "description": "The figures, in order. value is the figure as it reads, label says what it counts, and note is one short line under it. With none, nothing renders."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          },
          {
            "name": "StatBandItem",
            "type": "{ value: string; label: ReactNode; note?: ReactNode }",
            "description": "Type. One figure."
          }
        ],
        "a11y": [
          "The figures are a list. Screen readers hear each value, then its label and note, like \"40, People, Across 12 countries\".",
          "Screen readers hear the real value even before the digits roll. The rolling digits are hidden from them.",
          "The server sends the real values, so the page reads right with no script, and before it loads.",
          "The roll plays only for a band that starts below the screen. Reduced motion, or motion off, shows the values with no roll, and so does a band already in view.",
          "Nothing in it takes focus. The hover fill is for the pointer only."
        ],
        "examples": [
          {
            "title": "Four figures about a company, under a Headline",
            "code": "<Headline title=\"The company\" muted=\"in numbers.\" />\n<StatBand\n  items={[\n    { value: \"40\", label: \"People\", note: \"Across 12 countries\" },\n    { value: \"6\", label: \"Offices\", note: \"In four time zones\" },\n    { value: \"2016\", label: \"Year founded\" },\n    { value: \"1,800\", label: \"Releases\", note: \"Since the first version\" },\n  ]}\n/>"
          },
          {
            "title": "Two figures side by side",
            "code": "<StatBand\n  items={[\n    { value: \"12\", label: \"Countries\" },\n    { value: \"3\", label: \"Products\" },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "team-grid",
      "type": "registry:block",
      "title": "Team Grid",
      "description": "A grid of people for an About page, each with an avatar, name, role, a line about them, and icon links, with an optional cell for open roles.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/button-variants",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/team-grid/team-grid.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/website/team-grid/team-grid-link.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "team",
        "about",
        "people",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The team section of an About page, under a Headline.",
            "Give each person a `role` and, if you like, a one-line `bio`. Keep every bio about the same length, so the tiles line up.",
            "Add `links` for a profile, a site, or an email. Each one is an icon button, and its `label` shows in a tooltip.",
            "Set `openRoles` to end the grid with a cell that links to your careers page.",
            "Pass your router's link through `renderLink`, like next/link, so links stay client-side.",
            "Pass a photo URL as `avatar`. Without one, the initials show in a tint the name picks."
          ],
          "avoid": [
            "A list of people inside an app, like members or invites. Use a table or Avatar Stack.",
            "Long bios. Keep it to one line, and link to a full profile.",
            "Quotes from people. Use Testimonial Wall.",
            "More than about sixteen people. Show the leads, and link to the rest."
          ]
        },
        "props": [
          {
            "name": "people",
            "type": "{ name: string; role?: ReactNode; bio?: ReactNode; avatar?: string; links?: { href: string; label: string; icon: ReactNode }[] }[]",
            "default": "[]",
            "description": "The people, in the order to show them. Two people may share a name. avatar is a photo URL. Each link's label shows in its tooltip, and screen readers hear it with the person's name."
          },
          {
            "name": "openRoles",
            "type": "{ href: string; title?: ReactNode; description?: ReactNode; label?: string }",
            "description": "Adds a last cell with a button to your open roles. title defaults to \"We're hiring\", and label, the button's words, to \"See open roles\"."
          },
          {
            "name": "renderLink",
            "type": "(props: TeamGridLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws each link. Spread the props onto your router's link: (props) => <Link {...props} />. Return the element itself, so its tooltip can attach."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query. One column when narrow, two from 28rem, three from 36rem, four from 56rem. A count like six, which fills rows of three but not four, stays at three."
          }
        ],
        "a11y": [
          "The people are a list. Each name is an h3, so put the grid under an h2, like a Headline.",
          "The avatar is hidden from screen readers, since the name is written beside it.",
          "Each icon link is named by its label and the person, like \"Website, Priya Raman\". Its tooltip shows the label on hover and on focus.",
          "Icon links grow to 44px on touch screens.",
          "In forced colors each tile gets a border, since its fill is painted away."
        ],
        "examples": [
          {
            "title": "People with links, and a cell for open roles",
            "code": "<TeamGrid\n  people={[\n    {\n      name: \"Priya Raman\",\n      role: \"Founder\",\n      bio: \"Leads product and design.\",\n      links: [\n        { href: \"/team/priya\", label: \"Profile\", icon: <UserRound /> },\n        { href: \"mailto:priya@example.com\", label: \"Email\", icon: <Mail /> },\n      ],\n    },\n    { name: \"Tomás Okafor\", role: \"Engineering lead\", bio: \"Runs the platform team.\" },\n    { name: \"Elin Sato\", role: \"Support lead\" },\n  ]}\n  openRoles={{ href: \"/careers\", description: \"Design, support, and engineering.\" }}\n/>"
          },
          {
            "title": "Under a headline, with next/link",
            "code": "<section className=\"flex flex-col gap-12\">\n  <Headline title=\"The team\" description=\"The people who build and run the product.\" />\n  <TeamGrid\n    people={[{ name: \"Priya Raman\", role: \"Founder\" }, { name: \"Tomás Okafor\", role: \"Engineering lead\" }]}\n    renderLink={(props) => <Link {...props} />}\n  />\n</section>"
          }
        ]
      }
    },
    {
      "name": "testimonial-wall",
      "type": "registry:block",
      "title": "Testimonial Wall",
      "description": "A headline over a masonry wall of quote cards, each with a name, role, and avatar, and an optional Show more.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/avatar",
        "@txbrendan/button",
        "@txbrendan/motion-timing"
      ],
      "files": [
        {
          "path": "registry/txbrendan/website/testimonial-wall/testimonial-wall.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "testimonials",
        "social proof",
        "marketing"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The social proof section of a landing or pricing page, between the features and the FAQ.",
            "Six to twelve quotes of mixed length. Short and long ones side by side make the wall.",
            "Give each person a `role` that says who they are and where, like \"Design lead at a studio\".",
            "Set `limit` to six or so when you have many quotes. The rest wait behind Show more.",
            "Give it a `title`, or an aria-label when the section has no headline.",
            "The headline is set in the theme's `font-headline`. Load Geist Serif as `--font-geist-serif`, like `geist/font/serif/variable` in Next.js, or it falls back to the system serif."
          ],
          "avoid": [
            "Quotes you made up or edited past what the person said. Ask each one first.",
            "One or two quotes. Put them in the hero, or in a plain row.",
            "Star ratings or reviews from a store. Link to the store instead.",
            "A wall of logos. Use Logo Marquee."
          ]
        },
        "props": [
          {
            "name": "testimonials",
            "type": "{ quote: ReactNode; name: string; role?: ReactNode; avatar?: string }[]",
            "description": "The quotes, in the order to read them. Each name must be unique, since it is the key. avatar is a photo URL. Without one, the initials show."
          },
          {
            "name": "title",
            "type": "ReactNode",
            "description": "The headline's first words, in full ink. It grows with the wall's width, from 28 to 36px."
          },
          {
            "name": "muted",
            "type": "ReactNode",
            "description": "The words that follow the title in a quieter gray, on the same line."
          },
          {
            "name": "description",
            "type": "ReactNode",
            "description": "A line or two under the headline."
          },
          {
            "name": "as",
            "type": "\"h2\" | \"h3\"",
            "default": "\"h2\"",
            "description": "The headline's level."
          },
          {
            "name": "limit",
            "type": "number",
            "default": "all of them",
            "description": "Shows this many quotes, then a Show more button for the rest. The bottom of the wall fades while some are hidden. The rest open as a second wall under the first, so no card you already see moves."
          },
          {
            "name": "showMoreLabel",
            "type": "string",
            "default": "\"Show more\"",
            "description": "The button's words while quotes are hidden."
          },
          {
            "name": "showLessLabel",
            "type": "string",
            "default": "\"Show less\"",
            "description": "The button's words once every quote shows."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the section, which holds the container query. One column when narrow, two from 36rem, three from 64rem."
          }
        ],
        "a11y": [
          "The wall is a list. Each card is a figure: the quote is a blockquote, and the name and role are its caption.",
          "The avatar is hidden from screen readers, since the name is written beside it.",
          "Screen readers read the quotes in the order you give them, which runs down the first column, then the next.",
          "Hidden quotes are not on the page, so screen readers never read a quote people cannot see. Show more adds the rest as a second list under the first.",
          "Show more is a button with aria-expanded, and aria-controls points at the list. Focus stays on it when it opens or closes.",
          "In forced colors each card gets a border, and the fade goes away."
        ],
        "examples": [
          {
            "title": "Under a headline, with Show more",
            "code": "<TestimonialWall\n  title=\"Loved by team leads,\"\n  muted=\"and their teams too.\"\n  limit={6}\n  testimonials={[\n    { name: \"Priya Raman\", role: \"Design lead at a studio\", quote: \"Now everyone answers in one place.\" },\n    { name: \"Tomás Okafor\", role: \"Office manager at a clinic\", quote: \"Setup took an afternoon.\" },\n    { name: \"Elin Sato\", role: \"Team lead at a library\", quote: \"People stopped asking me what time the all-hands is.\" },\n  ]}\n/>"
          },
          {
            "title": "With photos and no headline",
            "code": "<TestimonialWall\n  aria-label=\"What teams say\"\n  testimonials={[\n    { name: \"Priya Raman\", role: \"Design lead\", avatar: \"/people/priya.jpg\", quote: \"Now everyone answers in one place.\" },\n    { name: \"Tomás Okafor\", role: \"Office manager\", avatar: \"/people/tomas.jpg\", quote: \"Setup took an afternoon.\" },\n  ]}\n/>"
          }
        ]
      }
    },
    {
      "name": "code-key",
      "type": "registry:block",
      "title": "Code Key",
      "description": "A picture of owning the code: a key whose handle holds a code mark, with scrambled characters in the corner.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/illustrations/code-key/code-key.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "ownership",
        "code",
        "key",
        "open"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A feature section about owning what you get, like code that is copied into your app for you to change.",
            "Put it in a Feature Grid item's `illustration`, or a Bento Card's `preview`.",
            "Set `tone` to color the code mark, like brand for your main color.",
            "Write `label` as one sentence that says what the picture shows. Screen readers hear it in place of the drawing."
          ],
          "avoid": [
            "A sign-in or locked screen. Use No Access.",
            "A small icon beside text. Use the Key icon from lucide-react."
          ]
        },
        "props": [
          {
            "name": "glyphs",
            "type": "string",
            "default": "\"0x1F{}<>;=/*\"",
            "description": "Scrambled characters in the top corner, four to a row. An empty string hides them."
          },
          {
            "name": "label",
            "type": "string",
            "default": "a sentence about the key",
            "description": "What the picture says, read out in its place."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The color of the code mark in the key's handle."
          },
          {
            "name": "pattern",
            "type": "boolean",
            "default": "true",
            "description": "A field of dots behind the key."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root."
          }
        ],
        "a11y": [
          "The root is one image, named by `label`. The drawing and the characters are hidden from screen readers.",
          "Nothing inside takes focus.",
          "On hover the key lifts by the motion distance. Reduced motion sets that to 0, so it stays still."
        ],
        "examples": [
          {
            "title": "The default key",
            "code": "<CodeKey />"
          },
          {
            "title": "No characters, in your brand color",
            "code": "<CodeKey glyphs=\"\" tone=\"brand\" label=\"The code is copied into your app, and it is yours.\" />"
          }
        ]
      }
    },
    {
      "name": "contrast-orb",
      "type": "registry:block",
      "title": "Contrast Orb",
      "description": "A picture of contrast in light and dark: an orb cut into a light half and a dark half, with letters on each.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/illustrations/contrast-orb/contrast-orb.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "contrast",
        "theme",
        "dark mode",
        "accessibility"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A feature section about readable color, like a theme checked for contrast in light and dark.",
            "Put it in a Feature Grid item's `illustration`, or a Bento Card's `preview`.",
            "Keep `sample` to two or three letters, so it fits each half.",
            "Write `label` as one sentence that says what the picture shows. Screen readers hear it in place of the drawing."
          ],
          "avoid": [
            "A real light and dark switch. Use the mode control in your app.",
            "Proof of contrast. A picture only sets the mood. Show the checked numbers in text."
          ]
        },
        "props": [
          {
            "name": "sample",
            "type": "string",
            "default": "\"Aa\"",
            "description": "The letters on each half."
          },
          {
            "name": "label",
            "type": "string",
            "default": "a sentence about the orb",
            "description": "What the picture says, read out in its place."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The color of the moon on the ring and its glow."
          },
          {
            "name": "pattern",
            "type": "boolean",
            "default": "true",
            "description": "A field of dots behind the orb."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root."
          }
        ],
        "a11y": [
          "The root is one image, named by `label`. The drawing and its letters are hidden from screen readers.",
          "Nothing inside takes focus.",
          "On hover the moon's glow fades up. It is a fade, so it stays with reduced motion, and it stops when motion is off."
        ],
        "examples": [
          {
            "title": "The default orb",
            "code": "<ContrastOrb />"
          },
          {
            "title": "In your brand color",
            "code": "<ContrastOrb tone=\"brand\" label=\"Every color pair is checked in light and dark.\" />"
          }
        ]
      }
    },
    {
      "name": "folder",
      "type": "registry:block",
      "title": "Folder",
      "description": "A folder whose front hinges forward as two papers rise and fan apart.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/illustrations/folder/folder.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "files",
        "documents",
        "organization",
        "motion"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A feature section about files, shared work, or keeping documents together.",
            "Set open when the surrounding content is revealed. The front hinges forward and two papers rise and fan apart. Their marks, lines, and chart reveal as they emerge; closing hides the details and returns the papers behind the front.",
            "For CSS-driven disclosures, set --folder-open to 0 or 1 on the picture or an ancestor. It overrides the open prop without adding another state.",
            "Set pattern to false when the surrounding design already provides a background."
          ],
          "avoid": [
            "A control for opening real files. Use a labeled control for that action.",
            "A small icon beside text. This illustration needs room for the folder and papers."
          ]
        },
        "props": [
          {
            "name": "label",
            "type": "string",
            "default": "\"A folder holding two documents\"",
            "description": "What the picture shows, read out in its place."
          },
          {
            "name": "open",
            "type": "boolean",
            "default": "false",
            "description": "Hinges the front toward the viewer and fans the papers above it. A change reverses an unfinished motion."
          },
          {
            "name": "pattern",
            "type": "boolean",
            "default": "true",
            "description": "A field of dots behind the folder."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, including a --folder-open pose override."
          }
        ],
        "a11y": [
          "The root is one image named by label. Its drawing is hidden from screen readers, and nothing inside takes focus.",
          "The folder and papers keep the same outer box in both states.",
          "Reduced motion changes the paper and front positions without movement and reveals the details without a wipe or delay; their opacity and the hover shadow can still fade. The theme's Off setting removes all transitions.",
          "The initial pose renders directly. A later opening takes twice --motion-duration-slow, with details delayed by --motion-duration-fast. Closing uses --motion-duration-close with no delay. Nothing repeats."
        ],
        "examples": [
          {
            "title": "A closed folder",
            "code": "<Folder />"
          },
          {
            "title": "An open folder without dots",
            "code": "<Folder open pattern={false} />"
          },
          {
            "title": "A pose controlled by surrounding CSS",
            "code": "<Folder open className=\"[--folder-open:0]\" label=\"Documents stored in a folder\" />"
          }
        ]
      }
    },
    {
      "name": "speed-gauge",
      "type": "registry:block",
      "title": "Speed Gauge",
      "description": "A picture of speed: a dial with a lit arc, ticks around its edge, and a needle near the top of its range.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/illustrations/speed-gauge/speed-gauge.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "speed",
        "performance",
        "gauge",
        "fast"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A feature section about speed, like small parts that keep pages fast.",
            "Put it in a Feature Grid item's `illustration`, or a Bento Card's `preview`.",
            "Set `value` from 0 to 1 to move the needle. The default sits near the top.",
            "Write `label` as one sentence that says what the picture shows. Screen readers hear it in place of the drawing."
          ],
          "avoid": [
            "A real reading people check, like a score or a load. Use Ring Progress or a chart.",
            "A small icon beside text. Use an icon from lucide-react."
          ]
        },
        "props": [
          {
            "name": "value",
            "type": "number",
            "default": "0.78",
            "description": "How far the needle points, from 0 to 1. Values outside are held to that range."
          },
          {
            "name": "label",
            "type": "string",
            "default": "a sentence about the dial",
            "description": "What the picture says, read out in its place."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The color of the lit arc and the needle's glow."
          },
          {
            "name": "pattern",
            "type": "boolean",
            "default": "true",
            "description": "A field of dots behind the gauge."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root."
          }
        ],
        "a11y": [
          "The root is one image, named by `label`. The drawing is hidden from screen readers.",
          "Nothing inside takes focus.",
          "On hover the glow at the needle's tip fades up. It is a fade, so it stays with reduced motion, and it stops when motion is off."
        ],
        "examples": [
          {
            "title": "The default gauge",
            "code": "<SpeedGauge />"
          },
          {
            "title": "In green",
            "code": "<SpeedGauge tone=\"success\" label=\"Every part stays small, so pages load fast.\" />"
          }
        ]
      }
    },
    {
      "name": "stacked-blocks",
      "type": "registry:block",
      "title": "Stacked Blocks",
      "description": "A picture of parts put together: a stack of blocks, with the last one floating down into place.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/illustrations/stacked-blocks/stacked-blocks.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "building",
        "parts",
        "agents",
        "isometric"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A feature section about building from parts, like agents putting a screen together from components.",
            "Put it in a Feature Grid item's `illustration`, or a Bento Card's `preview`.",
            "Set `tone` to color the last block and its spark, like brand for your main color.",
            "Write `label` as one sentence that says what the picture shows. Screen readers hear it in place of the drawing."
          ],
          "avoid": [
            "A real diagram people read for facts. A picture only sets the mood.",
            "A small icon beside text. Use an icon from lucide-react."
          ]
        },
        "props": [
          {
            "name": "label",
            "type": "string",
            "default": "a sentence about the blocks",
            "description": "What the picture says, read out in its place."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The color of the last block and its spark."
          },
          {
            "name": "pattern",
            "type": "boolean",
            "default": "true",
            "description": "A field of dots behind the blocks."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root."
          }
        ],
        "a11y": [
          "The root is one image, named by `label`. The drawing is hidden from screen readers.",
          "Nothing inside takes focus.",
          "On hover the last block drops by three times the motion distance. Reduced motion sets that to 0, so it stays still."
        ],
        "examples": [
          {
            "title": "The default blocks",
            "code": "<StackedBlocks />"
          },
          {
            "title": "In your brand color",
            "code": "<StackedBlocks tone=\"brand\" label=\"Agents put your screens together from our parts.\" />"
          }
        ]
      }
    },
    {
      "name": "theme-layers",
      "type": "registry:block",
      "title": "Theme Layers",
      "description": "A picture of one theme under every part: three sheets in the air, with a grid, lines of type, and color swatches.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme"
      ],
      "files": [
        {
          "path": "registry/txbrendan/illustrations/theme-layers/theme-layers.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "theme",
        "tokens",
        "design system",
        "isometric"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A feature section about one theme for everything, like tokens for color, type, and spacing.",
            "Put it in a Feature Grid item's `illustration`, or a Bento Card's `preview`.",
            "Set `tone` to color the first swatch on the top sheet, like brand for your main color.",
            "Write `label` as one sentence that says what the picture shows. Screen readers hear it in place of the drawing."
          ],
          "avoid": [
            "A real list of your theme's colors. Show the tokens themselves.",
            "A small icon beside text. Use an icon from lucide-react."
          ]
        },
        "props": [
          {
            "name": "label",
            "type": "string",
            "default": "a sentence about the sheets",
            "description": "What the picture says, read out in its place."
          },
          {
            "name": "tone",
            "type": "\"neutral\" | \"brand\" | \"danger\" | \"success\" | \"warning\"",
            "default": "\"neutral\"",
            "description": "The color of the first swatch on the top sheet."
          },
          {
            "name": "pattern",
            "type": "boolean",
            "default": "true",
            "description": "A field of dots behind the sheets."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root."
          }
        ],
        "a11y": [
          "The root is one image, named by `label`. The drawing is hidden from screen readers.",
          "Nothing inside takes focus.",
          "On hover the top two sheets rise by the motion distance, so they spread apart. Reduced motion sets that to 0, so they stay still."
        ],
        "examples": [
          {
            "title": "The default sheets",
            "code": "<ThemeLayers />"
          },
          {
            "title": "In your brand color",
            "code": "<ThemeLayers tone=\"brand\" label=\"One theme sets the color, type, and spacing of every part.\" />"
          }
        ]
      }
    },
    {
      "name": "about-page",
      "type": "registry:block",
      "title": "About Page",
      "description": "A whole about page, built from our website components: a floating navbar, a page title, the story beside its headline, a few figures, what you hold to, the people, a closing band, and a footer.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/navbar",
        "@txbrendan/headline",
        "@txbrendan/feature-grid",
        "@txbrendan/team-grid",
        "@txbrendan/call-to-action-band",
        "@txbrendan/footer",
        "@txbrendan/stat-band"
      ],
      "files": [
        {
          "path": "registry/txbrendan/templates/about-page/about-page.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "about",
        "company",
        "team",
        "marketing",
        "page"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The about page of a product site. Render it as the whole page, from your app's page file.",
            "Write every word in the props: one object for the navbar, the header, the story, the values, the team, the closing band, and the footer.",
            "Keep the story to two to four short paragraphs: why you started, what you built, and where it stands.",
            "Use `stats` for two to four figures that are true today, like the size of the team or the year you started.",
            "Use `values` for what you hold to, two to six, each a short title and one line.",
            "Use `team` for the people, and set `team.team.openRoles` to end the grid with a link to open roles.",
            "Use `cta` for the one thing to do next, like signing up or seeing open roles.",
            "Pass your router's link once in `renderLink`. The navbar, the team, the closing band, and the footer all use it. From a server page, render the page from a client file, since a server page cannot pass a function.",
            "Set `embedded` when you show the page inside another page, like a gallery, so it has no second main."
          ],
          "avoid": [
            "A history told year by year. Tell it in a few lines, and link to a changelog for the rest.",
            "Figures that will be out of date next month, like a count of signups, unless you update the page.",
            "Values no one would disagree with, like quality. Name what you do, like answering every message.",
            "A team grid of more than about sixteen people. Show the leads, and link to the rest."
          ]
        },
        "props": [
          {
            "name": "navbar",
            "type": "NavbarProps",
            "description": "The Navbar's props. It floats as a pill once the page scrolls, and folds into a menu below 42rem."
          },
          {
            "name": "header",
            "type": "HeadlineProps",
            "description": "The headline at the top of the page. It renders as the page's one h1."
          },
          {
            "name": "story",
            "type": "{ id?: string; headline: HeadlineProps; paragraphs: ReactNode[] }",
            "description": "Why you started, as a headline beside a few short paragraphs. They stack below 64rem. Its id defaults to \"story\"."
          },
          {
            "name": "stats",
            "type": "{ id?: string; headline?: HeadlineProps; label?: string; items: StatBandItem[] }",
            "description": "Two to four figures, as a Stat Band, each with a `value`, a `label`, and an optional `note`. Its id defaults to \"numbers\". With no headline, `label` names it for screen readers, default \"Numbers\". With no items, it is left out."
          },
          {
            "name": "values",
            "type": "{ id?: string; headline?: HeadlineProps; label?: string; items: FeatureGridItemProps[] }",
            "description": "What you hold to, one Feature Grid cell each. Its id defaults to \"values\". With no headline, `label` names it for screen readers, default \"Values\". With no items, it is left out."
          },
          {
            "name": "team",
            "type": "{ id?: string; headline?: HeadlineProps; label?: string; team: TeamGridProps }",
            "description": "The people, as a Team Grid. Its id defaults to \"team\". With no headline, `label` names it for screen readers, default \"Team\". With no people and no open roles, it is left out."
          },
          {
            "name": "cta",
            "type": "CallToActionBandProps",
            "description": "The closing band above the footer."
          },
          {
            "name": "footer",
            "type": "FooterProps",
            "description": "The Footer's props."
          },
          {
            "name": "renderLink",
            "type": "(props: NavbarLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws every link in the navbar, the team, the closing band, and the footer, unless a part sets its own."
          },
          {
            "name": "embedded",
            "type": "boolean",
            "default": "false",
            "description": "Renders the content in a div, not a main, for a preview inside another page."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          }
        ],
        "a11y": [
          "The content sits in a main landmark, between the navbar's nav and the footer, so skip links and screen readers find it.",
          "The header renders the page's one h1. Each section's headline is an h2, and each person's name an h3.",
          "Each section has an id, so the navbar can link to it, and it scrolls into view below the floating navbar.",
          "Each part keeps its own accessibility, listed on its page."
        ],
        "examples": [
          {
            "title": "A short about page",
            "code": "<AboutPage\n  navbar={{ brand: \"Workspace\", links: [{ href: \"/about\", label: \"About\", current: true }] }}\n  header={{ title: \"We build the app\", muted: \"we wanted for our own teams.\" }}\n  story={{\n    headline: { title: \"Why we\", muted: \"started.\" },\n    paragraphs: [\"We ran teams with a spreadsheet and a chat.\", \"So we built one place for the week.\"],\n  }}\n  team={{\n    headline: { title: \"The people.\" },\n    team: { people: [{ name: \"Ana Lindqvist\", role: \"Founder\" }] },\n  }}\n  cta={{ title: \"Plan your week.\", action: { label: \"Start free\", href: \"/signup\" } }}\n  footer={{\n    brand: \"Workspace\",\n    columns: [{ title: \"Company\", links: [{ href: \"/about\", label: \"About\" }] }],\n    legal: \"© 2026 Workspace\",\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "blog-page",
      "type": "registry:block",
      "title": "Blog Page",
      "description": "A whole blog index, built from our website components: a floating navbar, a page title, every post with the newest drawn wide and a row of tags to narrow them, a closing band, and a footer.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/navbar",
        "@txbrendan/headline",
        "@txbrendan/post-grid",
        "@txbrendan/call-to-action-band",
        "@txbrendan/footer"
      ],
      "files": [
        {
          "path": "registry/txbrendan/templates/blog-page/blog-page.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "blog",
        "posts",
        "marketing",
        "page"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The list of posts on a product site's blog. Render it as the whole page, from your app's page file.",
            "Write every word in the props: one object for the navbar, the header, the posts, the closing band, and the footer.",
            "Pass the posts newest first, each with an `href` to its own page. Post Page draws that page.",
            "Give each post one `tag`, so the row of tags can narrow the list. Set `posts.filter` to false to hide the row.",
            "Pick a post count that fills the rows: the newest drawn wide, then rows of three, like 7 or 10.",
            "Pass your router's link once in `renderLink`. The navbar, the posts, the closing band, and the footer all use it. From a server page, render the page from a client file, since a server page cannot pass a function.",
            "Set `embedded` when you show the page inside another page, like a gallery, so it has no second main."
          ],
          "avoid": [
            "Hundreds of posts on one page. Show the newest, and split the rest across pages.",
            "A tag on only one post. Each tag should narrow the list to a few."
          ]
        },
        "props": [
          {
            "name": "navbar",
            "type": "NavbarProps",
            "description": "The Navbar's props. It floats as a pill once the page scrolls, and folds into a menu below 42rem."
          },
          {
            "name": "header",
            "type": "HeadlineProps",
            "description": "The headline at the top of the page. It renders as the page's one h1."
          },
          {
            "name": "posts",
            "type": "PostGridProps",
            "description": "Required. Post Grid's props, like `posts`. `featured` and `filter` default to true here. Each post's title is an h2, right under the page's h1."
          },
          {
            "name": "cta",
            "type": "CallToActionBandProps",
            "description": "The closing band above the footer."
          },
          {
            "name": "footer",
            "type": "FooterProps",
            "description": "The Footer's props."
          },
          {
            "name": "renderLink",
            "type": "(props: NavbarLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws every link in the navbar, the posts, the closing band, and the footer, unless a part sets its own."
          },
          {
            "name": "embedded",
            "type": "boolean",
            "default": "false",
            "description": "Renders the content in a div, not a main, for a preview inside another page."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          }
        ],
        "a11y": [
          "The content sits in a main landmark, between the navbar's nav and the footer, so skip links and screen readers find it.",
          "The header renders the page's one h1, and each post's title is an h2 under it.",
          "Each part keeps its own accessibility, listed on its page."
        ],
        "examples": [
          {
            "title": "A short blog index",
            "code": "<BlogPage\n  navbar={{ brand: \"Workspace\", links: [{ href: \"/blog\", label: \"Blog\", current: true }] }}\n  header={{ title: \"The blog.\", muted: \"What we ship, and how we work.\" }}\n  posts={{\n    posts: [\n      { href: \"/blog/shared-folders\", title: \"Shared folders are here\", tag: \"Product\", date: \"2026-10-28\" },\n      { href: \"/blog/plan-a-release\", title: \"How to plan a release\", tag: \"Guides\", date: \"2026-10-12\" },\n    ],\n  }}\n  footer={{\n    brand: \"Workspace\",\n    columns: [{ title: \"Company\", links: [{ href: \"/blog\", label: \"Blog\" }] }],\n    legal: \"© 2026 Workspace\",\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "changelog-page",
      "type": "registry:block",
      "title": "Changelog Page",
      "description": "A whole changelog page, built from our website components: a floating navbar, a page title, every release newest first with tabs to narrow them to one type of change, a closing band, and a footer.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/navbar",
        "@txbrendan/headline",
        "@txbrendan/changelog",
        "@txbrendan/call-to-action-band",
        "@txbrendan/footer"
      ],
      "files": [
        {
          "path": "registry/txbrendan/templates/changelog-page/changelog-page.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "changelog",
        "releases",
        "marketing",
        "page"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The list of releases on a product site. Render it as the whole page, from your app's page file.",
            "Write every word in the props: one object for the navbar, the header, the changelog, the closing band, and the footer.",
            "Pass the releases newest first, each with a `date` and a `version`. The version gives each release an id, so a link to #3-10 lands on it.",
            "Set `changelog.limit` to show the newest few first, with a button that shows the rest in place.",
            "Mark each change New, Improved, Fixed, or Removed, so the tabs can narrow the list. Set `changelog.filter` to false to hide them.",
            "Pass your router's link once in `renderLink`. The navbar, the closing band, and the footer all use it. From a server page, render the page from a client file, since a server page cannot pass a function.",
            "Set `embedded` when you show the page inside another page, like a gallery, so it has no second main."
          ],
          "avoid": [
            "Every small fix since the start. Keep the changes people would notice, and show the newest first.",
            "Two releases with the same version, since the version is also the release's id."
          ]
        },
        "props": [
          {
            "name": "navbar",
            "type": "NavbarProps",
            "description": "The Navbar's props. It floats as a pill once the page scrolls, and folds into a menu below 42rem."
          },
          {
            "name": "header",
            "type": "HeadlineProps",
            "description": "The headline at the top of the page. It renders as the page's one h1, at the start, lined up with the releases."
          },
          {
            "name": "changelog",
            "type": "ChangelogProps",
            "description": "Required. Changelog's props, like `releases` and `limit`. `filter` defaults to true here. Each release's title is an h2, right under the page's h1."
          },
          {
            "name": "cta",
            "type": "CallToActionBandProps",
            "description": "The closing band above the footer."
          },
          {
            "name": "footer",
            "type": "FooterProps",
            "description": "The Footer's props."
          },
          {
            "name": "renderLink",
            "type": "(props: NavbarLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws every link in the navbar, the closing band, and the footer, unless a part sets its own."
          },
          {
            "name": "embedded",
            "type": "boolean",
            "default": "false",
            "description": "Renders the content in a div, not a main, for a preview inside another page."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          }
        ],
        "a11y": [
          "The content sits in a main landmark, between the navbar's nav and the footer, so skip links and screen readers find it.",
          "The header renders the page's one h1, and each release's title is an h2 under it.",
          "Each release has an id, so a link lands on it below the floating navbar.",
          "Each part keeps its own accessibility, listed on its page."
        ],
        "examples": [
          {
            "title": "A short changelog page",
            "code": "<ChangelogPage\n  navbar={{ brand: \"Workspace\", links: [{ href: \"/changelog\", label: \"Changelog\", current: true }] }}\n  header={{ title: \"Changelog.\", muted: \"What is new, better, and fixed.\" }}\n  changelog={{\n    releases: [\n      {\n        date: \"2026-10-28\",\n        version: \"3.11\",\n        title: \"Shared views\",\n        changes: [{ type: \"new\", text: \"Save any filter as a view.\" }],\n      },\n      {\n        date: \"2026-10-14\",\n        version: \"3.10\",\n        title: \"Faster search\",\n        changes: [{ type: \"fixed\", text: \"Accents no longer change the results.\" }],\n      },\n    ],\n  }}\n  footer={{\n    brand: \"Workspace\",\n    columns: [{ title: \"Product\", links: [{ href: \"/changelog\", label: \"Changelog\" }] }],\n    legal: \"© 2026 Workspace\",\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "contact-page",
      "type": "registry:block",
      "title": "Contact Page",
      "description": "A whole contact page, built from our website components: a floating navbar, a page title over the ways to reach you, a form in one soft card, a FAQ, and a footer.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/navbar",
        "@txbrendan/headline",
        "@txbrendan/faq",
        "@txbrendan/footer",
        "@txbrendan/button",
        "@txbrendan/field-frame",
        "@txbrendan/input",
        "@txbrendan/textarea",
        "@txbrendan/select",
        "@txbrendan/glide-highlight"
      ],
      "files": [
        {
          "path": "registry/txbrendan/templates/contact-page/contact-page.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/contact-page/contact-page-form.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "contact",
        "form",
        "marketing",
        "page"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The contact page of a product site. Render it as the whole page, from your app's page file.",
            "Write every word in the props: one object for the navbar, the header, the ways to reach you, the form, the FAQ, and the footer.",
            "Keep the form short: a name, an email, a topic, and a message. Every field you add loses people.",
            "Send the message in `form.onSubmit`, like a fetch to your own route. Throw when it fails, and the form says so and keeps what they wrote.",
            "From a server page, pass a Server Action as `form.onSubmit`, since a server page cannot pass a plain function. Or render the page from a client file.",
            "Use `channels` for the places people can go instead, like a sales email, support, or the docs. Give each one a `label`, like the address, which reads as the link.",
            "Use the FAQ for what people ask before they write, like when you answer.",
            "Pass your router's link once in `renderLink`. The navbar, the ways to reach you, and the footer all use it.",
            "Set `embedded` when you show the page inside another page, like a gallery, so it has no second main."
          ],
          "avoid": [
            "A form with no `onSubmit`. It shows the sent note, but the message goes nowhere.",
            "A promise you can't keep in `note`, like an answer within an hour.",
            "Asking for a phone number when email will do."
          ]
        },
        "props": [
          {
            "name": "navbar",
            "type": "NavbarProps",
            "description": "The Navbar's props. It floats as a pill once the page scrolls, and folds into a menu below 42rem."
          },
          {
            "name": "header",
            "type": "HeadlineProps",
            "description": "The headline at the top, left-aligned over the ways to reach you. It renders as the page's one h1."
          },
          {
            "name": "channels",
            "type": "{ title: string; description?: ReactNode; href: string; label?: string }[]",
            "description": "Ways to reach you besides the form, one row each, with a highlight that glides from row to row. Each title must be unique, since it is the key."
          },
          {
            "name": "channelsLabel",
            "type": "string",
            "default": "\"Other ways to reach us\"",
            "description": "Names the ways to reach you for screen readers."
          },
          {
            "name": "form",
            "type": "ContactFormProps",
            "description": "Required. The form's `fields` (each with a unique `name`, a `label`, and a `type` of text, email, tel, textarea, or select), its `title`, `submitLabel`, `note`, `onSubmit`, the `sent` note, and the `failed` line."
          },
          {
            "name": "faq",
            "type": "{ id?: string; headline?: HeadlineProps; faq: FaqProps }",
            "description": "Questions people ask before they write, under the form. Its id defaults to \"faq\"."
          },
          {
            "name": "footer",
            "type": "FooterProps",
            "description": "The Footer's props."
          },
          {
            "name": "renderLink",
            "type": "(props: NavbarLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws every link in the navbar, the ways to reach you, and the footer, unless a part sets its own."
          },
          {
            "name": "embedded",
            "type": "boolean",
            "default": "false",
            "description": "Renders the content in a div, not a main, for a preview inside another page."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          }
        ],
        "a11y": [
          "The content sits in a main landmark, between the navbar's nav and the footer, so skip links and screen readers find it.",
          "The header renders the page's one h1. The form's title and the FAQ's headline are h2s.",
          "Every field has a visible label, and its hint is linked to it, so screen readers read it.",
          "Send checks every field, gives each one that is wrong a red edge, and moves focus to the first. One line under the button says what to fix, and each wrong field is linked to it. It is under the button, so it never pushes the button. Typing in a field clears its edge.",
          "While it sends, the button is busy and takes no second press. A failure, or no answer within 20 seconds, is announced under the button, and the form keeps what was typed.",
          "Once sent, focus moves to the sent note, which is announced and scrolls clear of the floating navbar. For a moment after, a second tap goes nowhere, so a double tap on Send cannot land on what slid under it. Send another clears every field and moves focus to the first.",
          "The form and the sent note slide and fade in place of each other. With reduced motion they only fade, and with motion off they swap at once.",
          "Each part keeps its own accessibility, listed on its page."
        ],
        "examples": [
          {
            "title": "A short contact page",
            "code": "<ContactPage\n  navbar={{ brand: \"Workspace\", links: [{ href: \"/contact\", label: \"Contact\", current: true }] }}\n  header={{ title: \"Talk to us.\", muted: \"We answer every message.\" }}\n  channels={[{ title: \"Support\", description: \"For help with your account.\", href: \"mailto:help@example.com\", label: \"help@example.com\" }]}\n  form={{\n    fields: [\n      { name: \"name\", label: \"Name\", required: true, autoComplete: \"name\", half: true },\n      { name: \"email\", label: \"Email\", type: \"email\", required: true, autoComplete: \"email\", half: true },\n      { name: \"message\", label: \"Message\", type: \"textarea\", required: true },\n    ],\n    note: \"We answer within one working day.\",\n    onSubmit: async (values) => {\n      const response = await fetch(\"/api/contact\", { method: \"POST\", body: JSON.stringify(values) });\n      if (!response.ok) throw new Error(\"Not sent\");\n    },\n  }}\n  footer={{\n    brand: \"Workspace\",\n    columns: [{ title: \"Company\", links: [{ href: \"/contact\", label: \"Contact\" }] }],\n    legal: \"© 2026 Workspace\",\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "landing-page",
      "type": "registry:block",
      "title": "Landing Page",
      "description": "A whole landing page, built from our website components: a floating navbar, a split hero, a logo row, bento grids, a comparison, a testimonial wall, pricing, a FAQ, and a footer.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/navbar",
        "@txbrendan/hero",
        "@txbrendan/logo-marquee",
        "@txbrendan/headline",
        "@txbrendan/bento-grid",
        "@txbrendan/comparison",
        "@txbrendan/faq",
        "@txbrendan/pricing",
        "@txbrendan/testimonial-wall",
        "@txbrendan/footer"
      ],
      "files": [
        {
          "path": "registry/txbrendan/templates/landing-page/landing-page.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "landing",
        "marketing",
        "page"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The home page of a product site. Render it as the whole page, from your app's page file.",
            "Write every word in the props: one object for the navbar, the hero, each section, and the footer.",
            "Order the sections the way a visitor decides: what it is, who uses it, what it does, how it compares, what people say, what it costs, then questions.",
            "Give a testimonials section its words in the section's `headline`, not the wall's own `title`, so every section heading matches.",
            "Put a logos section first, right under the hero, with six to twelve customer logos. Leave the hero's own `logos` out then, so the logos show once.",
            "Give each section an `id`, like \"pricing\", and point the navbar's links at it, like \"#pricing\".",
            "Pass your router's link once in `renderLink`. The navbar, the bento cards, and the footer all use it.",
            "Set `embedded` when you show the page inside another page, like a gallery, so it has no second main."
          ],
          "avoid": [
            "A section type the template does not have. Install the part and add it to your copy, which is your own code once installed.",
            "More than one pricing section, or more than three plans in it.",
            "Words you can't stand behind, like a customer logo you have no right to show."
          ]
        },
        "props": [
          {
            "name": "navbar",
            "type": "NavbarProps",
            "description": "The Navbar's props. It floats as a pill once the page scrolls, and folds into a menu below 42rem."
          },
          {
            "name": "hero",
            "type": "HeroProps",
            "description": "The Hero's props: the claim, the lead, the call to action, logos, and the preview."
          },
          {
            "name": "sections",
            "type": "LandingSection[]",
            "default": "[]",
            "description": "The sections under the hero, top to bottom. Each has an optional `id` and `headline`, and a `type` that says which part draws it."
          },
          {
            "name": "footer",
            "type": "FooterProps",
            "description": "The Footer's props: the brand, a line about you, link columns, and the legal line."
          },
          {
            "name": "renderLink",
            "type": "(props: NavbarLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws every link in the navbar, the bento cards, and the footer, unless a part sets its own."
          },
          {
            "name": "embedded",
            "type": "boolean",
            "default": "false",
            "description": "Renders the content in a div, not a main, for a preview inside another page."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          },
          {
            "name": "LandingSection",
            "type": "{ id?: string; headline?: HeadlineProps } & ({ type: \"bento\"; cards: BentoCardProps[] } | { type: \"comparison\"; comparison: ComparisonProps } | { type: \"faq\"; faq: FaqProps } | { type: \"logos\"; logos: LogoMarqueeProps } | { type: \"pricing\"; plans: PricingCardProps[]; billing?: PricingBilling } | { type: \"testimonials\"; testimonials: TestimonialWallProps })",
            "description": "Type. One section. Bento draws a BentoGrid of cards, comparison a Comparison, faq a FAQ, logos a Logo Marquee, testimonials a Testimonial Wall, and pricing a row of PricingCards, with an optional monthly or yearly switch."
          }
        ],
        "a11y": [
          "The content sits in a main landmark, between the navbar's nav and the footer, so skip links and screen readers find it.",
          "The Hero renders the page's one h1. Each section's Headline is an h2.",
          "Sections with an `id` keep clear of the floating navbar when a link jumps to them.",
          "Each part keeps its own accessibility, listed on its page."
        ],
        "examples": [
          {
            "title": "A short landing page",
            "code": "<LandingPage\n  navbar={{\n    brand: \"Workspace\",\n    links: [{ href: \"#features\", label: \"Features\" }, { href: \"#pricing\", label: \"Pricing\" }],\n    actions: <Button tone=\"brand\" size=\"sm\">Create a team</Button>,\n  }}\n  hero={{\n    title: \"The app your team\",\n    accent: \"is missing.\",\n    description: \"Plan the week, see who is coming, and keep everyone in one place.\",\n    actions: <Button tone=\"brand\" size=\"lg\">Create a team</Button>,\n  }}\n  sections={[\n    {\n      id: \"features\",\n      type: \"bento\",\n      headline: { title: \"Everything in one place.\" },\n      cards: [{ title: \"Reminders\", description: \"Emails go to whoever hasn't answered.\" }],\n    },\n    {\n      id: \"pricing\",\n      type: \"pricing\",\n      headline: { title: \"One plan.\", muted: \"Every team.\" },\n      plans: [{ title: \"Team\", price: \"$12\", period: \"a month\", action: <Button tone=\"brand\">Start</Button> }],\n    },\n  ]}\n  footer={{\n    brand: \"Workspace\",\n    columns: [{ title: \"Product\", links: [{ href: \"#pricing\", label: \"Pricing\" }] }],\n    legal: \"© 2026 Workspace\",\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "post-page",
      "type": "registry:block",
      "title": "Post Page",
      "description": "A whole blog post page, built from our website components: a floating navbar, the post with its author and a list of its sections, other posts to read next, a closing band, and a footer.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/navbar",
        "@txbrendan/article",
        "@txbrendan/headline",
        "@txbrendan/post-grid",
        "@txbrendan/call-to-action-band",
        "@txbrendan/footer"
      ],
      "files": [
        {
          "path": "registry/txbrendan/templates/post-page/post-page.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "blog",
        "post",
        "article",
        "marketing",
        "page"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "One post on a product site's blog. Render it as the whole page, from your app's page file, once per post.",
            "Pass the post in `article`: its title, date, author, and the post itself as `article.children`, in plain HTML like p, h2, ul, and pre.",
            "Give the post two or more h2s. On wide screens a list of them sits beside the post.",
            "Set `article.back` to link to the list of posts, which Blog Page draws.",
            "Use `more` for up to three other posts, like the newest ones. Leave out the post on this page.",
            "Pass your router's link once in `renderLink`. From a server page, render the page from a client file, since a server page cannot pass a function. Pass the post's body from the server page as children, so it is not sent as code.",
            "Set `embedded` when you show the page inside another page, like a gallery, so it has no second main."
          ],
          "avoid": [
            "A second h1. The post's title is the page's h1.",
            "More than three other posts. Link to the blog for the rest."
          ]
        },
        "props": [
          {
            "name": "navbar",
            "type": "NavbarProps",
            "description": "The Navbar's props. It floats as a pill once the page scrolls, and folds into a menu below 42rem."
          },
          {
            "name": "article",
            "type": "ArticleProps",
            "description": "Required. Article's props: `title`, `date`, `author`, `back`, `cover`, and the post as `children`. The title renders as the page's one h1."
          },
          {
            "name": "more",
            "type": "{ id?: string; headline?: HeadlineProps; posts: Post[] }",
            "description": "Other posts to read next, as Post Grid cards, under a headline that defaults to \"More posts.\". Its id defaults to \"more\"."
          },
          {
            "name": "cta",
            "type": "CallToActionBandProps",
            "description": "The closing band above the footer."
          },
          {
            "name": "footer",
            "type": "FooterProps",
            "description": "The Footer's props."
          },
          {
            "name": "renderLink",
            "type": "(props: NavbarLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws every link in the navbar, the back link, the other posts, the closing band, and the footer, unless a part sets its own."
          },
          {
            "name": "embedded",
            "type": "boolean",
            "default": "false",
            "description": "Renders the content in a div, not a main, for a preview inside another page."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          }
        ],
        "a11y": [
          "The content sits in a main landmark, between the navbar's nav and the footer, so skip links and screen readers find it.",
          "The post's title is the page's one h1. The post's sections and the More posts headline are h2s, and each other post's title an h3.",
          "Each part keeps its own accessibility, listed on its page."
        ],
        "examples": [
          {
            "title": "A short post page",
            "code": "<PostPage\n  navbar={{ brand: \"Workspace\", links: [{ href: \"/blog\", label: \"Blog\", current: true }] }}\n  article={{\n    back: { href: \"/blog\", label: \"All posts\" },\n    title: \"How to plan a release\",\n    date: \"2026-10-12\",\n    tag: \"Guides\",\n    // The same cover as the post's card on the blog.\n    cover: <PostGridCover post={{ href: \"/blog/plan-a-release\", tag: \"Guides\" }} />,\n    children: (\n      <>\n        <p>A release goes better when everyone knows what is in it.</p>\n        <h2>Write down the scope</h2>\n        <p>Start with a short list of what the release changes.</p>\n      </>\n    ),\n  }}\n  more={{ posts: [{ href: \"/blog/shared-folders\", title: \"Shared folders are here\", date: \"2026-10-28\" }] }}\n  footer={{\n    brand: \"Workspace\",\n    columns: [{ title: \"Company\", links: [{ href: \"/blog\", label: \"Blog\" }] }],\n    legal: \"© 2026 Workspace\",\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "pricing-page",
      "type": "registry:block",
      "title": "Pricing Page",
      "description": "A whole pricing page, built from our website components: a floating navbar, a two-tone page title, the plans side by side, a comparison, a testimonial wall, a FAQ, and a footer.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/navbar",
        "@txbrendan/headline",
        "@txbrendan/pricing",
        "@txbrendan/comparison",
        "@txbrendan/testimonial-wall",
        "@txbrendan/faq",
        "@txbrendan/footer"
      ],
      "files": [
        {
          "path": "registry/txbrendan/templates/pricing-page/pricing-page.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "pricing",
        "marketing",
        "page"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The pricing page of a product site. Render it as the whole page, from your app's page file.",
            "Write every word in the props: one object for the navbar, the header, the plans, the comparison, the testimonials, the FAQ, and the footer.",
            "Put the plans first, under a header that says who they are for. People come to this page to see prices.",
            "Use `note` for what every plan shares, like the currency, tax, or a free trial.",
            "Use the comparison for what each plan includes, and the FAQ for billing questions, like canceling or refunds.",
            "Use testimonials for what customers say about the price and what it got them. Put the heading in the section's `headline`, not the wall's own `title`.",
            "Pass your router's link once in `renderLink`. The navbar and the footer both use it.",
            "Set `embedded` when you show the page inside another page, like a gallery, so it has no second main."
          ],
          "avoid": [
            "More than three plans. Pick the three most people choose from.",
            "A price or a promise you can't stand behind, like \"cancel any time\" when you can't.",
            "A hero with a product preview. That belongs on the Landing Page template."
          ]
        },
        "props": [
          {
            "name": "navbar",
            "type": "NavbarProps",
            "description": "The Navbar's props. It floats as a pill once the page scrolls, and folds into a menu below 42rem."
          },
          {
            "name": "header",
            "type": "HeadlineProps",
            "description": "The headline at the top: the claim, the gray rest, and a line under it. It renders as the page's one h1."
          },
          {
            "name": "plans",
            "type": "PricingCardProps[]",
            "description": "The plans, one PricingCard each, side by side. Two or fewer sit in a narrower row, centered."
          },
          {
            "name": "plansLabel",
            "type": "string",
            "default": "\"Plans\"",
            "description": "Names the plans for screen readers, as a hidden h2 over the cards."
          },
          {
            "name": "billing",
            "type": "PricingBilling",
            "description": "Pricing's billing switch, over the plans, between periods like monthly and yearly. Give each plan's price and period one entry per period id."
          },
          {
            "name": "note",
            "type": "ReactNode",
            "description": "A small line under the plans, like \"Prices in USD, before tax.\""
          },
          {
            "name": "comparison",
            "type": "{ id?: string; headline?: HeadlineProps; comparison: ComparisonProps }",
            "description": "A Comparison under its own headline. Its id defaults to \"compare\"."
          },
          {
            "name": "testimonials",
            "type": "{ id?: string; headline?: HeadlineProps; testimonials: TestimonialWallProps }",
            "description": "A Testimonial Wall under its own headline, between the comparison and the FAQ. Its id defaults to \"testimonials\"."
          },
          {
            "name": "faq",
            "type": "{ id?: string; headline?: HeadlineProps; faq: FaqProps }",
            "description": "A FAQ under its own headline. Its id defaults to \"faq\"."
          },
          {
            "name": "footer",
            "type": "FooterProps",
            "description": "The Footer's props: the brand, a line about you, link columns, and the legal line."
          },
          {
            "name": "renderLink",
            "type": "(props: NavbarLinkProps) => ReactNode",
            "default": "a plain <a>",
            "description": "Draws every link in the navbar and the footer, unless a part sets its own."
          },
          {
            "name": "embedded",
            "type": "boolean",
            "default": "false",
            "description": "Renders the content in a div, not a main, for a preview inside another page."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the root, which holds the container query."
          }
        ],
        "a11y": [
          "The content sits in a main landmark, between the navbar's nav and the footer, so skip links and screen readers find it.",
          "The header renders the page's one h1. The comparison's, the testimonials', and the FAQ's headlines are h2s.",
          "The plans sit in a section named by a hidden h2, so the cards' h3 titles follow in order and screen readers can jump to the plans.",
          "The comparison, the testimonials, and the FAQ keep clear of the floating navbar when a link jumps to their ids.",
          "Each part keeps its own accessibility, listed on its page."
        ],
        "examples": [
          {
            "title": "A short pricing page",
            "code": "<PricingPage\n  navbar={{\n    brand: \"Workspace\",\n    links: [{ href: \"/pricing\", label: \"Pricing\", current: true }, { href: \"#faq\", label: \"FAQ\" }],\n    actions: <Button tone=\"brand\" size=\"sm\">Create a team</Button>,\n  }}\n  header={{ title: \"One price per team.\", muted: \"Everyone on it is free.\" }}\n  plans={[\n    {\n      title: \"Team\",\n      price: \"$12\",\n      period: \"a month\",\n      features: [\"Who's coming\", \"Reminder emails\"],\n      action: <Button tone=\"brand\">Start</Button>,\n    },\n  ]}\n  note=\"Prices in USD, before tax.\"\n  faq={{\n    headline: { title: \"Questions.\" },\n    faq: { items: [{ question: \"Can I cancel?\", answer: \"Yes, from your settings.\" }] },\n  }}\n  footer={{\n    brand: \"Workspace\",\n    columns: [{ title: \"Product\", links: [{ href: \"/pricing\", label: \"Pricing\" }] }],\n    legal: \"© 2026 Workspace\",\n  }}\n/>"
          }
        ]
      }
    },
    {
      "name": "projects",
      "type": "registry:block",
      "title": "Projects",
      "description": "A projects page with board, list, and timeline layouts that share one shell per project.",
      "dependencies": [],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/badge",
        "@txbrendan/card",
        "@txbrendan/progress",
        "@txbrendan/ring-progress",
        "@txbrendan/search-field",
        "@txbrendan/select",
        "@txbrendan/skeleton",
        "@txbrendan/tooltip",
        "@txbrendan/view-morph",
        "@txbrendan/view-switch"
      ],
      "files": [
        {
          "path": "registry/txbrendan/templates/projects/projects.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/projects/projects-data.ts",
          "type": "registry:component"
        }
      ],
      "categories": [
        "dashboard",
        "projects"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A page of projects someone switches between a board, a list, and a timeline.",
            "Pass projects, and a today date, when the records are yours. The defaults are a worked example, and the page says so.",
            "Set embedded when the page sits inside another page, so it does not add a second main.",
            "Set loading while the records are on the way. The skeleton matches the layout. An empty projects array is the empty page. A search or priority that matches nothing says so, and a filter that matches some says how many of the total.",
            "Set error to a sentence that says what to do next, when the records could not be loaded."
          ],
          "avoid": [
            "A table you sort and page. The list is one of three layouts of the same shells, not a data table.",
            "A second accent color. Orange is only the high pill. Red is only the date line. Timeline bars share one neutral fill."
          ]
        },
        "props": [
          {
            "name": "projects",
            "type": "Project[]",
            "description": "The records. Default is a worked example."
          },
          {
            "name": "today",
            "type": "string",
            "description": "The date the page treats as today, YYYY-MM-DD. Omit it and the page uses 2026-10-27 and says that date is a fixed example. A project due before the date, and not done, is overdue."
          },
          {
            "name": "loading",
            "type": "boolean",
            "default": "false",
            "description": "Shows a skeleton in the current layout's shape instead of the records."
          },
          {
            "name": "error",
            "type": "string",
            "description": "Replaces the records with this message. Say what to do next."
          },
          {
            "name": "embedded",
            "type": "boolean",
            "default": "false",
            "description": "Renders a div instead of a main, for a preview inside another page."
          },
          {
            "name": "titleLevel",
            "type": "\"h1\" | \"h2\"",
            "description": "Heading level of the title. Default h1, or h2 when embedded."
          },
          {
            "name": "defaultLayout",
            "type": "\"board\" | \"list\" | \"timeline\"",
            "default": "\"board\"",
            "description": "The layout on first paint, when layout is not set."
          },
          {
            "name": "layout",
            "type": "\"board\" | \"list\" | \"timeline\"",
            "description": "The layout that is showing. Use with onLayoutChange."
          },
          {
            "name": "onLayoutChange",
            "type": "(layout: \"board\" | \"list\" | \"timeline\") => void",
            "description": "Called with the layout that was just chosen."
          },
          {
            "name": "defaultQuery",
            "type": "string",
            "default": "\"\"",
            "description": "The search box on first paint."
          },
          {
            "name": "defaultPriority",
            "type": "\"all\" | \"urgent\" | \"high\" | \"medium\" | \"low\"",
            "default": "\"all\"",
            "description": "The priority filter on first paint."
          },
          {
            "name": "interactive",
            "type": "boolean",
            "default": "true",
            "description": "When false, the project controls leave the tab order. The layout switch stays."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Classes on the root, which holds the container query."
          },
          {
            "name": "Part. Project",
            "type": "{ id: string; name: string; client: string; summary: string; stage: \"todo\" | \"progress\" | \"review\" | \"done\"; priority: \"urgent\" | \"high\" | \"medium\" | \"low\"; done: number; total: number; start: string; due: string; roles: string[] }",
            "description": "One project. id is stable across layouts. start and due are YYYY-MM-DD. roles are job names, such as Lead, not people."
          }
        ],
        "a11y": [
          "The page is a main, unless embedded. The title is an h1, or an h2 when embedded, unless titleLevel says otherwise.",
          "The layout control is a tab list. The morph is the tab panel, named by the active tab.",
          "The board is a list of lists: one list named Board, and a list named for each stage. The list is a table. The timeline groups its rows, and each group is a list named for its stage.",
          "Each project has one button, named with the project, the client, and the priority. The card, the row, and the timeline name are that button. The priority pill is named with its level. A tooltip carries the summary, the priority, the roles, and the task count. Enter or Space selects it. A layout change that drops focus puts it back on that button.",
          "Set interactive to false to take those buttons out of the tab order. The layout switch stays in it.",
          "A timeline bar's text equivalent is the name, the priority, the start, the due date, and the percent. The row shows that priority once, on the pill.",
          "A status outside the busy region says when the page is loading, empty, or filtered to nothing.",
          "The move follows the motion tokens. Motion off and reduced motion do not translate the shells."
        ],
        "examples": [
          {
            "title": "The worked example",
            "code": "<Projects />"
          },
          {
            "title": "Inside another page",
            "code": "<Projects embedded interactive={false} titleLevel=\"h1\" />"
          }
        ]
      }
    },
    {
      "name": "projects-page",
      "type": "registry:block",
      "title": "Projects Page",
      "description": "A whole projects page for an app: the left rail beside one set of projects shown as a board, a list, or a timeline. On narrow screens the rail moves into a menu.",
      "dependencies": [
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/project-views",
        "@txbrendan/rail",
        "@txbrendan/sheet",
        "@txbrendan/button",
        "@txbrendan/tooltip"
      ],
      "files": [
        {
          "path": "registry/txbrendan/templates/projects-page/projects-page.tsx",
          "type": "registry:component"
        }
      ],
      "categories": [
        "dashboard",
        "projects",
        "page"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "The projects screen of an app. Render it as the whole page, from your app's page file.",
            "Pass the rail's places in `rail` and the projects in `projectViews`. Each takes the props of its own part.",
            "Mark the place you're in with the rail's `active` or `defaultActive`. The rail beside the page and the one in the menu stay in step.",
            "Render one Toaster on the page, so moving or removing a project can offer Undo.",
            "Set `embedded` when you show the page inside another page, like a gallery, so it has no second main."
          ],
          "avoid": [
            "The rail's `onSearch` here. The rail draws twice, beside the page and in the menu, so its shortcut would listen twice. Put search in a Command Menu instead.",
            "A hero, a tagline, or a feature grid on top. An app screen leads with the work."
          ]
        },
        "props": [
          {
            "name": "rail",
            "type": "RailProps",
            "description": "Required. The Rail's props: the app's name, the places, and Settings. Below 40rem the rail moves into a menu, and its header sits beside the menu button."
          },
          {
            "name": "projectViews",
            "type": "ProjectViewsProps",
            "description": "Required. The Project Views block's props: the projects, the stages, today, and the view to start on."
          },
          {
            "name": "menuLabel",
            "type": "string",
            "default": "\"Menu\"",
            "description": "Names the menu button and the menu on narrow screens."
          },
          {
            "name": "embedded",
            "type": "boolean",
            "default": "false",
            "description": "Renders the content in a div, not a main, for a preview inside another page."
          },
          {
            "name": "className",
            "type": "string",
            "description": "Extra classes on the page. Later classes win."
          }
        ],
        "a11y": [
          "The content sits in one main, unless embedded is set.",
          "On narrow screens the menu button has its label as its name and its tooltip, and the menu opens as a dialog named by the same label. Choosing a place closes it.",
          "The rail and the projects keep their own keyboard and screen reader behavior."
        ],
        "examples": [
          {
            "title": "Projects beside the app's rail",
            "code": "<ProjectsPage\n  rail={{\n    header: <span className=\"px-2 font-(--type-weight-strong)\">Workspace</span>,\n    groups: [{ items: [{ id: \"home\", label: \"Overview\", icon: \"house\" }, { id: \"projects\", label: \"Projects\", icon: \"file-text\" }] }],\n    defaultActive: \"projects\",\n    footer: { id: \"settings\", label: \"Settings\", icon: \"settings\" },\n  }}\n  projectViews={{\n    today: \"2026-10-30\",\n    defaultProjects: [\n      { id: \"help\", name: \"Help center\", stage: \"review\", start: \"2026-10-12\", due: \"2026-10-31\" },\n      { id: \"payments\", name: \"Payments page\", stage: \"doing\", start: \"2026-10-19\", due: \"2026-11-06\" },\n    ],\n  }}\n/>"
          },
          {
            "title": "A narrow-screen menu with its own name",
            "code": "<ProjectsPage\n  menuLabel=\"Places\"\n  rail={{\n    groups: [{ items: [{ id: \"home\", label: \"Overview\", icon: \"house\" }, { id: \"projects\", label: \"Projects\", icon: \"file-text\" }] }],\n    defaultActive: \"projects\",\n  }}\n  projectViews={{ today: \"2026-10-30\" }}\n/>"
          }
        ]
      }
    },
    {
      "name": "site",
      "type": "registry:block",
      "title": "Site",
      "description": "A whole product website in page files: home, pricing, a blog with a page per post, a changelog, about, and contact, with one navbar and footer, ready to run in a Next.js app.",
      "dependencies": [
        "class-variance-authority@^0.7.1",
        "lucide-react@^1.59.0"
      ],
      "registryDependencies": [
        "utils",
        "@txbrendan/theme",
        "@txbrendan/button-variants",
        "@txbrendan/navbar",
        "@txbrendan/footer",
        "@txbrendan/hero",
        "@txbrendan/landing-page",
        "@txbrendan/pricing-page",
        "@txbrendan/contact-page",
        "@txbrendan/about-page",
        "@txbrendan/blog-page",
        "@txbrendan/post-page",
        "@txbrendan/post-grid",
        "@txbrendan/changelog-page",
        "@txbrendan/changelog"
      ],
      "files": [
        {
          "path": "registry/txbrendan/templates/site/site.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-about-page.tsx",
          "type": "registry:page",
          "target": "app/about/page.tsx"
        },
        {
          "path": "registry/txbrendan/templates/site/site-about.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-blog-page.tsx",
          "type": "registry:page",
          "target": "app/blog/page.tsx"
        },
        {
          "path": "registry/txbrendan/templates/site/site-blog.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-changelog-page.tsx",
          "type": "registry:page",
          "target": "app/changelog/page.tsx"
        },
        {
          "path": "registry/txbrendan/templates/site/site-changelog.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-contact-action.ts",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-contact-page.tsx",
          "type": "registry:page",
          "target": "app/contact/page.tsx"
        },
        {
          "path": "registry/txbrendan/templates/site/site-contact.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-home-page.tsx",
          "type": "registry:page",
          "target": "app/page.tsx"
        },
        {
          "path": "registry/txbrendan/templates/site/site-home.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-post-bodies.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-post-page.tsx",
          "type": "registry:page",
          "target": "app/blog/[slug]/page.tsx"
        },
        {
          "path": "registry/txbrendan/templates/site/site-post.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-posts.ts",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-pricing-page.tsx",
          "type": "registry:page",
          "target": "app/pricing/page.tsx"
        },
        {
          "path": "registry/txbrendan/templates/site/site-pricing.tsx",
          "type": "registry:component"
        },
        {
          "path": "registry/txbrendan/templates/site/site-releases.ts",
          "type": "registry:component"
        }
      ],
      "categories": [
        "site",
        "marketing",
        "pages"
      ],
      "meta": {
        "status": "beta",
        "usage": {
          "use": [
            "A new product website in a Next.js app. Install it, and each page lands at its route, like app/pricing/page.tsx.",
            "Start from a new app. It writes app/page.tsx as your home page, and shadcn asks before it replaces yours.",
            "Edit each page's words in its own file, like site-pricing.tsx. The navbar, the footer, and your logo live in site.tsx, so every page shares them.",
            "Add a post in two places: its card in site-posts.ts, and its body in site-post-bodies.tsx, under the same slug. Each post gets its own page at /blog/<slug> when you build.",
            "Write each body in plain HTML, with two or more h2s for its list of sections. Give each h2 an id, so a link to a section works before the page's code loads. The body renders on the server, so it is not sent as code.",
            "Add a release at the top of site-releases.ts. Its version is also its id, so a link to /changelog#3-11 lands on it.",
            "Connect the contact form in site-contact-action.ts, which runs on your server. Until you do, the form says the message did not send.",
            "Each page loads only its own parts, so a visit to one page does not load the others."
          ],
          "avoid": [
            "An app that already has these routes. Install the page templates one at a time instead, like Pricing Page.",
            "A route group, like app/(site)/. Next.js builds it beside app/page.tsx with no warning, and the starter page wins."
          ]
        },
        "props": [],
        "a11y": [
          "Every page has one main, one h1, and the same navbar and footer, and the navbar marks the page you are on.",
          "Each page sets its own title and description, so each tab and search result says which page it is.",
          "Each page template keeps its own accessibility, listed on its page."
        ],
        "examples": [
          {
            "title": "Install the whole site",
            "code": "npx shadcn@latest add @txbrendan/site"
          },
          {
            "title": "Send the contact form with your email service",
            "code": "\"use server\";\n\nexport async function sendMessage(values: Record<string, string>) {\n  const response = await fetch(\"https://api.example.com/send\", {\n    method: \"POST\",\n    body: JSON.stringify(values),\n  });\n  if (!response.ok) throw new Error(\"Not sent\");\n}"
          }
        ]
      }
    }
  ]
}
