diff --git a/.env.example b/.env.example
index d70a79e..e83c624 100644
--- a/.env.example
+++ b/.env.example
@@ -46,3 +46,10 @@ SNIPE_DB=data/snipe.db
# Heimdall license server — for tier resolution and free-key auto-provisioning.
# HEIMDALL_URL=https://license.circuitforge.tech
# HEIMDALL_ADMIN_TOKEN=
+
+# ── In-app feedback (beta) ────────────────────────────────────────────────────
+# When set, a feedback FAB appears in the UI and routes submissions to Forgejo.
+# Leave unset to silently hide the button (demo/offline deployments).
+# FORGEJO_API_TOKEN=
+# FORGEJO_REPO=Circuit-Forge/snipe
+# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
diff --git a/api/main.py b/api/main.py
index 6bd87a3..e0f7e03 100644
--- a/api/main.py
+++ b/api/main.py
@@ -11,6 +11,12 @@ from pathlib import Path
import csv
import io
+import platform as _platform
+import subprocess
+from datetime import datetime, timezone
+from typing import Literal
+
+import requests as _requests
from fastapi import Depends, FastAPI, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
@@ -629,3 +635,119 @@ async def import_blocklist(
log.info("Blocklist import: %d added, %d errors", imported, len(errors))
return {"imported": imported, "errors": errors}
+
+
+# ── Feedback ────────────────────────────────────────────────────────────────
+# Creates Forgejo issues from in-app beta feedback.
+# Silently disabled when FORGEJO_API_TOKEN is not set.
+
+_FEEDBACK_LABEL_COLORS = {
+ "beta-feedback": "#0075ca",
+ "needs-triage": "#e4e669",
+ "bug": "#d73a4a",
+ "feature-request": "#a2eeef",
+ "question": "#d876e3",
+}
+
+
+def _fb_headers() -> dict:
+ token = os.environ.get("FORGEJO_API_TOKEN", "")
+ return {"Authorization": f"token {token}", "Content-Type": "application/json"}
+
+
+def _ensure_feedback_labels(names: list[str]) -> list[int]:
+ base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
+ repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/snipe")
+ resp = _requests.get(f"{base}/repos/{repo}/labels", headers=_fb_headers(), timeout=10)
+ existing = {lb["name"]: lb["id"] for lb in resp.json()} if resp.ok else {}
+ ids: list[int] = []
+ for name in names:
+ if name in existing:
+ ids.append(existing[name])
+ else:
+ r = _requests.post(
+ f"{base}/repos/{repo}/labels",
+ headers=_fb_headers(),
+ json={"name": name, "color": _FEEDBACK_LABEL_COLORS.get(name, "#ededed")},
+ timeout=10,
+ )
+ if r.ok:
+ ids.append(r.json()["id"])
+ return ids
+
+
+class FeedbackRequest(BaseModel):
+ title: str
+ description: str
+ type: Literal["bug", "feature", "other"] = "other"
+ repro: str = ""
+ view: str = "unknown"
+ submitter: str = ""
+
+
+class FeedbackResponse(BaseModel):
+ issue_number: int
+ issue_url: str
+
+
+@app.get("/api/feedback/status")
+def feedback_status() -> dict:
+ """Return whether feedback submission is configured on this instance."""
+ demo = os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes")
+ return {"enabled": bool(os.environ.get("FORGEJO_API_TOKEN")) and not demo}
+
+
+@app.post("/api/feedback", response_model=FeedbackResponse)
+def submit_feedback(payload: FeedbackRequest) -> FeedbackResponse:
+ """File a Forgejo issue from in-app feedback."""
+ token = os.environ.get("FORGEJO_API_TOKEN", "")
+ if not token:
+ raise HTTPException(status_code=503, detail="Feedback disabled: FORGEJO_API_TOKEN not configured.")
+ if os.environ.get("DEMO_MODE", "false").lower() in ("1", "true", "yes"):
+ raise HTTPException(status_code=403, detail="Feedback disabled in demo mode.")
+
+ try:
+ version = subprocess.check_output(
+ ["git", "describe", "--tags", "--always"],
+ cwd=Path(__file__).resolve().parents[1], text=True, timeout=5,
+ ).strip()
+ except Exception:
+ version = "dev"
+
+ _TYPE_LABELS = {"bug": "🐛 Bug", "feature": "✨ Feature Request", "other": "💬 Other"}
+ body_lines = [
+ f"## {_TYPE_LABELS.get(payload.type, '💬 Other')}",
+ "",
+ payload.description,
+ "",
+ ]
+ if payload.type == "bug" and payload.repro:
+ body_lines += ["### Reproduction Steps", "", payload.repro, ""]
+ body_lines += [
+ "### Context", "",
+ f"- **view:** {payload.view}",
+ f"- **version:** {version}",
+ f"- **platform:** {_platform.platform()}",
+ f"- **timestamp:** {datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')}",
+ "",
+ ]
+ if payload.submitter:
+ body_lines += ["---", f"*Submitted by: {payload.submitter}*"]
+
+ labels = ["beta-feedback", "needs-triage",
+ {"bug": "bug", "feature": "feature-request"}.get(payload.type, "question")]
+ base = os.environ.get("FORGEJO_API_URL", "https://git.opensourcesolarpunk.com/api/v1")
+ repo = os.environ.get("FORGEJO_REPO", "Circuit-Forge/snipe")
+
+ label_ids = _ensure_feedback_labels(labels)
+ resp = _requests.post(
+ f"{base}/repos/{repo}/issues",
+ headers=_fb_headers(),
+ json={"title": payload.title, "body": "\n".join(body_lines), "labels": label_ids},
+ timeout=15,
+ )
+ if not resp.ok:
+ raise HTTPException(status_code=502, detail=f"Forgejo error: {resp.text[:200]}")
+
+ data = resp.json()
+ return FeedbackResponse(issue_number=data["number"], issue_url=data["html_url"])
diff --git a/web/src/App.vue b/web/src/App.vue
index abbd99e..39bf49d 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -8,23 +8,28 @@
Skip to main content
+
+
+
+
+