// ProposalView.jsx — §9.3 pending-idea view. // // Renders the proposed entry's body and frontmatter (read-only) with a // status banner ("Pending idea — awaiting review"). The header strip // carries Merge / Decline / Withdraw per the viewer's affordances. The // decline two-step composer-then-preview from §9.3 ships in Slice 1 // as a single-step required-comment input; the preview-confirm ceremony // can land with the rest of §9.3's UX polish in the §19.2 "pending-idea // view's interaction design (remainder)" topic. import { useEffect, useState } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { renderMarkdown } from '../lib/sanitizeHtml' import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api' import { entryPath, useProjectId } from '../lib/entryPaths' export default function ProposalView({ viewer, onChange }) { const { prNumber } = useParams() const [data, setData] = useState(null) const [error, setError] = useState(null) const [acting, setActing] = useState(false) const [declineOpen, setDeclineOpen] = useState(false) const [declineComment, setDeclineComment] = useState('') const navigate = useNavigate() const pid = useProjectId() function refresh() { setData(null); setError(null) getProposal(prNumber).then(setData).catch(err => setError(err.message)) } useEffect(refresh, [prNumber]) if (error) return

Error: {error}

if (!data) return
Loading…
const isOpen = data.state === 'open' async function doMerge() { setActing(true) try { const { slug } = await mergeProposal(prNumber) onChange?.() navigate(entryPath(pid, slug)) } catch (e) { setError(e.message) setActing(false) } } async function doDecline() { if (!declineComment.trim()) return setActing(true) try { await declineProposal(prNumber, declineComment.trim()) onChange?.() refresh() setDeclineOpen(false) setDeclineComment('') } catch (e) { setError(e.message) } finally { setActing(false) } } async function doWithdraw() { if (!confirm('Withdraw this proposal? The PR will be closed; the conversation stays attached as historical record.')) return setActing(true) try { await withdrawProposal(prNumber) onChange?.() refresh() } catch (e) { setError(e.message) } finally { setActing(false) } } return (
{isOpen ? 'Pending idea — awaiting review' : data.state === 'merged' ? 'Merged — now a super-draft' : 'Closed'}

{data.entry?.title || data.title.replace(/^Propose:\s*/, '')}

PR #{data.pr_number} {data.opened_by && <> · proposed by @{data.opened_by}} {data.opened_at && <> · {new Date(data.opened_at).toLocaleDateString()}} {data.entry?.tags?.length > 0 && (
{data.entry.tags.map(t => {t})}
)}
{isOpen && (
{data.affordances.merge && ( )} {data.affordances.decline && ( )} {data.affordances.withdraw && ( )}
)} {declineOpen && (
{ if (e.target === e.currentTarget) setDeclineOpen(false) }}>

Decline proposal