{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "control-effects",
  "type": "registry:style",
  "title": "ControlEffects",
  "description": "CSS-driven control effects (top-shine, ripple, hover-circle) that follow every control app-wide through the emitted anatomy — portalled surfaces included.",
  "dependencies": [],
  "registryDependencies": [
    "https://control-ui.dev/r/core.json"
  ],
  "files": [
    {
      "path": "src/registry/sources/control-ui/extensions/control-effects-root.tsx",
      "target": "@components/control-ui/extensions/control-effects-root.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport type { ComponentProps } from \"react\";\nimport { useEffect } from \"react\";\n\nimport { cn } from \"@/components/control-ui/lib/cn\";\nimport { type ControlEffect, controlEffectsAttribute, skinEffects } from \"@/components/control-ui/skin\";\n\ntype ControlEffectsRootProps = ComponentProps<\"div\"> & {\n  effects?: ControlEffect[];\n};\n\nconst EMPTY_EFFECTS: ControlEffect[] = [];\nconst controlSelector = '[data-control-ui][data-control=\"true\"]';\n\n// effects.css does painting; only imperative work left is pointer geometry cascade cannot observe.\nfunction onRippleEnd(event: AnimationEvent) {\n  if (event.animationName !== \"aui-ripple-pulse\") return;\n  const control = event.currentTarget;\n  if (!(control instanceof HTMLElement)) return;\n  control.removeAttribute(\"data-ripple\");\n  control.removeEventListener(\"animationend\", onRippleEnd);\n}\n\nfunction startRipple(control: HTMLElement, event: PointerEvent) {\n  if (event.pointerType === \"mouse\" && event.button !== 0) return;\n  if (control.matches(\":disabled,[aria-disabled='true'],[data-disabled='true']\")) return;\n\n  const rect = control.getBoundingClientRect();\n  control.style.setProperty(\"--aui-ripple-x\", `${event.clientX - rect.left}px`);\n  control.style.setProperty(\"--aui-ripple-y\", `${event.clientY - rect.top}px`);\n  /* final radius = 1.1 × longest side, so pulse covers control from any click point */\n  control.style.setProperty(\"--aui-ripple-r\", `${Math.ceil(Math.max(rect.width, rect.height) * 1.1)}px`);\n\n  /* attribute drop + style flush + re-add is what replays keyframes from 0% mid-pulse */\n  control.removeAttribute(\"data-ripple\");\n  void control.offsetWidth;\n  control.setAttribute(\"data-ripple\", \"\");\n  control.addEventListener(\"animationend\", onRippleEnd);\n}\n\n// Delegated at document, refcounted across every mounted root: portalled control mounts under <body>, where root-scoped listener would never see it.\nlet rippleClients = 0;\n\nfunction onDocumentPointerDown(event: PointerEvent) {\n  if (event.defaultPrevented || !(event.target instanceof Element)) return;\n  const control = event.target.closest<HTMLElement>(controlSelector);\n  if (!control?.closest('[data-effects~=\"ripple\"]')) return;\n  startRipple(control, event);\n}\n\nfunction acquireRippleListener() {\n  if (rippleClients === 0) document.addEventListener(\"pointerdown\", onDocumentPointerDown);\n  rippleClients += 1;\n  return () => {\n    rippleClients -= 1;\n    if (rippleClients === 0) document.removeEventListener(\"pointerdown\", onDocumentPointerDown);\n  };\n}\n\n// CSS owns disc and its grow/shrink; this only streams coordinates. Rect is cached per control entered, not per move.\nlet hoverCircleClients = 0;\nlet hoverCircleControl: HTMLElement | null = null;\nlet hoverCircleRect: DOMRect | null = null;\n\nfunction onDocumentPointerMove(event: PointerEvent) {\n  if (!(event.target instanceof Element)) return;\n  const control = event.target.closest<HTMLElement>(controlSelector);\n  if (control !== hoverCircleControl) {\n    hoverCircleControl = control?.closest('[data-effects~=\"hover-circle\"]') ? control : null;\n    hoverCircleRect = hoverCircleControl?.getBoundingClientRect() ?? null;\n    if (hoverCircleControl && hoverCircleRect) {\n      /* same sizing rule as ripple radius */\n      const radius = Math.ceil(Math.max(hoverCircleRect.width, hoverCircleRect.height) * 1.1);\n      hoverCircleControl.style.setProperty(\"--aui-hover-circle-r\", `${radius}px`);\n    }\n  }\n  if (!hoverCircleControl || !hoverCircleRect) return;\n  hoverCircleControl.style.setProperty(\"--aui-hover-circle-x\", `${event.clientX - hoverCircleRect.left}px`);\n  hoverCircleControl.style.setProperty(\"--aui-hover-circle-y\", `${event.clientY - hoverCircleRect.top}px`);\n}\n\nfunction acquireHoverCircleListener() {\n  if (hoverCircleClients === 0) document.addEventListener(\"pointermove\", onDocumentPointerMove, { passive: true });\n  hoverCircleClients += 1;\n  return () => {\n    hoverCircleClients -= 1;\n    if (hoverCircleClients === 0) {\n      document.removeEventListener(\"pointermove\", onDocumentPointerMove);\n      hoverCircleControl = null;\n      hoverCircleRect = null;\n    }\n  };\n}\n\n/** Caller-wins local override of skin-declared list. Children that portal away follow skin instead — portals re-stamp from it, not from this wrapper. */\nexport function ControlEffectsRoot({ effects = EMPTY_EFFECTS, className, children, ...props }: ControlEffectsRootProps) {\n  const effectsValue = controlEffectsAttribute(effects);\n  const hasRipple = effects.includes(\"ripple\");\n  const hasHoverCircle = effects.includes(\"hover-circle\");\n\n  useEffect(() => (hasRipple ? acquireRippleListener() : undefined), [hasRipple]);\n  useEffect(() => (hasHoverCircle ? acquireHoverCircleListener() : undefined), [hasHoverCircle]);\n\n  return (\n    <div data-effects={effectsValue} className={cn(\"contents\", className)} {...props}>\n      {children}\n    </div>\n  );\n}\n\n/** Mount once in app layout. Mirrors skin's effects onto <html> and renders nothing; only remount re-resolves, since skinEffects() reads mutable config. */\nexport function ControlEffectsRuntime() {\n  const effectsValue = skinEffects();\n\n  useEffect(() => {\n    const html = document.documentElement;\n    if (effectsValue === undefined) html.removeAttribute(\"data-effects\");\n    else html.setAttribute(\"data-effects\", effectsValue);\n    return () => html.removeAttribute(\"data-effects\");\n  }, [effectsValue]);\n\n  useEffect(() => acquireRippleListener(), []);\n  /* pointermove is hot — stream only while skin declares hover-circle */\n  useEffect(() => (effectsValue?.includes(\"hover-circle\") ? acquireHoverCircleListener() : undefined), [effectsValue]);\n\n  return null;\n}\n"
    },
    {
      "path": "src/registry/sources/control-ui/extensions/control-effects.css",
      "target": "@components/control-ui/styles/control-effects.css",
      "type": "registry:file",
      "content": "/* Control effects: data-driven + CSS-first. Primitives emit stable anatomy plus data-control; a skin or extension root opts in via data-effects=\"top-shine ripple\".\n * No effect injects DOM: top-shine on ::before, ripple on control's own background-image (clipped by border-radius, composes with both).\n * ControlEffectsRoot only feeds click geometry via --aui-ripple-* + retriggers keyframes — streaming DOM updates cost nothing (no observer, nothing to re-sync). */\n[data-effects~=\"top-shine\"] :where([data-control-ui][data-control=\"true\"]),\n[data-effects~=\"ripple\"] :where([data-control-ui][data-control=\"true\"]),\n[data-effects~=\"hover-circle\"] :where([data-control-ui][data-control=\"true\"]) {\n  position: relative;\n  overflow: visible;\n  isolation: isolate;\n}\n\n[data-effects~=\"top-shine\"] :where([data-control-ui][data-control=\"true\"])::before {\n  content: \"\";\n  pointer-events: none;\n  position: absolute;\n  z-index: 2;\n  inset-inline: 0.5rem;\n  top: 0;\n  height: 1px;\n  background: linear-gradient(90deg, transparent, oklch(from var(--foreground) l c h / 0.32), transparent);\n  opacity: 0;\n  transition: opacity var(--duration-fast) var(--ease-standard);\n}\n\n[data-effects~=\"top-shine\"] :where([data-control-ui][data-control=\"true\"]:hover)::before,\n[data-effects~=\"top-shine\"] :where([data-control-ui][data-control=\"true\"]:focus-visible)::before {\n  opacity: 1;\n}\n\n/* ripple — click feedback via control's own background-image; ControlEffectsRoot sets --aui-ripple-x/y/r from pointer + toggles data-ripple, keyframes animate the two registered properties the radial-gradient reads.\n * Registration required for interpolation — browsers without @property degrade to a brief static flash, never broken layout. */\n@property --cui-ripple-scale {\n  syntax: \"<number>\";\n  inherits: false;\n  initial-value: 0;\n}\n\n@property --cui-ripple-alpha {\n  syntax: \"<number>\";\n  inherits: false;\n  initial-value: 0;\n}\n\n[data-effects~=\"ripple\"] :where([data-control-ui][data-control=\"true\"][data-ripple]) {\n  background-image: radial-gradient(\n    circle calc(var(--aui-ripple-r, 0px) * var(--cui-ripple-scale)) at var(--aui-ripple-x, 50%) var(--aui-ripple-y, 50%),\n    oklch(from var(--foreground) l c h / calc(0.24 * var(--cui-ripple-alpha))) 0%,\n    oklch(from var(--foreground) l c h / calc(0.14 * var(--cui-ripple-alpha))) 42%,\n    transparent 70%\n  );\n  animation: aui-ripple-pulse calc(var(--duration-slow) * 2.2) var(--ease-emphasized) forwards;\n}\n\n/* Reduced motion: no pulse — gradient stays at registered initial values (scale 0/alpha 0), i.e. invisible. */\n@media (prefers-reduced-motion: reduce) {\n  [data-effects~=\"ripple\"] :where([data-control-ui][data-control=\"true\"][data-ripple]) {\n    animation: none;\n    background-image: none;\n  }\n}\n\n@keyframes aui-ripple-pulse {\n  0% {\n    --cui-ripple-scale: 0;\n    --cui-ripple-alpha: 0.45;\n  }\n  70% {\n    --cui-ripple-alpha: 0.18;\n  }\n  100% {\n    --cui-ripple-scale: 1;\n    --cui-ripple-alpha: 0;\n  }\n}\n\n/* hover-circle — pointer-following blend disc: grows from the cursor on enter, tracks it across the\n * control, recedes on leave; mix-blend-mode:difference inverts whatever it crosses (text included).\n * ControlEffectsRoot only streams --aui-hover-circle-x/y/r (CSS can't observe pointer coords); the\n * grow/shrink is a registered-property transition on :hover, everything else stays declarative.\n * The disc is the ::after's own background — clipped by border-radius:inherit, no injected DOM.\n * Tokens: --aui-hover-circle-color (disc paint), radius fed per-control by the runtime.\n * Registration required for interpolation — without @property the disc still appears/disappears, it just skips the growth. */\n@property --cui-hover-circle-scale {\n  syntax: \"<number>\";\n  inherits: false;\n  initial-value: 0;\n}\n\n/* Hover-capable pointers only: on touch, :hover sticks after tap and the disc would freeze mid-control. */\n@media (hover: hover) {\n  [data-effects~=\"hover-circle\"] :where([data-control-ui][data-control=\"true\"])::after {\n    content: \"\";\n    pointer-events: none;\n    position: absolute;\n    z-index: 2;\n    inset: 0;\n    border-radius: inherit;\n    background-image: radial-gradient(\n      circle calc(var(--aui-hover-circle-r, 6rem) * var(--cui-hover-circle-scale)) at var(--aui-hover-circle-x, 50%)\n        var(--aui-hover-circle-y, 50%),\n      var(--aui-hover-circle-color, oklch(1 0 0 / 0.24)) calc(100% - 1px),\n      transparent 100%\n    );\n    mix-blend-mode: difference;\n    transition: --cui-hover-circle-scale calc(var(--duration-slow) * 1.6) var(--ease-emphasized);\n  }\n\n  /* Same disabled guard as the ripple's JS check — a dead control never lights up. */\n  [data-effects~=\"hover-circle\"]\n  :where([data-control-ui][data-control=\"true\"]:hover:not(:disabled, [aria-disabled=\"true\"], [data-disabled=\"true\"]))::after {\n    --cui-hover-circle-scale: 1;\n  }\n}\n"
    }
  ],
  "css": {
    "@import \"../components/control-ui/styles/control-effects.css\"": {}
  },
  "meta": {}
}
