{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"cookie-consent","title":"Cookie Consent","author":"CoDesign-Spa27","description":"A paper-and-cobalt cookie notice with an isometric cookie break animation, confirm-and-undo controls, and reduced-motion support.","dependencies":["motion"],"files":[{"path":"components/ui-components/cookie-consent.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useIsPresent, useReducedMotion } from \"motion/react\";\nimport type { CSSProperties } from \"react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type CookieConsentChoice = \"accept\" | \"reject\";\n\nexport type CookieConsentProps = {\n  className?: string;\n  style?: CSSProperties;\n  title?: string;\n  description?: string;\n  acceptLabel?: string;\n  rejectLabel?: string;\n  confirmLabel?: string;\n  undoLabel?: string;\n  /** Omit to hide the policy link. Supply your site's actual cookie policy URL. */\n  policyHref?: string;\n  policyLabel?: string;\n  /** Controlled visibility. Update via onOpenChange after confirmation. */\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Fires once on Confirm. Persistence and tracking belong to the caller. */\n  onConsent?: (choice: CookieConsentChoice) => void;\n  /** Fires after the card has finished exiting. Useful for restoring focus. */\n  onDismiss?: () => void;\n};\n\n// Matches the existing --ease-sidebar token and the Animate skill's UI ease-out.\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\nconst SPLIT_DELAY = 0.04;\nconst SPLIT_DURATION = 0.18;\nconst IDENTITY = \"translate(0%, 0%) rotate(0deg)\";\nconst WHOLE =\n  \"M190 46 C203 39 214 45 222 51 C236 50 244 57 247 65 C260 70 262 80 255 87 C266 97 258 108 247 111 C244 122 233 125 220 124 C209 133 194 132 179 130 C165 131 154 126 150 122 C135 123 128 115 126 107 C116 102 116 94 121 86 C114 76 122 65 132 64 C136 53 145 50 156 52 C165 42 178 43 190 46Z\";\nconst LEFT =\n  \"M190 46 L181 65 L192 78 L178 91 L189 111 L179 130 C165 131 154 126 150 122 C135 123 128 115 126 107 C116 102 116 94 121 86 C114 76 122 65 132 64 C136 53 145 50 156 52 C165 42 178 43 190 46Z\";\nconst RIGHT =\n  \"M190 46 C203 39 214 45 222 51 C236 50 244 57 247 65 C260 70 262 80 255 87 C266 97 258 108 247 111 C244 122 233 125 220 124 C209 133 194 132 179 130 L189 111 L178 91 L192 78 L181 65Z\";\nconst LEFT_CHIPS =\n  \"M137 79L146 74L152 79L144 86Z M154 105L164 100L170 105L161 112Z M163 60L171 56L177 61L169 66Z\";\nconst RIGHT_CHIPS =\n  \"M209 62L219 57L226 62L216 69Z M220 98L230 93L237 99L227 105Z M201 113L209 109L215 114L207 119Z\";\nconst LEFT_MARKS = \"M128 95L135 91M150 120L155 116M165 83L169 80\";\nconst RIGHT_MARKS = \"M236 80L240 77M208 88L212 85M232 117L236 114\";\nconst GRID =\n  Array.from({ length: 17 }, (_, i) => `M${i * 24} 0V202`).join(\" \") +\n  \" \" +\n  Array.from({ length: 9 }, (_, i) => `M0 ${i * 24}H380`).join(\" \");\nconst CRUMBS = [\n  {\n    id: \"upper-left\",\n    left: \"48%\",\n    top: \"37%\",\n    apex: \"translate(-10px, -36px) rotate(-20deg)\",\n    landing: \"translate(-18px, -24px) rotate(-45deg)\",\n  },\n  {\n    id: \"upper-right\",\n    left: \"50%\",\n    top: \"38%\",\n    apex: \"translate(14px, -43px) rotate(35deg)\",\n    landing: \"translate(24px, -29px) rotate(75deg)\",\n  },\n  {\n    id: \"lower\",\n    left: \"49%\",\n    top: \"53%\",\n    apex: \"translate(1px, 16px) rotate(20deg)\",\n    landing: \"translate(2px, 36px) rotate(45deg)\",\n  },\n] as const;\n\nexport function CookieConsent({\n  open,\n  defaultOpen = true,\n  onOpenChange,\n  onConsent,\n  onDismiss,\n  ...props\n}: CookieConsentProps) {\n  const [internalOpen, setInternalOpen] = useState(defaultOpen);\n  const returnFocus = useRef<HTMLElement | null>(null);\n  const visible = open ?? internalOpen;\n\n  const close = () => {\n    if (open === undefined) setInternalOpen(false);\n    onOpenChange?.(false);\n  };\n\n  return (\n    <AnimatePresence\n      onExitComplete={() => {\n        // Do not steal focus if the user has already moved elsewhere on the page.\n        if (document.activeElement === document.body && returnFocus.current?.isConnected) {\n          returnFocus.current.focus({ preventScroll: true });\n        }\n        returnFocus.current = null;\n        onDismiss?.();\n      }}\n    >\n      {visible ? (\n        <CookieConsentCard\n          {...props}\n          key=\"cookie-consent\"\n          onConsent={onConsent}\n          onComplete={close}\n          onFocusFromOutside={(element) => {\n            returnFocus.current = element;\n          }}\n        />\n      ) : null}\n    </AnimatePresence>\n  );\n}\n\nfunction CookieConsentCard({\n  className,\n  style,\n  title = \"May we use cookies?\",\n  description = \"We use cookies to remember your preferences and understand how you use this site.\",\n  acceptLabel = \"Accept\",\n  rejectLabel = \"Reject\",\n  confirmLabel = \"Confirm\",\n  undoLabel = \"Undo choice\",\n  policyHref,\n  policyLabel = \"Cookie policy\",\n  onConsent,\n  onComplete,\n  onFocusFromOutside,\n}: Omit<CookieConsentProps, \"open\" | \"defaultOpen\" | \"onOpenChange\" | \"onDismiss\"> & {\n  onComplete: () => void;\n  onFocusFromOutside: (element: HTMLElement) => void;\n}) {\n  const [choice, setChoice] = useState<CookieConsentChoice | null>(null);\n  const [ready, setReady] = useState(false);\n  const [lockedHeight, setLockedHeight] = useState<number | null>(null);\n  const cardRef = useRef<HTMLElement>(null);\n  const confirmRef = useRef<HTMLButtonElement>(null);\n  const pendingFocus = useRef<Element | null>(null);\n  const choiceRef = useRef<CookieConsentChoice | null>(null);\n  const completed = useRef(false);\n  const reducedMotion = useReducedMotion();\n  const isPresent = useIsPresent();\n  const titleId = useId();\n  const descriptionId = useId();\n  const pendingTitleId = useId();\n  const pendingDescriptionId = useId();\n\n  const finishBreak = () => {\n    if (isPresent && choiceRef.current && !completed.current) setReady(true);\n  };\n\n  const choose = (nextChoice: CookieConsentChoice) => {\n    if (!isPresent || choiceRef.current || completed.current) return;\n    choiceRef.current = nextChoice;\n    pendingFocus.current = document.activeElement;\n    // Freeze the actual frame before changing the artwork or controls.\n    setLockedHeight(cardRef.current?.offsetHeight ?? null);\n    setReady(Boolean(reducedMotion));\n    setChoice(nextChoice);\n  };\n\n  const confirm = () => {\n    if (!isPresent || !ready || !choiceRef.current || completed.current) return;\n    completed.current = true;\n    try {\n      onConsent?.(choiceRef.current);\n    } finally {\n      onComplete();\n    }\n  };\n\n  const undo = () => {\n    if (!isPresent || completed.current) return;\n    choiceRef.current = null;\n    setChoice(null);\n    setReady(false);\n    setLockedHeight(null);\n  };\n\n  useEffect(() => {\n    if (choice && reducedMotion) setReady(true);\n  }, [choice, reducedMotion]);\n\n  useEffect(() => {\n    if (\n      ready &&\n      choice &&\n      (document.activeElement === pendingFocus.current || document.activeElement === document.body)\n    ) {\n      confirmRef.current?.focus({ preventScroll: true });\n    }\n  }, [ready, choice]);\n\n  const buttonClass =\n    \"min-h-11 min-w-0 rounded-lg border border-primary px-3 py-2 text-sm font-semibold leading-5 transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary disabled:cursor-default\";\n  const displayFont = 'var(--cookie-font-display, \"Space Grotesk\"), sans-serif';\n  const morphTransition = { duration: reducedMotion ? 0 : 0.22, ease: EASE_OUT };\n\n  return (\n    <motion.section\n      ref={cardRef}\n      aria-busy={choice !== null && !ready}\n      aria-describedby={choice ? pendingDescriptionId : descriptionId}\n      aria-labelledby={choice ? pendingTitleId : titleId}\n      className={cn(\n        \"relative w-[380px] max-w-full overflow-hidden break-words rounded-2xl border bg-background text-foreground selection:bg-accent selection:text-accent-foreground\",\n        className,\n      )}\n      style={{\n        fontFamily: 'var(--cookie-font-body, \"DM Sans\"), sans-serif',\n        ...style,\n        ...(lockedHeight ? { height: lockedHeight } : {}),\n      }}\n      initial={{ opacity: 0, transform: reducedMotion ? \"none\" : \"translateY(12px)\" }}\n      animate={{ opacity: 1, transform: reducedMotion ? \"none\" : \"translateY(0px)\" }}\n      exit={{ opacity: 0, transform: reducedMotion ? \"none\" : \"translateY(12px)\" }}\n      transition={{ duration: reducedMotion ? 0.12 : 0.18, ease: EASE_OUT }}\n      onFocusCapture={(event) => {\n        const previous = event.relatedTarget;\n        if (previous instanceof HTMLElement && !event.currentTarget.contains(previous)) {\n          onFocusFromOutside(previous);\n        }\n      }}\n    >\n      <motion.div\n        initial={false}\n        animate={{ transform: choice ? \"scale(1.16)\" : \"scale(1)\" }}\n        style={{ transformOrigin: \"50% 0%\" }}\n        transition={morphTransition}\n      >\n        <CookieArtwork\n          breaking={choice !== null}\n          reducedMotion={Boolean(reducedMotion)}\n          onBreakComplete={finishBreak}\n        />\n      </motion.div>\n      <div className=\"relative px-6 pt-6\">\n        {/* Keep the original copy in flow so the outer frame never changes height. */}\n        <motion.div\n          aria-hidden={choice !== null}\n          initial={false}\n          animate={{ opacity: choice ? 0 : 1 }}\n          transition={{ duration: 0.12, ease: EASE_OUT }}\n        >\n          <h2\n            className=\"text-[26px] font-semibold leading-8 tracking-[-0.03em]\"\n            id={titleId}\n            style={{ fontFamily: displayFont }}\n          >\n            {title}\n          </h2>\n          <p className=\"mt-2 text-[15px] leading-[22px] text-muted-foreground\" id={descriptionId}>\n            {description}\n          </p>\n        </motion.div>\n        <motion.div\n          aria-hidden={choice === null}\n          className=\"absolute inset-x-6 top-6\"\n          initial={false}\n          animate={{\n            opacity: choice ? 1 : 0,\n            transform: choice ? \"translateY(28px)\" : \"translateY(0px)\",\n          }}\n          transition={{ ...morphTransition, opacity: { duration: 0.12 } }}\n        >\n          <h2\n            className=\"text-[26px] font-semibold leading-8 tracking-[-0.03em]\"\n            id={pendingTitleId}\n            style={{ fontFamily: displayFont }}\n          >\n            {choice === \"reject\" ? \"Reject cookies?\" : \"Accept cookies?\"}\n          </h2>\n          <p\n            className=\"mt-2 text-[15px] leading-[22px] text-muted-foreground\"\n            id={pendingDescriptionId}\n          >\n            Confirm to save your preference.\n          </p>\n        </motion.div>\n      </div>\n      <div className=\"relative flex gap-3 px-6 pb-5 pt-5\" style={{ fontFamily: displayFont }}>\n        {([\"reject\", \"accept\"] as const).map((side) => {\n          const isConfirm = choice === side;\n          const isUndo = choice !== null && !isConfirm;\n          const isPrimary = isConfirm || (choice === null && side === \"accept\");\n\n          return (\n            <motion.button\n              key={side}\n              ref={isConfirm ? confirmRef : undefined}\n              aria-label={\n                isUndo\n                  ? undoLabel\n                  : isConfirm\n                    ? `${confirmLabel} ${side === \"reject\" ? \"rejection\" : \"acceptance\"}`\n                    : undefined\n              }\n              layout={!reducedMotion}\n              className={cn(\n                buttonClass,\n                \"flex items-center justify-center\",\n                isPrimary\n                  ? \"bg-primary text-primary-foreground [@media(hover:hover)]:enabled:hover:bg-primary/90\"\n                  : \"bg-background text-primary [@media(hover:hover)]:enabled:hover:bg-muted\",\n              )}\n              style={{ flex: isUndo ? \"0 0 44px\" : \"1 1 0%\", borderRadius: 8 }}\n              disabled={!isPresent || completed.current || (isConfirm && !ready)}\n              onClick={choice ? (isConfirm ? confirm : undo) : () => choose(side)}\n              transition={morphTransition}\n              type=\"button\"\n            >\n              <AnimatePresence initial={false} mode=\"wait\">\n                <motion.span\n                  key={isUndo ? \"undo\" : isConfirm ? \"confirm\" : side}\n                  className=\"flex items-center justify-center\"\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0 }}\n                  transition={{ duration: 0.08 }}\n                >\n                  {isUndo ? (\n                    <svg\n                      aria-hidden=\"true\"\n                      width=\"18\"\n                      height=\"18\"\n                      viewBox=\"0 0 18 18\"\n                      fill=\"none\"\n                      focusable=\"false\"\n                    >\n                      <path\n                        d=\"M5 5L13 13M13 5L5 13\"\n                        stroke=\"currentColor\"\n                        strokeWidth=\"1.7\"\n                        strokeLinecap=\"round\"\n                      />\n                    </svg>\n                  ) : isConfirm ? (\n                    confirmLabel\n                  ) : side === \"reject\" ? (\n                    rejectLabel\n                  ) : (\n                    acceptLabel\n                  )}\n                </motion.span>\n              </AnimatePresence>\n            </motion.button>\n          );\n        })}\n      </div>\n      {policyHref ? (\n        <div className=\"relative -mt-2 px-6 pb-5 text-center\">\n          <a\n            className=\"inline-flex min-h-6 items-center rounded-sm text-[13px] leading-[18px] text-muted-foreground underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-primary\"\n            href={policyHref}\n          >\n            {policyLabel}\n          </a>\n        </div>\n      ) : null}\n    </motion.section>\n  );\n}\n\nfunction CookieArtwork({\n  breaking,\n  reducedMotion,\n  onBreakComplete,\n}: {\n  breaking: boolean;\n  reducedMotion: boolean;\n  onBreakComplete: () => void;\n}) {\n  return (\n    <div aria-hidden=\"true\" className=\"relative aspect-[380/202] w-full overflow-hidden bg-muted\">\n      <svg\n        className=\"absolute inset-0 size-full\"\n        viewBox=\"0 0 380 202\"\n        fill=\"none\"\n        focusable=\"false\"\n        style={{\n          maskImage:\n            \"linear-gradient(to bottom, transparent 0%, black 16%, black 84%, transparent 100%)\",\n          WebkitMaskImage:\n            \"linear-gradient(to bottom, transparent 0%, black 16%, black 84%, transparent 100%)\",\n        }}\n      >\n        <path d={GRID} stroke=\"var(--border)\" strokeOpacity=\"0.45\" strokeWidth=\"0.8\" />\n      </svg>\n      <div className=\"absolute inset-0 -translate-y-5\">\n        <svg\n          className=\"absolute inset-0 size-full\"\n          viewBox=\"0 0 380 202\"\n          fill=\"none\"\n          focusable=\"false\"\n        >\n          <path\n            d=\"M85 134L185 80L292 136L190 191Z\"\n            fill=\"#D8DCF0\"\n            stroke=\"#7885C8\"\n            strokeWidth=\"1.3\"\n          />\n          <path d=\"M85 134V143L190 200V191Z\" fill=\"#A5AFE4\" stroke=\"#7885C8\" strokeWidth=\"1.3\" />\n          <path\n            d=\"M190 191L292 136V145L190 200Z\"\n            fill=\"#BFC6E9\"\n            stroke=\"#7885C8\"\n            strokeWidth=\"1.3\"\n          />\n          <ellipse cx=\"190\" cy=\"143\" rx=\"62\" ry=\"22\" fill=\"#8997D8\" opacity=\"0.35\" />\n          <path\n            d=\"M73 60V77M65 68H81M295 95V106M290 101H301\"\n            stroke=\"#243ACB\"\n            strokeWidth=\"1.6\"\n            strokeLinecap=\"round\"\n          />\n          <path\n            d=\"M278 42L284 47L279 53L273 48Z\"\n            fill=\"#A5AFE4\"\n            stroke=\"#243ACB\"\n            strokeWidth=\"1.3\"\n          />\n        </svg>\n        <motion.div\n          className=\"absolute inset-0\"\n          initial={false}\n          animate={{ opacity: breaking ? 0 : 1 }}\n          transition={{\n            duration: 0.08,\n            delay: breaking || reducedMotion ? 0 : SPLIT_DURATION,\n            ease: EASE_OUT,\n          }}\n        >\n          <CookieShape\n            outline={WHOLE}\n            chips={`${LEFT_CHIPS} ${RIGHT_CHIPS}`}\n            marks={`${LEFT_MARKS}${RIGHT_MARKS}`}\n          />\n        </motion.div>\n        {/* HTML wrappers keep transforms accelerated and scale with the SVG's viewBox. */}\n        <motion.div\n          className=\"absolute inset-0\"\n          style={{ transformOrigin: \"40.79% 39.6%\" }}\n          initial={false}\n          animate={{\n            opacity: breaking ? 1 : 0,\n            transform: breaking ? \"translate(-4.47%, -3.96%) rotate(-8deg)\" : IDENTITY,\n          }}\n          transition={{\n            duration: reducedMotion ? 0 : SPLIT_DURATION,\n            delay: breaking && !reducedMotion ? SPLIT_DELAY : 0,\n            ease: EASE_OUT,\n            // Match the whole-cookie fade so undo never exposes the tile between layers.\n            opacity: {\n              duration: breaking ? 0 : 0.08,\n              delay: breaking || reducedMotion ? 0 : SPLIT_DURATION,\n              ease: EASE_OUT,\n            },\n          }}\n          onAnimationComplete={() => {\n            if (breaking) onBreakComplete();\n          }}\n        >\n          <CookieShape outline={LEFT} chips={LEFT_CHIPS} marks={LEFT_MARKS} />\n        </motion.div>\n        <motion.div\n          className=\"absolute inset-0\"\n          style={{ transformOrigin: \"58.68% 41.09%\" }}\n          initial={false}\n          animate={{\n            opacity: breaking ? 1 : 0,\n            transform: breaking ? \"translate(5%, -2.48%) rotate(9deg)\" : IDENTITY,\n          }}\n          transition={{\n            duration: reducedMotion ? 0 : SPLIT_DURATION,\n            delay: breaking && !reducedMotion ? SPLIT_DELAY : 0,\n            ease: EASE_OUT,\n            // Match the whole-cookie fade so undo never exposes the tile between layers.\n            opacity: {\n              duration: breaking ? 0 : 0.08,\n              delay: breaking || reducedMotion ? 0 : SPLIT_DURATION,\n              ease: EASE_OUT,\n            },\n          }}\n        >\n          <CookieShape outline={RIGHT} chips={RIGHT_CHIPS} marks={RIGHT_MARKS} />\n        </motion.div>\n        {CRUMBS.map((crumb) => (\n          <motion.div\n            className=\"absolute size-2 border border-[#243acb] bg-[#a5afe4]\"\n            key={crumb.id}\n            style={{\n              left: crumb.left,\n              top: crumb.top,\n              visibility: breaking && !reducedMotion ? \"visible\" : \"hidden\",\n            }}\n            initial={false}\n            animate={{\n              opacity: breaking ? [0, 1, 0] : 0,\n              transform: breaking\n                ? [\"translate(0px, 0px) rotate(0deg)\", crumb.apex, crumb.landing]\n                : \"translate(0px, 0px) rotate(0deg)\",\n            }}\n            transition={{\n              duration: 0.26,\n              delay: SPLIT_DELAY,\n              ease: EASE_OUT,\n              transform: { times: [0, 0.55, 1] },\n              opacity: { times: [0, 0.15, 1] },\n            }}\n          />\n        ))}\n      </div>\n    </div>\n  );\n}\n\nfunction CookieShape({ outline, chips, marks }: { outline: string; chips: string; marks: string }) {\n  return (\n    <svg className=\"size-full\" viewBox=\"0 0 380 202\" fill=\"none\" focusable=\"false\">\n      <path\n        d={outline}\n        transform=\"translate(0 13)\"\n        fill=\"#A5AFE4\"\n        stroke=\"#243ACB\"\n        strokeWidth=\"1.6\"\n      />\n      <path d={outline} fill=\"#F5F4EA\" stroke=\"#243ACB\" strokeWidth=\"1.6\" />\n      <path d={chips} fill=\"#243ACB\" />\n      <path d={marks} stroke=\"#7885C8\" strokeWidth=\"1.4\" strokeLinecap=\"round\" />\n    </svg>\n  );\n}\n","type":"registry:ui"}],"categories":["consent","interface","animation"],"type":"registry:ui"}