Slice 1: scaffolding + propose-to-super-draft vertical

Brings the §1 bot wrapper, the §4 cache (webhook + reconciler), the
§5 schema (six numbered migrations), Gitea OAuth + §6 user
provisioning, the §7 catalog left pane, and the propose-to-merge
vertical: propose modal opens an idea PR against the meta repo, an
owner merges from the pending-idea view, the cache picks it up via
webhook or reconciler sweep, and the catalog renders the new
super-draft.

Per §1 the bot is the only Git writer; every commit, branch
creation, and PR merge carries the §6.5 On-behalf-of: trailer and
an `actions` audit row. Per §4 the cache is never written from a
user action — it's webhook+reconciler only.

Covered by `backend/tests/test_propose_vertical.py` against an
in-process Gitea simulator.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-24 04:31:11 -07:00
commit 779ba6db59
42 changed files with 10385 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Wiggleverse RFCs</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1707
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@tiptap/extension-placeholder": "^3.5.0",
"@tiptap/pm": "^3.5.0",
"@tiptap/react": "^3.5.0",
"@tiptap/starter-kit": "^3.5.0",
"marked": "^18.0.4",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.2.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"vite": "^8.0.12"
}
}
+319
View File
@@ -0,0 +1,319 @@
/* Adapted from the prototype's App.css per §18, narrowed to slice-1 surfaces. */
.boot {
display: flex; align-items: center; justify-content: center;
height: 100vh; color: #888; font-size: 14px;
}
.app { height: 100vh; display: flex; flex-direction: column; }
.app-header {
height: 48px; flex-shrink: 0;
background: #1a1a1a; color: #fff;
display: flex; align-items: center; justify-content: space-between;
padding: 0 20px;
}
.app-brand a { color: #fff; text-decoration: none; font-weight: 600; font-size: 14px; }
.header-right { display: flex; align-items: center; gap: 12px; font-size: 13px; }
.user-name { color: #ddd; }
.user-role-badge {
font-size: 10px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
padding: 2px 6px; border-radius: 4px;
background: rgba(255,255,255,0.15);
}
.role-owner { background: #b45309; }
.role-admin { background: #4338ca; }
.btn-link {
color: #fff; text-decoration: none;
background: rgba(255,255,255,0.15);
border-radius: 6px; padding: 4px 10px;
font-size: 13px;
}
.btn-link:hover { background: rgba(255,255,255,0.25); }
.app-body { flex: 1; display: flex; overflow: hidden; }
/* --- Catalog (left pane, §7) --- */
.catalog {
width: 320px; flex-shrink: 0;
background: #fff; border-right: 1px solid #e5e5e5;
display: flex; flex-direction: column;
overflow: hidden;
}
.catalog-search {
padding: 12px 14px;
border-bottom: 1px solid #f0f0ee;
}
.catalog-search input {
width: 100%; border: 1px solid #e5e5e5; border-radius: 6px;
padding: 6px 10px; font-size: 13px; outline: none;
}
.catalog-search input:focus { border-color: #1a1a1a; }
.catalog-controls {
display: flex; align-items: center; gap: 8px;
padding: 8px 14px;
border-bottom: 1px solid #f0f0ee;
font-size: 12px; color: #555;
}
.catalog-controls select {
border: 1px solid #e5e5e5; border-radius: 4px;
font-size: 12px; padding: 2px 4px;
}
.catalog-chips {
display: flex; flex-wrap: wrap; gap: 4px;
padding: 6px 14px 10px;
border-bottom: 1px solid #f0f0ee;
}
.chip {
font-size: 11px;
background: #f0f0ee; color: #444;
border: 1px solid transparent; border-radius: 999px;
padding: 2px 9px; cursor: pointer;
}
.chip.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.catalog-list { flex: 1; overflow-y: auto; padding: 4px 0; }
.catalog-row {
display: flex; flex-direction: column;
padding: 8px 14px;
border: none; background: none; text-align: left; cursor: pointer;
border-left: 3px solid transparent; width: 100%;
}
.catalog-row:hover { background: #f7f7f5; }
.catalog-row.active { background: #f0f0ee; border-left-color: #1a1a1a; }
.catalog-row .row-top { display: flex; align-items: center; gap: 6px; }
.row-id {
font-size: 10px; font-weight: 700;
color: #888; text-transform: uppercase;
letter-spacing: 0.04em;
}
.row-id.super { color: #b45309; }
.row-title { font-size: 13px; margin-top: 2px; }
.catalog-row.is-super .row-title { color: #555; }
.row-tags { font-size: 11px; color: #999; margin-top: 2px; }
.catalog-pending {
border-top: 1px solid #f0f0ee;
flex-shrink: 0;
background: #fafaf8;
}
.pending-header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 14px;
font-size: 11px; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.06em;
color: #888;
cursor: pointer;
background: none; border: none; width: 100%; text-align: left;
}
.pending-count {
background: #e5e5e5; color: #333;
border-radius: 999px;
padding: 1px 8px; font-size: 11px;
}
.pending-list { padding: 2px 0 8px; }
.pending-row {
display: block;
padding: 6px 14px;
font-size: 13px; color: #444; text-decoration: none;
cursor: pointer; background: none; border: none; text-align: left; width: 100%;
}
.pending-row:hover { background: #fff; color: #1a1a1a; }
.pending-row.active { background: #fff; color: #1a1a1a; }
.pending-row .pending-by {
font-size: 11px; color: #999; margin-top: 1px;
}
.catalog-footer {
border-top: 1px solid #e5e5e5;
padding: 10px 14px;
flex-shrink: 0;
background: #fff;
}
.btn-propose {
width: 100%;
background: #1a1a1a; color: #fff;
border: none; border-radius: 6px;
padding: 8px 12px;
font-size: 13px; font-weight: 600;
cursor: pointer;
}
.btn-propose:hover { background: #333; }
.btn-propose:disabled { opacity: 0.5; cursor: default; }
/* --- Main pane --- */
.main-pane {
flex: 1; overflow-y: auto;
padding: 32px 48px;
}
.welcome { max-width: 640px; }
.welcome h1 { font-size: 22px; font-weight: 600; margin: 0 0 16px; }
.welcome p { line-height: 1.7; color: #444; }
/* --- RFC / Proposal view (read-only for slice 1) --- */
.entry-view { max-width: 720px; margin: 0 auto; }
.entry-state-banner {
font-size: 12px; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.06em;
color: #b45309;
padding: 6px 10px;
background: #fffbeb; border: 1px solid #fde68a;
border-radius: 6px;
display: inline-block; margin-bottom: 16px;
}
.entry-state-banner.active { color: #166534; background: #f0fdf4; border-color: #bbf7d0; }
.entry-state-banner.declined { color: #991b1b; background: #fef2f2; border-color: #fecaca; }
.entry-state-banner.merged { color: #1e40af; background: #eff6ff; border-color: #bfdbfe; }
.entry-title { font-size: 26px; font-weight: 700; margin: 0 0 8px; }
.entry-meta { font-size: 12px; color: #999; margin-bottom: 24px; }
.entry-meta .entry-tag {
display: inline-block;
background: #f0f0ee; color: #555;
padding: 1px 8px; border-radius: 999px;
margin-right: 4px;
}
.entry-body {
font-size: 15px; line-height: 1.75; color: #1a1a1a;
}
.entry-body h1, .entry-body h2, .entry-body h3 { font-weight: 600; }
.entry-body h1 { font-size: 22px; margin-top: 24px; }
.entry-body h2 { font-size: 17px; margin-top: 20px; }
.entry-body h3 { font-size: 15px; margin-top: 16px; }
.entry-body p { margin: 0 0 12px; }
.entry-body ul, .entry-body ol { padding-left: 24px; }
.entry-body code { background: #f0f0ee; padding: 1px 5px; border-radius: 3px; font-size: 13px; }
.entry-actions {
display: flex; gap: 8px; margin: 16px 0 24px;
padding-bottom: 24px;
border-bottom: 1px solid #e5e5e5;
}
.btn-primary {
background: #166534; color: #fff;
border: none; border-radius: 6px;
padding: 7px 14px;
font-size: 13px; font-weight: 600;
cursor: pointer;
}
.btn-primary:hover { background: #14532d; }
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
background: #fff; color: #1a1a1a;
border: 1px solid #d4d4d4; border-radius: 6px;
padding: 7px 14px;
font-size: 13px; font-weight: 600;
cursor: pointer;
}
.btn-secondary:hover { background: #f7f7f5; }
.btn-danger {
background: #fff; color: #991b1b;
border: 1px solid #fecaca; border-radius: 6px;
padding: 7px 14px;
font-size: 13px; font-weight: 600;
cursor: pointer;
}
.btn-danger:hover { background: #fef2f2; }
/* --- Modals --- */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(20, 20, 20, 0.4);
display: flex; align-items: center; justify-content: center;
z-index: 100;
}
.modal {
background: #fff;
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0,0,0,0.15);
width: 560px; max-width: calc(100vw - 40px);
max-height: calc(100vh - 80px);
display: flex; flex-direction: column;
}
.modal-header {
padding: 18px 20px;
border-bottom: 1px solid #f0f0ee;
display: flex; align-items: center; justify-content: space-between;
}
.modal-header h2 { margin: 0; font-size: 17px; font-weight: 600; }
.modal-close {
background: none; border: none; font-size: 22px; cursor: pointer;
color: #999; line-height: 1; padding: 0;
}
.modal-body {
padding: 20px;
overflow-y: auto;
flex: 1;
}
.modal-body label {
display: block;
font-size: 12px; font-weight: 600;
color: #555;
text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: 4px;
}
.modal-body input, .modal-body textarea {
width: 100%;
border: 1px solid #d4d4d4; border-radius: 6px;
padding: 8px 10px;
font-size: 14px; font-family: inherit;
outline: none; margin-bottom: 14px;
}
.modal-body input:focus, .modal-body textarea:focus { border-color: #1a1a1a; }
.modal-body textarea { resize: vertical; min-height: 100px; }
.field-help {
font-size: 12px; color: #999;
margin-top: -10px; margin-bottom: 14px;
}
.field-error {
font-size: 12px; color: #991b1b;
margin-top: -10px; margin-bottom: 14px;
}
.modal-actions {
border-top: 1px solid #f0f0ee;
padding: 14px 20px;
display: flex; justify-content: flex-end; gap: 10px;
}
/* --- Landing page (pre-login, §14.1) --- */
.landing {
height: 100vh;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
padding: 40px;
text-align: center;
}
.landing h1 { font-size: 28px; font-weight: 700; margin: 0 0 8px; }
.landing .subtitle { font-size: 16px; color: #666; margin: 0 0 28px; }
.landing .pitch {
max-width: 540px;
line-height: 1.7; color: #333;
font-size: 15px;
margin: 0 0 28px;
}
.landing .btn-signin {
background: #1a1a1a; color: #fff;
border: none; border-radius: 8px;
padding: 10px 20px;
font-size: 14px; font-weight: 600;
text-decoration: none;
}
.landing .btn-signin:hover { background: #333; }
.landing .secondary-link {
margin-top: 14px;
font-size: 13px;
color: #666; text-decoration: none;
}
.landing .secondary-link:hover { color: #1a1a1a; text-decoration: underline; }
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { Routes, Route, Link, useNavigate } from 'react-router-dom'
import { getMe } from './api'
import Catalog from './components/Catalog.jsx'
import RFCView from './components/RFCView.jsx'
import ProposalView from './components/ProposalView.jsx'
import ProposeModal from './components/ProposeModal.jsx'
import Landing from './components/Landing.jsx'
import './App.css'
export default function App() {
const [me, setMe] = useState(null)
const [loading, setLoading] = useState(true)
const [proposeOpen, setProposeOpen] = useState(false)
const [catalogVersion, setCatalogVersion] = useState(0)
const navigate = useNavigate()
useEffect(() => {
getMe()
.then(setMe)
.catch(() => setMe({ authenticated: false }))
.finally(() => setLoading(false))
}, [])
if (loading) {
return <div className="boot">Loading</div>
}
if (!me?.authenticated) {
return <Landing />
}
return (
<div className="app">
<header className="app-header">
<div className="app-brand">
<Link to="/">Wiggleverse RFCs</Link>
</div>
<div className="header-right">
<span className="user-name">{me.user.display_name}</span>
<span className={`user-role-badge role-${me.user.role}`}>{me.user.role}</span>
<a className="btn-link" href="/auth/logout">Sign out</a>
</div>
</header>
<div className="app-body">
<Catalog
onProposeRFC={() => setProposeOpen(true)}
version={catalogVersion}
/>
<main className="main-pane">
<Routes>
<Route path="/" element={<Welcome viewer={me.user} />} />
<Route path="/rfc/:slug" element={<RFCView />} />
<Route path="/proposals/:prNumber" element={<ProposalView viewer={me.user} onChange={() => setCatalogVersion(v => v + 1)} />} />
</Routes>
</main>
</div>
{proposeOpen && (
<ProposeModal
onClose={() => setProposeOpen(false)}
onSubmitted={({ pr_number }) => {
setProposeOpen(false)
setCatalogVersion(v => v + 1)
navigate(`/proposals/${pr_number}`)
}}
/>
)}
</div>
)
}
function Welcome({ viewer }) {
return (
<div className="welcome">
<h1>Welcome, {viewer.display_name}.</h1>
<p>
The catalog on the left lists every super-draft and active RFC in the
framework. Open one to read the canonical body, or use{' '}
<strong>Propose New RFC</strong> at the bottom of the catalog to open an
idea PR against the meta repository.
</p>
<p>
Slice 1 of the build is in place: propose idea PR owner merges
super-draft appears in the catalog super-draft view renders. The
revision flow, per-branch chat, AI participation, and the PR surface
land in subsequent slices.
</p>
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
// api.js — every backend call lives here.
//
// All write requests pass {credentials: 'include'} implicitly because
// the dev proxy and the production deploy serve the API from the same
// origin as the frontend. If you split origins later, change here.
async function jsonOrThrow(res) {
if (!res.ok) {
let detail = ''
try {
const body = await res.json()
detail = body.detail || JSON.stringify(body)
} catch {
detail = await res.text()
}
const error = new Error(detail || `HTTP ${res.status}`)
error.status = res.status
throw error
}
return res.json()
}
export async function getMe() {
const res = await fetch('/api/auth/me')
return jsonOrThrow(res)
}
export async function listRFCs() {
return jsonOrThrow(await fetch('/api/rfcs'))
}
export async function getRFC(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}`))
}
export async function listProposals() {
return jsonOrThrow(await fetch('/api/proposals'))
}
export async function getProposal(prNumber) {
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
}
export async function proposeRFC({ title, slug, pitch, tags }) {
const res = await fetch('/api/rfcs/propose', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, slug, pitch, tags: tags || [] }),
})
return jsonOrThrow(res)
}
export async function mergeProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/merge`, { method: 'POST' })
return jsonOrThrow(res)
}
export async function declineProposal(prNumber, comment) {
const res = await fetch(`/api/proposals/${prNumber}/decline`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment }),
})
return jsonOrThrow(res)
}
export async function withdrawProposal(prNumber) {
const res = await fetch(`/api/proposals/${prNumber}/withdraw`, { method: 'POST' })
return jsonOrThrow(res)
}
+155
View File
@@ -0,0 +1,155 @@
// Catalog.jsx — the §7 left pane.
//
// One scrollable flat list of every super-draft and active entry,
// state-styled rather than grouped. Filter chips are AND-combined per
// §7.1; search is fuzzy over title + slug + id. The "Pending ideas"
// disclosure per §7.3 lives at the bottom, above the "+ Propose New RFC"
// button.
import { useEffect, useMemo, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { listRFCs, listProposals } from '../api'
const STATE_CHIPS = [
{ id: 'super-draft', label: 'Super-draft' },
{ id: 'active', label: 'Active' },
]
const SORT_OPTIONS = [
{ id: 'recent', label: 'Recently active' },
{ id: 'title', label: 'Title' },
{ id: 'id', label: 'ID' },
{ id: 'state', label: 'State' },
]
export default function Catalog({ onProposeRFC, version }) {
const [rfcs, setRfcs] = useState([])
const [proposals, setProposals] = useState([])
const [search, setSearch] = useState('')
const [sort, setSort] = useState('recent')
const [activeChips, setActiveChips] = useState(new Set())
const [pendingOpen, setPendingOpen] = useState(true)
const { slug, prNumber } = useParams()
useEffect(() => {
listRFCs().then(d => setRfcs(d.items)).catch(() => setRfcs([]))
listProposals().then(d => setProposals(d.items)).catch(() => setProposals([]))
}, [version])
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase()
let items = rfcs.filter(r => {
if (activeChips.size > 0 && !activeChips.has(r.state)) return false
if (!needle) return true
const hay = [r.title, r.slug, r.id || ''].join(' ').toLowerCase()
return hay.includes(needle)
})
items = [...items].sort((a, b) => {
// Starred items pin to the top of the current sort per §7.2.
if (a.starred_by_me !== b.starred_by_me) return a.starred_by_me ? -1 : 1
if (sort === 'title') return a.title.localeCompare(b.title)
if (sort === 'id') return (a.id || 'zzz').localeCompare(b.id || 'zzz')
if (sort === 'state') return a.state.localeCompare(b.state)
// recent
return (b.last_active_at || '').localeCompare(a.last_active_at || '')
})
return items
}, [rfcs, search, sort, activeChips])
function toggleChip(id) {
const next = new Set(activeChips)
next.has(id) ? next.delete(id) : next.add(id)
setActiveChips(next)
}
return (
<aside className="catalog">
<div className="catalog-search">
<input
placeholder="Search title, slug, ID…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
<div className="catalog-controls">
<label htmlFor="catalog-sort">Sort:</label>
<select id="catalog-sort" value={sort} onChange={e => setSort(e.target.value)}>
{SORT_OPTIONS.map(o => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</div>
<div className="catalog-chips">
{STATE_CHIPS.map(chip => (
<button
key={chip.id}
className={`chip ${activeChips.has(chip.id) ? 'active' : ''}`}
onClick={() => toggleChip(chip.id)}
>
{chip.label}
</button>
))}
</div>
<div className="catalog-list">
{filtered.length === 0 ? (
<div style={{ padding: '24px 14px', color: '#999', fontSize: 13 }}>
{rfcs.length === 0
? 'No RFCs in the catalog yet. Propose one below.'
: 'No matches.'}
</div>
) : (
filtered.map(r => {
const isActive = slug === r.slug
const isSuper = r.state === 'super-draft'
return (
<Link
key={r.slug}
to={`/rfc/${r.slug}`}
className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`}
>
<div className="row-top">
<span className={`row-id ${isSuper ? 'super' : ''}`}>
{isSuper ? 'super-draft' : (r.id || '—')}
</span>
</div>
<span className="row-title">{r.title}</span>
{r.tags.length > 0 && (
<span className="row-tags">{r.tags.join(' · ')}</span>
)}
</Link>
)
})
)}
</div>
<div className="catalog-pending">
<button className="pending-header" onClick={() => setPendingOpen(o => !o)}>
<span>{pendingOpen ? '▾' : '▸'} Pending ideas</span>
<span className="pending-count">{proposals.length}</span>
</button>
{pendingOpen && proposals.length > 0 && (
<div className="pending-list">
{proposals.map(p => (
<Link
key={p.pr_number}
to={`/proposals/${p.pr_number}`}
className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`}
>
<div>{p.title.replace(/^Propose:\s*/, '')}</div>
<div className="pending-by">by @{p.opened_by || '—'} · PR #{p.pr_number}</div>
</Link>
))}
</div>
)}
{pendingOpen && proposals.length === 0 && (
<div style={{ padding: '0 14px 12px', color: '#aaa', fontSize: 12 }}>
No pending proposals.
</div>
)}
</div>
<div className="catalog-footer">
<button className="btn-propose" onClick={onProposeRFC}>+ Propose New RFC</button>
</div>
</aside>
)
}
+24
View File
@@ -0,0 +1,24 @@
// Landing.jsx — §14.1's pre-login surface.
//
// Title, subtitle, the short-form pitch from PHILOSOPHY.md, then the
// single primary action: "Sign in with Gitea." The visual design is
// deferred per §14.4; the structural commitments are here.
export default function Landing() {
return (
<div className="landing">
<h1>Wiggleverse RFCs</h1>
<p className="subtitle">A standards process for shared meaning between humans and machines.</p>
<p className="pitch">
Large language models work brilliantly with programming languages because every
word in Python or C has a definitive meaning enforced by tooling. They struggle
with natural language because no such dictionary exists for words like
<em> consent</em>, <em> trait</em>, or <em> agency</em> words that do enormous
work in any system that interacts with humans. The Wiggleverse RFC framework is
a standardization process for that vocabulary. Build the dictionary first.
</p>
<a className="btn-signin" href="/auth/login">Sign in with Gitea</a>
<a className="secondary-link" href="/philosophy">Read the full philosophy </a>
</div>
)
}
+168
View File
@@ -0,0 +1,168 @@
// 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 { marked } from 'marked'
import { getProposal, mergeProposal, declineProposal, withdrawProposal } from '../api'
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()
function refresh() {
setData(null); setError(null)
getProposal(prNumber).then(setData).catch(err => setError(err.message))
}
useEffect(refresh, [prNumber])
if (error) return <div className="entry-view"><p>Error: {error}</p></div>
if (!data) return <div className="entry-view">Loading</div>
const isOpen = data.state === 'open'
async function doMerge() {
setActing(true)
try {
const { slug } = await mergeProposal(prNumber)
onChange?.()
navigate(`/rfc/${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 (
<article className="entry-view">
<div className={`entry-state-banner ${data.state === 'merged' ? 'merged' : data.state === 'open' ? '' : 'declined'}`}>
{isOpen ? 'Pending idea — awaiting review'
: data.state === 'merged' ? 'Merged — now a super-draft'
: 'Closed'}
</div>
<h1 className="entry-title">
{data.entry?.title || data.title.replace(/^Propose:\s*/, '')}
</h1>
<div className="entry-meta">
<span>PR #{data.pr_number}</span>
{data.opened_by && <> · proposed by <strong>@{data.opened_by}</strong></>}
{data.opened_at && <> · {new Date(data.opened_at).toLocaleDateString()}</>}
{data.entry?.tags?.length > 0 && (
<div style={{ marginTop: 6 }}>
{data.entry.tags.map(t => <span key={t} className="entry-tag">{t}</span>)}
</div>
)}
</div>
{isOpen && (
<div className="entry-actions">
{data.affordances.merge && (
<button className="btn-primary" onClick={doMerge} disabled={acting}>
{acting ? 'Merging…' : 'Merge proposal'}
</button>
)}
{data.affordances.decline && (
<button className="btn-danger" onClick={() => setDeclineOpen(true)} disabled={acting}>
Decline
</button>
)}
{data.affordances.withdraw && (
<button className="btn-secondary" onClick={doWithdraw} disabled={acting}>
Withdraw proposal
</button>
)}
</div>
)}
{declineOpen && (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) setDeclineOpen(false) }}>
<div className="modal">
<div className="modal-header">
<h2>Decline proposal</h2>
<button className="modal-close" onClick={() => setDeclineOpen(false)}>×</button>
</div>
<div className="modal-body">
<label>Comment to the proposer (required)</label>
<textarea
value={declineComment}
onChange={e => setDeclineComment(e.target.value)}
rows={5}
placeholder="The proposer will read this verbatim."
autoFocus
/>
<p className="field-help">
Per §9.3, the decline ceremony's two-step preview-and-confirm
surface lands with the rest of §9.3 UX in Slice 2. For now the
comment goes directly to the PR and to the proposer's inbox.
</p>
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={() => setDeclineOpen(false)}>Cancel</button>
<button
type="button"
className="btn-danger"
onClick={doDecline}
disabled={!declineComment.trim() || acting}
>
{acting ? 'Declining…' : 'Send decline'}
</button>
</div>
</div>
</div>
)}
<h3 style={{ fontSize: 13, fontWeight: 700, color: '#888', textTransform: 'uppercase', letterSpacing: '0.05em', marginTop: 24 }}>
Proposed entry
</h3>
<div
className="entry-body"
dangerouslySetInnerHTML={{ __html: marked.parse(data.entry?.body || '') }}
/>
</article>
)
}
+150
View File
@@ -0,0 +1,150 @@
// ProposeModal.jsx — §9.1.
//
// Title (required) and pitch (required textarea), with a slug field
// that auto-fills from the title via the same deterministic kebab-case
// the backend uses. Tags are chip-input (free-form for slice 1; the
// AI-suggested chips of §9.1 are deferred to Slice 2 when the AI surface
// is wired up).
//
// The submit button drives the §17 POST /api/rfcs/propose endpoint;
// success navigates the proposer to the pending-idea view per §9.3.
import { useEffect, useState } from 'react'
import { proposeRFC } from '../api'
function slugify(title) {
return title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
export default function ProposeModal({ onClose, onSubmitted }) {
const [title, setTitle] = useState('')
const [slug, setSlug] = useState('')
const [slugEdited, setSlugEdited] = useState(false)
const [pitch, setPitch] = useState('')
const [tagInput, setTagInput] = useState('')
const [tags, setTags] = useState([])
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
if (!slugEdited) setSlug(slugify(title))
}, [title, slugEdited])
function addTag() {
const t = tagInput.trim()
if (t && !tags.includes(t)) setTags([...tags, t])
setTagInput('')
}
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim() || !slug || !pitch.trim()) return
setSubmitting(true)
setError(null)
try {
const result = await proposeRFC({
title: title.trim(),
slug,
pitch: pitch.trim(),
tags,
})
onSubmitted?.(result)
} catch (err) {
setError(err.message || 'Submission failed.')
} finally {
setSubmitting(false)
}
}
return (
<div className="modal-overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
<div className="modal">
<div className="modal-header">
<h2>Propose a New RFC</h2>
<button className="modal-close" onClick={onClose}>×</button>
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
<label htmlFor="propose-title">Title</label>
<input
id="propose-title"
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="e.g. Open Human Model"
autoFocus
required
/>
<p className="field-help">The word or topic this RFC will define.</p>
<label htmlFor="propose-slug">Slug</label>
<input
id="propose-slug"
value={slug}
onChange={e => { setSlug(slugify(e.target.value)); setSlugEdited(true) }}
placeholder="open-human-model"
required
/>
<p className="field-help">Auto-derived from the title; edit if needed.</p>
<label htmlFor="propose-pitch">Why is this RFC needed?</label>
<textarea
id="propose-pitch"
value={pitch}
onChange={e => setPitch(e.target.value)}
placeholder="One or two paragraphs answering 'why this RFC is needed.'"
rows={5}
required
/>
<label htmlFor="propose-tag">Tags (optional)</label>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
<input
id="propose-tag"
value={tagInput}
onChange={e => setTagInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addTag() }
}}
placeholder="identity, schema"
style={{ marginBottom: 0 }}
/>
<button type="button" className="btn-secondary" onClick={addTag} style={{ padding: '6px 12px' }}>
Add
</button>
</div>
{tags.length > 0 && (
<div style={{ marginTop: 4, marginBottom: 14 }}>
{tags.map(t => (
<span key={t} className="entry-tag" style={{ display: 'inline-block', marginRight: 4 }}>
{t}{' '}
<button
type="button"
onClick={() => setTags(tags.filter(x => x !== t))}
style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#999' }}
>×</button>
</span>
))}
</div>
)}
{error && <p className="field-error">{error}</p>}
</div>
<div className="modal-actions">
<button type="button" className="btn-secondary" onClick={onClose}>Cancel</button>
<button
type="submit"
className="btn-primary"
disabled={!title.trim() || !slug || !pitch.trim() || submitting}
>
{submitting ? 'Opening PR…' : 'Open proposal PR'}
</button>
</div>
</form>
</div>
</div>
)
}
+55
View File
@@ -0,0 +1,55 @@
// RFCView.jsx — §9.4 super-draft view (and a stub for active RFCs).
//
// Slice 1 ships read-only body rendering: the breadcrumb names the
// entry, the body renders via marked. The discuss-vs-contribute toggle,
// per-branch chat, change-card panel, and breadcrumb dropdown all land
// in Slice 2 per §8.
import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { marked } from 'marked'
import { getRFC } from '../api'
export default function RFCView() {
const { slug } = useParams()
const [entry, setEntry] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
setEntry(null); setError(null)
getRFC(slug).then(setEntry).catch(err => setError(err.message))
}, [slug])
if (error) return <div className="entry-view"><p>Error: {error}</p></div>
if (!entry) return <div className="entry-view">Loading</div>
const stateClass = entry.state === 'active' ? 'active' : ''
return (
<article className="entry-view">
<div className={`entry-state-banner ${stateClass}`}>
{entry.state === 'super-draft' ? 'Super-draft' : (entry.id || 'Active')}
</div>
<h1 className="entry-title">{entry.title}</h1>
<div className="entry-meta">
<span>{entry.slug}</span>
{entry.proposed_by && <> · proposed by <strong>{entry.proposed_by}</strong></>}
{entry.proposed_at && <> · {entry.proposed_at}</>}
{entry.tags.length > 0 && (
<div style={{ marginTop: 6 }}>
{entry.tags.map(t => <span key={t} className="entry-tag">{t}</span>)}
</div>
)}
</div>
{entry.state === 'active' && (
<div className="entry-state-banner" style={{ background: '#fffbeb', borderColor: '#fde68a', color: '#92400e' }}>
The active-RFC view (editor, branches, chat) lands in Slice 2.
The body below is the canonical main-branch text.
</div>
)}
<div
className="entry-body"
dangerouslySetInnerHTML={{ __html: marked.parse(entry.body || '') }}
/>
</article>
)
}
+14
View File
@@ -0,0 +1,14 @@
:root {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
Arial, sans-serif;
color: #1a1a1a;
background: #fafaf8;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { overflow: hidden; }
a { color: inherit; }
button { font-family: inherit; }
+13
View File
@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// In dev, the frontend runs on Vite's port and proxies the API
// (and /auth/*, /api/webhooks/*) to the FastAPI process.
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': 'http://localhost:8000',
'/auth': 'http://localhost:8000',
},
},
})