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
+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>
)
}