{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "effect-05",
  "title": "Flap clock",
  "description": "The local time or a countdown on split flaps: a figure that changes folds over its hinge in perspective, the old upper half falling forward and the new lower half landing, and a switch between the two folds every card at once.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://ui.ndekocode.dev/r/chat-motion.json",
    "https://ui.ndekocode.dev/r/demonstration.json",
    "https://ui.ndekocode.dev/r/segments.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",
    "https://ui.ndekocode.dev/r/words.json"
  ],
  "files": [
    {
      "path": "registry/ndeko/components/effect/effect-05.tsx",
      "content": "'use client';\n\nimport { useInView } from 'motion/react';\nimport { useRef, useState } from 'react';\n\nimport {\n  useDocumentVisible,\n  useNow,\n} from '@/registry/ndeko/hooks/use-agent-run';\nimport {\n  useAutoplay,\n  type AutoplayStep,\n} from '@/registry/ndeko/hooks/use-autoplay';\nimport { hold } from '@/registry/ndeko/lib/demonstration';\nimport { cn } from '@/registry/ndeko/lib/utils';\nimport { countOf } from '@/registry/ndeko/lib/words';\nimport { Swap } from '@/registry/ndeko/ui/chat-motion';\nimport { Segments, type Segment } from '@/registry/ndeko/ui/segments';\n\nimport { Flap, FLAP_PLACEHOLDER } from './_parts/flap';\nimport { useHover } from './_parts/use-hover';\n\nexport type FlapUnit = 'days' | 'hours' | 'minutes' | 'seconds';\n\nexport type FlapClockMode = 'time' | 'countdown';\n\ntype Figure = {\n  unit: FlapUnit;\n  value: number;\n};\n\nconst TICK_MS = 1000;\n\nconst SECONDS_IN: Record<FlapUnit, number> = {\n  days: 86_400,\n  hours: 3600,\n  minutes: 60,\n  seconds: 1,\n};\n\nconst UNIT_ORDER: readonly FlapUnit[] = ['days', 'hours', 'minutes', 'seconds'];\n\nconst DEFAULT_UNITS: readonly FlapUnit[] = ['hours', 'minutes', 'seconds'];\n\nconst UNIT_WORDS: Record<FlapUnit, string> = {\n  days: 'Days',\n  hours: 'Hours',\n  minutes: 'Minutes',\n  seconds: 'Seconds',\n};\n\nconst UNIT_NOUNS: Record<FlapUnit, string> = {\n  days: 'day',\n  hours: 'hour',\n  minutes: 'minute',\n  seconds: 'second',\n};\n\n/** A card is two characters wide, so the largest unit of a countdown stops at this. */\nconst LARGEST_FIGURE = 99;\n\nfunction orderedUnitsOf(units: readonly FlapUnit[]) {\n  return UNIT_ORDER.filter((unit) => units.includes(unit));\n}\n\nfunction timeValueOf(unit: FlapUnit, date: Date) {\n  if (unit === 'hours') {\n    return date.getHours();\n  }\n  if (unit === 'minutes') {\n    return date.getMinutes();\n  }\n  return date.getSeconds();\n}\n\nfunction timeFiguresOf(units: readonly FlapUnit[], now: number): Figure[] {\n  const date = new Date(now);\n  const clockUnits = orderedUnitsOf(units).filter((unit) => unit !== 'days');\n  return clockUnits.map((unit) => ({ unit, value: timeValueOf(unit, date) }));\n}\n\nfunction countdownFiguresOf(\n  units: readonly FlapUnit[],\n  until: number,\n  now: number\n): Figure[] {\n  const remainingMs = Math.max(0, until - now);\n  let rest = Math.ceil(remainingMs / TICK_MS);\n  const figures: Figure[] = [];\n  for (const unit of orderedUnitsOf(units)) {\n    const value = Math.floor(rest / SECONDS_IN[unit]);\n    rest -= value * SECONDS_IN[unit];\n    figures.push({ unit, value });\n  }\n  return figures;\n}\n\nfunction figuresOf(\n  mode: FlapClockMode,\n  units: readonly FlapUnit[],\n  until: number,\n  now: number\n) {\n  if (mode === 'time') {\n    return timeFiguresOf(units, now);\n  }\n  return countdownFiguresOf(units, until, now);\n}\n\nfunction twoDigitsOf(value: number) {\n  const capped = Math.min(LARGEST_FIGURE, value);\n  return String(capped).padStart(2, '0');\n}\n\nfunction flapTextOf(figure: Figure, hasClock: boolean) {\n  if (!hasClock) {\n    return FLAP_PLACEHOLDER;\n  }\n  return twoDigitsOf(figure.value);\n}\n\nfunction spokenOf(\n  mode: FlapClockMode,\n  figures: readonly Figure[],\n  hasClock: boolean\n) {\n  if (!hasClock) {\n    return '';\n  }\n  if (mode === 'time') {\n    const digits = figures.map((figure) => twoDigitsOf(figure.value));\n    return digits.join(':');\n  }\n  const counts = figures.map((figure) => {\n    return countOf(figure.value, UNIT_NOUNS[figure.unit]);\n  });\n  return counts.join(', ');\n}\n\nexport type FlapClockProps = {\n  /** `time` shows the local time; `countdown` counts down to `until`. */\n  mode: FlapClockMode;\n  /** The moment a countdown ends, as a timestamp in milliseconds. */\n  until?: number;\n  /** The cards to show, left to right. `days` only counts down, and a countdown shows up to 99 of its largest unit. */\n  units?: readonly FlapUnit[];\n  /** What the clock tells, read by screen readers: \"Doors open in\". */\n  label: string;\n  className?: string;\n};\n\n/**\n * Split flap cards that tell the local time or count down to a moment. A card folds over its hinge\n * when its figure changes. The clock ticks once a second, only while it is on screen and the tab is\n * shown; it is the one part of the page that re-renders. Its size follows the width it is given.\n */\nexport function FlapClock({\n  mode,\n  until = 0,\n  units = DEFAULT_UNITS,\n  label,\n  className,\n}: FlapClockProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const isInView = useInView(ref);\n  const isTabVisible = useDocumentVisible();\n  const isOnScreen = isInView && isTabVisible;\n  const now = useNow(TICK_MS, isOnScreen);\n  const hasClock = now > 0;\n  const figures = figuresOf(mode, units, until, now);\n  const spoken = spokenOf(mode, figures, hasClock);\n\n  return (\n    <div\n      ref={ref}\n      role='timer'\n      aria-label={label}\n      className={cn('@container w-full', className)}\n    >\n      <span className='sr-only'>{spoken}</span>\n      <div\n        aria-hidden='true'\n        className='flex justify-center gap-[0.3em] text-[length:clamp(1.75rem,15cqw,3.25rem)]'\n      >\n        {figures.map((figure) => (\n          <div key={figure.unit} className='flex flex-col items-center gap-2'>\n            <Flap value={flapTextOf(figure, hasClock)} />\n            <span className='text-muted-foreground text-xs'>\n              {UNIT_WORDS[figure.unit]}\n            </span>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n}\n\nconst MODES: Segment<FlapClockMode>[] = [\n  { value: 'countdown', label: 'Countdown' },\n  { value: 'time', label: 'Local time' },\n];\n\nconst CAPTIONS: Record<FlapClockMode, string> = {\n  countdown: 'Doors open in',\n  time: 'Your local time',\n};\n\n/** A countdown that starts a few seconds above two hours, so that a few seconds in, hours, minutes and seconds all fold at once. */\nconst COUNTDOWN_MS = 7_203_500;\n\nconst LOADED_AT = Date.now();\n\nexport default function Effect05() {\n  const [mode, setMode] = useState<FlapClockMode>('countdown');\n  const [until, setUntil] = useState(LOADED_AT + COUNTDOWN_MS);\n  const { isHovered, hoverHandlers } = useHover();\n\n  function startCountdown() {\n    setMode('countdown');\n    setUntil(Date.now() + COUNTDOWN_MS);\n  }\n\n  const steps: AutoplayStep[] = [\n    { after: 700, run: startCountdown },\n    { after: 8200, run: () => setMode('time') },\n    { after: 6500, run: hold },\n  ];\n  const { ref } = useAutoplay(steps, {\n    loopAfter: 600,\n    isEnabled: !isHovered,\n  });\n\n  return (\n    <div\n      ref={ref}\n      className='@container flex w-full max-w-[22rem] flex-col items-center gap-4'\n      {...hoverHandlers}\n    >\n      <p aria-hidden='true' className='text-muted-foreground text-xs'>\n        <Swap swapKey={mode}>{CAPTIONS[mode]}</Swap>\n      </p>\n      <FlapClock mode={mode} until={until} label={CAPTIONS[mode]} />\n      <Segments\n        value={mode}\n        segments={MODES}\n        onValueChange={setMode}\n        label='Clock'\n      />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/effect/effect-05.tsx"
    },
    {
      "path": "registry/ndeko/components/effect/_parts/flap.tsx",
      "content": "'use client';\n\nimport { useCallback, useEffect, useRef, useState } from 'react';\n\nimport { usePrefersReducedMotion } from '@/registry/ndeko/hooks/use-prefers-reduced-motion';\nimport { cn } from '@/registry/ndeko/lib/utils';\n\ntype HalfSide = 'top' | 'bottom';\n\ntype Fold = {\n  from: string;\n  to: string;\n};\n\n/** What a flap shows until its clock has a reading, so the server and the browser print the same text. */\nexport const FLAP_PLACEHOLDER = '--';\n\nconst FALL_MS = 170;\n\nconst LAND_MS = 170;\n\nconst PERSPECTIVE = 'perspective(14em)';\n\nconst LIT = 'brightness(1)';\n\nconst SHADED = 'brightness(0.82)';\n\n/** The upper leaf speeds up as it falls and the lower one slows down as it lands, so the card keeps one speed through the vertical. */\nconst FALL_EASING = 'cubic-bezier(0.4, 0, 1, 1)';\n\nconst LAND_EASING = 'cubic-bezier(0.23, 1, 0.32, 1)';\n\nconst FALL_KEYFRAMES: Keyframe[] = [\n  { transform: `${PERSPECTIVE} rotateX(0deg)`, filter: LIT },\n  { transform: `${PERSPECTIVE} rotateX(-90deg)`, filter: SHADED },\n];\n\nconst LAND_KEYFRAMES: Keyframe[] = [\n  { transform: `${PERSPECTIVE} rotateX(90deg)`, filter: SHADED },\n  { transform: `${PERSPECTIVE} rotateX(0deg)`, filter: LIT },\n];\n\n/** The lower leaf waits edge-on until it is its turn to land. */\nconst WAITING_LEAF: React.CSSProperties = {\n  transform: `${PERSPECTIVE} rotateX(90deg)`,\n};\n\nconst PLATE_SIDES: Record<HalfSide, string> = {\n  top: 'top-0 origin-bottom rounded-t-xl bg-card',\n  bottom: 'bottom-0 origin-top rounded-b-xl bg-muted dark:bg-background',\n};\n\nconst GLYPH_SIDES: Record<HalfSide, string> = {\n  top: 'top-0',\n  bottom: 'bottom-0',\n};\n\ntype HalfProps = {\n  digits: string;\n  side: HalfSide;\n  ref?: React.Ref<HTMLSpanElement>;\n  style?: React.CSSProperties;\n  className?: string;\n};\n\n/** One half of a card: the figure is laid out over the whole card and this window shows its upper or its lower part. */\nfunction Half({ digits, side, ref, style, className }: HalfProps) {\n  return (\n    <span\n      ref={ref}\n      aria-hidden='true'\n      style={style}\n      className={cn(\n        'absolute inset-x-0 h-1/2 overflow-hidden',\n        PLATE_SIDES[side],\n        className\n      )}\n    >\n      <span\n        className={cn(\n          'absolute inset-x-0 flex h-[200%] items-center justify-center',\n          GLYPH_SIDES[side]\n        )}\n      >\n        {digits}\n      </span>\n    </span>\n  );\n}\n\ntype LeavesProps = {\n  fold: Fold;\n  onDone: () => void;\n};\n\n/** The two leaves of one fold: the old upper half falls forward, then the new lower half lands. They exist only while the fold plays. */\nfunction Leaves({ fold, onDone }: LeavesProps) {\n  const fallingRef = useRef<HTMLSpanElement>(null);\n  const landingRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const falling = fallingRef.current;\n    const landing = landingRef.current;\n    if (!falling || !landing) {\n      return;\n    }\n    const fall = falling.animate(FALL_KEYFRAMES, {\n      duration: FALL_MS,\n      easing: FALL_EASING,\n      fill: 'forwards',\n    });\n    const land = landing.animate(LAND_KEYFRAMES, {\n      duration: LAND_MS,\n      delay: FALL_MS,\n      easing: LAND_EASING,\n      fill: 'both',\n    });\n    land.onfinish = onDone;\n    return () => {\n      fall.cancel();\n      land.cancel();\n    };\n  }, [onDone]);\n\n  return (\n    <>\n      <Half ref={fallingRef} digits={fold.from} side='top' className='z-10' />\n      <Half\n        ref={landingRef}\n        digits={fold.to}\n        side='bottom'\n        style={WAITING_LEAF}\n        className='z-20'\n      />\n    </>\n  );\n}\n\nexport type FlapProps = {\n  /** The figure on the card, two characters wide. */\n  value: string;\n  className?: string;\n};\n\n/**\n * A split flap card. When its figure changes the old upper half falls forward over the hinge and\n * the new lower half lands behind it, in perspective. The first reading after the placeholder and\n * any change under reduced motion simply appear. Its size follows the font size of its parent.\n */\nexport function Flap({ value, className }: FlapProps) {\n  const isStill = usePrefersReducedMotion();\n  const [settled, setSettled] = useState(value);\n  const [fold, setFold] = useState<Fold | null>(null);\n  const finishFold = useCallback(() => setFold(null), []);\n  const hasChanged = value !== settled;\n\n  if (hasChanged) {\n    const hadReading = settled !== FLAP_PLACEHOLDER;\n    const canFold = hadReading && !isStill;\n    setSettled(value);\n    if (canFold) {\n      setFold({ from: settled, to: value });\n    } else {\n      setFold(null);\n    }\n  }\n\n  const lowerDigits = fold?.from ?? settled;\n\n  return (\n    <span\n      className={cn(\n        'hairline-ring ring-border text-foreground relative block h-[1.8em] w-[1.5em] rounded-xl leading-none font-semibold tabular-nums shadow-[0_1px_2px_rgb(0_0_0/0.05)]',\n        className\n      )}\n    >\n      <Half digits={settled} side='top' />\n      <Half digits={lowerDigits} side='bottom' />\n      {fold && (\n        <Leaves\n          key={`${fold.from}:${fold.to}`}\n          fold={fold}\n          onDone={finishFold}\n        />\n      )}\n      <span\n        aria-hidden='true'\n        className='bg-foreground/10 absolute inset-x-0 top-1/2 z-30 h-(--hairline) -translate-y-1/2'\n      />\n    </span>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/effect/_parts/flap.tsx"
    },
    {
      "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"
}