{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fleet-sky",
  "title": "FleetSky",
  "description": "Many agents at once, as birds on lines back to you. The one that needs you flies in on a taut line.",
  "registryDependencies": [
    "https://creance.pages.dev/r/badge.json",
    "https://creance.pages.dev/r/creance-base.json"
  ],
  "files": [
    {
      "path": "fleet-sky/FleetSky.css",
      "content": ".cr-sky {\n  position: relative;\n  min-height: 320px;\n  overflow: hidden;\n  border-radius: var(--cr-radius-xl);\n  background: var(--cr-gradient-horizon), var(--cr-gradient-canvas), var(--cr-color-bg-canvas);\n  color: var(--cr-color-text-default);\n  font-family: var(--cr-font-family-sans);\n}\n\n.cr-sky__canvas {\n  position: absolute;\n  inset: 0;\n}\n\n.cr-sky__tags {\n  margin: 0;\n  padding: 0;\n  list-style: none;\n}\n\n/* Tags follow their bird; the transform is set every frame. */\n.cr-sky__tag {\n  position: absolute;\n  top: 0;\n  left: 0;\n  will-change: transform;\n}\n\n.cr-sky__tag-button {\n  display: grid;\n  justify-items: center;\n  padding: 2px var(--cr-space-2);\n  border: 0;\n  border-radius: var(--cr-radius-md);\n  background: transparent;\n  color: inherit;\n  font: inherit;\n  white-space: nowrap;\n  text-align: center;\n}\n\nbutton.cr-sky__tag-button {\n  cursor: pointer;\n}\n\nbutton.cr-sky__tag-button:hover {\n  background: var(--cr-color-bg-subtle);\n}\n\nbutton.cr-sky__tag-button:focus-visible {\n  outline: var(--cr-focus-width) solid var(--cr-color-focus);\n  outline-offset: var(--cr-focus-offset);\n}\n\n.cr-sky__name {\n  font-size: var(--cr-font-size-sm);\n  font-weight: var(--cr-font-weight-semibold);\n}\n\n.cr-sky__status {\n  color: var(--cr-color-text-muted);\n  font-size: var(--cr-font-size-xs);\n}\n\n.cr-sky__tag[data-status='waiting'] .cr-sky__status { color: var(--cr-color-text-default); font-weight: var(--cr-font-weight-semibold); }\n.cr-sky__tag[data-status='failed'] .cr-sky__status { color: var(--cr-color-status-failed-fg); }\n.cr-sky__tag[data-status='done'] .cr-sky__status { color: var(--cr-color-status-done-fg); }\n\n.cr-sky__detail {\n  display: none;\n}\n\n.cr-sky__tag[data-status='waiting'] .cr-sky__detail {\n  display: inline;\n}\n\n.cr-sky__you {\n  position: absolute;\n  left: 50%;\n  bottom: 2%;\n  transform: translateX(-50%);\n  color: var(--cr-color-text-muted);\n  font-size: var(--cr-font-size-sm);\n  font-weight: var(--cr-font-weight-semibold);\n}\n",
      "type": "registry:file",
      "target": "@components/creance/fleet-sky/FleetSky.css"
    },
    {
      "path": "fleet-sky/FleetSky.tsx",
      "content": "'use client';\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState } from 'react';\nimport { statusLabels, type Status } from '@/components/creance/badge/Badge';\nimport { cx } from '@/lib/creance/cx';\nimport { LiveRegion } from '@/lib/creance/LiveRegion';\nimport './FleetSky.css';\n\nexport interface SkyAgent {\n  id: string;\n  name: string;\n  status: Status;\n  /** One short line, e.g. \"4 steps in\" or \"wants to run npm install zod@3\". */\n  detail?: string | undefined;\n}\n\nexport interface FleetSkyProps {\n  agents: SkyAgent[];\n  /** Makes each agent's name tag a button, e.g. to open its run. */\n  onSelect?: (id: string) => void;\n  /** Accessible name of the sky. */\n  label?: string;\n  /** Name of the anchor every line runs back to. */\n  you?: string;\n  className?: string;\n}\n\ninterface Bird {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  sag: number;\n}\n\nconst ANCHOR = { x: 0.5, y: 0.84 };\n\n/** Where an agent wants to be, as a fraction of the sky, at time t (seconds). */\nfunction target(agent: SkyAgent, index: number, count: number, t: number) {\n  const phase = index * 2.1;\n  const speed = 0.28 + (index % 3) * 0.07;\n  switch (agent.status) {\n    case 'waiting': {\n      // Returning: hovers close to you, slightly offset so several requests don't overlap.\n      const offset = (index - (count - 1) / 2) * 0.09;\n      return { x: 0.5 + offset + Math.sin(t * 0.9 + phase) * 0.01, y: 0.58 + Math.cos(t * 1.1 + phase) * 0.012 };\n    }\n    case 'done':\n      return { x: 0.5 + (index - (count - 1) / 2) * 0.16, y: 0.74 };\n    case 'failed':\n      return { x: 0.12 + (0.76 * (index + 0.5)) / count, y: 0.66 };\n    case 'blocked':\n      return { x: 0.12 + (0.76 * (index + 0.5)) / count, y: 0.34 + Math.sin(t * 0.6 + phase) * 0.01 };\n    default:\n      return {\n        x: 0.12 + (0.76 * (index + 0.5)) / count + Math.cos(t * speed + phase) * 0.07,\n        y: 0.24 + (index % 2) * 0.12 + Math.sin(t * speed * 1.3 + phase) * 0.06,\n      };\n  }\n}\n\n/**\n * The fleet as birds in a sky, each on a line back to you. A slack line is an agent flying on its\n * own; a taut, glowing line is one that needs you. The canvas is decorative; the name tags are real\n * elements (buttons when `onSelect` is set) and carry the status in text.\n */\nexport function FleetSky({ agents, onSelect, label = 'Your agents', you = 'You', className }: FleetSkyProps) {\n  const rootRef = useRef<HTMLElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const tagRefs = useRef(new Map<string, HTMLElement>());\n  const birds = useRef(new Map<string, Bird>());\n  const agentsRef = useRef(agents);\n  const redrawRef = useRef<(() => void) | undefined>(undefined);\n  useLayoutEffect(() => {\n    agentsRef.current = agents;\n  }, [agents]);\n  const headingId = useId();\n\n  // Announce agents as they start needing the person.\n  const [announcement, setAnnouncement] = useState('');\n  const seenWaiting = useRef(new Set<string>());\n  useEffect(() => {\n    const newly = agents.filter((a) => a.status === 'waiting' && !seenWaiting.current.has(a.id));\n    agents.forEach((a) => (a.status === 'waiting' ? seenWaiting.current.add(a.id) : seenWaiting.current.delete(a.id)));\n    if (newly.length) setAnnouncement(`${newly.map((a) => a.name).join(', ')} ${newly.length > 1 ? 'need' : 'needs'} you`);\n  }, [agents]);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext('2d');\n    if (!root || !canvas || !ctx) return;\n    const reduce = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;\n    const dpr = window.devicePixelRatio || 1;\n    const stars = Array.from({ length: 70 }, (_, i) => [((i * 97) % 100) / 100, ((i * 53) % 55) / 100, i]);\n    let width = 0;\n    let height = 0;\n    let colors: Record<string, string> = {};\n    let frame = 0;\n    let raf = 0;\n\n    const readColors = () => {\n      const cs = getComputedStyle(root);\n      const v = (name: string) => cs.getPropertyValue(name).trim();\n      colors = {\n        slack: v('--cr-color-tether-slack'),\n        taut: v('--cr-color-tether-taut'),\n        done: v('--cr-color-status-done-fg'),\n        failed: v('--cr-color-status-failed-fg'),\n        blocked: v('--cr-color-status-blocked-fg'),\n        bird: v('--cr-color-text-default'),\n        star: v('--cr-color-sky-star'),\n      };\n    };\n    const resize = () => {\n      width = root.clientWidth;\n      height = root.clientHeight;\n      canvas.width = width * dpr;\n      canvas.height = height * dpr;\n      canvas.style.width = `${width}px`;\n      canvas.style.height = `${height}px`;\n      // Resizing clears the canvas; under reduced motion nothing else would repaint it.\n      redraw();\n    };\n\n    const draw = (ms: number) => {\n      const t = reduce ? 0 : ms / 1000;\n      if (frame++ % 30 === 0) readColors();\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n\n      for (const [sx, sy, i] of stars) {\n        ctx.globalAlpha = 0.35 + 0.65 * Math.abs(Math.sin(t * 0.5 + (i as number)));\n        ctx.fillStyle = colors.star ?? 'transparent';\n        ctx.fillRect((sx as number) * width, (sy as number) * height, 1.5, 1.5);\n      }\n      ctx.globalAlpha = 1;\n\n      const ax = ANCHOR.x * width;\n      const ay = ANCHOR.y * height;\n      const list = agentsRef.current;\n      list.forEach((agent, i) => {\n        const goal = target(agent, i, list.length, t);\n        const gx = goal.x * width;\n        const gy = goal.y * height;\n        let b = birds.current.get(agent.id);\n        if (!b) {\n          b = { x: gx, y: gy, vx: 0, vy: 0, sag: 1 };\n          birds.current.set(agent.id, b);\n        }\n        if (reduce) {\n          b.x = gx;\n          b.y = gy;\n        } else {\n          // A soft spring, so a change of status reads as the bird flying somewhere new.\n          b.vx = (b.vx + (gx - b.x) * 0.02) * 0.86;\n          b.vy = (b.vy + (gy - b.y) * 0.02) * 0.86;\n          b.x += b.vx;\n          b.y += b.vy;\n        }\n        const taut = agent.status === 'waiting';\n        b.sag += ((taut ? 0 : 1) - b.sag) * (reduce ? 1 : 0.05);\n\n        // The line back to you.\n        const line =\n          agent.status === 'done'\n            ? colors.done\n            : agent.status === 'failed'\n              ? colors.failed\n              : agent.status === 'blocked'\n                ? colors.blocked\n                : taut\n                  ? colors.taut\n                  : colors.slack;\n        ctx.save();\n        ctx.strokeStyle = line ?? '';\n        ctx.lineWidth = taut ? 2.4 : 1.3;\n        if (agent.status === 'failed') ctx.setLineDash([5, 6]);\n        if (taut) {\n          ctx.shadowColor = colors.taut ?? '';\n          ctx.shadowBlur = 16;\n        }\n        const dist = Math.hypot(b.x - ax, b.y - ay);\n        ctx.beginPath();\n        ctx.moveTo(ax, ay);\n        ctx.quadraticCurveTo((ax + b.x) / 2, Math.max(ay, b.y) + b.sag * dist * 0.35, b.x, b.y);\n        ctx.stroke();\n        ctx.restore();\n\n        // The bird: wings flap in flight, fold when done, droop when failed.\n        const resting = agent.status === 'done' || agent.status === 'failed';\n        const flap = resting ? 2 : Math.sin(t * (taut ? 6 : 9) + i) * 6;\n        const s = taut ? 1.35 : 1;\n        ctx.save();\n        ctx.translate(b.x, b.y);\n        if (agent.status === 'failed') ctx.rotate(0.5);\n        ctx.strokeStyle = taut ? (colors.taut ?? '') : (colors.bird ?? '');\n        ctx.lineWidth = 2.2 * s;\n        ctx.lineCap = 'round';\n        ctx.beginPath();\n        ctx.moveTo(-14 * s, -flap);\n        ctx.quadraticCurveTo(-6 * s, -3 * s, 0, 0);\n        ctx.quadraticCurveTo(6 * s, -3 * s, 14 * s, -flap);\n        ctx.stroke();\n        ctx.restore();\n\n        const tag = tagRefs.current.get(agent.id);\n        if (tag) tag.style.transform = `translate(${Math.round(b.x)}px, ${Math.round(b.y + 14 * s)}px) translateX(-50%)`;\n      });\n\n      // You.\n      ctx.fillStyle = colors.taut ?? '';\n      ctx.beginPath();\n      ctx.arc(ax, ay, 5, 0, Math.PI * 2);\n      ctx.fill();\n\n      if (!reduce) raf = requestAnimationFrame(draw);\n    };\n    const redraw = () => {\n      cancelAnimationFrame(raf);\n      raf = requestAnimationFrame(draw);\n    };\n    // Reduced motion draws on demand: on resize, on a theme change, and when agents change.\n    redrawRef.current = () => {\n      readColors();\n      redraw();\n    };\n    const theme = new MutationObserver(redrawRef.current);\n    theme.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'data-transparency'] });\n    const scheme = window.matchMedia?.('(prefers-color-scheme: dark)');\n    scheme?.addEventListener('change', redrawRef.current);\n\n    const observer = new ResizeObserver(resize);\n    observer.observe(root);\n    resize();\n    readColors();\n    return () => {\n      cancelAnimationFrame(raf);\n      observer.disconnect();\n      theme.disconnect();\n      if (redrawRef.current) scheme?.removeEventListener('change', redrawRef.current);\n      redrawRef.current = undefined;\n    };\n  }, []);\n\n  useEffect(() => redrawRef.current?.(), [agents]);\n\n  // Forget birds for agents that left.\n  useEffect(() => {\n    const ids = new Set(agents.map((a) => a.id));\n    for (const id of birds.current.keys()) if (!ids.has(id)) birds.current.delete(id);\n  }, [agents]);\n\n  return (\n    <section ref={rootRef} className={cx('cr-sky', className)} aria-labelledby={headingId}>\n      <h2 id={headingId} className=\"cr-visually-hidden\">\n        {label}\n      </h2>\n      <canvas ref={canvasRef} className=\"cr-sky__canvas\" aria-hidden=\"true\" />\n      <ul className=\"cr-sky__tags\">\n        {agents.map((agent) => {\n          const text = (\n            <>\n              <span className=\"cr-sky__name\">{agent.name}</span>\n              <span className=\"cr-sky__status\">\n                {agent.status === 'waiting' ? 'needs you' : statusLabels[agent.status].toLowerCase()}\n                {agent.detail && <span className=\"cr-sky__detail\">, {agent.detail}</span>}\n              </span>\n            </>\n          );\n          return (\n            <li\n              key={agent.id}\n              ref={(el) => {\n                if (el) tagRefs.current.set(agent.id, el);\n                else tagRefs.current.delete(agent.id);\n              }}\n              className=\"cr-sky__tag\"\n              data-status={agent.status}\n            >\n              {onSelect ? (\n                <button type=\"button\" className=\"cr-sky__tag-button\" onClick={() => onSelect(agent.id)}>\n                  {text}\n                </button>\n              ) : (\n                <span className=\"cr-sky__tag-button\">{text}</span>\n              )}\n            </li>\n          );\n        })}\n      </ul>\n      <span className=\"cr-sky__you\" aria-hidden=\"true\">\n        {you}\n      </span>\n      <LiveRegion message={announcement} />\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/creance/fleet-sky/FleetSky.tsx"
    }
  ],
  "categories": [
    "organism",
    "agent"
  ],
  "type": "registry:ui"
}