feat: proposal intake pipeline with AI-powered generation on /proposals/new

Add full proposal system: DB schema (proposals + proposal_gaps tables),
CLI `ietf intake` command, and web UI with Quick Generate on /proposals/new.
The new page merges AI intake (paste URL/text → Haiku generates multiple
proposals auto-linked to gaps) with manual form entry. Generated proposals
are clickable cards that fill the editor below for refinement.

Uses claude_model_cheap (Haiku) for cost-efficient web intake. Includes
CaML-inspired draft proposals from arXiv:2503.18813 analysis.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 03:15:11 +01:00
parent ae5e5f8cbf
commit 5ec7410b89
20 changed files with 3316 additions and 2 deletions

View File

@@ -132,6 +132,10 @@
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4.5c-.77-.833-2.694-.833-3.464 0L3.34 16.5c-.77.833.192 2.5 1.732 2.5z"/></svg>
Gap Explorer
</a>
<a href="/proposals" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'proposals' }}">
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Proposals
</a>
{% endif %}
<a href="/timeline" class="sidebar-link flex items-center gap-3 px-5 py-2.5 text-sm text-slate-300 {{ 'active' if active_page == 'timeline' }}">
<svg class="w-4 h-4 opacity-60" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>

View File

@@ -86,6 +86,44 @@
</div>
</div>
<!-- Linked Proposals -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Linked Proposals</h2>
<a href="/proposals/new?gap_id={{ gap.id }}" class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
New Proposal
</a>
</div>
{% if proposals %}
<div class="space-y-3">
{% for p in proposals %}
<a href="/proposals/{{ p.id }}" class="block bg-slate-800/50 rounded-lg border border-slate-700 hover:border-slate-600 p-4 transition group">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-white group-hover:text-blue-400 transition">{{ p.title }}</h3>
{% if p.description %}
<p class="text-xs text-slate-400 mt-1">{{ p.description[:120] }}{% if p.description | length > 120 %}...{% endif %}</p>
{% endif %}
</div>
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold whitespace-nowrap shrink-0
{% if p.status == 'idea' %}bg-purple-500/20 text-purple-400
{% elif p.status == 'outline' %}bg-blue-500/20 text-blue-400
{% elif p.status == 'draft' %}bg-yellow-500/20 text-yellow-400
{% elif p.status == 'submitted' %}bg-green-500/20 text-green-400
{% elif p.status == 'merged' %}bg-emerald-500/20 text-emerald-400
{% else %}bg-slate-600/20 text-slate-500{% endif %}">
{{ p.status | upper }}
</span>
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-slate-500">No proposals yet -- <a href="/proposals/new?gap_id={{ gap.id }}" class="text-blue-400 hover:text-blue-300 transition">create one?</a></p>
{% endif %}
</div>
<!-- Draft Generation Section -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<div class="flex items-center justify-between mb-4">

View File

@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% set active_page = "proposals" %}
{% block title %}{{ proposal.title }} — Proposals{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<nav class="mb-6 text-sm">
<a href="/proposals" class="text-blue-400 hover:text-blue-300 transition">Proposals</a>
<span class="text-slate-600 mx-2">/</span>
<span class="text-slate-400">{{ proposal.title }}</span>
</nav>
<!-- Header -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
<div class="flex items-start justify-between gap-4 mb-4">
<h1 class="text-2xl font-bold text-white">{{ proposal.title }}</h1>
<span class="px-3 py-1 rounded-full text-xs font-bold whitespace-nowrap
{% if proposal.status == 'idea' %}bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30
{% elif proposal.status == 'outline' %}bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30
{% elif proposal.status == 'draft' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
{% elif proposal.status == 'submitted' %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30
{% elif proposal.status == 'merged' %}bg-emerald-500/20 text-emerald-400 ring-1 ring-emerald-500/30
{% else %}bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30{% endif %}">
{{ proposal.status | upper }}
</span>
</div>
{% if proposal.description %}
<p class="text-sm text-slate-300 leading-relaxed mb-4">{{ proposal.description }}</p>
{% endif %}
<!-- Metadata grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
{% if proposal.intended_wg %}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Working Group</h3>
<p class="text-sm text-slate-300">{{ proposal.intended_wg }}</p>
</div>
{% endif %}
{% if proposal.draft_name %}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Draft Name</h3>
<p class="text-sm text-slate-300 font-mono text-xs">{{ proposal.draft_name }}</p>
</div>
{% endif %}
{% if proposal.source_paper %}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Source Paper</h3>
{% if proposal.source_url %}
<a href="{{ proposal.source_url }}" target="_blank" class="text-sm text-blue-400 hover:text-blue-300 transition">{{ proposal.source_paper }}</a>
{% else %}
<p class="text-sm text-slate-300">{{ proposal.source_paper }}</p>
{% endif %}
</div>
{% endif %}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">Dates</h3>
<p class="text-xs text-slate-400">Created: {{ proposal.created_at[:10] if proposal.created_at else 'N/A' }}</p>
<p class="text-xs text-slate-400">Updated: {{ proposal.updated_at[:10] if proposal.updated_at else 'N/A' }}</p>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-3 pt-4 border-t border-slate-800/50">
<a href="/proposals/{{ proposal.id }}/edit" class="inline-flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-white text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
Edit
</a>
<form method="POST" action="/proposals/{{ proposal.id }}/delete" onsubmit="return confirm('Delete this proposal? This cannot be undone.');" class="inline">
<button type="submit" class="inline-flex items-center gap-2 px-4 py-2 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm font-medium rounded-lg transition ring-1 ring-red-500/20">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
Delete
</button>
</form>
</div>
</div>
<!-- Linked Gaps -->
{% if proposal.gaps %}
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
<h2 class="text-lg font-semibold text-white mb-4">Linked Gaps ({{ proposal.gaps | length }})</h2>
<div class="space-y-3">
{% for gap in proposal.gaps %}
<a href="/gaps/{{ gap.id }}" class="block bg-slate-800/50 rounded-lg border
{% if gap.severity == 'critical' %}border-red-500/30 hover:border-red-500/50
{% elif gap.severity == 'high' %}border-orange-500/20 hover:border-orange-500/40
{% elif gap.severity == 'medium' %}border-yellow-500/15 hover:border-yellow-500/30
{% else %}border-slate-700 hover:border-slate-600{% endif %}
p-4 transition group">
<div class="flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-white group-hover:text-blue-400 transition">{{ gap.topic }}</h3>
<p class="text-xs text-slate-400 mt-1">{{ gap.description[:120] }}{% if gap.description | length > 120 %}...{% endif %}</p>
</div>
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold whitespace-nowrap shrink-0
{% if gap.severity == 'critical' %}bg-red-500/20 text-red-400
{% elif gap.severity == 'high' %}bg-orange-500/20 text-orange-400
{% elif gap.severity == 'medium' %}bg-yellow-500/20 text-yellow-400
{% else %}bg-green-500/20 text-green-400{% endif %}">
{{ gap.severity | upper }}
</span>
</div>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Content -->
{% if proposal.content_md %}
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-lg font-semibold text-white mb-4">Content</h2>
<div class="bg-slate-950 rounded-lg border border-slate-800 p-4">
<pre class="text-sm text-slate-300 font-mono leading-relaxed whitespace-pre-wrap">{{ proposal.content_md }}</pre>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,470 @@
{% extends "base.html" %}
{% set active_page = "proposals" %}
{% block title %}{% if proposal %}Edit {{ proposal.title }}{% else %}New Proposal{% endif %} — Proposals{% endblock %}
{% block extra_head %}
<style>
.intake-spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.gen-card {
animation: fadeIn 0.3s ease-out;
cursor: pointer;
}
.gen-card:hover { border-color: rgba(59, 130, 246, 0.5); }
.gen-card.selected { border-color: rgba(59, 130, 246, 0.7); background: rgba(59, 130, 246, 0.05); }
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<nav class="mb-6 text-sm">
<a href="/proposals" class="text-blue-400 hover:text-blue-300 transition">Proposals</a>
<span class="text-slate-600 mx-2">/</span>
{% if proposal %}
<a href="/proposals/{{ proposal.id }}" class="text-blue-400 hover:text-blue-300 transition">{{ proposal.title }}</a>
<span class="text-slate-600 mx-2">/</span>
<span class="text-slate-400">Edit</span>
{% else %}
<span class="text-slate-400">New Proposal</span>
{% endif %}
</nav>
{% if not proposal %}
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- AI Generate Section (only on new, not edit) -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<svg class="w-5 h-5 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
Quick Generate
</h2>
<p class="text-xs text-slate-500 mt-1">Paste a URL, article text, or notes — Claude generates multiple proposals automatically, linked to gaps.</p>
</div>
<button type="button" id="toggleManual" onclick="toggleManualForm()" class="text-xs text-slate-500 hover:text-slate-300 transition">
Skip to manual form &darr;
</button>
</div>
<div class="mb-4">
<textarea id="intakeInput" rows="6"
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-3 text-sm text-slate-200 font-mono placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 resize-y"
placeholder="Paste one or more of:&#10;&#10;• A URL (https://arxiv.org/..., blog post, RFC)&#10;• Article text or paper abstract&#10;• Your own notes or rough ideas&#10;&#10;URLs are fetched automatically. Multiple proposals will be generated and cross-referenced with existing gaps."></textarea>
</div>
<div class="flex items-center justify-between">
<div class="text-xs text-slate-500">
<span id="charCount">0</span> chars
<span id="urlCount" class="ml-3 hidden">
<span class="text-blue-400"><span id="urlNum">0</span> URL(s)</span> detected
</span>
</div>
<button type="button" id="generateBtn" onclick="runGenerate()"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-500 text-white text-sm font-medium rounded-lg transition">
<svg id="genIcon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span id="genText">Generate Proposals</span>
</button>
</div>
<!-- Status -->
<div id="genStatus" class="hidden mt-4 p-3 rounded-lg bg-blue-500/10 border border-blue-500/20">
<div class="flex items-center gap-3 text-sm text-blue-400">
<span class="intake-spinner"></span>
<div>
<span>Analyzing input and generating proposals...</span>
<p class="text-xs text-blue-400/60 mt-0.5">Uses Haiku for cost efficiency. May take 15-30s.</p>
</div>
</div>
</div>
<!-- Error -->
<div id="genError" class="hidden mt-4 p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<p class="text-sm text-red-400" id="genErrorText"></p>
</div>
<!-- Generated proposals (pick one to fill the form below) -->
<div id="genResults" class="hidden mt-4">
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-slate-300">
<span id="genCount">0</span> proposal(s) generated
<span id="genUsage" class="ml-2 text-xs text-slate-600"></span>
</p>
<p class="text-xs text-slate-500">Click a proposal to fill the form below, or save all directly.</p>
</div>
<div id="genList" class="space-y-3"></div>
<div class="mt-4 flex gap-3">
<button type="button" onclick="saveAllProposals()" id="saveAllBtn"
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-500 text-white text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
Save All Proposals
</button>
<span id="saveAllStatus" class="text-sm text-slate-400 self-center"></span>
</div>
</div>
</div>
<div id="manualDivider" class="flex items-center gap-4 mb-6">
<div class="flex-1 border-t border-slate-800"></div>
<span class="text-xs text-slate-600 uppercase tracking-wider">or create manually</span>
<div class="flex-1 border-t border-slate-800"></div>
</div>
{% endif %}
<!-- ═══════════════════════════════════════════════════════════════════ -->
<!-- Manual Form (also used for edit) -->
<!-- ═══════════════════════════════════════════════════════════════════ -->
<form method="POST" class="space-y-6" id="proposalForm">
<!-- Title & Slug -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h2 class="text-lg font-semibold text-white mb-4">{% if proposal %}Edit Proposal{% else %}Manual Entry{% endif %}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label for="title" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Title *</label>
<input type="text" name="title" id="title" required
value="{{ proposal.title if proposal else '' }}"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
placeholder="e.g., Agent Capability Discovery Protocol"
oninput="autoSlug()">
</div>
<div>
<label for="slug" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Slug</label>
<input type="text" name="slug" id="slug"
value="{{ proposal.slug if proposal else '' }}"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
placeholder="auto-generated-from-title">
<p class="text-[10px] text-slate-600 mt-1">Leave blank to auto-generate from title</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label for="status" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Status</label>
<select name="status" id="status"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 transition">
{% set current_status = proposal.status if proposal else 'idea' %}
<option value="idea" {% if current_status == 'idea' %}selected{% endif %}>Idea</option>
<option value="outline" {% if current_status == 'outline' %}selected{% endif %}>Outline</option>
<option value="draft" {% if current_status == 'draft' %}selected{% endif %}>Draft</option>
<option value="submitted" {% if current_status == 'submitted' %}selected{% endif %}>Submitted</option>
<option value="merged" {% if current_status == 'merged' %}selected{% endif %}>Merged</option>
<option value="abandoned" {% if current_status == 'abandoned' %}selected{% endif %}>Abandoned</option>
</select>
</div>
<div>
<label for="intended_wg" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Intended Working Group</label>
<input type="text" name="intended_wg" id="intended_wg"
value="{{ proposal.intended_wg if proposal else '' }}"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
placeholder="e.g., opsawg, httpbis">
</div>
<div>
<label for="draft_name" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Draft Name</label>
<input type="text" name="draft_name" id="draft_name"
value="{{ proposal.draft_name if proposal else '' }}"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition font-mono text-xs"
placeholder="draft-nennemann-ai-agent-capability-00">
</div>
</div>
<div>
<label for="description" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Description</label>
<textarea name="description" id="description" rows="3"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
placeholder="Brief summary of the proposal idea...">{{ proposal.description if proposal else '' }}</textarea>
</div>
</div>
<!-- Source -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h3 class="text-sm font-semibold text-slate-300 mb-4">Source Reference</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="source_paper" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Source Paper / Document</label>
<input type="text" name="source_paper" id="source_paper"
value="{{ proposal.source_paper if proposal else '' }}"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
placeholder="e.g., RFC 9999, research paper title">
</div>
<div>
<label for="source_url" class="block text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1.5">Source URL</label>
<input type="url" name="source_url" id="source_url"
value="{{ proposal.source_url if proposal else '' }}"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition"
placeholder="https://...">
</div>
</div>
</div>
<!-- Content -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h3 class="text-sm font-semibold text-slate-300 mb-4">Content (Markdown)</h3>
<textarea name="content_md" id="content_md" rows="20"
class="w-full px-3 py-2 bg-slate-950 border border-slate-700 rounded-lg text-sm text-slate-300 placeholder-slate-500 focus:outline-none focus:border-blue-500 transition font-mono leading-relaxed"
placeholder="# Abstract
Write your proposal content here in Markdown...
## Introduction
## Problem Statement
## Proposed Solution
## Security Considerations">{{ proposal.content_md if proposal else '' }}</textarea>
</div>
<!-- Gap Links -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6">
<h3 class="text-sm font-semibold text-slate-300 mb-4">Linked Gaps</h3>
<p class="text-xs text-slate-500 mb-4">Select the gaps this proposal addresses.</p>
{% set selected_gap_ids = proposal.gap_ids if proposal else [] %}
<!-- Group by severity -->
{% set severities = ['critical', 'high', 'medium', 'low'] %}
{% for sev in severities %}
{% set sev_gaps = gaps | selectattr('severity', 'equalto', sev) | list %}
{% if sev_gaps %}
<div class="mb-4">
<h4 class="text-xs font-semibold uppercase tracking-wider mb-2
{% if sev == 'critical' %}text-red-400
{% elif sev == 'high' %}text-orange-400
{% elif sev == 'medium' %}text-yellow-400
{% else %}text-green-400{% endif %}">{{ sev }} ({{ sev_gaps | length }})</h4>
<div class="space-y-2">
{% for gap in sev_gaps %}
<label class="flex items-start gap-3 p-3 rounded-lg bg-slate-800/30 hover:bg-slate-800/60 transition cursor-pointer border border-transparent hover:border-slate-700">
<input type="checkbox" name="gap_ids" value="{{ gap.id }}"
{% if gap.id in selected_gap_ids %}checked{% endif %}
class="mt-0.5 rounded border-slate-600 text-blue-500 focus:ring-blue-500 bg-slate-900">
<div>
<span class="text-sm text-white">{{ gap.topic }}</span>
<span class="ml-2 px-1.5 py-0.5 rounded text-[10px] font-semibold
{% if sev == 'critical' %}bg-red-500/20 text-red-400
{% elif sev == 'high' %}bg-orange-500/20 text-orange-400
{% elif sev == 'medium' %}bg-yellow-500/20 text-yellow-400
{% else %}bg-green-500/20 text-green-400{% endif %}">{{ sev | upper }}</span>
<p class="text-xs text-slate-500 mt-1">{{ gap.description[:100] }}{% if gap.description | length > 100 %}...{% endif %}</p>
</div>
</label>
{% endfor %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
<!-- Actions -->
<div class="flex items-center gap-4">
<button type="submit" class="inline-flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
{% if proposal %}Save Changes{% else %}Create Proposal{% endif %}
</button>
{% if proposal %}
<a href="/proposals/{{ proposal.id }}" class="px-4 py-2.5 text-sm text-slate-400 hover:text-white transition">Cancel</a>
{% else %}
<a href="/proposals" class="px-4 py-2.5 text-sm text-slate-400 hover:text-white transition">Cancel</a>
{% endif %}
</div>
</form>
{% endblock %}
{% block extra_scripts %}
<script>
let slugManuallyEdited = {{ 'true' if proposal and proposal.slug else 'false' }};
document.getElementById('slug').addEventListener('input', function() {
slugManuallyEdited = this.value.length > 0;
});
function autoSlug() {
if (slugManuallyEdited) return;
const title = document.getElementById('title').value;
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
document.getElementById('slug').value = slug;
}
{% if not proposal %}
// Pre-select gap from URL parameter
const params = new URLSearchParams(window.location.search);
const preGapId = params.get('gap_id');
if (preGapId) {
const checkbox = document.querySelector(`input[name="gap_ids"][value="${preGapId}"]`);
if (checkbox) checkbox.checked = true;
}
// ── Quick Generate ──────────────────────────────────────────────────
const intakeInput = document.getElementById('intakeInput');
const charCountEl = document.getElementById('charCount');
const urlCountEl = document.getElementById('urlCount');
const urlNumEl = document.getElementById('urlNum');
// Store generated proposals for "Save All"
let generatedProposals = [];
intakeInput.addEventListener('input', () => {
charCountEl.textContent = intakeInput.value.length;
const urls = intakeInput.value.match(/https?:\/\/[^\s<>"')\]]+/g) || [];
if (urls.length > 0) {
urlCountEl.classList.remove('hidden');
urlNumEl.textContent = urls.length;
} else {
urlCountEl.classList.add('hidden');
}
});
function toggleManualForm() {
const form = document.getElementById('proposalForm');
const divider = document.getElementById('manualDivider');
form.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function runGenerate() {
const input = intakeInput.value.trim();
if (!input) return;
const btn = document.getElementById('generateBtn');
const icon = document.getElementById('genIcon');
const text = document.getElementById('genText');
const status = document.getElementById('genStatus');
const error = document.getElementById('genError');
const results = document.getElementById('genResults');
btn.disabled = true;
icon.innerHTML = '';
icon.classList.add('intake-spinner');
text.textContent = 'Processing...';
status.classList.remove('hidden');
error.classList.add('hidden');
results.classList.add('hidden');
const formData = new FormData();
formData.append('input_text', input);
fetch('/proposals/intake', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
status.classList.add('hidden');
icon.classList.remove('intake-spinner');
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
if (data.error) {
error.classList.remove('hidden');
document.getElementById('genErrorText').textContent = data.error;
text.textContent = 'Retry';
btn.disabled = false;
return;
}
generatedProposals = data.proposals;
document.getElementById('genCount').textContent = data.count;
// Show usage info
if (data.usage) {
const u = data.usage;
document.getElementById('genUsage').textContent =
`${u.model} · ${u.input_tokens.toLocaleString()} in / ${u.output_tokens.toLocaleString()} out · $${u.cost_usd.toFixed(3)}`;
}
const list = document.getElementById('genList');
list.innerHTML = '';
data.proposals.forEach((p, i) => {
const gapPills = (p.gap_ids || []).map(gid =>
`<span class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400">#${gid}</span>`
).join(' ');
const card = document.createElement('div');
card.className = 'gen-card bg-slate-950 rounded-lg border border-slate-700 p-4';
card.style.animationDelay = `${i * 0.1}s`;
card.dataset.index = i;
card.onclick = () => fillFormFromProposal(i);
card.innerHTML = `
<div class="flex items-start justify-between gap-3 mb-1.5">
<h3 class="text-sm font-semibold text-white">${p.title}</h3>
<span class="px-2 py-0.5 rounded-full text-[10px] font-semibold bg-green-500/20 text-green-400 ring-1 ring-green-500/30 whitespace-nowrap">SAVED</span>
</div>
<p class="text-xs text-slate-400 mb-2">${p.description || ''}</p>
<div class="flex items-center gap-3 flex-wrap text-[10px]">
<span class="text-slate-500">Gaps: ${gapPills || '<span class="text-slate-600">none</span>'}</span>
${p.intended_wg ? `<span class="text-slate-500">WG: <span class="text-slate-400">${p.intended_wg}</span></span>` : ''}
${p.draft_name ? `<span class="text-slate-500 font-mono">${p.draft_name}</span>` : ''}
</div>
<p class="text-[10px] text-blue-400/60 mt-2">Click to load into editor below &darr;</p>
`;
list.appendChild(card);
});
results.classList.remove('hidden');
text.textContent = 'Generate More';
btn.disabled = false;
// Update Save All button text
document.getElementById('saveAllBtn').querySelector('span') ||
(document.getElementById('saveAllBtn').textContent = `All ${data.count} saved`);
// Proposals were already saved by the intake endpoint
document.getElementById('saveAllBtn').classList.add('hidden');
document.getElementById('saveAllStatus').textContent = `All ${data.count} proposals saved automatically.`;
})
.catch(err => {
status.classList.add('hidden');
error.classList.remove('hidden');
document.getElementById('genErrorText').textContent = 'Network error: ' + err.message;
icon.classList.remove('intake-spinner');
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
text.textContent = 'Retry';
btn.disabled = false;
});
}
function fillFormFromProposal(index) {
const p = generatedProposals[index];
if (!p) return;
// Highlight selected card
document.querySelectorAll('.gen-card').forEach(c => c.classList.remove('selected'));
document.querySelectorAll('.gen-card')[index].classList.add('selected');
// Fill form fields
document.getElementById('title').value = p.title || '';
slugManuallyEdited = false;
autoSlug();
if (p.slug) {
document.getElementById('slug').value = p.slug;
slugManuallyEdited = true;
}
document.getElementById('description').value = p.description || '';
document.getElementById('intended_wg').value = p.intended_wg || '';
document.getElementById('draft_name').value = p.draft_name || '';
document.getElementById('source_paper').value = p.source_paper || '';
document.getElementById('source_url').value = p.source_url || '';
document.getElementById('content_md').value = p.content_md || '';
// Check matching gap checkboxes
document.querySelectorAll('input[name="gap_ids"]').forEach(cb => {
cb.checked = (p.gap_ids || []).includes(parseInt(cb.value));
});
// Scroll to form
document.getElementById('proposalForm').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function saveAllProposals() {
// Proposals are already saved by the intake endpoint, this is a no-op
document.getElementById('saveAllStatus').textContent = 'All proposals were saved during generation.';
}
{% endif %}
</script>
{% endblock %}

View File

@@ -0,0 +1,190 @@
{% extends "base.html" %}
{% set active_page = "proposals" %}
{% block title %}Proposal Intake — IETF Draft Analyzer{% endblock %}
{% block extra_head %}
<style>
.intake-spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.result-card {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
</style>
{% endblock %}
{% block content %}
<!-- Breadcrumb -->
<nav class="mb-6 text-sm">
<a href="/proposals" class="text-blue-400 hover:text-blue-300 transition">Proposals</a>
<span class="text-slate-600 mx-2">/</span>
<span class="text-slate-400">Intake</span>
</nav>
<div class="mb-6">
<h1 class="text-2xl font-bold text-white">Proposal Intake</h1>
<p class="text-slate-400 text-sm mt-1">Paste article text, URLs, or notes below. Claude will analyze the input against all current gaps and generate structured IETF draft proposals automatically.</p>
</div>
<!-- Input form -->
<div class="bg-slate-900 rounded-xl border border-slate-800 p-6 mb-6">
<div class="mb-4">
<label for="inputText" class="block text-sm font-medium text-slate-300 mb-2">Input Material</label>
<textarea id="inputText" rows="12"
class="w-full bg-slate-950 border border-slate-700 rounded-lg px-4 py-3 text-sm text-slate-200 font-mono placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 resize-y"
placeholder="Paste one or more of:&#10;&#10;• Article text or paper abstract&#10;• URLs (https://arxiv.org/..., blog posts, etc.)&#10;• Your own notes or ideas&#10;• A mix of all the above&#10;&#10;URLs will be fetched automatically. The system will cross-reference everything with the 12 existing gaps and generate draft proposals."></textarea>
</div>
<div class="flex items-center justify-between">
<div class="text-xs text-slate-500">
<span id="charCount">0</span> chars
<span id="urlCount" class="ml-3 hidden">
<span class="text-blue-400"><span id="urlNum">0</span> URL(s)</span> detected — will be fetched
</span>
</div>
<button id="submitBtn" onclick="runIntake()"
class="inline-flex items-center gap-2 px-5 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-slate-700 disabled:text-slate-500 text-white text-sm font-medium rounded-lg transition">
<svg id="submitIcon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
<span id="submitText">Generate Proposals</span>
</button>
</div>
</div>
<!-- Status -->
<div id="statusArea" class="hidden mb-6 p-4 rounded-xl bg-blue-500/10 border border-blue-500/20">
<div class="flex items-center gap-3 text-sm text-blue-400">
<span class="intake-spinner"></span>
<div>
<span id="statusText">Analyzing input and generating proposals...</span>
<p class="text-xs text-blue-400/60 mt-1">This may take 30-60 seconds. Claude is reading the input, cross-referencing gaps, and writing full proposal outlines.</p>
</div>
</div>
</div>
<!-- Error -->
<div id="errorArea" class="hidden mb-6 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
<p class="text-sm text-red-400" id="errorText"></p>
</div>
<!-- Results -->
<div id="resultsArea" class="hidden">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">
Generated <span id="resultCount">0</span> Proposal(s)
</h2>
<a href="/proposals" class="text-sm text-blue-400 hover:text-blue-300 transition">View all proposals &rarr;</a>
</div>
<div id="resultsList" class="space-y-4"></div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
const textarea = document.getElementById('inputText');
const charCount = document.getElementById('charCount');
const urlCount = document.getElementById('urlCount');
const urlNum = document.getElementById('urlNum');
textarea.addEventListener('input', () => {
charCount.textContent = textarea.value.length;
const urls = textarea.value.match(/https?:\/\/[^\s<>"')\]]+/g) || [];
if (urls.length > 0) {
urlCount.classList.remove('hidden');
urlNum.textContent = urls.length;
} else {
urlCount.classList.add('hidden');
}
});
function runIntake() {
const input = textarea.value.trim();
if (!input) return;
const btn = document.getElementById('submitBtn');
const icon = document.getElementById('submitIcon');
const text = document.getElementById('submitText');
const status = document.getElementById('statusArea');
const error = document.getElementById('errorArea');
const results = document.getElementById('resultsArea');
btn.disabled = true;
icon.innerHTML = '';
icon.classList.add('intake-spinner');
text.textContent = 'Processing...';
status.classList.remove('hidden');
error.classList.add('hidden');
results.classList.add('hidden');
const formData = new FormData();
formData.append('input_text', input);
fetch('/proposals/intake', {
method: 'POST',
body: formData,
})
.then(r => r.json())
.then(data => {
status.classList.add('hidden');
icon.classList.remove('intake-spinner');
if (data.error) {
error.classList.remove('hidden');
document.getElementById('errorText').textContent = data.error;
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
text.textContent = 'Retry';
btn.disabled = false;
} else {
document.getElementById('resultCount').textContent = data.count;
const list = document.getElementById('resultsList');
list.innerHTML = '';
data.proposals.forEach((p, i) => {
const gapPills = (p.gap_ids || []).map(gid =>
`<a href="/gaps/${gid}" class="px-2 py-0.5 rounded text-[10px] bg-slate-800 text-slate-400 hover:text-blue-400 transition">#${gid}</a>`
).join(' ');
const card = document.createElement('div');
card.className = 'result-card bg-slate-900 rounded-xl border border-green-500/30 p-5';
card.style.animationDelay = `${i * 0.1}s`;
card.innerHTML = `
<div class="flex items-start justify-between gap-3 mb-2">
<a href="/proposals/${p.id}" class="text-base font-semibold text-white hover:text-blue-400 transition">${p.title}</a>
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30 whitespace-nowrap">IDEA</span>
</div>
<p class="text-sm text-slate-400 mb-3">${p.description}</p>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-[10px] text-slate-500">Gaps:</span>
${gapPills || '<span class="text-[10px] text-slate-600">none linked</span>'}
</div>
`;
list.appendChild(card);
});
results.classList.remove('hidden');
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>';
text.textContent = 'Done — Generate More?';
btn.disabled = false;
}
})
.catch(err => {
status.classList.add('hidden');
error.classList.remove('hidden');
document.getElementById('errorText').textContent = 'Network error: ' + err.message;
icon.classList.remove('intake-spinner');
icon.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>';
text.textContent = 'Retry';
btn.disabled = false;
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,175 @@
{% extends "base.html" %}
{% set active_page = "proposals" %}
{% block title %}Proposals — IETF Draft Analyzer{% endblock %}
{% block extra_head %}
<style>
.status-pill { cursor: pointer; transition: all 0.2s; }
.status-pill:hover { opacity: 0.9; }
.status-pill.active { ring: 2px; }
</style>
{% endblock %}
{% block content %}
<div class="mb-6 flex items-start justify-between">
<div>
<h1 class="text-2xl font-bold text-white">Proposals</h1>
<p class="text-slate-400 text-sm mt-1">{{ proposals | length }} draft proposal{{ 's' if proposals | length != 1 }} tracking ideas for new Internet-Drafts linked to identified gaps.</p>
</div>
<div class="flex gap-2 shrink-0">
<a href="/proposals/intake" class="inline-flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-500 text-white text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
Intake
</a>
<a href="/proposals/new" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
New
</a>
</div>
</div>
<!-- Status filter pills -->
<div class="flex flex-wrap gap-2 mb-6">
<button onclick="filterStatus('all')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-slate-800 text-slate-300 ring-1 ring-slate-700" data-status="all">
All ({{ proposals | length }})
</button>
{% set ns = namespace(idea=0, outline=0, draft=0, submitted=0, merged=0, abandoned=0) %}
{% for p in proposals %}
{% if p.status == 'idea' %}{% set ns.idea = ns.idea + 1 %}
{% elif p.status == 'outline' %}{% set ns.outline = ns.outline + 1 %}
{% elif p.status == 'draft' %}{% set ns.draft = ns.draft + 1 %}
{% elif p.status == 'submitted' %}{% set ns.submitted = ns.submitted + 1 %}
{% elif p.status == 'merged' %}{% set ns.merged = ns.merged + 1 %}
{% elif p.status == 'abandoned' %}{% set ns.abandoned = ns.abandoned + 1 %}
{% endif %}
{% endfor %}
<button onclick="filterStatus('idea')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30" data-status="idea">
Idea ({{ ns.idea }})
</button>
<button onclick="filterStatus('outline')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30" data-status="outline">
Outline ({{ ns.outline }})
</button>
<button onclick="filterStatus('draft')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30" data-status="draft">
Draft ({{ ns.draft }})
</button>
<button onclick="filterStatus('submitted')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-green-500/20 text-green-400 ring-1 ring-green-500/30" data-status="submitted">
Submitted ({{ ns.submitted }})
</button>
<button onclick="filterStatus('merged')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-emerald-500/20 text-emerald-400 ring-1 ring-emerald-500/30" data-status="merged">
Merged ({{ ns.merged }})
</button>
<button onclick="filterStatus('abandoned')" class="status-pill px-3 py-1.5 rounded-full text-xs font-semibold bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30" data-status="abandoned">
Abandoned ({{ ns.abandoned }})
</button>
</div>
<!-- Search -->
<div class="mb-6">
<input type="text" id="searchInput" placeholder="Search proposals..." oninput="filterProposals()"
class="w-full md:w-96 px-4 py-2 bg-slate-900 border border-slate-700 rounded-lg text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 transition">
</div>
<!-- Proposal cards -->
<div class="space-y-4" id="proposalList">
{% for p in proposals %}
<a href="/proposals/{{ p.id }}" class="proposal-card block bg-slate-900 rounded-xl border border-slate-800 hover:border-slate-600 p-5 transition group"
data-status="{{ p.status }}" data-title="{{ p.title | lower }}" data-desc="{{ p.description | lower }}">
<div class="flex items-start justify-between gap-3 mb-3">
<h2 class="text-base font-semibold text-white group-hover:text-blue-400 transition">{{ p.title }}</h2>
<div class="flex items-center gap-2 shrink-0">
<span class="px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap
{% if p.status == 'idea' %}bg-purple-500/20 text-purple-400 ring-1 ring-purple-500/30
{% elif p.status == 'outline' %}bg-blue-500/20 text-blue-400 ring-1 ring-blue-500/30
{% elif p.status == 'draft' %}bg-yellow-500/20 text-yellow-400 ring-1 ring-yellow-500/30
{% elif p.status == 'submitted' %}bg-green-500/20 text-green-400 ring-1 ring-green-500/30
{% elif p.status == 'merged' %}bg-emerald-500/20 text-emerald-400 ring-1 ring-emerald-500/30
{% else %}bg-slate-600/20 text-slate-500 ring-1 ring-slate-600/30{% endif %}">
{{ p.status | upper }}
</span>
<svg class="w-4 h-4 text-slate-600 group-hover:text-blue-400 transition" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
</div>
</div>
{% if p.description %}
<p class="text-sm text-slate-400 leading-relaxed mb-3">{{ p.description[:200] }}{% if p.description | length > 200 %}...{% endif %}</p>
{% endif %}
<div class="flex flex-wrap items-center gap-3 text-xs text-slate-500">
{% if p.gaps %}
<div class="flex flex-wrap gap-1.5">
{% for gap in p.gaps %}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-slate-800 text-slate-400">
<span class="w-1.5 h-1.5 rounded-full
{% if gap.severity == 'critical' %}bg-red-400
{% elif gap.severity == 'high' %}bg-orange-400
{% elif gap.severity == 'medium' %}bg-yellow-400
{% else %}bg-green-400{% endif %}"></span>
{{ gap.topic[:30] }}{% if gap.topic | length > 30 %}...{% endif %}
</span>
{% endfor %}
</div>
{% endif %}
{% if p.source_paper %}
<span class="text-slate-600">|</span>
<span>{{ p.source_paper }}</span>
{% endif %}
{% if p.intended_wg %}
<span class="text-slate-600">|</span>
<span>WG: {{ p.intended_wg }}</span>
{% endif %}
{% if p.updated_at %}
<span class="text-slate-600">|</span>
<span>{{ p.updated_at[:10] }}</span>
{% endif %}
</div>
</a>
{% endfor %}
{% if not proposals %}
<div class="bg-slate-900 rounded-xl border border-slate-800 p-12 text-center">
<svg class="w-12 h-12 text-slate-700 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>
<p class="text-slate-400 mb-4">No proposals yet. Start tracking your draft ideas.</p>
<a href="/proposals/new" class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/></svg>
Create First Proposal
</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block extra_scripts %}
<script>
let currentStatus = 'all';
function filterStatus(status) {
currentStatus = status;
// Update pill styling
document.querySelectorAll('.status-pill').forEach(pill => {
if (pill.dataset.status === status) {
pill.style.outline = '2px solid rgba(96, 165, 250, 0.5)';
pill.style.outlineOffset = '1px';
} else {
pill.style.outline = 'none';
}
});
filterProposals();
}
function filterProposals() {
const query = document.getElementById('searchInput').value.toLowerCase();
document.querySelectorAll('.proposal-card').forEach(card => {
const matchStatus = currentStatus === 'all' || card.dataset.status === currentStatus;
const matchSearch = !query || card.dataset.title.includes(query) || card.dataset.desc.includes(query);
card.style.display = (matchStatus && matchSearch) ? 'block' : 'none';
});
}
// Initialize with "all" active
filterStatus('all');
</script>
{% endblock %}