diff --git a/CLAUDE.md b/CLAUDE.md
index aeecbc1..7c0c959 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -84,6 +84,44 @@ All agents should:
- Use `rich` for console output
- Save multi-step workflows as scripts in `scripts/`
+## Web UI: Public vs Dev-Only Pages
+
+The web dashboard runs in two modes: production (default) and dev (`--dev` flag).
+**When adding new pages, always decide which mode they belong to.**
+
+Use `@admin_required` decorator on dev-only routes, and `{% if is_admin %}` in `base.html` nav links.
+
+### Public pages (visible to everyone)
+Pages showing **publicly available data** or **high-level results** that are defensible:
+- Overview, Draft Explorer, Draft Detail — browsable catalog
+- Authors — public data from Datatracker
+- Citations — public citation data
+- Ratings — score distributions (aggregate, not per-draft methodology)
+- Timeline — submission trends (factual)
+- Search — search functionality
+- About, Impressum, Datenschutz — legal/info pages
+
+### Dev-only pages (`@admin_required`, `--dev` mode)
+Pages exposing **internal methodology**, **LLM judgments**, **cost data**, or **debatable analysis**:
+- Gap Explorer, Gap Generation — internal gap analysis, draft generation
+- Monitor — pipeline health, API costs, token usage
+- Analytics — pageview tracking
+- Compare — side-by-side draft comparison (uses Claude)
+- AI Ask/Synthesize — Claude-powered Q&A (costs tokens)
+- Annotations — internal notes
+- False Positives — exposes filtering methodology, raw LLM judgment calls
+- Complexity — correlations between LLM ratings and structural metrics (methodologically debatable)
+- Idea Analysis — LLM-generated novelty scores could be challenged
+- Trends — safety ratio uses internal category mappings
+- Sources — rating comparisons across standards bodies (could offend orgs)
+- Similarity — embedding-based methodology
+- Landscape — t-SNE map (methodology-dependent)
+- Obsidian Export — internal tool
+
+### Decision criteria for new pages
+- **Public if**: data comes from public sources (Datatracker, standards body websites), or shows aggregate statistics without exposing LLM methodology
+- **Dev-only if**: page reveals how Claude rates/classifies things, shows internal cost/token data, compares organizations in potentially sensitive ways, or uses methodology that could be questioned without full context
+
## Current Status (2026-03-03)
- v0.2.0, 361 drafts (101 new, unprocessed)
diff --git a/src/webui/app.py b/src/webui/app.py
index d71107c..4a383a5 100644
--- a/src/webui/app.py
+++ b/src/webui/app.py
@@ -292,6 +292,7 @@ def ratings():
@app.route("/landscape")
+@admin_required
def landscape():
distributions = get_rating_distributions(db())
tsne_data = get_landscape_tsne(db())
@@ -326,6 +327,7 @@ def api_architecture():
@app.route("/similarity")
+@admin_required
def similarity():
network = get_similarity_graph(db())
return render_template("similarity.html", network=network)
@@ -349,9 +351,10 @@ def authors():
@app.route("/citations")
def citations():
+ from webui.auth import is_admin as check_admin
graph = get_citation_graph(db())
- influence = get_citation_influence(db())
- bcp = get_bcp_analysis(db())
+ influence = get_citation_influence(db()) if check_admin() else None
+ bcp = get_bcp_analysis(db()) if check_admin() else None
return render_template("citations.html", graph=graph, influence=influence, bcp=bcp)
@@ -601,6 +604,7 @@ def api_timeline():
@app.route("/api/landscape")
+@admin_required
def api_landscape():
data = get_landscape_tsne(db())
if request.args.get("format") == "csv":
@@ -609,6 +613,7 @@ def api_landscape():
@app.route("/api/similarity")
+@admin_required
def api_similarity():
data = get_similarity_graph(db())
return jsonify(data)
@@ -679,6 +684,7 @@ def api_annotate(name: str):
@app.route("/export/obsidian")
+@admin_required
def export_obsidian():
"""Download the entire research corpus as an Obsidian vault (ZIP)."""
data = build_obsidian_vault(db())
@@ -699,24 +705,28 @@ def create_app(dev: bool = False) -> Flask:
@app.route("/sources")
+@admin_required
def sources_page():
data = get_source_comparison(db())
return render_template("sources.html", data=data)
@app.route("/false-positives")
+@admin_required
def false_positives_page():
data = get_false_positive_profile(db())
return render_template("false_positives.html", data=data)
@app.route("/api/sources")
+@admin_required
def api_sources():
data = get_source_comparison(db())
return jsonify(data)
@app.route("/api/false-positives")
+@admin_required
def api_false_positives():
data = get_false_positive_profile(db())
return jsonify(data)
@@ -726,11 +736,13 @@ def api_false_positives():
@app.route("/api/citations/influence")
+@admin_required
def api_citation_influence():
return jsonify(get_citation_influence(db()))
@app.route("/api/citations/bcp")
+@admin_required
def api_bcp_analysis():
return jsonify(get_bcp_analysis(db()))
@@ -739,12 +751,14 @@ def api_bcp_analysis():
@app.route("/idea-analysis")
+@admin_required
def idea_analysis():
data = get_idea_analysis(db())
return render_template("idea_analysis.html", data=data)
@app.route("/api/idea-analysis")
+@admin_required
def api_idea_analysis():
data = get_idea_analysis(db())
return jsonify(data)
@@ -754,23 +768,27 @@ def api_idea_analysis():
@app.route("/trends")
+@admin_required
def trends():
data = get_trends_data(db())
return render_template("trends_analysis.html", data=data)
@app.route("/complexity")
+@admin_required
def complexity():
data = get_complexity_data(db())
return render_template("complexity.html", data=data)
@app.route("/api/trends")
+@admin_required
def api_trends():
return jsonify(get_trends_data(db()))
@app.route("/api/complexity")
+@admin_required
def api_complexity():
return jsonify(get_complexity_data(db()))
diff --git a/src/webui/templates/base.html b/src/webui/templates/base.html
index 8448252..e29d11b 100644
--- a/src/webui/templates/base.html
+++ b/src/webui/templates/base.html
@@ -117,10 +117,12 @@
Idea Clusters
+ {% if is_admin %}
+ {% endif %}
+ {% if is_admin %}
+ {% endif %}
+ {% if is_admin %}
+ {% endif %}