{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"art-piece","type":"registry:ui","title":"ArtPiece","description":"A design frame \u2014 image \/ HTML \/ live JSX.","package":"fancy-artboard","dependencies":["@particle-academy\/react-fancy"],"registryDependencies":["types","art-board","inline-editor","lib","html"],"files":[{"path":"components\/fancy\/art-piece\/ArtPiece.tsx","content":"import { type ReactNode } from \"react\";\r\nimport type { ArtPieceContent } from \"..\/..\/types\";\r\n\r\nexport type ArtPieceProps = {\r\n  \/** Stable handle \u2014 agents\/selectors target this without guessing the DOM. *\/\r\n  id: string;\r\n  label?: string;\r\n  width?: number;\r\n  height?: number;\r\n  \/**\r\n   * Content source. Omit to render `children` as a `kind:\"node\"` piece\r\n   * (JSX authoring sugar), or pass an explicit image\/html\/node descriptor.\r\n   *\/\r\n  content?: ArtPieceContent;\r\n  \/** Proposed (agent-staged) piece \u2014 renders with a trust-but-verify affordance. *\/\r\n  pending?: boolean;\r\n  \/** JSX content when authoring inline (compiled to a `kind:\"node\"` piece). *\/\r\n  children?: ReactNode;\r\n};\r\n\r\n\/**\r\n * Authoring marker for a single design frame. Renders nothing itself \u2014 the\r\n * parent `<ArtBoard>` walks `<ArtPiece>` children to compile the\r\n * `ArtBoardValue` and a content registry (for `kind:\"node\"` JSX). The frame\r\n * chrome + content host is rendered by `PieceFrame`.\r\n *\/\r\nexport function ArtPiece(_props: ArtPieceProps): null {\r\n  return null;\r\n}\r\nArtPiece.displayName = \"ArtPiece\";\r\n","type":"registry:ui","target":"components\/fancy\/art-piece\/ArtPiece.tsx"},{"path":"components\/fancy\/art-piece\/PieceFrame.tsx","content":"import { useRef, useState, type CSSProperties } from \"react\";\r\nimport { Button, Dropdown, Tooltip } from \"@particle-academy\/react-fancy\";\r\nimport type { ArtPieceData, ArtSectionData } from \"..\/..\/types\";\r\nimport { useArtBoard } from \"..\/ArtBoard\/context\";\r\nimport { InlineEditor } from \"..\/InlineEditor\";\r\nimport { exportPiece, exportName, type ExportKind } from \"..\/..\/lib\/export\";\r\nimport { cx } from \"..\/..\/lib\/cx\";\r\nimport { PolicyHtml } from \"..\/..\/html\/PolicyHtml\";\n\r\nconst DEFAULT_W = 260;\r\nconst DEFAULT_H = 480;\r\n\r\ntype PieceFrameProps = {\r\n  sectionId: string;\r\n  piece: ArtPieceData;\r\n  \/** Ordered piece ids in the section \u2014 for live drag-reorder. *\/\r\n  order: string[];\r\n};\r\n\r\nexport function PieceFrame({ sectionId, piece, order }: PieceFrameProps) {\r\n  const board = useArtBoard();\r\n  const slotRef = useRef<HTMLDivElement>(null);\r\n  const cardRef = useRef<HTMLDivElement>(null);\r\n  \/\/ Two-click delete confirm \u2014 persists across menu re-opens.\r\n  const [confirming, setConfirming] = useState(false);\r\n  const width = piece.width ?? DEFAULT_W;\r\n  const height = piece.height ?? DEFAULT_H;\r\n  const id = piece.id;\r\n  const label = piece.label ?? id;\r\n\r\n  const renameLabel = (next: string) => {\r\n    board.setSections((sections) =>\r\n      sections.map((s) =>\r\n        s.id === sectionId\r\n          ? { ...s, pieces: s.pieces.map((p) => (p.id === id ? { ...p, label: next } : p)) }\r\n          : s,\r\n      ),\r\n    );\r\n  };\r\n\r\n  const reorder = (nextOrder: string[]) => {\r\n    board.setSections((sections) =>\r\n      sections.map((s) => {\r\n        if (s.id !== sectionId) return s;\r\n        const byId = new Map(s.pieces.map((p) => [p.id, p]));\r\n        return { ...s, pieces: nextOrder.map((k) => byId.get(k)!).filter(Boolean) };\r\n      }),\r\n    );\r\n  };\r\n\r\n  const remove = () => {\r\n    board.setSections((sections) =>\r\n      sections.map((s) =>\r\n        s.id === sectionId ? { ...s, pieces: s.pieces.filter((p) => p.id !== id) } : s,\r\n      ),\r\n    );\r\n  };\r\n\r\n  const doExport = (kind: ExportKind) => {\r\n    board.onExport?.(id, kind);\r\n    if (!cardRef.current) return;\r\n    exportPiece(cardRef.current, width, height, exportName(piece.label, id), kind).catch((e) =>\r\n      \/\/ eslint-disable-next-line no-console\r\n      console.error(\"[fancy-artboard] export failed:\", e),\r\n    );\r\n  };\r\n\r\n  const focus = () => board.setFocus(id);\r\n\r\n  \/\/ Live drag-reorder: dragged card sticks to the cursor; siblings slide into\r\n  \/\/ their would-be slots via transforms; DOM order commits only on drop.\r\n  const onGripDown = (e: React.PointerEvent) => {\r\n    e.preventDefault();\r\n    e.stopPropagation();\r\n    const me = slotRef.current;\r\n    if (!me) return;\r\n    \/\/ translateX is in local (pre-scale) space but pointer deltas + rect.left\r\n    \/\/ are screen-space \u2014 divide by the live zoom so it tracks the cursor.\r\n    const scale = me.getBoundingClientRect().width \/ me.offsetWidth || 1;\r\n    const peers = Array.from(\r\n      document.querySelectorAll<HTMLElement>(\r\n        `[data-fa-section=\"${CSS.escape(sectionId)}\"] [data-fa-piece]`,\r\n      ),\r\n    );\r\n    const homes = peers.map((el) => ({\r\n      el,\r\n      id: el.dataset.faPiece as string,\r\n      x: el.getBoundingClientRect().left,\r\n    }));\r\n    const slotXs = homes.map((h) => h.x);\r\n    const startIdx = order.indexOf(id);\r\n    const startX = e.clientX;\r\n    let liveOrder = order.slice();\r\n    me.classList.add(\"fa-dragging\");\r\n\r\n    const layout = () => {\r\n      for (const h of homes) {\r\n        if (h.id === id) continue;\r\n        const slot = liveOrder.indexOf(h.id);\r\n        h.el.style.transform = `translateX(${(slotXs[slot] - h.x) \/ scale}px)`;\r\n      }\r\n    };\r\n\r\n    const move = (ev: PointerEvent) => {\r\n      const dx = ev.clientX - startX;\r\n      me.style.transform = `translateX(${dx \/ scale}px)`;\r\n      const cur = homes[startIdx].x + dx;\r\n      let nearest = 0;\r\n      let best = Infinity;\r\n      for (let i = 0; i < slotXs.length; i++) {\r\n        const d = Math.abs(slotXs[i] - cur);\r\n        if (d < best) {\r\n          best = d;\r\n          nearest = i;\r\n        }\r\n      }\r\n      if (liveOrder.indexOf(id) !== nearest) {\r\n        liveOrder = order.filter((k) => k !== id);\r\n        liveOrder.splice(nearest, 0, id);\r\n        layout();\r\n      }\r\n    };\r\n\r\n    const up = () => {\r\n      document.removeEventListener(\"pointermove\", move);\r\n      document.removeEventListener(\"pointerup\", up);\r\n      const finalSlot = liveOrder.indexOf(id);\r\n      me.classList.remove(\"fa-dragging\");\r\n      me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) \/ scale}px)`;\r\n      \/\/ After the settle transition, kill transitions + clear transforms +\r\n      \/\/ commit in the same frame so there's no snap-back flash.\r\n      setTimeout(() => {\r\n        for (const h of homes) {\r\n          h.el.style.transition = \"none\";\r\n          h.el.style.transform = \"\";\r\n        }\r\n        if (liveOrder.join(\"|\") !== order.join(\"|\")) reorder(liveOrder);\r\n        requestAnimationFrame(() =>\r\n          requestAnimationFrame(() => {\r\n            for (const h of homes) h.el.style.transition = \"\";\r\n          }),\r\n        );\r\n      }, 180);\r\n    };\r\n\r\n    document.addEventListener(\"pointermove\", move);\r\n    document.addEventListener(\"pointerup\", up);\r\n  };\r\n\r\n  const cardStyle: CSSProperties = {\r\n    width,\r\n    height,\r\n  };\r\n\r\n  return (\r\n    <div ref={slotRef} className=\"fa-slot\" data-fa-piece={id}>\r\n      <div\r\n        className=\"fa-header\"\r\n        data-fa-chrome=\"\"\r\n        onPointerDown={(e) => e.stopPropagation()}\r\n      >\r\n        <div className=\"fa-labelrow\">\r\n          <span\r\n            className=\"fa-grip\"\r\n            onPointerDown={onGripDown}\r\n            title=\"Drag to reorder\"\r\n            aria-label=\"Drag to reorder\"\r\n          >\r\n            <svg width=\"9\" height=\"13\" viewBox=\"0 0 9 13\" fill=\"currentColor\" aria-hidden>\r\n              <circle cx=\"2\" cy=\"2\" r=\"1.1\" \/>\r\n              <circle cx=\"7\" cy=\"2\" r=\"1.1\" \/>\r\n              <circle cx=\"2\" cy=\"6.5\" r=\"1.1\" \/>\r\n              <circle cx=\"7\" cy=\"6.5\" r=\"1.1\" \/>\r\n              <circle cx=\"2\" cy=\"11\" r=\"1.1\" \/>\r\n              <circle cx=\"7\" cy=\"11\" r=\"1.1\" \/>\r\n            <\/svg>\r\n          <\/span>\r\n          <span className=\"fa-labeltext\" onClick={focus} title=\"Click to focus\">\r\n            <InlineEditor\r\n              value={label}\r\n              onChange={renameLabel}\r\n              onClick={(e) => e.stopPropagation()}\r\n              style={{ fontSize: 15, fontWeight: 500, lineHeight: 1 }}\r\n            \/>\r\n          <\/span>\r\n        <\/div>\r\n        <div className=\"fa-btns\">\r\n          <Dropdown placement=\"bottom-start\">\r\n            <Dropdown.Trigger>\r\n              <Tooltip content=\"More\">\r\n                <Button variant=\"ghost\" size=\"sm\" icon=\"more-horizontal\" aria-label=\"More\" \/>\r\n              <\/Tooltip>\r\n            <\/Dropdown.Trigger>\r\n            <Dropdown.Items className=\"fa-menu\">\r\n              <Dropdown.Item\r\n                onClick={() => {\r\n                  setConfirming(false);\r\n                  doExport(\"png\");\r\n                }}\r\n              >\r\n                Download PNG\r\n              <\/Dropdown.Item>\r\n              <Dropdown.Item\r\n                onClick={() => {\r\n                  setConfirming(false);\r\n                  doExport(\"html\");\r\n                }}\r\n              >\r\n                Download HTML\r\n              <\/Dropdown.Item>\r\n              <Dropdown.Separator \/>\r\n              <Dropdown.Item\r\n                danger\r\n                onClick={() => {\r\n                  if (confirming) {\r\n                    setConfirming(false);\r\n                    remove();\r\n                  } else {\r\n                    setConfirming(true);\r\n                  }\r\n                }}\r\n              >\r\n                {confirming ? \"Click again to delete\" : \"Delete\"}\r\n              <\/Dropdown.Item>\r\n            <\/Dropdown.Items>\r\n          <\/Dropdown>\r\n          <Tooltip content=\"Focus\">\r\n            <Button variant=\"ghost\" size=\"sm\" icon=\"maximize\" onClick={focus} aria-label=\"Focus\" \/>\r\n          <\/Tooltip>\r\n        <\/div>\r\n      <\/div>\r\n      <div\r\n        ref={cardRef}\r\n        className={cx(\"fa-card\", piece.pending && \"fa-pending\")}\r\n        style={cardStyle}\r\n      >\r\n        <PieceContent piece={piece} sectionId={sectionId} \/>\r\n        {piece.pending && <span className=\"fa-pending-badge\">proposed<\/span>}\r\n      <\/div>\r\n    <\/div>\r\n  );\r\n}\r\n\r\n\/** Renders the piece content inline so it scales crisply under the world transform. *\/\r\nfunction PieceContent({ piece, sectionId }: { piece: ArtPieceData; sectionId: string }) {\r\n  const board = useArtBoard();\r\n  const c = piece.content;\r\n  if (c.kind === \"image\") {\r\n    return (\r\n      <img\r\n        src={c.src}\r\n        alt={c.alt ?? piece.label ?? piece.id}\r\n        style={{ width: \"100%\", height: \"100%\", objectFit: \"cover\", display: \"block\" }}\r\n      \/>\r\n    );\r\n  }\r\n  if (c.kind === \"html\") {\r\n    return (\r\n      <PolicyHtml\r\n        html={c.html}\r\n        policy={board.htmlPolicy}\r\n        context={{\r\n          pieceId: piece.id,\r\n          sectionId,\r\n          origin: piece.origin ?? \"agent\",\r\n          reviewState: piece.pending ? \"pending\" : \"accepted\",\r\n        }}\r\n      \/>\r\n    );\r\n  }\r\n  \/\/ kind: \"node\" \u2014 the JSX child the human authored.\r\n  const node = board.nodes.get(piece.id);\r\n  return <>{node ?? null}<\/>;\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/art-piece\/PieceFrame.tsx"}]}