{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "newsletter-signup",
  "type": "registry:block",
  "title": "Newsletter signup",
  "description": "An email signup framed by a stack of past issues; subscribing drops the next issue, addressed to you, onto the front.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/media.json",
    "https://irisui.dev/r/animated-counter.json",
    "https://irisui.dev/r/segmented-control.json",
    "https://irisui.dev/r/switch.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/blocks/newsletter-signup/newsletter-signup-data.ts",
      "content": "import { photo, type PhotoId } from \"../../lib/media\";\n\n/** One story teased in an issue. `image` is a small square-cropped thumbnail. */\nexport interface NewsletterStory {\n  title: string;\n  image: string;\n  minutes: number;\n}\n\n/** One issue of the newsletter, as it appears on the stack. */\nexport interface NewsletterIssue {\n  number: number;\n  /** Send date as it should read, such as \"Friday, September 25\". */\n  date: string;\n  subject: string;\n  /** Up to three stories. */\n  stories: NewsletterStory[];\n}\n\n/** The publication shown beside the form: the next issue lands on the stack of recent ones when someone subscribes. */\nexport interface NewsletterPublication {\n  name: string;\n  /** The next issue. It is addressed to the new reader and joins the front of the stack on success. */\n  upcoming: NewsletterIssue;\n  /** Recent issues, newest first. The first three make the stack. */\n  recent: NewsletterIssue[];\n}\n\nconst story = (title: string, image: PhotoId, minutes: number): NewsletterStory => ({\n  title,\n  image: photo(image).src,\n  minutes,\n});\n\nexport const newsletterCopy = {\n  inline: {\n    title: \"Five minutes on humane work design, every Friday\",\n    description:\n      \"One short essay and three notes on software that adapts to changing capacity, from the Kumbatio team.\",\n  },\n  card: {\n    title: \"Get the next letter on Friday\",\n    description: \"One essay and three notes on working with your energy, not against it. Five minutes, once a week.\",\n  },\n  privacy: \"No tracking pixels. Unsubscribe with one click.\",\n  privacyLink: { label: \"Privacy policy\", href: \"#privacy\" },\n};\n\n/** Sample publication: the Kumbatio letter. Dates follow a Friday send; thumbnails are the flat placeholders in `@/lib/media`. */\nexport const newsletterPublication: NewsletterPublication = {\n  name: \"Kumbatio notes\",\n  upcoming: {\n    number: 149,\n    date: \"Friday, 2 Oct\",\n    subject: \"Rest is empty, never an alarm\",\n    stories: [\n      story(\"What a screen shows when the level is Rest\", \"kumbatio-levels\", 5),\n      story(\"Deferral as a strategy, not a failure\", \"meltemi-inbox\", 4),\n      story(\"Five levels, one shared library\", \"anasa-note\", 7),\n    ],\n  },\n  recent: [\n    {\n      number: 148,\n      date: \"Friday, 25 Sep\",\n      subject: \"Reading a day as a demand ledger\",\n      stories: [\n        story(\"A reply-all thread of 41 asks for 40 min\", \"meltemi-inbox\", 4),\n        story(\"Counting attention in minutes, not messages\", \"kumbatio-levels\", 3),\n        story(\"Why a shipping notice still costs 2 min\", \"meltemi-inbox\", 6),\n      ],\n    },\n    {\n      number: 147,\n      date: \"Friday, 18 Sep\",\n      subject: \"Interfaces that show less on a Low day\",\n      stories: [\n        story(\"The ui-visibility strategy in practice\", \"mpath-board\", 5),\n        story(\"Notifications that wait for a fuller day\", \"kumbatio-levels\", 3),\n        story(\"Interaction forgiveness and the undo window\", \"notdocs-spec\", 4),\n      ],\n    },\n    {\n      number: 146,\n      date: \"Friday, 11 Sep\",\n      subject: \"Productive hours fit inside work hours\",\n      stories: [\n        story(\"ph ≤ wh, explained on one page\", \"kumbatio-levels\", 4),\n        story(\"Planning at Peak, reviewing at Low\", \"mpath-board\", 3),\n        story(\"Demand admission for mixed-neurotype teams\", \"notdocs-spec\", 5),\n      ],\n    },\n  ],\n};\n\n/** Readers shown under the stack and in the card. No faces by default; pass image URLs to show up to three. */\nexport const newsletterReaders: { count: number; faces: string[] } = {\n  count: 1240,\n  faces: [],\n};\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/newsletter-signup/newsletter-signup-data.ts"
    },
    {
      "path": "src/registry/blocks/newsletter-signup/newsletter-signup.tsx",
      "content": "\"use client\";\n\nimport { forwardRef, useEffect, useId, useRef, useState } from \"react\";\nimport type { FormEvent, ReactNode } from \"react\";\nimport Image from \"next/image\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { ArrowRight } from \"lucide-react\";\nimport AnimatedCounter from \"../../animated-counter/animated-counter\";\nimport SegmentedControl from \"../../segmented-control/segmented-control\";\nimport { Switch } from \"../../switch/switch\";\nimport { motionTokens } from \"../../lib/motion-tokens\";\nimport { cn } from \"../../lib/utils\";\nimport { newsletterCopy, newsletterPublication, newsletterReaders } from \"./newsletter-signup-data\";\nimport type { NewsletterIssue, NewsletterPublication } from \"./newsletter-signup-data\";\n\nexport type { NewsletterIssue, NewsletterPublication, NewsletterStory } from \"./newsletter-signup-data\";\nexport type NewsletterVariant = \"inline\" | \"card\";\n\nexport interface NewsletterSignupProps {\n  /** `inline` puts the copy and form beside the issue stack; `card` is a self-contained card with the stack in a tray on top. */\n  variant?: NewsletterVariant;\n  title?: string;\n  description?: string;\n  placeholder?: string;\n  buttonLabel?: string;\n  /** Short line under the form about frequency and privacy. */\n  privacyNote?: ReactNode;\n  /** Link after the privacy note. Pass null to hide it. */\n  privacyLink?: { label: string; href: string } | null;\n  /** Reader count and up to three faces. The count ticks up by one when someone subscribes. Pass null to hide it. */\n  readers?: { count: number; faces: string[] } | null;\n  /** The issue stack. On success the upcoming issue, addressed to the new reader, lands on top. Pass null for a form without the stack. */\n  publication?: NewsletterPublication | null;\n  /** Called with a valid, trimmed email. Reject to show an error and keep the email; resolve to show the success state. */\n  onSubscribe?: (email: string) => void | Promise<void>;\n  className?: string;\n}\n\ntype Phase = \"idle\" | \"sending\" | \"done\";\ntype Problem = { kind: \"invalid\" | \"failed\"; text: string };\ntype Bezier = [number, number, number, number];\nconst enter = [...motionTokens.ease.enter] as Bezier;\nconst standard = [...motionTokens.ease.standard] as Bezier;\nconst MESSAGES = {\n  empty: \"Enter your email address\",\n  format: \"Enter an email like name@company.com\",\n  failed: \"That didn't go through. Your email is kept, so try again.\",\n};\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]{2,}$/;\n/** How far each issue behind the front one peeks out above it. */\nconst STEP = 12;\n\n/*\n * Container steps of the `newsletter` container: 860 px (inline stacks), 520 px (tighter gutters, smaller issue),\n * 400 px (stories drop their minutes, an addressed issue drops its date), 360 px (the reader line may wrap).\n * The form is its own `signup` container: below 300 px the field and the button separate and stack.\n */\nconst issueText = \"text-xs leading-body text-ink-3 tabular-nums\";\nconst descriptionClass = \"mb-0 leading-body text-ink-2 text-pretty\";\nconst noteClass = `m-0 pl-[22px] text-sm leading-body text-ink-3 text-pretty @max-[520px]/newsletter:pl-1 @max-[300px]/signup:pl-1`;\nconst textLink =\n  \"text-ink underline decoration-line-strong underline-offset-3 transition-[text-decoration-color] duration-fast ease-standard hover:decoration-current\";\nconst labelSlot = \"inline-flex items-center gap-2 [grid-area:1/1]\";\nconst check = \"inline-flex size-4 [&_svg]:size-4\";\nconst displayHeading = \"m-0 font-display font-medium leading-display tracking-display text-balance\";\n\nfunction validate(value: string): Problem | null {\n  const email = value.trim();\n  if (!email) return { kind: \"invalid\", text: MESSAGES.empty };\n  if (!EMAIL.test(email)) return { kind: \"invalid\", text: MESSAGES.format };\n  return null;\n}\n\nconst swapIn = { opacity: 0, y: 6, filter: `blur(${motionTokens.blur.subtle}px)` };\nconst swapShown = { opacity: 1, y: 0, filter: \"blur(0px)\" };\nconst swapOut = {\n  opacity: 0,\n  y: -6,\n  filter: `blur(${motionTokens.blur.subtle}px)`,\n  transition: { duration: motionTokens.duration.fast, ease: standard },\n};\nconst fadeIn = { opacity: 0 };\nconst fadeOut = { opacity: 0, transition: { duration: motionTokens.duration.instant } };\n\n/** A line that swaps in place: the old text lifts away and the new one rises out of a soft blur. */\nfunction Swap({\n  id,\n  children,\n  reduced,\n  className,\n  live,\n}: {\n  id: string;\n  children: ReactNode;\n  reduced: boolean;\n  className?: string;\n  live?: \"polite\";\n}) {\n  return (\n    <div className={className} aria-live={live}>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.div\n          key={id}\n          initial={reduced ? fadeIn : swapIn}\n          animate={swapShown}\n          exit={reduced ? fadeOut : swapOut}\n          transition={{\n            duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard,\n            ease: enter,\n          }}\n        >\n          {children}\n        </motion.div>\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/** One issue: masthead, recipient line, subject, and three stories with thumbnails. */\nfunction IssueCard({\n  issue,\n  name,\n  to,\n  compact,\n}: {\n  issue: NewsletterIssue;\n  name: string;\n  to?: string;\n  compact?: boolean;\n}) {\n  /* An addressed issue keeps the recipient over the date once space runs short. */\n  const dateClass = to ? \"@max-[400px]/newsletter:hidden\" : undefined;\n  return (\n    <>\n      <header className=\"flex items-baseline justify-between gap-3\">\n        <span className=\"font-display text-xl leading-display font-medium tracking-display\">{name}</span>\n        <span className={issueText}>Issue {issue.number}</span>\n      </header>\n      <p\n        className={cn(\n          issueText,\n          \"mt-0.5 mb-0 flex min-w-0 gap-1.5 whitespace-nowrap [&>span:last-child]:min-w-0 [&>span:last-child]:truncate\",\n        )}\n      >\n        <span className={dateClass}>{issue.date}</span>\n        {to && (\n          <>\n            <span className={dateClass} aria-hidden=\"true\">\n              &middot;\n            </span>\n            <span className=\"text-ink-2\">\n              To <span className=\"text-ink\">{to}</span>\n            </span>\n          </>\n        )}\n      </p>\n      <h3\n        className={`mt-5 mb-4 box-content min-h-[calc(2em*var(--leading-display))] border-t border-line pt-5 font-display leading-display font-medium tracking-display text-balance ${compact ? \"text-xl\" : `text-2xl @max-[520px]/newsletter:text-xl`}`}\n      >\n        {issue.subject}\n      </h3>\n      <ul className=\"m-0 grid list-none gap-2.5 p-0\">\n        {issue.stories.slice(0, 3).map((story) => (\n          <li\n            key={story.title}\n            className=\"grid min-h-11 grid-cols-[44px_minmax(0,1fr)_auto] items-center gap-3 @max-[400px]/newsletter:grid-cols-[44px_minmax(0,1fr)]\"\n          >\n            <Image\n              className=\"block size-11 rounded-[10px] bg-hover object-cover\"\n              src={story.image}\n              alt=\"\"\n              width={88}\n              height={88}\n              sizes=\"44px\"\n            />\n            <span className=\"line-clamp-2 overflow-hidden text-sm leading-body text-ink-2 text-pretty\">\n              {story.title}\n            </span>\n            <span className=\"text-xs/[inherit] whitespace-nowrap text-ink-3 tabular-nums @max-[400px]/newsletter:hidden\">\n              {story.minutes} min\n            </span>\n          </li>\n        ))}\n      </ul>\n    </>\n  );\n}\n\n/** The paper of an issue: the front one is lit and lifted, the ones behind are flatter and darker. */\nfunction issueClass(depth: number, flat: boolean) {\n  return cn(\n    `grid min-w-0 origin-top content-start rounded-[22px] border border-line bg-(--paper) px-[22px] pt-[22px] pb-[18px] [grid-area:1/1] [box-shadow:var(--paper-shadow)] transition-[background-color] duration-considered ease-standard motion-reduce:transition-none @max-[520px]/newsletter:rounded-[20px] @max-[520px]/newsletter:px-[18px] @max-[520px]/newsletter:pt-[18px] @max-[520px]/newsletter:pb-4`,\n    depth === 1 && \"bg-(--paper-back) [box-shadow:none]\",\n    depth === 2 && \"bg-(--paper-far) [box-shadow:none]\",\n    flat && \"[box-shadow:none]\",\n  );\n}\n\ntype Placement = { depth: number; upcoming: boolean };\n/** The front issue sits flat; each one behind steps up and shrinks a little so its top edge shows. */\nconst stackVariants: Variants = {\n  placed: ({ depth }: Placement) => ({ opacity: 1, y: -depth * STEP, scale: 1 - depth * 0.045 }),\n  // The upcoming issue arrives from below, where the form is; older ones slide in from behind.\n  away: ({ upcoming }: Placement) =>\n    upcoming ? { opacity: 0, y: 56, scale: 1 } : { opacity: 0, y: -3 * STEP, scale: 1 - 3 * 0.045 },\n};\n/** Position rides the no-overshoot spring; opacity resolves fast so two issues never read through each other. */\nconst settle = { ...motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant, ease: standard } };\n/** Reduced motion: issues take their places at once and only fade. */\nconst still = { duration: 0, opacity: { duration: motionTokens.duration.fast } };\n\n/**\n * A stack of recent issues. When someone subscribes, the upcoming issue, addressed to them, lands on the front\n * and the older ones step back; the oldest drops away.\n */\nfunction IssueStack({\n  publication,\n  delivered,\n  to,\n  reduced,\n  compact,\n  className,\n}: {\n  publication: NewsletterPublication;\n  delivered: boolean;\n  to: string;\n  reduced: boolean;\n  compact?: boolean;\n  className?: string;\n}) {\n  const issues = (delivered ? [publication.upcoming, ...publication.recent] : publication.recent).slice(0, 3);\n  return (\n    <div className={cn(\"grid w-full max-w-[440px] pt-6\", className)} data-compact={compact ? \"\" : undefined}>\n      {/* An unseen copy of the upcoming issue keeps the stack as tall as the tallest issue it will ever hold. */}\n      <div className={cn(issueClass(0, true), \"invisible\")} aria-hidden=\"true\">\n        <IssueCard issue={publication.upcoming} name={publication.name} to={to || \" \"} compact={compact} />\n      </div>\n      <AnimatePresence initial={false}>\n        {issues.map((issue, depth) => {\n          const upcoming = issue === publication.upcoming;\n          const placement: Placement = { depth, upcoming };\n          return (\n            <motion.article\n              key={issue.number}\n              className={issueClass(depth, Boolean(compact))}\n              data-depth={depth}\n              aria-hidden={depth > 0 ? true : undefined}\n              aria-label={depth === 0 ? `${publication.name}, issue ${issue.number}` : undefined}\n              custom={placement}\n              variants={stackVariants}\n              initial=\"away\"\n              animate=\"placed\"\n              exit=\"away\"\n              transition={reduced ? still : settle}\n              style={{ zIndex: 3 - depth }}\n            >\n              <IssueCard issue={issue} name={publication.name} to={upcoming ? to : undefined} compact={compact} />\n            </motion.article>\n          );\n        })}\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/** Faces and a reader count that ticks up by one when you join. */\nfunction Readers({\n  readers,\n  done,\n  reduced,\n  inCard,\n}: {\n  readers: { count: number; faces: string[] };\n  done: boolean;\n  reduced: boolean;\n  inCard?: boolean;\n}) {\n  return (\n    <div\n      className={cn(\n        \"flex items-center gap-3 text-sm leading-body text-ink-2 @max-[400px]/newsletter:gap-2 @max-[360px]/newsletter:items-start\",\n        inCard && \"mt-5 border-t border-line-soft pt-5\",\n      )}\n    >\n      <span className=\"flex flex-none pl-1.5\" aria-hidden=\"true\">\n        {readers.faces.slice(0, 3).map((src) => (\n          <Image\n            key={src}\n            className={cn(\n              \"-ml-1.5 block size-7 rounded-full border-2 border-page object-cover @max-[400px]/newsletter:size-6\",\n              inCard && \"border-surface\",\n            )}\n            src={src}\n            alt=\"\"\n            width={56}\n            height={56}\n            sizes=\"28px\"\n          />\n        ))}\n      </span>\n      <span className=\"min-w-0 whitespace-nowrap tabular-nums @max-[360px]/newsletter:min-h-[calc(2*1.4em)] @max-[360px]/newsletter:whitespace-normal\">\n        <span className=\"inline-block text-ink [&>span]:align-bottom [&>span]:[font-family:inherit] [&>span]:[font-size:inherit] [&>span]:font-medium [&>span]:[line-height:inherit] [&>span]:[letter-spacing:inherit] [&>span]:text-inherit\">\n          <AnimatedCounter value={readers.count + (done ? 1 : 0)} />\n        </span>{\" \"}\n        readers\n        <AnimatePresence initial={false}>\n          {/* The phrase opens its own width on the no-overshoot spring, so the centred line glides instead of jumping sideways. */}\n          {done && (\n            <motion.span\n              key=\"you\"\n              className=\"inline-block overflow-hidden align-bottom whitespace-nowrap text-ink-2\"\n              initial={reduced ? { opacity: 0 } : { opacity: 0, width: 0 }}\n              animate={reduced ? { opacity: 1 } : { opacity: 1, width: \"auto\" }}\n              exit={reduced ? { opacity: 0 } : { opacity: 0, width: 0 }}\n              transition={\n                reduced\n                  ? { duration: motionTokens.duration.instant }\n                  : {\n                      width: motionTokens.spring.smooth,\n                      opacity: { duration: motionTokens.duration.standard, ease: enter, delay: 0.12 },\n                    }\n              }\n            >\n              , including you\n            </motion.span>\n          )}\n        </AnimatePresence>\n      </span>\n    </div>\n  );\n}\n\n/**\n * A newsletter signup framed by the newsletter itself: a stack of recent issues. Subscribing drops the next issue,\n * addressed to the new reader, onto the front of the stack and ticks the reader count up by one.\n * Comes as an inline page section or a self-contained card.\n */\nexport const NewsletterSignup = forwardRef<HTMLElement, NewsletterSignupProps>(function NewsletterSignup(\n  {\n    variant = \"inline\",\n    title,\n    description,\n    placeholder = \"you@company.com\",\n    buttonLabel = \"Subscribe\",\n    privacyNote = newsletterCopy.privacy,\n    privacyLink = newsletterCopy.privacyLink,\n    readers = newsletterReaders,\n    publication = newsletterPublication,\n    onSubscribe,\n    className,\n  },\n  ref,\n) {\n  const id = useId();\n  const reduced = !!useReducedMotion();\n  const [email, setEmail] = useState(\"\");\n  const [problem, setProblem] = useState<Problem | null>(null);\n  const [tried, setTried] = useState(false);\n  const [phase, setPhase] = useState<Phase>(\"idle\");\n  const [sentTo, setSentTo] = useState(\"\");\n  const input = useRef<HTMLInputElement>(null);\n  const again = useRef<HTMLButtonElement>(null);\n  const busy = useRef(false);\n  const refocus = useRef(false);\n  const [pill, animatePill] = useAnimate<HTMLDivElement>();\n  const copy = newsletterCopy[variant];\n\n  useEffect(() => {\n    if (phase === \"idle\" && refocus.current) {\n      refocus.current = false;\n      input.current?.focus();\n    }\n    if (phase === \"done\") again.current?.focus();\n  }, [phase]);\n\n  function shake() {\n    if (reduced || !pill.current) return;\n    animatePill(pill.current, { x: [0, -6, 5, -3, 1, 0] }, { duration: 0.36, ease: \"easeOut\" });\n  }\n\n  async function submit(event: FormEvent) {\n    event.preventDefault();\n    if (busy.current || phase !== \"idle\") return;\n    setTried(true);\n    const found = validate(email);\n    setProblem(found);\n    if (found) {\n      shake();\n      input.current?.focus();\n      return;\n    }\n    const value = email.trim();\n    busy.current = true;\n    setPhase(\"sending\");\n    try {\n      await (onSubscribe ? onSubscribe(value) : new Promise((resolve) => setTimeout(resolve, 1100)));\n      setSentTo(value);\n      setPhase(\"done\");\n    } catch {\n      setPhase(\"idle\");\n      setProblem({ kind: \"failed\", text: MESSAGES.failed });\n      shake();\n    } finally {\n      busy.current = false;\n    }\n  }\n\n  function reset() {\n    setEmail(\"\");\n    setProblem(null);\n    setTried(false);\n    refocus.current = true;\n    setPhase(\"idle\");\n  }\n\n  const done = phase === \"done\";\n  const aside = Boolean(publication || readers);\n  const sending = phase === \"sending\";\n  const messageId = `${id}-message`;\n  const labelKey = done ? \"done\" : sending ? \"sending\" : problem?.kind === \"failed\" ? \"retry\" : \"idle\";\n  const labels: Record<string, ReactNode> = {\n    idle: (\n      <>\n        {buttonLabel}\n        <ArrowRight size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n      </>\n    ),\n    retry: (\n      <>\n        Try again\n        <ArrowRight size={16} strokeWidth={1.75} aria-hidden=\"true\" />\n      </>\n    ),\n    sending: (\n      <>\n        <span\n          className=\"size-3.5 animate-spin rounded-full border-2 border-[color-mix(in_oklab,currentColor_30%,transparent)] border-t-current [animation-duration:700ms] motion-reduce:[animation-duration:1.6s]\"\n          aria-hidden=\"true\"\n        />\n        Subscribing\n      </>\n    ),\n    done: (\n      <>\n        <span className={check} aria-hidden=\"true\">\n          <svg\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeWidth=\"2.4\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n          >\n            <motion.path\n              d=\"M5 12.5l4.5 4.5L19 7.5\"\n              initial={reduced ? false : { pathLength: 0 }}\n              animate={{ pathLength: 1 }}\n              transition={{ duration: reduced ? 0 : 0.34, ease: enter, delay: 0.12 }}\n            />\n          </svg>\n        </span>\n        Subscribed\n      </>\n    ),\n  };\n\n  const doneText = \"Check your inbox to confirm.\";\n  const note = (\n    <>\n      {privacyNote}\n      {privacyLink && (\n        <>\n          {\" \"}\n          <a className={textLink} href={privacyLink.href}>\n            {privacyLink.label}\n          </a>\n        </>\n      )}\n    </>\n  );\n  // Plain copies of the note and the confirmation, used only to reserve the line's height.\n  const measures = [\n    <>\n      {privacyNote}\n      {privacyLink && ` ${privacyLink.label}`}\n    </>,\n    `${doneText} Use a different email`,\n    MESSAGES.empty,\n    MESSAGES.format,\n    MESSAGES.failed,\n  ];\n  const form = (spacing: string) => (\n    <form\n      className={cn(\"@container/signup grid min-w-0 gap-3\", spacing)}\n      onSubmit={submit}\n      noValidate\n      aria-label={title ?? copy.title}\n    >\n      <motion.div\n        ref={pill}\n        className={cn(\n          \"relative flex h-[58px] items-center gap-1.5 rounded-full border bg-surface py-[5px] pr-[5px] pl-[22px] [transition:border-color_var(--duration-fast)_var(--ease-standard),background-color_var(--duration-standard)_var(--ease-standard)] motion-reduce:transition-none\",\n          \"@max-[300px]/signup:h-auto @max-[300px]/signup:flex-col @max-[300px]/signup:items-stretch @max-[300px]/signup:gap-2 @max-[300px]/signup:border-0 @max-[300px]/signup:bg-transparent @max-[300px]/signup:p-0\",\n          problem\n            ? \"border-red\"\n            : done\n              ? \"border-[color-mix(in_oklab,var(--green)_45%,var(--line))]\"\n              : \"border-line-strong hover:border-[color-mix(in_oklab,var(--ink)_32%,var(--line-strong))] focus-within:border-ink\",\n        )}\n        data-invalid={problem ? \"\" : undefined}\n        data-done={done ? \"\" : undefined}\n      >\n        <label htmlFor={`${id}-email`} className=\"sr-only\">\n          Email address\n        </label>\n        <input\n          ref={input}\n          id={`${id}-email`}\n          className={cn(\n            \"h-full w-full min-w-0 flex-1 p-0 text-base/[inherit] text-ink outline-none transition-[color] duration-standard ease-standard placeholder:text-ink-3 motion-reduce:transition-none\",\n            \"@max-[300px]/signup:h-[52px] @max-[300px]/signup:flex-none @max-[300px]/signup:rounded-full @max-[300px]/signup:border @max-[300px]/signup:bg-surface @max-[300px]/signup:px-5 @max-[300px]/signup:[transition:border-color_var(--duration-fast)_var(--ease-standard),color_var(--duration-standard)_var(--ease-standard)]\",\n            problem\n              ? \"@max-[300px]/signup:border-red\"\n              : done\n                ? \"@max-[300px]/signup:border-[color-mix(in_oklab,var(--green)_45%,var(--line))]\"\n                : \"@max-[300px]/signup:border-line-strong @max-[300px]/signup:focus:border-ink\",\n            done && \"text-ink-2\",\n          )}\n          type=\"email\"\n          name=\"email\"\n          inputMode=\"email\"\n          autoComplete=\"email\"\n          enterKeyHint=\"send\"\n          autoCapitalize=\"off\"\n          spellCheck={false}\n          placeholder={placeholder}\n          value={email}\n          readOnly={phase !== \"idle\"}\n          aria-invalid={problem?.kind === \"invalid\" ? true : undefined}\n          aria-describedby={messageId}\n          onBlur={() => {\n            if (email.trim() && !tried && phase === \"idle\") {\n              setTried(true);\n              setProblem(validate(email));\n            }\n          }}\n          onChange={(event) => {\n            setEmail(event.target.value);\n            if (tried) setProblem(validate(event.target.value));\n            else if (problem) setProblem(null);\n          }}\n        />\n        <motion.button\n          type={done ? \"button\" : \"submit\"}\n          className={cn(\n            \"relative inline-flex h-full min-h-11 flex-none cursor-pointer items-center justify-center rounded-button px-5 text-sm/[inherit] font-medium whitespace-nowrap [-webkit-tap-highlight-color:transparent] [transition:background-color_var(--duration-standard)_var(--ease-standard),color_var(--duration-standard)_var(--ease-standard)] motion-reduce:transition-none @max-[300px]/signup:h-12\",\n            done\n              ? \"cursor-default bg-[color-mix(in_oklab,var(--green)_13%,var(--surface))] text-green\"\n              : \"bg-ink text-page\",\n            (labelKey === \"idle\" || labelKey === \"retry\") &&\n              \"hover:bg-[color-mix(in_oklab,var(--ink)_84%,var(--page))]\",\n            sending && \"cursor-progress\",\n          )}\n          data-state={labelKey}\n          aria-busy={sending || undefined}\n          aria-disabled={sending || done || undefined}\n          tabIndex={done ? -1 : undefined}\n          whileTap={{ scale: reduced || sending || done ? 1 : 0.97 }}\n          transition={motionTokens.spring.snappy}\n        >\n          <span className=\"inline-grid place-items-center\">\n            {Object.keys(labels).map((key) => (\n              <span key={key} className={cn(labelSlot, \"invisible\")} aria-hidden=\"true\">\n                {key === \"done\" ? (\n                  <>\n                    <span className={check} />\n                    Subscribed\n                  </>\n                ) : (\n                  labels[key]\n                )}\n              </span>\n            ))}\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              <motion.span\n                key={labelKey}\n                className={labelSlot}\n                initial={reduced ? fadeIn : swapIn}\n                animate={swapShown}\n                exit={reduced ? fadeOut : swapOut}\n                transition={{\n                  duration: reduced ? motionTokens.duration.instant : motionTokens.duration.standard,\n                  ease: enter,\n                }}\n              >\n                {labels[labelKey]}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n        </motion.button>\n      </motion.div>\n      <div className=\"grid *:[grid-area:1/1] *:min-w-0\">\n        {/* Every message the line can show sits here unseen, so the line is always as tall as the longest one and nothing below it moves. */}\n        {measures.map((text, index) => (\n          <p key={index} className={cn(noteClass, \"invisible\")} aria-hidden=\"true\">\n            {text}\n          </p>\n        ))}\n        <Swap\n          id={problem ? `problem-${problem.text}` : done ? \"done\" : \"note\"}\n          reduced={reduced}\n          className=\"relative\"\n          live=\"polite\"\n        >\n          <p\n            id={messageId}\n            className={cn(noteClass, problem ? \"text-red\" : done && \"text-ink-2\")}\n            data-tone={problem ? \"error\" : done ? \"success\" : undefined}\n            role={problem ? \"alert\" : undefined}\n          >\n            {problem?.text ??\n              (done ? (\n                <>\n                  {doneText}\n                  <span className=\"sr-only\"> The link went to {sentTo}.</span>{\" \"}\n                  <button ref={again} type=\"button\" className={cn(textLink, \"cursor-pointer p-0\")} onClick={reset}>\n                    Use a different email\n                  </button>\n                </>\n              ) : (\n                note\n              ))}\n          </p>\n        </Swap>\n      </div>\n    </form>\n  );\n\n  const root = cn(\n    \"@container/newsletter bg-page text-start font-body tracking-body text-ink\",\n    \"[--paper:var(--surface)] [--paper-back:color-mix(in_oklab,var(--surface),var(--hover)_55%)] [--paper-far:var(--hover)] [--paper-shadow:0_1px_1px_oklch(0%_0_0/.03),0_10px_28px_-6px_oklch(0%_0_0/.09)]\",\n    \"dark:[--paper:oklch(24.5%_0_0)] dark:[--paper-back:oklch(22.5%_0_0)] dark:[--paper-far:oklch(21%_0_0)] dark:[--paper-shadow:0_1px_1px_oklch(0%_0_0/.2),0_14px_32px_-6px_oklch(0%_0_0/.45)]\",\n    variant === \"card\" && `px-6 py-20 @max-[520px]/newsletter:px-4 @max-[520px]/newsletter:py-10`,\n    className,\n  );\n\n  if (variant === \"card\") {\n    return (\n      <section ref={ref} className={root} data-variant=\"card\" aria-labelledby={`${id}-title`}>\n        {/* The shell spans the section so a host layout can set its gutter without reaching into the card. */}\n        <div className=\"grid w-full place-items-center\">\n          <div\n            className={`grid w-[min(100%,460px)] overflow-hidden rounded-surface border border-line bg-surface shadow-resting @max-[520px]/newsletter:rounded-[28px]`}\n          >\n            {publication && (\n              <div\n                className={`grid h-[248px] grid-rows-[minmax(0,1fr)] justify-items-center overflow-hidden border-b border-line bg-hover px-8 pt-5 dark:bg-page @max-[520px]/newsletter:h-[232px] @max-[520px]/newsletter:px-5 @max-[520px]/newsletter:pt-4`}\n              >\n                <IssueStack\n                  className=\"h-full [-webkit-mask-image:linear-gradient(to_bottom,#000_calc(100%_-_64px),transparent)] [mask-image:linear-gradient(to_bottom,#000_calc(100%_-_64px),transparent)]\"\n                  publication={publication}\n                  delivered={done}\n                  to={sentTo}\n                  reduced={reduced}\n                  compact\n                />\n              </div>\n            )}\n            <div className={`grid p-8 @max-[520px]/newsletter:p-6`}>\n              <h2 id={`${id}-title`} className={`${displayHeading} text-2xl`}>\n                {title ?? copy.title}\n              </h2>\n              <p className={`${descriptionClass} mt-2 text-base`}>{description ?? copy.description}</p>\n              {form(\"mt-6\")}\n              {readers && <Readers readers={readers} done={done} reduced={reduced} inCard />}\n            </div>\n          </div>\n        </div>\n      </section>\n    );\n  }\n\n  return (\n    <section\n      ref={ref}\n      className={root}\n      data-variant=\"inline\"\n      data-aside={aside ? \"\" : undefined}\n      aria-labelledby={`${id}-title`}\n    >\n      <div\n        className={cn(\n          \"mx-auto grid max-w-[1120px] grid-cols-[minmax(0,1fr)] gap-x-16 gap-y-12 px-8 py-24\",\n          aside ? \"grid-cols-[minmax(0,1fr)_minmax(0,440px)] items-center\" : \"max-w-[720px]\",\n          `@max-[860px]/newsletter:grid-cols-[minmax(0,1fr)] @max-[860px]/newsletter:gap-12 @max-[860px]/newsletter:px-8 @max-[860px]/newsletter:py-16 @max-[520px]/newsletter:gap-10 @max-[520px]/newsletter:px-4 @max-[520px]/newsletter:py-12`,\n        )}\n      >\n        <div className=\"grid max-w-[540px] @max-[860px]/newsletter:max-w-[600px]\">\n          <h2\n            id={`${id}-title`}\n            className={`${displayHeading} text-[length:clamp(2rem,1.1rem_+_3.2cqi,var(--text-4xl))]`}\n          >\n            {title ?? copy.title}\n          </h2>\n          <p className={`${descriptionClass} mt-4 text-lg @max-[520px]/newsletter:text-base`}>\n            {description ?? copy.description}\n          </p>\n          {form(`mt-10 @max-[520px]/newsletter:mt-8`)}\n        </div>\n        {aside && (\n          <div className=\"grid min-w-0 justify-items-center gap-6\">\n            {publication && <IssueStack publication={publication} delivered={done} to={sentTo} reduced={reduced} />}\n            {readers && <Readers readers={readers} done={done} reduced={reduced} />}\n          </div>\n        )}\n      </div>\n    </section>\n  );\n});\n\nNewsletterSignup.displayName = \"NewsletterSignup\";\n\nconst variantOptions = [\n  { value: \"inline\", label: \"Inline\" },\n  { value: \"card\", label: \"Card\" },\n];\n\n/** Preview: both layouts. Subscribing is simulated and nothing is sent; the switch makes the next send fail. */\nexport function NewsletterSignupBlock() {\n  const [variant, setVariant] = useState<NewsletterVariant>(\"inline\");\n  const [fail, setFail] = useState(false);\n  const switchId = useId();\n  const simulate = () =>\n    new Promise<void>((resolve, reject) =>\n      setTimeout(() => (fail ? reject(new Error(\"Simulated failure\")) : resolve()), 1100),\n    );\n  return (\n    <div className=\"grid w-full justify-items-center gap-4\">\n      <div className=\"flex flex-wrap items-center justify-center gap-x-6 gap-y-3\">\n        <SegmentedControl\n          label=\"Newsletter layout\"\n          options={variantOptions}\n          value={variant}\n          onValueChange={(next) => setVariant(next as NewsletterVariant)}\n        />\n        <div className=\"inline-flex items-center gap-2.5 text-sm/[inherit] text-ink-2 [&_label]:cursor-pointer\">\n          <Switch id={switchId} checked={fail} onCheckedChange={setFail} />\n          <label htmlFor={switchId}>Fail the next send</label>\n        </div>\n      </div>\n      <div className=\"w-full overflow-hidden rounded-[20px] border border-line bg-page\">\n        <NewsletterSignup key={variant} variant={variant} onSubscribe={simulate} />\n      </div>\n      <p className=\"m-0 text-center text-xs leading-body text-ink-3\">\n        Subscribing is simulated in this preview. Nothing is sent.\n      </p>\n    </div>\n  );\n}\n\nexport default NewsletterSignupBlock;\n",
      "type": "registry:component",
      "target": "@components/iris/blocks/newsletter-signup/newsletter-signup.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/newsletter-signup",
  "meta": {
    "kind": "block",
    "group": "Blocks",
    "export": "NewsletterSignup",
    "docs": "https://irisui.dev/components/newsletter-signup"
  }
}
