Propose a project or working group from the site itself
Members can now sketch a new project or working group directly from /projects/propose and /working-groups/propose without asking a maintainer to draft a markdown file. Two pages, two named form components over one shared body, a new Proposal table on Turso, and a public 'recent proposals' rail beneath each form so submitters see the queue they're joining. The bar to propose is intentionally low; the bar to lead is whoever is willing.
Changelog — 2026-04-29 (03)
Propose pipeline
Single commit: 2269666 — propose pages, forms, API, DB table.
Why Care?
The dojo’s premise is that working groups and projects are member-driven. Until today, the only way to propose one was to message a maintainer in chat. The CTAs on /projects and /working-groups already pointed at /projects/propose and /working-groups/propose — and 404’d. This entry replaces the 404s with a working pipeline: signed-in member → two-field form → row in Turso → maintainer reviews and promotes promising ones to a markdown file.
The Architectural Choice: DB First, Markdown Later
The instinct on this codebase is markdown-as-database — the polling system seeds rows from markdown, the participants are markdown, the projects and working groups are markdown. So the obvious move would have been: form → GitHub App bot → commit a .md file under src/content/{projects,working-groups}/proposed/. That’s how the StackBuilder write path works.
We didn’t do that here. Reasons:
- Commit-history pollution. Stack edits are infrequent and meaningful (a Fellow updates their tool list). Proposals will be exploratory — “what if we did X?” — and many will be junk. One commit per junk submission would drown the feature-commit history that makes
git loguseful. - Drafts deserve curation. A submitted proposal isn’t yet a
proposed/content entry. The maintainer reads, decides, and promotes the good ones into markdown by hand. The DB row is the backlog, the markdown file is the commitment. - No GitHub App roundtrip. Submitting from the form should feel synchronous. Inserting a row in Turso is ~50ms; round-tripping through the GitHub App + waiting on Vercel rebuild is closer to 30 seconds.
So: a new Proposal table on Turso. The maintainer flips status from submitted to accepted after promoting it to a markdown file.
// db/config.ts — Proposal
const Proposal = defineTable({
columns: {
id: column.text({ primaryKey: true }),
kind: column.text(), // 'project' | 'working-group'
title: column.text(),
body: column.text(),
user_id: column.text(), // canonical = lowercased roster email
user_name: column.text({ optional: true }), // snapshotted for display
user_handle: column.text({ optional: true }),
user_provider: column.text({ optional: true }),
status: column.text(), // 'submitted' | 'accepted' | …
created_at: column.date(),
updated_at: column.date(),
},
indexes: { proposals_kind_status: { on: ['kind', 'status'] } },
});
Proposal.user_id is the lowercased roster email — same key as Vote.user_id and User.id. A Fellow’s votes, account, and proposals all join on the same column. Display fields (user_name, user_handle) are snapshotted at submission time so the recent-proposals list renders without joins.
The Form
src/components/forms/_ProposeEntityForm.astro is the shared body. Three render states driven by the props the page passes in from session resolution:
- Logged out → form dimmed, a small lock-pill overlay sits in the corner (“Sign in above to enable the form”), and the auth cluster at the top shows both providers as amber “Connect” links.
- Partially linked → form active, the connected provider shows a green ring, the unconnected shows an amber ring with a “Connect” link, and the auth cluster’s message reads “If you connect both, we love you more!” — the same micro-copy the header tooltip uses, deliberately.
- Both linked → both provider cards show green rings; message acknowledges the user by name.
┌──────────────────────────────────────────────────┐
│ If you connect both, we love you more! │
│ ┌────────┐ ┌──────────┐ │
│ │ GH │ │ in │ │
│ │ green │ │ amber │ │
│ │connected Connect ↗ │ │
│ └────────┘ └──────────┘ │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ You can propose a working group without any │
│ obligation to lead it yourself. That said — │
│ proposals without a champion behind them often │
│ stall… │
│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │
│ PROPOSED NAME OR TITLE (DRAFT) │
│ ┌────────────────────────────────────────────┐ │
│ │ │ │
│ └────────────────────────────────────────────┘ │
│ A FEW THOUGHTS, DETAILS, REASONS │
│ ┌────────────────────────────────────────────┐ │
│ │ │ │
│ │ │ │
│ └────────────────────────────────────────────┘ │
│ [ Submit proposal ] │
└──────────────────────────────────────────────────┘
Two named wrappers — Projects__ProposeProject.astro and WorkingGroups__ProposeWorkingGroup.astro — exist so call sites reach for the kind they want by intent rather than configuring a generic. Each wrapper just sets kind and kindLabel on the shared form.
Submit is a plain fetch('/api/proposals', { method: 'POST' }) — JSON body, JWT cookie, server validates and inserts. The endpoint mirrors /api/polls/[id]/votes for auth: verify session JWT, match against the Kauffman roster, reject otherwise.
The Backlog Rail
Each propose page also renders the 10 most recent submitted proposals beneath the form:
- Title in display font
- Author name + relative time (“3h ago”, “2d ago”, etc.)
- Body clamped to 4 lines so longer drafts don’t dominate the rail
This is deliberate. Without it, submitting feels like dropping a message into a void — did anything happen? With it, the submitter sees their own draft show up at the top of the queue immediately on reload, alongside what other members have already sketched. The queue becomes a soft signal of community momentum.
The user_id (canonical email) is stripped from the GET response before it goes over the wire — only user_name and user_handle are exposed publicly.
Decisions Encoded
- DB-first, markdown-second. Submission writes to Turso. Maintainer hand-writes the canonical markdown after triage. Status flag flips
submitted→acceptedto remove from the public list. - Roster-gated on POST, not on the page. The page itself renders for everyone (logged-in or not); the form just dims when there’s no session. Keeps the propose page indexable and scrolling-friendly while still rejecting un-rostered POSTs at the API.
- Two named wrappers, one shared body. The two propose surfaces are 95% identical, but the file names tell the truth about what each one is. Avoids
<ProposeForm kind="project" />boilerplate at the call site. - Recent rail uses absolute timestamps via
<time datetime>plus a relative-time text label. Screen readers + search indexers get the absolute date; humans get “3h ago.”
Behavior
- POST
/api/proposalsreturns 401 (no session), 403 (session but not on roster), 400 (validation), 200 ok. - Title 3–140 chars; body 10–4000 chars.
- Form posts → success state →
setTimeout(reload, 600)so the recent-proposals rail catches up without a manual scroll-restoration dance. - Unauthenticated users still see the form (dimmed), the auth cluster at the top, and the existing backlog of recent submissions.
Migration Note
The new table needs to be pushed to Turso before the live form can write:
pnpm astro db push --remote
Local dev picks up the schema change automatically from db/config.ts on next dev-server start.
Open Work
- Maintainer-only
/admin/proposalsview to triagesubmittedrows: read full body, accept (flips toaccepted), reject with a reason. - Rate-limit per
user_id(no spam, no test-drives that pollute the rail). - Optional “I’m willing to lead this” checkbox — when set, the proposal renders with a leading hand-raise glyph in the rail, distinguishing champions from sketches.
- Cross-link from
proposed/markdown back to the originating Proposal row, so the file’s history starts where the idea started.
Verification
pnpm astro db push --remote— pushes the newProposaltable to Turso./projects/proposeand/working-groups/propose— render correctly logged out (form dimmed, lock pill visible) and logged in (form active, auth cluster green/amber per state).- Submit a draft logged in → status text reads “Submitted. Refreshing…”, page reloads, the new row appears at the top of the recent rail with the submitter’s name and “just now.”
- Submit logged out (via direct
curl -X POST) → 401. curl /api/proposals?kind=project→ returns the recent rail’s data withoutuser_idfield.
Files modified (7)
sites/fullstack-vc/db/config.tssites/fullstack-vc/src/pages/api/proposals.tssites/fullstack-vc/src/components/forms/_ProposeEntityForm.astrosites/fullstack-vc/src/components/forms/Projects__ProposeProject.astrosites/fullstack-vc/src/components/forms/WorkingGroups__ProposeWorkingGroup.astrosites/fullstack-vc/src/pages/projects/propose.astrosites/fullstack-vc/src/pages/working-groups/propose.astro