diff --git a/app/api.py b/app/api.py
index 5355628..29f3af2 100644
--- a/app/api.py
+++ b/app/api.py
@@ -287,6 +287,59 @@ def test_account(req: AccountTestRequest):
from fastapi.responses import StreamingResponse
+# ---------------------------------------------------------------------------
+# Benchmark endpoints
+# ---------------------------------------------------------------------------
+
+@app.get("/api/benchmark/results")
+def get_benchmark_results():
+ """Return the most recently saved benchmark results, or an empty envelope."""
+ path = _DATA_DIR / "benchmark_results.json"
+ if not path.exists():
+ return {"models": {}, "sample_count": 0, "timestamp": None}
+ return json.loads(path.read_text())
+
+
+@app.get("/api/benchmark/run")
+def run_benchmark(include_slow: bool = False):
+ """Spawn the benchmark script and stream stdout as SSE progress events."""
+ import subprocess
+
+ python_bin = "/devl/miniconda3/envs/job-seeker-classifiers/bin/python"
+ script = str(_ROOT / "scripts" / "benchmark_classifier.py")
+ cmd = [python_bin, script, "--score", "--save"]
+ if include_slow:
+ cmd.append("--include-slow")
+
+ def generate():
+ try:
+ proc = subprocess.Popen(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ cwd=str(_ROOT),
+ )
+ for line in proc.stdout:
+ line = line.rstrip()
+ if line:
+ yield f"data: {json.dumps({'type': 'progress', 'message': line})}\n\n"
+ proc.wait()
+ if proc.returncode == 0:
+ yield f"data: {json.dumps({'type': 'complete'})}\n\n"
+ else:
+ yield f"data: {json.dumps({'type': 'error', 'message': f'Process exited with code {proc.returncode}'})}\n\n"
+ except Exception as exc:
+ yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n"
+
+ return StreamingResponse(
+ generate(),
+ media_type="text/event-stream",
+ headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
+ )
+
+
@app.get("/api/fetch/stream")
def fetch_stream(
accounts: str = Query(default=""),
diff --git a/scripts/benchmark_classifier.py b/scripts/benchmark_classifier.py
index 3f661a6..947f909 100644
--- a/scripts/benchmark_classifier.py
+++ b/scripts/benchmark_classifier.py
@@ -163,7 +163,8 @@ def run_scoring(
gold = [r["label"] for r in rows]
results: dict[str, Any] = {}
- for adapter in adapters:
+ for i, adapter in enumerate(adapters, 1):
+ print(f"[{i}/{len(adapters)}] Running {adapter.name} ({len(rows)} samples) …", flush=True)
preds: list[str] = []
t0 = time.monotonic()
for row in rows:
@@ -177,6 +178,7 @@ def run_scoring(
metrics = compute_metrics(preds, gold, LABELS)
metrics["latency_ms"] = round(elapsed_ms / len(rows), 1)
results[adapter.name] = metrics
+ print(f" → macro-F1 {metrics['__macro_f1__']:.3f} accuracy {metrics['__accuracy__']:.3f} {metrics['latency_ms']:.1f} ms/email", flush=True)
adapter.unload()
return results
@@ -375,6 +377,31 @@ def cmd_score(args: argparse.Namespace) -> None:
print(row_str)
print()
+ if args.save:
+ import datetime
+ rows = load_scoring_jsonl(args.score_file)
+ save_data = {
+ "timestamp": datetime.datetime.utcnow().isoformat() + "Z",
+ "sample_count": len(rows),
+ "models": {
+ name: {
+ "macro_f1": round(m["__macro_f1__"], 4),
+ "accuracy": round(m["__accuracy__"], 4),
+ "latency_ms": m["latency_ms"],
+ "per_label": {
+ label: {k: round(v, 4) for k, v in m[label].items()}
+ for label in LABELS
+ if label in m
+ },
+ }
+ for name, m in results.items()
+ },
+ }
+ save_path = Path(args.score_file).parent / "benchmark_results.json"
+ with open(save_path, "w") as f:
+ json.dump(save_data, f, indent=2)
+ print(f"Results saved → {save_path}", flush=True)
+
def cmd_compare(args: argparse.Namespace) -> None:
active = _active_models(args.include_slow)
@@ -431,6 +458,8 @@ def main() -> None:
parser.add_argument("--days", type=int, default=90, help="Days back for IMAP search")
parser.add_argument("--include-slow", action="store_true", help="Include non-default heavy models")
parser.add_argument("--models", nargs="+", help="Override: run only these model names")
+ parser.add_argument("--save", action="store_true",
+ help="Save results to data/benchmark_results.json (for the web UI)")
args = parser.parse_args()
diff --git a/web/index.html b/web/index.html
index 508eabd..a97bf18 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,7 +4,12 @@
-
web
+ Avocet — Label Tool
+
+
diff --git a/web/src/App.vue b/web/src/App.vue
index a5422ff..f15fb5c 100644
--- a/web/src/App.vue
+++ b/web/src/App.vue
@@ -11,11 +11,13 @@
import { onMounted } from 'vue'
import { RouterView } from 'vue-router'
import { useMotion } from './composables/useMotion'
-import { useHackerMode } from './composables/useEasterEgg'
+import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
import AppSidebar from './components/AppSidebar.vue'
const motion = useMotion()
-const { restore } = useHackerMode()
+const { toggle, restore } = useHackerMode()
+
+useKonamiCode(toggle)
onMounted(() => {
restore() // re-apply hacker mode from localStorage on page load
diff --git a/web/src/assets/avocet.css b/web/src/assets/avocet.css
index 3ad8a01..7a91dac 100644
--- a/web/src/assets/avocet.css
+++ b/web/src/assets/avocet.css
@@ -8,8 +8,29 @@
Accent — Russet (#B8622A) — inspired by avocet's vivid orange-russet head
*/
+/* ── Page-level overrides — must be in avocet.css (applied after theme.css base) ── */
+html {
+ /* Prevent Mac Chrome's horizontal swipe-to-navigate page animation
+ from triggering when the user scrolls near the viewport edge */
+ overscroll-behavior-x: none;
+ /* clip (not hidden) — prevents overflowing content from expanding the html layout
+ width beyond the viewport. Without this, body's overflow-x:hidden propagates to
+ the viewport and body has no BFC, so long email URLs inflate the layout and
+ margin:0 auto centering drifts rightward as fonts load. */
+ overflow-x: clip;
+}
+
+body {
+ /* Prevent horizontal scroll from card swipe animations */
+ overflow-x: hidden;
+}
+
+
/* ── Light mode (default) ──────────────────────────── */
:root {
+ /* Aliases bridging avocet component vars to CircuitForge base theme vars */
+ --color-bg: var(--color-surface); /* App.vue body bg → #eaeff8 in light */
+ --color-text-secondary: var(--color-text-muted); /* muted label text */
/* Primary — Slate Teal */
--app-primary: #2A6080; /* 4.8:1 on light surface #eaeff8 — ✅ AA */
--app-primary-hover: #1E4D66; /* darker for hover */
diff --git a/web/src/components/AppSidebar.vue b/web/src/components/AppSidebar.vue
index 2df47e8..f691c9b 100644
--- a/web/src/components/AppSidebar.vue
+++ b/web/src/components/AppSidebar.vue
@@ -62,10 +62,11 @@ import { RouterLink } from 'vue-router'
const LS_KEY = 'cf-avocet-nav-stowed'
const navItems = [
- { path: '/', icon: '🃏', label: 'Label' },
- { path: '/fetch', icon: '📥', label: 'Fetch' },
- { path: '/stats', icon: '📊', label: 'Stats' },
- { path: '/settings', icon: '⚙️', label: 'Settings' },
+ { path: '/', icon: '🃏', label: 'Label' },
+ { path: '/fetch', icon: '📥', label: 'Fetch' },
+ { path: '/stats', icon: '📊', label: 'Stats' },
+ { path: '/benchmark', icon: '🏁', label: 'Benchmark' },
+ { path: '/settings', icon: '⚙️', label: 'Settings' },
]
const stowed = ref(localStorage.getItem(LS_KEY) === 'true')
diff --git a/web/src/components/EmailCard.vue b/web/src/components/EmailCard.vue
index 2180df2..beb994d 100644
--- a/web/src/components/EmailCard.vue
+++ b/web/src/components/EmailCard.vue
@@ -86,6 +86,7 @@ const displayBody = computed(() => {
font-size: 0.9375rem;
line-height: 1.6;
white-space: pre-wrap;
+ overflow-wrap: break-word;
margin: 0;
}
diff --git a/web/src/components/EmailCardStack.vue b/web/src/components/EmailCardStack.vue
index acacbea..2abc6c3 100644
--- a/web/src/components/EmailCardStack.vue
+++ b/web/src/components/EmailCardStack.vue
@@ -84,6 +84,8 @@ const FLING_WINDOW_MS = 50 // rolling sample window in ms
let velocityBuf: { x: number; y: number; t: number }[] = []
function onPointerDown(e: PointerEvent) {
+ // Let clicks on interactive children (expand/collapse, links, etc.) pass through
+ if ((e.target as Element).closest('button, a, input, select, textarea')) return
if (!motion.rich.value) return
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
pickupX.value = e.clientX
diff --git a/web/src/components/LabelBucketGrid.vue b/web/src/components/LabelBucketGrid.vue
index f1102c2..ebd1500 100644
--- a/web/src/components/LabelBucketGrid.vue
+++ b/web/src/components/LabelBucketGrid.vue
@@ -1,7 +1,7 @@