{
  "name": "review-gate",
  "dependencies": [],
  "registryDependencies": [],
  "files": [
    {
      "path": "review-gate.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, themeVars as theme } from \"@yugnex/core\";\r\nimport { useCallback, useMemo, useState, type ReactNode } from \"react\";\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Model\r\n * ------------------------------------------------------------------ */\r\n\r\n/**\r\n * What a decision applies to. The four levels are hierarchical: deciding at\r\n * `document` implies every section, file, and hunk beneath it.\r\n */\r\nexport type ReviewScope = \"document\" | \"section\" | \"file\" | \"hunk\";\r\n\r\nexport type ReviewVerdict = \"accepted\" | \"rejected\" | \"changes-requested\";\r\n\r\nexport interface ReviewDecision {\r\n  scope: ReviewScope;\r\n  /** Identifies the target within its scope; the document scope uses \"document\". */\r\n  id: string;\r\n  verdict: ReviewVerdict;\r\n  /** Required for \"changes-requested\" — a rejection with no reason is not actionable. */\r\n  note?: string;\r\n  at: number;\r\n}\r\n\r\nexport type ReviewState = Record<string, ReviewDecision>;\r\n\r\n/** Keys a decision by scope and id so a file and a hunk can't collide. */\r\nexport function decisionKey(scope: ReviewScope, id: string): string {\r\n  return `${scope}:${id}`;\r\n}\r\n\r\nconst SCOPE_RANK: Record<ReviewScope, number> = { document: 0, section: 1, file: 2, hunk: 3 };\r\n\r\nexport interface ResolveOptions {\r\n  /** Ancestors from broadest to narrowest, e.g. [[\"section\",\"auth\"],[\"file\",\"a.ts\"]]. */\r\n  ancestors?: Array<[ReviewScope, string]>;\r\n}\r\n\r\n/**\r\n * Resolves the verdict in force for a target.\r\n *\r\n * A decision on the target itself always wins; otherwise the *narrowest*\r\n * ancestor decision applies. That ordering is what makes \"accept everything,\r\n * then reject this one hunk\" behave the way a reviewer expects, rather than\r\n * the document-level accept overriding the specific rejection.\r\n */\r\nexport function resolveVerdict(\r\n  state: ReviewState,\r\n  scope: ReviewScope,\r\n  id: string,\r\n  options: ResolveOptions = {},\r\n): { verdict: ReviewVerdict | undefined; inherited: boolean; from?: ReviewDecision } {\r\n  const own = state[decisionKey(scope, id)];\r\n  if (own) return { verdict: own.verdict, inherited: false, from: own };\r\n\r\n  const ancestors = [...(options.ancestors ?? [])].sort(\r\n    (a, b) => SCOPE_RANK[b[0]] - SCOPE_RANK[a[0]],\r\n  );\r\n\r\n  for (const [ancestorScope, ancestorId] of ancestors) {\r\n    const decision = state[decisionKey(ancestorScope, ancestorId)];\r\n    if (decision) return { verdict: decision.verdict, inherited: true, from: decision };\r\n  }\r\n\r\n  return { verdict: undefined, inherited: false };\r\n}\r\n\r\n/** Tracks review decisions. Controlled via `value`/`onChange`, or uncontrolled. */\r\nexport function useReviewState(initial: ReviewState = {}) {\r\n  const [state, setState] = useState<ReviewState>(initial);\r\n\r\n  const decide = useCallback((scope: ReviewScope, id: string, verdict: ReviewVerdict, note?: string) => {\r\n    setState((prev) => ({\r\n      ...prev,\r\n      [decisionKey(scope, id)]: { scope, id, verdict, note, at: Date.now() },\r\n    }));\r\n  }, []);\r\n\r\n  const clear = useCallback((scope: ReviewScope, id: string) => {\r\n    setState((prev) => {\r\n      const next = { ...prev };\r\n      delete next[decisionKey(scope, id)];\r\n      return next;\r\n    });\r\n  }, []);\r\n\r\n  const reset = useCallback(() => setState({}), []);\r\n\r\n  return { state, decide, clear, reset };\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Styles\r\n * ------------------------------------------------------------------ */\r\n\r\nconst rootVariantBase = {\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[2],\r\n  fontFamily: theme.fontFamily.sans,\r\n} as const;\r\n\r\nconst barClass = css({\r\n  ...rootVariantBase,\r\n  flexWrap: \"wrap\",\r\n  padding: `${theme.space[2]} ${theme.space[3]}`,\r\n  borderRadius: theme.radius.md,\r\n  border: `1px solid ${theme.color.border}`,\r\n  backgroundColor: theme.color.card,\r\n});\r\n\r\nconst inlineClass = css({ ...rootVariantBase });\r\n\r\nconst buttonBase = {\r\n  display: \"inline-flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[1],\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  fontFamily: theme.fontFamily.sans,\r\n  fontWeight: theme.fontWeight.medium,\r\n  cursor: \"pointer\",\r\n  transitionProperty: \"background-color, border-color, color\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"1px\" },\r\n  \"&:disabled\": { opacity: 0.5, cursor: \"not-allowed\" },\r\n} as const;\r\n\r\nconst acceptClass = css({\r\n  ...buttonBase,\r\n  height: \"1.875rem\",\r\n  padding: `0 ${theme.space[2.5]}`,\r\n  fontSize: theme.fontSize.xs,\r\n  \"&:hover:not(:disabled)\": { borderColor: theme.color.success, color: theme.color.success },\r\n  '&[aria-pressed=\"true\"]': {\r\n    backgroundColor: theme.color.success,\r\n    borderColor: theme.color.success,\r\n    color: theme.color.successForeground,\r\n  },\r\n});\r\n\r\nconst rejectClass = css({\r\n  ...buttonBase,\r\n  height: \"1.875rem\",\r\n  padding: `0 ${theme.space[2.5]}`,\r\n  fontSize: theme.fontSize.xs,\r\n  \"&:hover:not(:disabled)\": { borderColor: theme.color.destructive, color: theme.color.destructive },\r\n  '&[aria-pressed=\"true\"]': {\r\n    backgroundColor: theme.color.destructive,\r\n    borderColor: theme.color.destructive,\r\n    color: theme.color.destructiveForeground,\r\n  },\r\n});\r\n\r\nconst changesClass = css({\r\n  ...buttonBase,\r\n  height: \"1.875rem\",\r\n  padding: `0 ${theme.space[2.5]}`,\r\n  fontSize: theme.fontSize.xs,\r\n  \"&:hover:not(:disabled)\": { borderColor: theme.color.warning, color: theme.color.warning },\r\n  '&[aria-pressed=\"true\"]': {\r\n    backgroundColor: theme.color.warning,\r\n    borderColor: theme.color.warning,\r\n    color: theme.color.warningForeground,\r\n  },\r\n});\r\n\r\nconst labelClass = css({\r\n  fontSize: theme.fontSize.sm,\r\n  fontWeight: theme.fontWeight.medium,\r\n  color: theme.color.foreground,\r\n  marginRight: \"auto\",\r\n});\r\n\r\nconst inheritedClass = css({\r\n  fontSize: theme.fontSize.xs,\r\n  color: theme.color.mutedForeground,\r\n  fontStyle: \"italic\",\r\n});\r\n\r\nconst noteFormClass = css({\r\n  display: \"flex\",\r\n  flexDirection: \"column\",\r\n  gap: theme.space[2],\r\n  width: \"100%\",\r\n  marginTop: theme.space[2],\r\n  paddingTop: theme.space[2],\r\n  borderTop: `1px solid ${theme.color.border}`,\r\n});\r\n\r\nconst noteInputClass = css({\r\n  width: \"100%\",\r\n  minHeight: \"4rem\",\r\n  padding: theme.space[2],\r\n  borderRadius: theme.radius.sm,\r\n  border: `1px solid ${theme.color.input}`,\r\n  backgroundColor: theme.color.background,\r\n  color: theme.color.foreground,\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.sm,\r\n  resize: \"vertical\",\r\n  \"&:focus-visible\": {\r\n    outline: \"none\",\r\n    borderColor: theme.color.ring,\r\n    boxShadow: `0 0 0 3px ${theme.color.accent}`,\r\n  },\r\n});\r\n\r\nconst noteActionsClass = css({ display: \"flex\", gap: theme.space[2], justifyContent: \"flex-end\" });\r\n\r\nconst submitClass = css({\r\n  ...buttonBase,\r\n  height: \"1.875rem\",\r\n  padding: `0 ${theme.space[3]}`,\r\n  fontSize: theme.fontSize.xs,\r\n  backgroundColor: theme.color.primary,\r\n  borderColor: theme.color.primary,\r\n  color: theme.color.primaryForeground,\r\n  \"&:hover:not(:disabled)\": { opacity: 0.92 },\r\n});\r\n\r\nconst cancelClass = css({\r\n  ...buttonBase,\r\n  height: \"1.875rem\",\r\n  padding: `0 ${theme.space[3]}`,\r\n  fontSize: theme.fontSize.xs,\r\n});\r\n\r\nconst noteShownClass = css({\r\n  width: \"100%\",\r\n  marginTop: theme.space[2],\r\n  paddingTop: theme.space[2],\r\n  borderTop: `1px solid ${theme.color.border}`,\r\n  fontSize: theme.fontSize.sm,\r\n  color: theme.color.mutedForeground,\r\n  whiteSpace: \"pre-wrap\",\r\n});\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Component\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface ReviewGateProps {\r\n  scope: ReviewScope;\r\n  /** Identifies the target within its scope. */\r\n  id: string;\r\n  /** Shown to the left of the controls, e.g. the file path or section title. */\r\n  label?: ReactNode;\r\n  /** Current decisions. Pass with `onDecide` for a controlled gate. */\r\n  value?: ReviewState;\r\n  onDecide?: (decision: ReviewDecision) => void;\r\n  onClear?: (scope: ReviewScope, id: string) => void;\r\n  /** Broader targets whose verdicts this one inherits when it has none of its own. */\r\n  ancestors?: Array<[ReviewScope, string]>;\r\n  /** `bar` draws a bordered surface; `inline` is bare, for embedding in a hunk header. */\r\n  variant?: \"bar\" | \"inline\";\r\n  disabled?: boolean;\r\n  className?: string;\r\n}\r\n\r\n/**\r\n * The Accept / Reject / Request-changes control, operable at document,\r\n * section, file, or hunk granularity.\r\n *\r\n * Requesting changes opens a required note field: a rejection with no reason\r\n * gives the generating agent nothing to act on, so the submit stays disabled\r\n * until the reviewer writes something.\r\n */\r\nexport function ReviewGate({\r\n  scope,\r\n  id,\r\n  label,\r\n  value,\r\n  onDecide,\r\n  onClear,\r\n  ancestors,\r\n  variant = \"bar\",\r\n  disabled = false,\r\n  className,\r\n}: ReviewGateProps) {\r\n  const [noteOpen, setNoteOpen] = useState(false);\r\n  const [note, setNote] = useState(\"\");\r\n\r\n  const state = value ?? {};\r\n  const resolved = useMemo(\r\n    () => resolveVerdict(state, scope, id, { ancestors }),\r\n    [state, scope, id, ancestors],\r\n  );\r\n\r\n  const emit = (verdict: ReviewVerdict, withNote?: string) => {\r\n    // Pressing the verdict already in force clears it, so a reviewer can undo\r\n    // without a separate reset control.\r\n    if (!resolved.inherited && resolved.verdict === verdict && verdict !== \"changes-requested\") {\r\n      onClear?.(scope, id);\r\n      return;\r\n    }\r\n    onDecide?.({ scope, id, verdict, note: withNote, at: Date.now() });\r\n  };\r\n\r\n  const submitNote = () => {\r\n    const trimmed = note.trim();\r\n    if (!trimmed) return;\r\n    emit(\"changes-requested\", trimmed);\r\n    setNote(\"\");\r\n    setNoteOpen(false);\r\n  };\r\n\r\n  const rootClass = variant === \"inline\" ? inlineClass : barClass;\r\n  const own = state[decisionKey(scope, id)];\r\n\r\n  return (\r\n    <div\r\n      className={className ? `${rootClass} ${className}` : rootClass}\r\n      role=\"group\"\r\n      aria-label={typeof label === \"string\" ? `Review ${label}` : `Review ${scope}`}\r\n    >\r\n      {label ? <span className={labelClass}>{label}</span> : null}\r\n\r\n      {resolved.inherited && resolved.verdict ? (\r\n        <span className={inheritedClass}>\r\n          inherited: {VERDICT_LABEL[resolved.verdict]} from {resolved.from?.scope}\r\n        </span>\r\n      ) : null}\r\n\r\n      <button\r\n        type=\"button\"\r\n        className={acceptClass}\r\n        aria-pressed={resolved.verdict === \"accepted\"}\r\n        disabled={disabled}\r\n        onClick={() => emit(\"accepted\")}\r\n      >\r\n        <CheckIcon /> Accept\r\n      </button>\r\n\r\n      <button\r\n        type=\"button\"\r\n        className={changesClass}\r\n        aria-pressed={resolved.verdict === \"changes-requested\"}\r\n        aria-expanded={noteOpen}\r\n        disabled={disabled}\r\n        onClick={() => setNoteOpen((open) => !open)}\r\n      >\r\n        <PencilIcon /> Request changes\r\n      </button>\r\n\r\n      <button\r\n        type=\"button\"\r\n        className={rejectClass}\r\n        aria-pressed={resolved.verdict === \"rejected\"}\r\n        disabled={disabled}\r\n        onClick={() => emit(\"rejected\")}\r\n      >\r\n        <CrossIcon /> Reject\r\n      </button>\r\n\r\n      {noteOpen ? (\r\n        <div className={noteFormClass}>\r\n          <textarea\r\n            className={noteInputClass}\r\n            placeholder=\"What needs to change?\"\r\n            aria-label=\"Requested changes\"\r\n            value={note}\r\n            autoFocus\r\n            onChange={(event) => setNote(event.target.value)}\r\n            onKeyDown={(event) => {\r\n              // Ctrl/Cmd+Enter submits, matching every review textarea people\r\n              // already use; plain Enter must still insert a newline.\r\n              if ((event.metaKey || event.ctrlKey) && event.key === \"Enter\") {\r\n                event.preventDefault();\r\n                submitNote();\r\n              }\r\n              if (event.key === \"Escape\") {\r\n                event.preventDefault();\r\n                setNoteOpen(false);\r\n              }\r\n            }}\r\n          />\r\n          <div className={noteActionsClass}>\r\n            <button type=\"button\" className={cancelClass} onClick={() => setNoteOpen(false)}>\r\n              Cancel\r\n            </button>\r\n            <button type=\"button\" className={submitClass} disabled={note.trim().length === 0} onClick={submitNote}>\r\n              Send request\r\n            </button>\r\n          </div>\r\n        </div>\r\n      ) : null}\r\n\r\n      {!noteOpen && own?.verdict === \"changes-requested\" && own.note ? (\r\n        <p className={noteShownClass}>{own.note}</p>\r\n      ) : null}\r\n    </div>\r\n  );\r\n}\r\n\r\nconst VERDICT_LABEL: Record<ReviewVerdict, string> = {\r\n  accepted: \"accepted\",\r\n  rejected: \"rejected\",\r\n  \"changes-requested\": \"changes requested\",\r\n};\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Summary\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface ReviewSummaryProps {\r\n  state: ReviewState;\r\n  /** Every target expected to be reviewed, for an outstanding count. */\r\n  expected?: Array<[ReviewScope, string]>;\r\n  className?: string;\r\n}\r\n\r\nconst summaryClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[3],\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.sm,\r\n  color: theme.color.mutedForeground,\r\n});\r\n\r\nconst countClass = css({ display: \"inline-flex\", alignItems: \"center\", gap: theme.space[1] });\r\n\r\n/** A tally of decisions so far — pairs with a document-level ReviewGate. */\r\nexport function ReviewSummary({ state, expected, className }: ReviewSummaryProps) {\r\n  const counts = useMemo(() => {\r\n    const tally = { accepted: 0, rejected: 0, \"changes-requested\": 0 };\r\n    for (const decision of Object.values(state)) tally[decision.verdict]++;\r\n    return tally;\r\n  }, [state]);\r\n\r\n  const outstanding = expected\r\n    ? expected.filter(([scope, id]) => !state[decisionKey(scope, id)]).length\r\n    : undefined;\r\n\r\n  return (\r\n    <div className={className ? `${summaryClass} ${className}` : summaryClass}>\r\n      <span className={countClass} style={{ color: theme.color.success }}>\r\n        {counts.accepted} accepted\r\n      </span>\r\n      <span className={countClass} style={{ color: theme.color.warning }}>\r\n        {counts[\"changes-requested\"]} changes requested\r\n      </span>\r\n      <span className={countClass} style={{ color: theme.color.destructive }}>\r\n        {counts.rejected} rejected\r\n      </span>\r\n      {outstanding !== undefined ? <span>{outstanding} awaiting review</span> : null}\r\n    </div>\r\n  );\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Icons\r\n * ------------------------------------------------------------------ */\r\n\r\nfunction CheckIcon() {\r\n  return (\r\n    <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" aria-hidden=\"true\">\r\n      <path d=\"M2.5 6.5L4.75 8.75L9.5 3.5\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\r\n    </svg>\r\n  );\r\n}\r\n\r\nfunction CrossIcon() {\r\n  return (\r\n    <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" aria-hidden=\"true\">\r\n      <path d=\"M3 3l6 6M9 3l-6 6\" stroke=\"currentColor\" strokeWidth=\"1.75\" strokeLinecap=\"round\" />\r\n    </svg>\r\n  );\r\n}\r\n\r\nfunction PencilIcon() {\r\n  return (\r\n    <svg width=\"12\" height=\"12\" viewBox=\"0 0 12 12\" fill=\"none\" aria-hidden=\"true\">\r\n      <path d=\"M8.5 1.5l2 2-6 6-2.5.5.5-2.5 6-6z\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinejoin=\"round\" />\r\n    </svg>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}