{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "effect-04",
  "title": "Announcement pill",
  "description": "The pill above a headline, where a light runs once round its edge from the side the pointer comes from as it gets within reach, then rests, and the arrow of the tag that says what a press does nudges forward.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/icons.json",
    "https://ui.ndekocode.dev/r/theme.json",
    "https://ui.ndekocode.dev/r/use-agent-run.json",
    "https://ui.ndekocode.dev/r/use-autoplay.json",
    "https://ui.ndekocode.dev/r/use-prefers-reduced-motion.json",
    "https://ui.ndekocode.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/effect/effect-04.tsx",
      "content": "'use client';\n\nimport { useInView } from 'motion/react';\nimport { useEffect, useImperativeHandle, useRef, useState } from 'react';\n\nimport { useDocumentVisible } from '@/registry/ndeko/hooks/use-agent-run';\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { ArrowRightIcon } from '@/registry/ndeko/lib/icons';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\nimport { hundredthsOf } from './_parts/rounded';\nimport { useHover } from './_parts/use-hover';\n\n/** The outline of a pill: two straight edges joined by two half circles. */\ntype Stadium = {\n  width: number;\n  height: number;\n  radius: number;\n  straight: number;\n  perimeter: number;\n};\n\ntype PillPoint = {\n  x: number;\n  y: number;\n};\n\n/** The light runs once the pointer comes this close to the pill, and again only after it went this far away. */\nconst REACH_PX = 120;\n\nconst REARM_PX = 160;\n\n/** The keyframes of a lap stand this far apart along the outline, so the light rounds the ends of the pill. */\nconst KEYFRAME_STEP_PX = 6;\n\nconst LAP_EASING = 'cubic-bezier(0.45, 0, 0.2, 1)';\n\nconst SHORTEST_LAP_MS = 900;\n\nconst LONGEST_LAP_MS = 1400;\n\nconst LAP_MS_PER_PX = 1.9;\n\n/** The light comes up over the first part of the lap and fades over the last, so it never starts or stops as a hard spot. */\nconst FADE_IN_SHARE = 0.08;\n\nconst FADE_OUT_SHARE = 0.2;\n\nfunction stadiumOf(width: number, height: number): Stadium {\n  const shortSide = Math.min(width, height);\n  const radius = shortSide / 2;\n  const straight = Math.max(0, width - shortSide);\n  const ends = 2 * Math.PI * radius;\n  return { width, height, radius, straight, perimeter: 2 * straight + ends };\n}\n\nfunction wrappedDistanceOf(distance: number, perimeter: number) {\n  const remainder = distance % perimeter;\n  return (remainder + perimeter) % perimeter;\n}\n\nfunction pointOnCap(centreX: number, radius: number, angle: number) {\n  const x = centreX + Math.cos(angle) * radius;\n  const y = radius + Math.sin(angle) * radius;\n  return { x, y };\n}\n\n/** The point of the outline this far along it, from the left end of the top edge, going clockwise. */\nfunction pointAlong(stadium: Stadium, distance: number): PillPoint {\n  const { width, height, radius, straight, perimeter } = stadium;\n  const capLength = Math.PI * radius;\n  let left = wrappedDistanceOf(distance, perimeter);\n  if (left <= straight) {\n    return { x: radius + left, y: 0 };\n  }\n  left -= straight;\n  if (left <= capLength) {\n    const angle = left / radius - Math.PI / 2;\n    return pointOnCap(width - radius, radius, angle);\n  }\n  left -= capLength;\n  if (left <= straight) {\n    return { x: width - radius - left, y: height };\n  }\n  left -= straight;\n  const angle = left / radius + Math.PI / 2;\n  return pointOnCap(radius, radius, angle);\n}\n\n/** How far along the outline, from the left end of the top edge, lies the point nearest to `from`. */\nfunction distanceNearest(stadium: Stadium, from: PillPoint) {\n  const { width, radius, straight } = stadium;\n  const capLength = Math.PI * radius;\n  const isPastLeftEnd = from.x < radius;\n  const isPastRightEnd = from.x > width - radius;\n  const isAlongStraight = !isPastLeftEnd && !isPastRightEnd;\n  if (isAlongStraight) {\n    const isAbove = from.y < radius;\n    if (isAbove) {\n      return from.x - radius;\n    }\n    const fromRightEnd = width - radius - from.x;\n    return straight + capLength + fromRightEnd;\n  }\n  if (isPastRightEnd) {\n    const angle = Math.atan2(from.y - radius, from.x - (width - radius));\n    return straight + (angle + Math.PI / 2) * radius;\n  }\n  const rawAngle = Math.atan2(from.y - radius, from.x - radius);\n  let angle = rawAngle;\n  if (angle < 0) {\n    angle += 2 * Math.PI;\n  }\n  return 2 * straight + capLength + (angle - Math.PI / 2) * radius;\n}\n\nfunction fadeAt(share: number) {\n  if (share < FADE_IN_SHARE) {\n    return share / FADE_IN_SHARE;\n  }\n  const fadeOutFrom = 1 - FADE_OUT_SHARE;\n  if (share > fadeOutFrom) {\n    const left = 1 - share;\n    return left / FADE_OUT_SHARE;\n  }\n  return 1;\n}\n\n/** One lap of the outline as keyframes spaced by length, so the light keeps one speed on the straight edges and round the ends. */\nfunction lapKeyframesOf(stadium: Stadium, start: number) {\n  const stepCount = Math.ceil(stadium.perimeter / KEYFRAME_STEP_PX);\n  const steps = Math.max(24, stepCount);\n  const keyframes: Keyframe[] = [];\n  for (let index = 0; index <= steps; index += 1) {\n    const share = index / steps;\n    const distance = start + stadium.perimeter * share;\n    const point = pointAlong(stadium, distance);\n    const x = hundredthsOf(point.x);\n    const y = hundredthsOf(point.y);\n    const opacity = hundredthsOf(fadeAt(share));\n    keyframes.push({\n      offset: share,\n      transform: `translate(${x}px, ${y}px)`,\n      opacity,\n    });\n  }\n  return keyframes;\n}\n\nfunction lapDurationOf(stadium: Stadium) {\n  const byLength = stadium.perimeter * LAP_MS_PER_PX;\n  const atLeast = Math.max(SHORTEST_LAP_MS, byLength);\n  return Math.min(LONGEST_LAP_MS, atLeast);\n}\n\n/**\n * A conic sweep would crawl along the long edges of a pill and leap round its ends; the light is a\n * small soft spot moved along the outline by a Web Animation on its transform, and a mask keeps only\n * the ring of the pill, so it shows as the edge lighting up.\n */\nfunction runLap(pill: HTMLElement, light: HTMLElement, from: PillPoint) {\n  const stadium = stadiumOf(pill.offsetWidth, pill.offsetHeight);\n  const start = distanceNearest(stadium, from);\n  const keyframes = lapKeyframesOf(stadium, start);\n  const duration = lapDurationOf(stadium);\n  return light.animate(keyframes, { duration, easing: LAP_EASING });\n}\n\nfunction gapToBox(box: DOMRect, x: number, y: number) {\n  const across = Math.max(box.left - x, 0, x - box.right);\n  const down = Math.max(box.top - y, 0, y - box.bottom);\n  return Math.hypot(across, down);\n}\n\nexport type AnnouncementPillHandle = {\n  /** Runs the light once round the pill from the side nearest a point given in shares of the pill, for a demonstration. */\n  reach: (across: number, down: number) => void;\n};\n\nexport type AnnouncementPillProps = {\n  href: string;\n  /** The news, in a few words. */\n  text: string;\n  /** What pressing the pill does, said by the tag at its end: \"Read the post\". */\n  action: string;\n  /** Shows the arrow nudged as it is under the pointer, for a demonstration. */\n  isNudged?: boolean;\n  /** Runs the light from outside, for a demonstration. */\n  handleRef?: React.Ref<AnnouncementPillHandle>;\n  className?: string;\n};\n\n/**\n * The pill above a headline. When the pointer comes within 120px, or the keyboard reaches it, a\n * light runs once round its edge from the side the pointer came from, then rests. It listens only\n * while it is on screen and the tab is shown, and never under reduced motion.\n */\nexport function AnnouncementPill({\n  href,\n  text,\n  action,\n  isNudged = false,\n  handleRef,\n  className,\n}: AnnouncementPillProps) {\n  const pillRef = useRef<HTMLAnchorElement>(null);\n  const lightRef = useRef<HTMLSpanElement>(null);\n  const lap = useRef<Animation | null>(null);\n  const isInView = useInView(pillRef);\n  const isTabVisible = useDocumentVisible();\n  const isStill = usePrefersReducedMotion();\n  const isOnScreen = isInView && isTabVisible;\n  const isListening = isOnScreen && !isStill;\n\n  useEffect(() => {\n    const pill = pillRef.current;\n    const light = lightRef.current;\n    const hasParts = pill !== null && light !== null;\n    if (!hasParts || !isListening) {\n      return;\n    }\n    let isArmed = true;\n\n    const watchReach = (event: PointerEvent) => {\n      if (event.pointerType === 'touch') {\n        return;\n      }\n      const box = pill.getBoundingClientRect();\n      const gap = gapToBox(box, event.clientX, event.clientY);\n      if (gap > REARM_PX) {\n        isArmed = true;\n        return;\n      }\n      const isWithinReach = gap <= REACH_PX;\n      if (!isWithinReach || !isArmed) {\n        return;\n      }\n      isArmed = false;\n      const from = { x: event.clientX - box.left, y: event.clientY - box.top };\n      lap.current?.cancel();\n      lap.current = runLap(pill, light, from);\n    };\n\n    document.addEventListener('pointermove', watchReach, { passive: true });\n    return () => {\n      document.removeEventListener('pointermove', watchReach);\n      lap.current?.cancel();\n      lap.current = null;\n    };\n  }, [isListening]);\n\n  function lapFrom(from: PillPoint) {\n    const pill = pillRef.current;\n    const light = lightRef.current;\n    const hasParts = pill !== null && light !== null;\n    if (!hasParts || !isListening) {\n      return;\n    }\n    lap.current?.cancel();\n    lap.current = runLap(pill, light, from);\n  }\n\n  function lapOnKeyboardFocus(event: React.FocusEvent<HTMLAnchorElement>) {\n    if (!event.target.matches(':focus-visible')) {\n      return;\n    }\n    const height = event.currentTarget.offsetHeight;\n    lapFrom({ x: 0, y: height / 2 });\n  }\n\n  useImperativeHandle(handleRef, () => ({\n    reach(across: number, down: number) {\n      const pill = pillRef.current;\n      if (!pill) {\n        return;\n      }\n      const x = across * pill.offsetWidth;\n      const y = down * pill.offsetHeight;\n      lapFrom({ x, y });\n    },\n  }));\n\n  return (\n    <a\n      ref={pillRef}\n      href={href}\n      data-nudged={isNudged}\n      onFocus={lapOnKeyboardFocus}\n      className={cn(\n        'group/pill bg-card hairline-ring ring-border hover:bg-foreground/[0.02] focus-visible:ring-ring/25 dark:bg-foreground/[0.04] dark:hover:bg-foreground/[0.06] relative inline-flex h-8 max-w-full items-center gap-2 rounded-full ps-3 pe-1 text-[13px] shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-[background-color,scale] duration-150 ease-(--ease-out-strong) outline-none focus-visible:ring-[3px] active:scale-[0.97]',\n        className\n      )}\n    >\n      <span className='min-w-0 truncate'>{text}</span>\n      <span className='bg-foreground/[0.06] inline-flex h-6 shrink-0 items-center gap-1 rounded-full ps-2.5 pe-2 text-xs font-medium'>\n        {action}\n        <ArrowRightIcon className='text-muted-foreground size-3.5 transition-transform duration-150 ease-(--ease-out-strong) group-hover/pill:translate-x-0.5 group-focus-visible/pill:translate-x-0.5 group-data-[nudged=true]/pill:translate-x-0.5' />\n      </span>\n      <span\n        aria-hidden='true'\n        className='pointer-events-none absolute inset-0 overflow-hidden rounded-full p-[1.5px] [mask:linear-gradient(black,black)_content-box_exclude,linear-gradient(black,black)]'\n      >\n        <span\n          ref={lightRef}\n          className='absolute -top-6 -left-6 size-12 rounded-full bg-[radial-gradient(closest-side,var(--primary),transparent)] opacity-0'\n        />\n      </span>\n    </a>\n  );\n}\n\nexport default function Effect04() {\n  const pill = useRef<AnnouncementPillHandle>(null);\n  const [isNudged, setIsNudged] = useState(false);\n  const { isHovered, hoverHandlers } = useHover();\n  const steps: AutoplayStep[] = [\n    { after: 1200, run: () => pill.current?.reach(0.14, 2.4) },\n    { after: 1000, run: () => setIsNudged(true) },\n    { after: 1600, run: () => setIsNudged(false) },\n  ];\n  const { ref, isAutoplaying } = useAutoplay(steps, {\n    loopAfter: 2200,\n    onReset: () => setIsNudged(false),\n    isEnabled: !isHovered,\n  });\n  /** A pointer on the piece only pauses the demonstration; once a person took over, the arrow the demonstration nudged goes back. */\n  const isDemoStopped = !isAutoplaying && !isHovered;\n  if (isDemoStopped && isNudged) {\n    setIsNudged(false);\n  }\n\n  return (\n    <div\n      ref={ref}\n      className='flex w-full max-w-[22rem] flex-col items-center gap-4 text-center'\n      {...hoverHandlers}\n    >\n      <AnnouncementPill\n        handleRef={pill}\n        href='#'\n        text='Shared inboxes are here'\n        action='Read the post'\n        isNudged={isNudged}\n      />\n      <p className='text-xl leading-tight font-semibold text-balance'>\n        Answer every customer from one inbox\n      </p>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/effect/effect-04.tsx"
    },
    {
      "path": "registry/ndeko/components/effect/_parts/rounded.ts",
      "content": "/**\n * Two engines can disagree on the last digit of a sine, and a frame writes many positions: a value\n * written into a style is rounded to the hundredth, so the server and the browser print the same\n * text and the style stays short.\n */\nexport function hundredthsOf(value: number) {\n  const hundredths = Math.round(value * 100);\n  return hundredths / 100;\n}\n",
      "type": "registry:component",
      "target": "components/effect/_parts/rounded.ts"
    },
    {
      "path": "registry/ndeko/components/effect/_parts/use-hover.ts",
      "content": "'use client';\n\nimport { useState } from 'react';\n\n/** Whether a mouse or a pen rests on an element. A finger does not hover, so a touch never counts. */\nexport function useHover() {\n  const [isHovered, setIsHovered] = useState(false);\n\n  function hoverStarts(event: React.PointerEvent<HTMLElement>) {\n    if (event.pointerType !== 'touch') {\n      setIsHovered(true);\n    }\n  }\n\n  function hoverEnds() {\n    setIsHovered(false);\n  }\n\n  const hoverHandlers = {\n    onPointerEnter: hoverStarts,\n    onPointerLeave: hoverEnds,\n  };\n\n  return { isHovered, hoverHandlers };\n}\n",
      "type": "registry:component",
      "target": "components/effect/_parts/use-hover.ts"
    }
  ],
  "categories": [
    "components",
    "effect"
  ],
  "type": "registry:component"
}