{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"artboard","type":"registry:ui","title":"ArtBoard","description":"Controlled pan\/zoom canvas root.","package":"fancy-artboard","dependencies":[],"registryDependencies":["types","art-piece","note","focus-overlay","html","inline-editor"],"files":[{"path":"components\/fancy\/artboard\/ArtBoard.tsx","content":"import {\r\n  Children,\r\n  Fragment,\r\n  isValidElement,\r\n  useCallback,\r\n  useMemo,\r\n  useRef,\r\n  useState,\r\n  type CSSProperties,\r\n  type ReactElement,\r\n  type ReactNode,\r\n} from \"react\";\r\nimport type {\r\n  ArtBoardValue,\r\n  ArtPieceData,\r\n  ArtSectionData,\r\n  Viewport,\r\n} from \"..\/..\/types\";\r\nimport { ArtBoardContext, type ArtBoardCtx } from \".\/context\";\r\nimport { ViewportEngine } from \".\/Viewport\";\r\nimport { Section, SectionView, type SectionProps } from \".\/Section\";\r\nimport { ArtPiece, type ArtPieceProps } from \"..\/ArtPiece\/ArtPiece\";\r\nimport { Note } from \"..\/Note\/Note\";\r\nimport { FocusOverlay } from \"..\/FocusOverlay\/FocusOverlay\";\r\nimport type { HtmlPolicy } from \"..\/..\/html\/policy\";\n\r\nconst DEFAULT_VIEWPORT: Viewport = { x: 0, y: 0, zoom: 1 };\r\n\r\nexport type ArtBoardProps = {\r\n  value?: ArtBoardValue;\r\n  defaultValue?: ArtBoardValue;\r\n  onChange?: (v: ArtBoardValue) => void;\r\n  viewport?: Viewport;\r\n  defaultViewport?: Viewport;\r\n  onViewportChange?: (v: Viewport) => void;\r\n  focus?: string | null;\r\n  onFocusChange?: (pieceId: string | null) => void;\r\n  minZoom?: number;\r\n  maxZoom?: number;\r\n  onExport?: (pieceId: string, kind: \"png\" | \"html\") => void;\n  \/**\n   * Trust policy for `kind:\"html\"` pieces. The HOST owns this \u2014 an agent's\n   * payload can never select or elevate its own render mode. Defaults to\n   * sandboxing pending content and sanitising accepted content.\n   *\/\n  htmlPolicy?: HtmlPolicy;\r\n  \/** `<ArtBoard.Section>` \/ `<ArtPiece>` authoring sugar. *\/\r\n  children?: ReactNode;\r\n  className?: string;\r\n  style?: CSSProperties;\r\n};\r\n\r\n\/\/ Recursively unwrap fragments so <>...<\/> grouping doesn't hide children.\r\nfunction flatten(children: ReactNode): ReactNode[] {\r\n  const out: ReactNode[] = [];\r\n  Children.forEach(children, (c) => {\r\n    if (isValidElement(c) && c.type === Fragment) {\r\n      out.push(...flatten((c.props as { children?: ReactNode }).children));\r\n    } else {\r\n      out.push(c);\r\n    }\r\n  });\r\n  return out;\r\n}\r\n\r\ntype Compiled = { value: ArtBoardValue; nodes: Map<string, ReactNode> };\r\n\r\n\/** Walk Section\/ArtPiece children into an ArtBoardValue + JSX node registry. *\/\r\nfunction compileChildren(children: ReactNode): Compiled {\r\n  const sections: ArtSectionData[] = [];\r\n  const nodes = new Map<string, ReactNode>();\r\n\r\n  flatten(children).forEach((sec) => {\r\n    if (!isValidElement(sec) || sec.type !== Section) return;\r\n    const sp = sec.props as SectionProps;\r\n    const sid = sp.id ?? sp.title;\r\n    if (!sid) return;\r\n    const pieces: ArtPieceData[] = [];\r\n    flatten(sp.children).forEach((pc) => {\r\n      if (!isValidElement(pc) || pc.type !== ArtPiece) return;\r\n      const pp = (pc as ReactElement).props as ArtPieceProps;\r\n      if (!pp.id) return;\r\n      const content = pp.content ?? { kind: \"node\" as const };\r\n      if (content.kind === \"node\") nodes.set(pp.id, pp.children);\r\n      pieces.push({\r\n        id: pp.id,\r\n        label: pp.label,\r\n        width: pp.width,\r\n        height: pp.height,\r\n        content,\r\n        pending: pp.pending,\r\n      });\r\n    });\r\n    sections.push({ id: sid, title: sp.title, subtitle: sp.subtitle, pieces });\r\n  });\r\n\r\n  return { value: { sections }, nodes };\r\n}\r\n\r\n\/**\r\n * Figma-style design canvas. Controlled (or uncontrolled) pan\/zoom board of\r\n * `ArtPiece`s grouped into `ArtBoard.Section`s. Composes react-fancy chrome and\r\n * emits no agent events itself \u2014 a bridge layer owns presence\/undo.\r\n *\/\r\nfunction ArtBoardRoot({\r\n  value,\r\n  defaultValue,\r\n  onChange,\r\n  viewport,\r\n  defaultViewport,\r\n  onViewportChange,\r\n  focus,\r\n  onFocusChange,\r\n  minZoom = 0.1,\r\n  maxZoom = 8,\r\n  onExport,\r\n  htmlPolicy,\r\n  children,\r\n  className,\r\n  style,\r\n}: ArtBoardProps) {\r\n  \/\/ children -> value + node registry (one source of truth). Recomputed each\r\n  \/\/ render so authored JSX stays live, but only used to seed uncontrolled state.\r\n  const compiled = useMemo(() => compileChildren(children), [children]);\r\n\r\n  \/\/ value: controlled if `value` defined, else internal seeded from\r\n  \/\/ defaultValue ?? compiled-from-children.\r\n  const [internalValue, setInternalValue] = useState<ArtBoardValue>(\r\n    () => defaultValue ?? compiled.value,\r\n  );\r\n  const resolvedValue = value ?? internalValue;\r\n\r\n  const setValue = useCallback(\r\n    (next: ArtBoardValue) => {\r\n      if (value === undefined) setInternalValue(next);\r\n      onChange?.(next);\r\n    },\r\n    [value, onChange],\r\n  );\r\n\r\n  const setSections = useCallback(\r\n    (fn: (sections: ArtSectionData[]) => ArtSectionData[]) => {\r\n      const cur = value ?? internalValueRef.current;\r\n      setValue({ sections: fn(cur.sections) });\r\n    },\r\n    \/\/ setValue depends on value\/onChange; cur is read via ref to stay fresh.\r\n    \/\/ eslint-disable-next-line react-hooks\/exhaustive-deps\r\n    [setValue, value],\r\n  );\r\n  \/\/ Keep latest internal value reachable from the stable setSections closure.\r\n  const internalValueRef = useRef(internalValue);\r\n  internalValueRef.current = internalValue;\r\n\r\n  \/\/ viewport: controlled or internal.\r\n  const [internalVp, setInternalVp] = useState<Viewport>(\r\n    () => defaultViewport ?? DEFAULT_VIEWPORT,\r\n  );\r\n  const resolvedVp = viewport ?? internalVp;\r\n  const setVp = useCallback(\r\n    (next: Viewport) => {\r\n      if (viewport === undefined) setInternalVp(next);\r\n      onViewportChange?.(next);\r\n    },\r\n    [viewport, onViewportChange],\r\n  );\r\n\r\n  \/\/ focus: controlled or internal.\r\n  const [internalFocus, setInternalFocus] = useState<string | null>(null);\r\n  const resolvedFocus = focus !== undefined ? focus : internalFocus;\r\n  const setFocus = useCallback(\r\n    (next: string | null) => {\r\n      if (focus === undefined) setInternalFocus(next);\r\n      onFocusChange?.(next);\r\n    },\r\n    [focus, onFocusChange],\r\n  );\r\n\r\n  \/\/ The node registry: when controlled by `value`, JSX nodes still come from\r\n  \/\/ children (the value JSON can't carry React nodes). Merge children-derived\r\n  \/\/ nodes regardless of the value source.\r\n  const ctx: ArtBoardCtx = useMemo(\r\n    () => ({\r\n      value: resolvedValue,\r\n      nodes: compiled.nodes,\r\n      patchValue: setValue,\r\n      setSections,\r\n      focus: resolvedFocus,\r\n      setFocus,\r\n      onExport,\r\n      htmlPolicy,\r\n    }),\r\n    [resolvedValue, compiled.nodes, setValue, setSections, resolvedFocus, setFocus, onExport, htmlPolicy],\r\n  );\r\n\r\n  return (\r\n    <ArtBoardContext.Provider value={ctx}>\r\n      <ViewportEngine\r\n        viewport={resolvedVp}\r\n        onViewportChange={setVp}\r\n        minZoom={minZoom}\r\n        maxZoom={maxZoom}\r\n        className={className}\r\n        style={style}\r\n      >\r\n        {resolvedValue.sections.map((section) => (\r\n          <SectionView key={section.id} section={section} \/>\r\n        ))}\r\n        {\/* Free-floating notes authored as direct children (outside sections). *\/}\r\n        {flatten(children).filter((c) => isValidElement(c) && c.type === Note)}\r\n      <\/ViewportEngine>\r\n      {resolvedFocus != null && <FocusOverlay \/>}\r\n    <\/ArtBoardContext.Provider>\r\n  );\r\n}\r\n\r\nexport const ArtBoard = Object.assign(ArtBoardRoot, {\r\n  Section,\r\n  Note,\r\n});\r\n","type":"registry:ui","target":"components\/fancy\/artboard\/ArtBoard.tsx"},{"path":"components\/fancy\/artboard\/Section.tsx","content":"import { type ReactNode } from \"react\";\r\nimport type { ArtSectionData } from \"..\/..\/types\";\r\nimport { useArtBoard } from \".\/context\";\r\nimport { InlineEditor } from \"..\/InlineEditor\";\r\nimport { PieceFrame } from \"..\/ArtPiece\/PieceFrame\";\r\n\r\nexport type SectionProps = {\r\n  \/** Stable handle. *\/\r\n  id: string;\r\n  title: string;\r\n  subtitle?: string;\r\n  \/** `<ArtPiece>` children (authoring sugar). Ignored when `value` drives the board. *\/\r\n  children?: ReactNode;\r\n};\r\n\r\n\/**\r\n * Authoring marker for a section. Renders nothing on its own \u2014 `<ArtBoard>`\r\n * compiles `<ArtBoard.Section>` + `<ArtPiece>` children into the value. The\r\n * live section (titled head + horizontal piece row) is rendered by\r\n * `SectionView` from the controlled value.\r\n *\/\r\nexport function Section(_props: SectionProps): null {\r\n  return null;\r\n}\r\nSection.displayName = \"ArtBoard.Section\";\r\n\r\n\/** The rendered section \u2014 head + horizontal row of piece frames in order. *\/\r\nexport function SectionView({ section }: { section: ArtSectionData }) {\r\n  const board = useArtBoard();\r\n  const order = section.pieces.map((p) => p.id);\r\n\r\n  const patchTitle = (next: string) => {\r\n    board.setSections((sections) =>\r\n      sections.map((s) => (s.id === section.id ? { ...s, title: next } : s)),\r\n    );\r\n  };\r\n\r\n  return (\r\n    <div className=\"fa-section\" data-fa-section={section.id}>\r\n      <div className=\"fa-sectionhead-wrap\">\r\n        <div className=\"fa-sectionhead\" data-fa-chrome=\"\">\r\n          <InlineEditor\r\n            as=\"div\"\r\n            value={section.title}\r\n            onChange={patchTitle}\r\n            className=\"fa-sectiontitle\"\r\n          \/>\r\n          {section.subtitle && <div className=\"fa-sectionsub\">{section.subtitle}<\/div>}\r\n        <\/div>\r\n      <\/div>\r\n      <div className=\"fa-row\">\r\n        {section.pieces.map((piece) => (\r\n          <PieceFrame key={piece.id} sectionId={section.id} piece={piece} order={order} \/>\r\n        ))}\r\n      <\/div>\r\n    <\/div>\r\n  );\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/artboard\/Section.tsx"},{"path":"components\/fancy\/artboard\/Viewport.tsx","content":"import {\r\n  useCallback,\r\n  useEffect,\r\n  useRef,\r\n  type CSSProperties,\r\n  type ReactNode,\r\n} from \"react\";\r\nimport type { Viewport } from \"..\/..\/types\";\r\n\r\ntype ViewportEngineProps = {\r\n  viewport: Viewport;\r\n  onViewportChange: (v: Viewport) => void;\r\n  minZoom: number;\r\n  maxZoom: number;\r\n  children: ReactNode;\r\n  className?: string;\r\n  style?: CSSProperties;\r\n};\r\n\r\nconst GRID_COLOR = \"rgba(0,0,0,0.06)\";\r\nconst gridSvg = `url(\"data:image\/svg+xml,%3Csvg width='120' height='120' xmlns='http:\/\/www.w3.org\/2000\/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(\r\n  GRID_COLOR,\r\n)}' stroke-width='1'\/%3E%3C\/svg%3E\")`;\r\n\r\n\/**\r\n * Transform-based pan\/zoom engine (internal). Writes `translate3d(x,y,0)\r\n * scale(zoom)` straight to a DOM ref so wheel ticks bypass React; the\r\n * controlled `viewport` prop is the source of truth and `onViewportChange`\r\n * fires after each gesture frame.\r\n *\r\n * Input mapping (Figma-style):\r\n *   - trackpad pinch (ctrlKey wheel, non-integer delta) -> zoom\r\n *   - notched mouse wheel (integer |deltaY|>=40 or deltaMode!==0) -> stepped zoom\r\n *   - two-finger scroll -> pan\r\n *   - middle-drag \/ primary-drag on empty background -> pan\r\n *   - Safari gesturestart\/change\/end -> zoom\r\n *\r\n * Cursor-anchored zoom keeps the world point under the cursor fixed, then\r\n * cancels the vertical drift introduced by `--fa-inv-zoom`-driven reflow of\r\n * the section heads (CSS `zoom`) by re-pinning the DOM element under the\r\n * cursor.\r\n *\/\r\nexport function ViewportEngine({\r\n  viewport,\r\n  onViewportChange,\r\n  minZoom,\r\n  maxZoom,\r\n  children,\r\n  className,\r\n  style,\r\n}: ViewportEngineProps) {\r\n  const vpRef = useRef<HTMLDivElement>(null);\r\n  const worldRef = useRef<HTMLDivElement>(null);\r\n  \/\/ Live transform state lives in a ref so high-frequency wheel\/pointer events\r\n  \/\/ don't round-trip through React. We mirror the controlled prop into it.\r\n  const tf = useRef<Viewport>(viewport);\r\n  const onChangeRef = useRef(onViewportChange);\r\n  onChangeRef.current = onViewportChange;\r\n  const limits = useRef({ minZoom, maxZoom });\r\n  limits.current = { minZoom, maxZoom };\r\n\r\n  \/\/ Write the live transform to the DOM + expose --fa-inv-zoom for chrome.\r\n  const apply = useCallback(() => {\r\n    const el = worldRef.current;\r\n    if (!el) return;\r\n    const { x, y, zoom } = tf.current;\r\n    el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${zoom})`;\r\n    el.style.setProperty(\"--fa-inv-zoom\", String(1 \/ zoom));\r\n  }, []);\r\n\r\n  \/\/ Sync external (controlled) changes into the ref + DOM. Skip if the value\r\n  \/\/ already matches what we wrote (our own gesture echo).\r\n  useEffect(() => {\r\n    const cur = tf.current;\r\n    if (cur.x !== viewport.x || cur.y !== viewport.y || cur.zoom !== viewport.zoom) {\r\n      tf.current = viewport;\r\n      apply();\r\n    }\r\n  }, [viewport, apply]);\r\n\r\n  \/\/ Initial paint.\r\n  useEffect(() => {\r\n    apply();\r\n  }, [apply]);\r\n\r\n  const commit = useCallback(() => {\r\n    onChangeRef.current({ ...tf.current });\r\n  }, []);\r\n\r\n  useEffect(() => {\r\n    const vp = vpRef.current;\r\n    if (!vp) return;\r\n\r\n    const zoomAt = (cx: number, cy: number, factor: number) => {\r\n      const r = vp.getBoundingClientRect();\r\n      const px = cx - r.left;\r\n      const py = cy - r.top;\r\n      const t = tf.current;\r\n      const next = Math.min(limits.current.maxZoom, Math.max(limits.current.minZoom, t.zoom * factor));\r\n      const k = next \/ t.zoom;\r\n\r\n      \/\/ The `--fa-inv-zoom`-driven section heads (CSS `zoom`) reflow on every\r\n      \/\/ scale change, shifting world layout vertically. Anchor the DOM element\r\n      \/\/ under the cursor: record its screen-Y, apply, then cancel the drift.\r\n      let marker: Element | null = null;\r\n      let markerY0 = 0;\r\n      if (k !== 1) {\r\n        const hit = document.elementFromPoint(cx, cy);\r\n        marker = hit ? hit.closest(\"[data-fa-piece],[data-fa-section]\") : null;\r\n        if (marker) markerY0 = marker.getBoundingClientRect().top;\r\n      }\r\n\r\n      \/\/ Keep the world point under the cursor fixed.\r\n      t.x = px - (px - t.x) * k;\r\n      t.y = py - (py - t.y) * k;\r\n      t.zoom = next;\r\n      apply();\r\n\r\n      if (marker) {\r\n        const drift = marker.getBoundingClientRect().top - (cy + (markerY0 - cy) * k);\r\n        if (Math.abs(drift) > 0.1) {\r\n          t.y -= drift;\r\n          apply();\r\n        }\r\n      }\r\n    };\r\n\r\n    \/\/ Physical wheel vs trackpad-scroll heuristic.\r\n    const isMouseWheel = (e: WheelEvent) =>\r\n      e.deltaMode !== 0 ||\r\n      (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40);\r\n\r\n    let isGesturing = false;\r\n\r\n    const onWheel = (e: WheelEvent) => {\r\n      e.preventDefault();\r\n      if (isGesturing) return; \/\/ Safari gesture* owns the pinch\r\n      if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) {\r\n        zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01));\r\n      } else if (isMouseWheel(e)) {\r\n        zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18));\r\n      } else {\r\n        tf.current.x -= e.deltaX;\r\n        tf.current.y -= e.deltaY;\r\n        apply();\r\n      }\r\n      commit();\r\n    };\r\n\r\n    \/\/ Safari native pinch.\r\n    let gsBase = 1;\r\n    const onGestureStart = (e: Event) => {\r\n      e.preventDefault();\r\n      isGesturing = true;\r\n      gsBase = tf.current.zoom;\r\n    };\r\n    const onGestureChange = (e: Event) => {\r\n      e.preventDefault();\r\n      const ge = e as Event & { scale: number; clientX: number; clientY: number };\r\n      zoomAt(ge.clientX, ge.clientY, (gsBase * ge.scale) \/ tf.current.zoom);\r\n      commit();\r\n    };\r\n    const onGestureEnd = (e: Event) => {\r\n      e.preventDefault();\r\n      isGesturing = false;\r\n      commit();\r\n    };\r\n\r\n    \/\/ Drag-pan: middle button anywhere, or primary on empty background.\r\n    let drag: { id: number; lx: number; ly: number } | null = null;\r\n    const onPointerDown = (e: PointerEvent) => {\r\n      const target = e.target as Element;\r\n      const onBg = !target.closest(\"[data-fa-piece], .fa-editable, [data-fa-chrome]\");\r\n      if (!(e.button === 1 || (e.button === 0 && onBg))) return;\r\n      e.preventDefault();\r\n      vp.setPointerCapture(e.pointerId);\r\n      drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY };\r\n      vp.style.cursor = \"grabbing\";\r\n    };\r\n    const onPointerMove = (e: PointerEvent) => {\r\n      if (!drag || e.pointerId !== drag.id) return;\r\n      tf.current.x += e.clientX - drag.lx;\r\n      tf.current.y += e.clientY - drag.ly;\r\n      drag.lx = e.clientX;\r\n      drag.ly = e.clientY;\r\n      apply();\r\n    };\r\n    const onPointerUp = (e: PointerEvent) => {\r\n      if (!drag || e.pointerId !== drag.id) return;\r\n      vp.releasePointerCapture(e.pointerId);\r\n      drag = null;\r\n      vp.style.cursor = \"\";\r\n      commit();\r\n    };\r\n\r\n    vp.addEventListener(\"wheel\", onWheel, { passive: false });\r\n    vp.addEventListener(\"gesturestart\", onGestureStart as EventListener, { passive: false });\r\n    vp.addEventListener(\"gesturechange\", onGestureChange as EventListener, { passive: false });\r\n    vp.addEventListener(\"gestureend\", onGestureEnd as EventListener, { passive: false });\r\n    vp.addEventListener(\"pointerdown\", onPointerDown);\r\n    vp.addEventListener(\"pointermove\", onPointerMove);\r\n    vp.addEventListener(\"pointerup\", onPointerUp);\r\n    vp.addEventListener(\"pointercancel\", onPointerUp);\r\n    return () => {\r\n      vp.removeEventListener(\"wheel\", onWheel);\r\n      vp.removeEventListener(\"gesturestart\", onGestureStart as EventListener);\r\n      vp.removeEventListener(\"gesturechange\", onGestureChange as EventListener);\r\n      vp.removeEventListener(\"gestureend\", onGestureEnd as EventListener);\r\n      vp.removeEventListener(\"pointerdown\", onPointerDown);\r\n      vp.removeEventListener(\"pointermove\", onPointerMove);\r\n      vp.removeEventListener(\"pointerup\", onPointerUp);\r\n      vp.removeEventListener(\"pointercancel\", onPointerUp);\r\n    };\r\n  }, [apply, commit]);\r\n\r\n  return (\r\n    <div ref={vpRef} className={\"fa-viewport \" + (className ?? \"\")} style={style}>\r\n      <div ref={worldRef} className=\"fa-world\">\r\n        <div\r\n          className=\"fa-grid\"\r\n          style={{ backgroundImage: gridSvg }}\r\n          aria-hidden\r\n        \/>\r\n        {children}\r\n      <\/div>\r\n    <\/div>\r\n  );\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/artboard\/Viewport.tsx"},{"path":"components\/fancy\/artboard\/context.ts","content":"import { createContext, useContext, type ReactNode } from \"react\";\r\nimport type { ArtBoardValue, ArtSectionData } from \"..\/..\/types\";\nimport type { HtmlPolicy } from \"..\/..\/html\/policy\";\r\n\r\n\/** Internal board context shared by Section \/ PieceFrame \/ FocusOverlay. *\/\r\nexport type ArtBoardCtx = {\r\n  value: ArtBoardValue;\r\n  \/** JSX content for `kind:\"node\"` pieces, keyed by piece id. *\/\r\n  nodes: Map<string, ReactNode>;\r\n  \/** Current scale (1\/--fa-inv-zoom) \u2014 used by drag math to map screen px. *\/\r\n  patchValue: (next: ArtBoardValue) => void;\r\n  setSections: (fn: (sections: ArtSectionData[]) => ArtSectionData[]) => void;\r\n  focus: string | null;\r\n  setFocus: (pieceId: string | null) => void;\r\n  onExport?: (pieceId: string, kind: \"png\" | \"html\") => void;\n  \/** Host-owned trust policy for agent-authored HTML. *\/\n  htmlPolicy?: HtmlPolicy;\r\n};\r\n\r\nexport const ArtBoardContext = createContext<ArtBoardCtx | null>(null);\r\n\r\nexport function useArtBoard(): ArtBoardCtx {\r\n  const ctx = useContext(ArtBoardContext);\r\n  if (!ctx) throw new Error(\"ArtBoard subcomponent used outside <ArtBoard>\");\r\n  return ctx;\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/artboard\/context.ts"}]}