{
  "name": "preview-frame",
  "dependencies": [],
  "registryDependencies": [
    "review-gate"
  ],
  "files": [
    {
      "path": "preview-frame.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, keyframes, themeVars as theme } from \"@yugnex/core\";\r\nimport { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\r\nimport { ReviewGate, type ReviewDecision, type ReviewState } from \"./review-gate\";\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Viewports\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface Viewport {\r\n  id: string;\r\n  label: string;\r\n  /** CSS width; `null` fills the available space. */\r\n  width: number | null;\r\n  height?: number | null;\r\n}\r\n\r\nexport const DEFAULT_VIEWPORTS: Viewport[] = [\r\n  { id: \"responsive\", label: \"Fill\", width: null },\r\n  { id: \"mobile\", label: \"Mobile\", width: 390, height: 844 },\r\n  { id: \"tablet\", label: \"Tablet\", width: 834, height: 1112 },\r\n  { id: \"desktop\", label: \"Desktop\", width: 1280, height: 800 },\r\n];\r\n\r\n/**\r\n * `stalled` rather than `error` on purpose.\r\n *\r\n * An iframe's `onError` does not fire for a dead server, a 404, or a 500 —\r\n * the browser navigates to its own error page and reports a perfectly normal\r\n * `load`. So a failed preview is not something the parent can *detect*, only\r\n * something it can *time out*. Naming the state after what we actually know\r\n * keeps the UI honest: the app has not signalled ready yet.\r\n */\r\nexport type PreviewStatus = \"loading\" | \"ready\" | \"stalled\";\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Styles\r\n * ------------------------------------------------------------------ */\r\n\r\nconst spin = keyframes({ from: { transform: \"rotate(0deg)\" }, to: { transform: \"rotate(360deg)\" } });\r\n\r\nconst rootClass = css({\r\n  display: \"flex\",\r\n  flexDirection: \"column\",\r\n  minHeight: 0,\r\n  borderRadius: theme.radius.lg,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.card,\r\n  overflow: \"hidden\",\r\n  fontFamily: theme.fontFamily.sans,\r\n});\r\n\r\nconst toolbarClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[2],\r\n  padding: theme.space[2],\r\n  borderBottom: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.muted,\r\n  flexWrap: \"wrap\",\r\n});\r\n\r\nconst urlClass = css({\r\n  flex: 1,\r\n  minWidth: \"8rem\",\r\n  padding: `${theme.space[1]} ${theme.space[2]}`,\r\n  borderRadius: theme.radius.sm,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.background,\r\n  color: theme.color.mutedForeground,\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: theme.fontSize.xs,\r\n  overflow: \"hidden\",\r\n  textOverflow: \"ellipsis\",\r\n  whiteSpace: \"nowrap\",\r\n});\r\n\r\nconst iconButtonClass = css({\r\n  display: \"inline-flex\",\r\n  alignItems: \"center\",\r\n  justifyContent: \"center\",\r\n  width: \"1.75rem\",\r\n  height: \"1.75rem\",\r\n  flexShrink: 0,\r\n  borderRadius: theme.radius.sm,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.background,\r\n  color: theme.color.foreground,\r\n  cursor: \"pointer\",\r\n  padding: 0,\r\n  transitionProperty: \"background-color, border-color\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:hover\": { borderColor: theme.color.ring },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"1px\" },\r\n});\r\n\r\nconst viewportGroupClass = css({\r\n  display: \"inline-flex\",\r\n  gap: \"2px\",\r\n  padding: \"2px\",\r\n  borderRadius: theme.radius.sm,\r\n  backgroundColor: theme.color.background,\r\n  border: `1px solid ${theme.color.border}`,\r\n});\r\n\r\nconst viewportButtonClass = css({\r\n  padding: `${theme.space[0.5]} ${theme.space[2]}`,\r\n  borderRadius: theme.radius.sm,\r\n  border: \"none\",\r\n  backgroundColor: \"transparent\",\r\n  color: theme.color.mutedForeground,\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.xs,\r\n  cursor: \"pointer\",\r\n  transitionProperty: \"background-color, color\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:hover\": { color: theme.color.foreground },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"1px\" },\r\n  '&[aria-pressed=\"true\"]': {\r\n    backgroundColor: theme.color.primary,\r\n    color: theme.color.primaryForeground,\r\n  },\r\n});\r\n\r\nconst stageClass = css({\r\n  position: \"relative\",\r\n  flex: 1,\r\n  minHeight: 0,\r\n  display: \"flex\",\r\n  alignItems: \"flex-start\",\r\n  justifyContent: \"center\",\r\n  padding: theme.space[3],\r\n  backgroundColor: theme.color.muted,\r\n  overflow: \"auto\",\r\n});\r\n\r\nconst frameShellClass = css({\r\n  position: \"relative\",\r\n  borderRadius: theme.radius.md,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: \"#ffffff\",\r\n  boxShadow: theme.shadow.md,\r\n  overflow: \"hidden\",\r\n  maxWidth: \"100%\",\r\n});\r\n\r\nconst iframeClass = css({\r\n  display: \"block\",\r\n  width: \"100%\",\r\n  height: \"100%\",\r\n  border: \"none\",\r\n});\r\n\r\nconst overlayClass = css({\r\n  position: \"absolute\",\r\n  inset: 0,\r\n  display: \"flex\",\r\n  flexDirection: \"column\",\r\n  alignItems: \"center\",\r\n  justifyContent: \"center\",\r\n  gap: theme.space[2],\r\n  backgroundColor: theme.color.card,\r\n  color: theme.color.mutedForeground,\r\n  fontSize: theme.fontSize.sm,\r\n  textAlign: \"center\",\r\n  padding: theme.space[4],\r\n});\r\n\r\nconst spinnerClass = css({\r\n  width: \"1.25rem\",\r\n  height: \"1.25rem\",\r\n  borderRadius: \"9999px\",\r\n  border: `2px solid ${theme.color.border}`,\r\n  borderTopColor: theme.color.primary,\r\n  animation: `${spin} 0.7s linear infinite`,\r\n});\r\n\r\nconst gateWrapClass = css({\r\n  borderTop: `1px solid ${theme.color.border}`,\r\n  padding: theme.space[2],\r\n  backgroundColor: theme.color.card,\r\n});\r\n\r\nconst dimensionsClass = css({\r\n  fontFamily: theme.fontFamily.mono,\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n  fontVariantNumeric: \"tabular-nums\",\r\n  flexShrink: 0,\r\n});\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Component\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface PreviewFrameProps {\r\n  /** URL of the running app. */\r\n  src: string;\r\n  title?: string;\r\n  viewports?: Viewport[];\r\n  defaultViewport?: string;\r\n  /** Frame height when the viewport does not specify one. */\r\n  height?: number | string;\r\n  /**\r\n   * Sandbox attribute for the iframe. The default allows scripts and\r\n   * same-origin so a real dev server renders, which also means the framed app\r\n   * can reach out of the sandbox — only point this at previews you trust.\r\n   */\r\n  sandbox?: string;\r\n  /**\r\n   * How long to wait for the frame's `load` before showing the stalled state,\r\n   * in ms. A cold dev server can take a while, so this is deliberately\r\n   * generous. Pass 0 to never show it.\r\n   */\r\n  loadTimeout?: number;\r\n  /** Renders a review gate under the frame. */\r\n  reviewId?: string;\r\n  reviewState?: ReviewState;\r\n  onDecide?: (decision: ReviewDecision) => void;\r\n  onClear?: (scope: \"document\" | \"section\" | \"file\" | \"hunk\", id: string) => void;\r\n  /** Replaces the built-in toolbar. */\r\n  toolbar?: ReactNode;\r\n  className?: string;\r\n}\r\n\r\n/**\r\n * A live app preview with device sizing, wrapping the review gate.\r\n *\r\n * Exists for the moment a generated UI is running but wrong: the reviewer\r\n * needs to see the real thing at a real width and say so in the same place,\r\n * rather than switching to a diff to reject code they judged visually.\r\n */\r\nexport function PreviewFrame({\r\n  src,\r\n  title = \"App preview\",\r\n  viewports = DEFAULT_VIEWPORTS,\r\n  defaultViewport,\r\n  height = 520,\r\n  sandbox = \"allow-scripts allow-same-origin allow-forms allow-popups\",\r\n  loadTimeout = 15000,\r\n  reviewId,\r\n  reviewState,\r\n  onDecide,\r\n  onClear,\r\n  toolbar,\r\n  className,\r\n}: PreviewFrameProps) {\r\n  const [viewportId, setViewportId] = useState(defaultViewport ?? viewports[0]?.id ?? \"responsive\");\r\n  const [status, setStatus] = useState<PreviewStatus>(\"loading\");\r\n  // Bumping this remounts the iframe, which is the only reliable cross-origin\r\n  // way to force a reload — touching contentWindow.location would throw.\r\n  const [reloadKey, setReloadKey] = useState(0);\r\n  const frameRef = useRef<HTMLIFrameElement | null>(null);\r\n\r\n  const viewport = useMemo(\r\n    () => viewports.find((v) => v.id === viewportId) ?? viewports[0],\r\n    [viewports, viewportId],\r\n  );\r\n\r\n  const reload = useCallback(() => {\r\n    setStatus(\"loading\");\r\n    setReloadKey((key) => key + 1);\r\n  }, []);\r\n\r\n  // Keyed on reloadKey so each (re)mount of the iframe gets a fresh window.\r\n  useEffect(() => {\r\n    if (loadTimeout <= 0 || status !== \"loading\") return;\r\n    const timer = setTimeout(() => setStatus(\"stalled\"), loadTimeout);\r\n    return () => clearTimeout(timer);\r\n  }, [loadTimeout, status, reloadKey]);\r\n\r\n  const frameWidth = viewport?.width ?? null;\r\n  const frameHeight = viewport?.height ?? height;\r\n\r\n  return (\r\n    <div className={className ? `${rootClass} ${className}` : rootClass}>\r\n      {toolbar ?? (\r\n        <div className={toolbarClass}>\r\n          <button type=\"button\" className={iconButtonClass} onClick={reload} aria-label=\"Reload preview\">\r\n            <svg width=\"13\" height=\"13\" viewBox=\"0 0 14 14\" fill=\"none\" aria-hidden=\"true\">\r\n              <path\r\n                d=\"M12 7a5 5 0 1 1-1.5-3.5M12 1.5V4H9.5\"\r\n                stroke=\"currentColor\"\r\n                strokeWidth=\"1.5\"\r\n                strokeLinecap=\"round\"\r\n                strokeLinejoin=\"round\"\r\n              />\r\n            </svg>\r\n          </button>\r\n\r\n          <span className={urlClass} title={src}>\r\n            {src}\r\n          </span>\r\n\r\n          <div className={viewportGroupClass} role=\"group\" aria-label=\"Viewport size\">\r\n            {viewports.map((v) => (\r\n              <button\r\n                key={v.id}\r\n                type=\"button\"\r\n                className={viewportButtonClass}\r\n                aria-pressed={v.id === viewportId}\r\n                onClick={() => setViewportId(v.id)}\r\n              >\r\n                {v.label}\r\n              </button>\r\n            ))}\r\n          </div>\r\n\r\n          {frameWidth ? (\r\n            <span className={dimensionsClass}>\r\n              {frameWidth}×{typeof frameHeight === \"number\" ? frameHeight : \"auto\"}\r\n            </span>\r\n          ) : null}\r\n        </div>\r\n      )}\r\n\r\n      <div className={stageClass}>\r\n        <div\r\n          className={frameShellClass}\r\n          style={{\r\n            width: frameWidth ? `${frameWidth}px` : \"100%\",\r\n            height: typeof frameHeight === \"number\" ? `${frameHeight}px` : frameHeight,\r\n          }}\r\n        >\r\n          <iframe\r\n            key={reloadKey}\r\n            ref={frameRef}\r\n            src={src}\r\n            title={title}\r\n            className={iframeClass}\r\n            sandbox={sandbox}\r\n            onLoad={() => setStatus(\"ready\")}\r\n          />\r\n\r\n          {status !== \"ready\" ? (\r\n            <div className={overlayClass} role=\"status\" aria-live=\"polite\">\r\n              {status === \"loading\" ? (\r\n                <>\r\n                  <span className={spinnerClass} aria-hidden=\"true\" />\r\n                  <span>Starting preview…</span>\r\n                </>\r\n              ) : (\r\n                <>\r\n                  <span>The preview has not loaded yet. The dev server may still be starting, or may not be running.</span>\r\n                  <button type=\"button\" className={iconButtonClass} onClick={reload} aria-label=\"Retry preview\">\r\n                    <svg width=\"13\" height=\"13\" viewBox=\"0 0 14 14\" fill=\"none\" aria-hidden=\"true\">\r\n                      <path\r\n                        d=\"M12 7a5 5 0 1 1-1.5-3.5M12 1.5V4H9.5\"\r\n                        stroke=\"currentColor\"\r\n                        strokeWidth=\"1.5\"\r\n                        strokeLinecap=\"round\"\r\n                        strokeLinejoin=\"round\"\r\n                      />\r\n                    </svg>\r\n                  </button>\r\n                </>\r\n              )}\r\n            </div>\r\n          ) : null}\r\n        </div>\r\n      </div>\r\n\r\n      {reviewId ? (\r\n        <div className={gateWrapClass}>\r\n          <ReviewGate\r\n            scope=\"document\"\r\n            id={reviewId}\r\n            label=\"Does this look right?\"\r\n            variant=\"inline\"\r\n            value={reviewState}\r\n            onDecide={onDecide}\r\n            onClear={onClear}\r\n          />\r\n        </div>\r\n      ) : null}\r\n    </div>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}