{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fx-bubbles",
  "type": "registry:component",
  "title": "Bubbles",
  "description": "A decorative field of rising bubbles",
  "author": "sudo-kw <https://github.com/sudo-kw>",
  "dependencies": [
    "@gpuix/native@0.11.0",
    "@gpuix/react@0.11.0",
    "effect@4.1.2",
    "react@^19.2.4"
  ],
  "registryDependencies": [
    "https://gpuixui.sudokw.com/gpuix-components.json",
    "https://gpuixui.sudokw.com/gpuix-motion.json",
    "https://gpuixui.sudokw.com/gpuix-tw.json"
  ],
  "files": [
    {
      "path": "packages/fx/src/bubbles.tsx",
      "target": "@ui/gpuixui/fx/bubbles.tsx",
      "type": "registry:component",
      "content": "/** @jsxImportSource @gpuix/react */\nimport type { EventPayload, MotionProps, Props } from \"@gpuix/react\";\nimport {\n  mergeStyles,\n  resolveStyle,\n  type StateStyle,\n} from \"@gpuix/react/floating\";\nimport type { MotionLevel } from \"../motion/timing.js\";\nimport { cn } from \"../tw/index.js\";\nimport * as Arr from \"effect/Array\";\nimport * as Option from \"effect/Option\";\nimport { useState } from \"react\";\nimport {\n  measuredBox,\n  fadeOnly,\n  useMotionPreference,\n  useSurfaceTiming,\n  recipeStyle,\n  countOf,\n  durationOf,\n  seedOf,\n  sizeRangeOf,\n  useTheme,\n} from \"../components/authoring.js\";\nimport {\n  bubbleCount,\n  bubbleCountCap,\n  bubbleField,\n  bubbleJourney,\n  bubbleMaxSize,\n  bubbleMinSize,\n  bubbleRiseCap,\n  bubbleRiseFloor,\n  bubbleRiseMs,\n  type BubbleBox,\n  type BubbleSpec,\n} from \"./bubbles/logic.js\";\n\nexport interface BubblesState {\n  readonly count: number;\n  readonly reducedMotion: boolean;\n  readonly width: number;\n  readonly height: number;\n}\n\nexport interface BubblesProps extends Omit<Props, \"children\" | \"style\"> {\n  readonly count?: number;\n\n  readonly seed?: number;\n\n  readonly minSize?: number;\n\n  readonly maxSize?: number;\n\n  readonly color?: string;\n\n  readonly riseMs?: number;\n\n  readonly level?: MotionLevel;\n\n  readonly className?: string;\n\n  readonly style?: StateStyle<BubblesState>;\n}\n\ninterface BubbleLapProps {\n  readonly spec: BubbleSpec;\n  readonly ink: string;\n  readonly radius: number;\n  readonly still: boolean;\n  readonly box: BubbleBox;\n}\n\nconst BubbleLap = ({ spec, ink, radius, still, box }: BubbleLapProps) => {\n  const [lap, setLap] = useState(0);\n  const journey = bubbleJourney(spec, box);\n  const motion: MotionProps = {\n    initial: { left: journey.fromLeft, top: journey.fromTop, opacity: 0 },\n    animate: {\n      left: journey.toLeft,\n      top: journey.toTop,\n      opacity: spec.peak,\n    },\n    transition: {\n      duration: spec.durationMs / 1000,\n      delay: spec.delayMs / 1000,\n      ease: \"easeInOut\",\n    },\n  };\n\n  return (\n    <div\n      key={lap}\n      testId={`bubble-${spec.key}`}\n      {...(still\n        ? {}\n        : {\n            motion,\n            onMotionComplete: () => setLap((current) => current + 1),\n          })}\n      style={{\n        position: \"absolute\",\n        left: still ? spec.x : journey.fromLeft,\n        top: still ? spec.y : journey.fromTop,\n        width: spec.size,\n        height: spec.size,\n        borderRadius: radius,\n        borderWidth: 1,\n        borderColor: ink,\n        backgroundColor: ink,\n        opacity: still ? spec.peak : 0,\n\n        pointerEvents: \"none\",\n      }}\n    />\n  );\n};\n\nexport const Bubbles = ({\n  count,\n  seed,\n  minSize,\n  maxSize,\n  color,\n  riseMs,\n  level = \"slow\",\n  className,\n  style,\n  ...rest\n}: BubblesProps) => {\n  const theme = useTheme();\n  const preference = useMotionPreference();\n  const timing = useSurfaceTiming(level);\n  const fieldCount = countOf(\n    Option.fromNullishOr(count),\n    bubbleCount,\n    bubbleCountCap,\n  );\n  const sizes = sizeRangeOf(\n    Option.fromNullishOr(minSize),\n    Option.fromNullishOr(maxSize),\n    bubbleMinSize,\n    bubbleMaxSize,\n  );\n  const layoutSeed = seedOf(Option.fromNullishOr(seed));\n  const rise = durationOf(\n    Option.fromNullishOr(riseMs),\n    bubbleRiseMs,\n    bubbleRiseFloor,\n    bubbleRiseCap,\n  );\n  const ink = color ?? theme.colors.primary;\n  const still = preference === \"reduced\";\n\n  const [box, setBox] = useState<Option.Option<BubbleBox>>(Option.none());\n\n  const noteBox = (event: EventPayload): void => {\n    Option.match(measuredBox(event), {\n      onNone: () => undefined,\n      onSome: (next) =>\n        setBox((held) =>\n          Option.match(held, {\n            onNone: () => Option.some(next),\n            onSome: (current) =>\n              current.width === next.width && current.height === next.height\n                ? held\n                : Option.some(next),\n          }),\n        ),\n    });\n  };\n\n  const measured: BubbleBox = Option.getOrElse(box, () => ({\n    width: 0,\n    height: 0,\n  }));\n  const specs: ReadonlyArray<BubbleSpec> =\n    measured.width > 0 && measured.height > 0\n      ? bubbleField({\n          width: measured.width,\n          height: measured.height,\n          count: fieldCount,\n          seed: layoutSeed,\n          minSize: sizes.min,\n          maxSize: sizes.max,\n          riseMs: rise,\n        })\n      : [];\n\n  const state: BubblesState = {\n    count: Arr.length(specs),\n    reducedMotion: still,\n    width: measured.width,\n    height: measured.height,\n  };\n\n  return (\n    <div\n      {...rest}\n      onLayout={noteBox}\n      motion={{ ...fadeOnly, transition: timing.enter }}\n      style={mergeStyles(\n        mergeStyles(recipeStyle(cn(className), theme), {\n          position: \"relative\",\n          overflow: \"hidden\",\n\n          pointerEvents: \"none\",\n        }),\n        resolveStyle(style, state),\n      )}\n    >\n      {Arr.map(specs, (spec) => (\n        <BubbleLap\n          key={spec.key}\n          spec={spec}\n          ink={ink}\n          radius={theme.radius.full}\n          still={still}\n          box={measured}\n        />\n      ))}\n    </div>\n  );\n};\n\nexport const bubblesMeta = {\n  name: \"Bubbles\",\n  tier: 5,\n  status: \"done\",\n  gpuixSupport: \"partial\",\n  limitations: [\n    \"a bubble rises by tweening top and left: the runtime has no transform (upstream issue #102), so it cannot scale, wobble or warp on its way up\",\n    \"a rise is a lap re-keyed when the tween completes, so a bubble restarts at the bottom rather than turning around; both ends are off-stage, so the restart is not visible\",\n    \"the field measures its own box, so the caller must bound it; before the first layout it paints nothing\",\n    \"the field publishes no accessibility node and takes no pointer input: name the content it sits behind, never the field\",\n    \"a size change re-draws the field from the same seed, which restarts every rise (a tween cannot be resumed at an arbitrary progress)\",\n  ],\n  docs: \"docs/bubbles.mdx\",\n  example: \"examples/bubbles.example.tsx\",\n  testFile: \"src/behaviour/bubbles.test.tsx\",\n} as const;\n"
    },
    {
      "path": "packages/fx/src/bubbles/logic.ts",
      "target": "@ui/gpuixui/fx/bubbles/logic.ts",
      "type": "registry:lib",
      "content": "import * as Arr from \"effect/Array\";\nimport { paintable, seededUnit, tenth } from \"../../components/authoring.js\";\n\nexport const bubbleCount = 24;\n\nexport const bubbleCountCap = 60;\n\nexport const bubbleMinSize = 6;\nexport const bubbleMaxSize = 28;\n\nexport const bubbleRiseMs = 7_000;\n\nexport const bubbleRiseFloor = 1_200;\n\nexport const bubbleRiseCap = 60_000;\n\nconst durationFloorShare = 0.7;\nconst durationCeilingShare = 1.3;\n\nconst delayShare = 0.9;\n\nconst peakFloor = 0.06;\nconst peakCeiling = 0.18;\n\nconst driftShare = 0.08;\nconst driftCap = 40;\n\nexport interface BubbleSpec {\n  readonly key: string;\n\n  readonly x: number;\n  readonly y: number;\n  readonly size: number;\n\n  readonly drift: number;\n\n  readonly peak: number;\n\n  readonly durationMs: number;\n\n  readonly delayMs: number;\n}\n\nexport interface BubbleFieldInput {\n  readonly width: number;\n  readonly height: number;\n  readonly count: number;\n  readonly seed: number;\n  readonly minSize: number;\n  readonly maxSize: number;\n  readonly riseMs: number;\n}\n\nexport interface BubbleBox {\n  readonly width: number;\n  readonly height: number;\n}\n\nexport interface BubbleJourney {\n  readonly fromLeft: number;\n  readonly fromTop: number;\n  readonly toLeft: number;\n  readonly toTop: number;\n}\n\nconst between = (value: number, floor: number, ceiling: number): number =>\n  Math.min(Math.max(value, floor), ceiling);\n\nexport const bubbleField = (\n  input: BubbleFieldInput,\n): ReadonlyArray<BubbleSpec> => {\n  if (!paintable(input.width) || !paintable(input.height)) return [];\n  const count = Math.min(Math.max(Math.round(input.count), 0), bubbleCountCap);\n  if (count === 0) return [];\n  const maxSize = Math.max(input.maxSize, input.minSize);\n  const spanSize = Math.max(input.width - maxSize, 0);\n  const spanY = Math.max(input.height - maxSize, 0);\n  const sway = Math.min(input.width * driftShare, driftCap);\n\n  return Arr.makeBy(count, (index) => {\n    const size = Math.round(\n      input.minSize +\n        seededUnit(input.seed, index, 2) * (maxSize - input.minSize),\n    );\n    const durationMs = Math.round(\n      input.riseMs *\n        (durationFloorShare +\n          seededUnit(input.seed, index, 3) *\n            (durationCeilingShare - durationFloorShare)),\n    );\n    return {\n      key: String(index),\n      x: tenth(seededUnit(input.seed, index, 0) * spanSize),\n      y: tenth(\n        Math.min(\n          seededUnit(input.seed, index, 1) * spanY,\n          Math.max(input.height - size, 0),\n        ),\n      ),\n      size: Math.max(size, 1),\n      drift: tenth((seededUnit(input.seed, index, 6) - 0.5) * 2 * sway),\n      peak: tenth(\n        peakFloor +\n          seededUnit(input.seed, index, 4) * (peakCeiling - peakFloor),\n      ),\n      durationMs,\n      delayMs: Math.round(\n        seededUnit(input.seed, index, 5) * durationMs * delayShare,\n      ),\n    };\n  });\n};\n\nexport const bubbleJourney = (\n  spec: BubbleSpec,\n  box: BubbleBox,\n): BubbleJourney => ({\n  fromLeft: spec.x,\n  fromTop: box.height,\n  toLeft: tenth(\n    between(spec.x + spec.drift, 0, Math.max(box.width - spec.size, 0)),\n  ),\n  toTop: -spec.size,\n});\n"
    }
  ],
  "docs": "[Bubbles](https://gpuixui.sudokw.com/effects/bubbles) — These are GPUix native components: the files render through GPUix's reconciler against `@gpuix/react`, not the DOM, and need the per-platform `@gpuix/native` binding. The CLI installs the `@gpuixui/*` runtime the components import as registry items, so the copied tree resolves on its own; wrap it in `ThemeProvider` before it renders.",
  "meta": {
    "url": "/effects/bubbles",
    "kind": "component",
    "runtime": "gpuix",
    "registry": "gpuixui"
  }
}