From 0e238a9e37ef1aae7e763219f924a7871ebf43c4 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:07:38 -0800 Subject: [PATCH 001/103] =?UTF-8?q?feat:=20initial=20avocet=20repo=20?= =?UTF-8?q?=E2=80=94=20email=20classifier=20training=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrape → Store → Process pipeline for building email classifier benchmark data across the CircuitForge menagerie. - app/label_tool.py — Streamlit card-stack UI, multi-account IMAP fetch, 6-bucket labeling, undo/skip, keyboard shortcuts (1-6/S/U) - scripts/classifier_adapters.py — ZeroShotAdapter (+ two_pass), GLiClassAdapter, RerankerAdapter; ABC with lazy model loading - scripts/benchmark_classifier.py — 13-model registry, --score, --compare, --list-models, --export-db; uses label_tool.yaml for IMAP - tests/ — 20 tests, all passing, zero model downloads required - config/label_tool.yaml.example — multi-account IMAP template - data/email_score.jsonl.example — sample labeled data for CI Labels: interview_scheduled, offer_received, rejected, positive_response, survey_received, neutral --- .gitignore | 16 + CLAUDE.md | 100 +++++ app/label_tool.py | 568 +++++++++++++++++++++++++++++ config/label_tool.yaml.example | 23 ++ data/.gitkeep | 0 data/email_score.jsonl.example | 8 + environment.yml | 25 ++ pytest.ini | 5 + scripts/__init__.py | 0 scripts/benchmark_classifier.py | 450 +++++++++++++++++++++++ scripts/classifier_adapters.py | 257 +++++++++++++ tests/__init__.py | 0 tests/test_benchmark_classifier.py | 94 +++++ tests/test_classifier_adapters.py | 177 +++++++++ 14 files changed, 1723 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 app/label_tool.py create mode 100644 config/label_tool.yaml.example create mode 100644 data/.gitkeep create mode 100644 data/email_score.jsonl.example create mode 100644 environment.yml create mode 100644 pytest.ini create mode 100644 scripts/__init__.py create mode 100644 scripts/benchmark_classifier.py create mode 100644 scripts/classifier_adapters.py create mode 100644 tests/__init__.py create mode 100644 tests/test_benchmark_classifier.py create mode 100644 tests/test_classifier_adapters.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..353eafc --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.coverage +*.egg-info/ + +# Secrets and personal data +config/label_tool.yaml + +# Data files (user-generated, not for version control) +data/email_score.jsonl +data/email_label_queue.jsonl +data/email_compare_sample.jsonl + +# Conda/pip artifacts +.env diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0790858 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,100 @@ +# Avocet — Email Classifier Training Tool + +## What it is + +Shared infrastructure for building and benchmarking email classifiers across the CircuitForge menagerie. +Named for the avocet's sweeping-bill technique — it sweeps through email streams and filters out categories. + +**Pipeline:** +``` +Scrape (IMAP, wide search, multi-account) → data/email_label_queue.jsonl + ↓ +Label (card-stack UI) → data/email_score.jsonl + ↓ +Benchmark (HuggingFace NLI/reranker) → per-model macro-F1 + latency +``` + +## Environment + +- Python env: `conda run -n job-seeker ` for basic use (streamlit, yaml, stdlib only) +- Classifier env: `conda run -n job-seeker-classifiers ` for benchmark (transformers, FlagEmbedding, gliclass) +- Run tests: `/devl/miniconda3/envs/job-seeker/bin/pytest tests/ -v` + (direct binary — `conda run pytest` can spawn runaway processes) +- Create classifier env: `conda env create -f environment.yml` + +## Label Tool (app/label_tool.py) + +Card-stack Streamlit UI for manually labeling recruitment emails. + +``` +conda run -n job-seeker streamlit run app/label_tool.py --server.port 8503 +``` + +- Config: `config/label_tool.yaml` (gitignored — copy from `.example`) +- Queue: `data/email_label_queue.jsonl` (gitignored) +- Output: `data/email_score.jsonl` (gitignored) +- Three tabs: 🃏 Label, 📥 Fetch, 📊 Stats +- Keyboard shortcuts: 1–6 = label, S = skip, U = undo +- Dedup: MD5 of `(subject + body[:100])` — cross-account safe + +## Benchmark (scripts/benchmark_classifier.py) + +``` +# List available models +conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --list-models + +# Score against labeled JSONL +conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --score + +# Visual comparison on live IMAP emails +conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --compare --limit 20 + +# Include slow/large models +conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --score --include-slow + +# Export DB-labeled emails (⚠️ LLM-generated labels — review first) +conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --export-db --db /path/to/staging.db +``` + +## Labels (peregrine defaults — configurable per product) + +| Label | Meaning | +|-------|---------| +| `interview_scheduled` | Phone screen, video call, or on-site invitation | +| `offer_received` | Formal job offer or offer letter | +| `rejected` | Application declined or not moving forward | +| `positive_response` | Recruiter interest or request to connect | +| `survey_received` | Culture-fit survey or assessment invitation | +| `neutral` | ATS confirmation or unrelated email | + +## Model Registry (13 models, 7 defaults) + +See `scripts/benchmark_classifier.py:MODEL_REGISTRY`. +Default models run without `--include-slow`. +Add `--models deberta-small deberta-small-2pass` to test a specific subset. + +## Config Files + +- `config/label_tool.yaml` — gitignored; multi-account IMAP config +- `config/label_tool.yaml.example` — committed template + +## Data Files + +- `data/email_score.jsonl` — gitignored; manually-labeled ground truth +- `data/email_score.jsonl.example` — committed sample for CI +- `data/email_label_queue.jsonl` — gitignored; IMAP fetch queue + +## Key Design Notes + +- `ZeroShotAdapter.load()` instantiates the pipeline object; `classify()` calls the object. + Tests patch `scripts.classifier_adapters.pipeline` (the module-level factory) with a + two-level mock: `mock_factory.return_value = MagicMock(return_value={...})`. +- `two_pass=True` on ZeroShotAdapter: first pass ranks all 6 labels; second pass re-runs + with only top-2, forcing a binary choice. 2× cost, better confidence. +- `--compare` uses the first account in `label_tool.yaml` for live IMAP emails. +- DB export labels are llama3.1:8b-generated — treat as noisy, not gold truth. + +## Relationship to Peregrine + +Avocet started as `peregrine/tools/label_tool.py` + `peregrine/scripts/classifier_adapters.py`. +Peregrine retains copies during stabilization; once avocet is proven, peregrine will import from here. diff --git a/app/label_tool.py b/app/label_tool.py new file mode 100644 index 0000000..a4a2fdd --- /dev/null +++ b/app/label_tool.py @@ -0,0 +1,568 @@ +"""Email Label Tool — card-stack UI for building classifier benchmark data. + +Philosophy: Scrape → Store → Process + Fetch (IMAP, wide search, multi-account) → data/email_label_queue.jsonl + Label (card stack) → data/email_score.jsonl + +Run: + conda run -n job-seeker streamlit run app/label_tool.py --server.port 8503 + +Config: config/label_tool.yaml (gitignored — see config/label_tool.yaml.example) +""" +from __future__ import annotations + +import email as _email_lib +import hashlib +import imaplib +import json +import sys +from datetime import datetime, timedelta +from email.header import decode_header as _raw_decode +from pathlib import Path +from typing import Any + +import streamlit as st +import yaml + +# ── Path setup ───────────────────────────────────────────────────────────── +_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(_ROOT)) + +_QUEUE_FILE = _ROOT / "data" / "email_label_queue.jsonl" +_SCORE_FILE = _ROOT / "data" / "email_score.jsonl" +_CFG_FILE = _ROOT / "config" / "label_tool.yaml" + +# ── Labels ───────────────────────────────────────────────────────────────── +LABELS = [ + "interview_scheduled", + "offer_received", + "rejected", + "positive_response", + "survey_received", + "neutral", +] + +_LABEL_META: dict[str, dict] = { + "interview_scheduled": {"emoji": "🗓️", "color": "#4CAF50", "key": "1"}, + "offer_received": {"emoji": "🎉", "color": "#2196F3", "key": "2"}, + "rejected": {"emoji": "❌", "color": "#F44336", "key": "3"}, + "positive_response": {"emoji": "👍", "color": "#FF9800", "key": "4"}, + "survey_received": {"emoji": "📋", "color": "#9C27B0", "key": "5"}, + "neutral": {"emoji": "⬜", "color": "#607D8B", "key": "6"}, +} + +# ── Wide IMAP search terms (cast a net across all 6 categories) ───────────── +_WIDE_TERMS = [ + # interview_scheduled + "interview", "phone screen", "video call", "zoom link", "schedule a call", + # offer_received + "offer letter", "job offer", "offer of employment", "pleased to offer", + # rejected + "unfortunately", "not moving forward", "other candidates", "regret to inform", + "no longer", "decided not to", "decided to go with", + # positive_response + "opportunity", "interested in your background", "reached out", "great fit", + "exciting role", "love to connect", + # survey_received + "assessment", "questionnaire", "culture fit", "culture-fit", "online assessment", + # neutral / ATS confirms + "application received", "thank you for applying", "application confirmation", + "you applied", "your application for", + # general recruitment + "application", "recruiter", "recruiting", "hiring", "candidate", +] + + +# ── IMAP helpers ──────────────────────────────────────────────────────────── + +def _decode_str(value: str | None) -> str: + if not value: + return "" + parts = _raw_decode(value) + out = [] + for part, enc in parts: + if isinstance(part, bytes): + out.append(part.decode(enc or "utf-8", errors="replace")) + else: + out.append(str(part)) + return " ".join(out).strip() + + +def _extract_body(msg: Any) -> str: + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + try: + charset = part.get_content_charset() or "utf-8" + return part.get_payload(decode=True).decode(charset, errors="replace") + except Exception: + pass + else: + try: + charset = msg.get_content_charset() or "utf-8" + return msg.get_payload(decode=True).decode(charset, errors="replace") + except Exception: + pass + return "" + + +def _fetch_account(cfg: dict, days: int, limit: int, known_keys: set[str], + progress_cb=None) -> list[dict]: + """Fetch emails from one IMAP account using wide recruitment search terms.""" + since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y") + host = cfg.get("host", "imap.gmail.com") + port = int(cfg.get("port", 993)) + use_ssl = cfg.get("use_ssl", True) + username = cfg["username"] + password = cfg["password"] + name = cfg.get("name", username) + + conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port) + conn.login(username, password) + + seen_uids: dict[bytes, None] = {} + conn.select("INBOX", readonly=True) + for term in _WIDE_TERMS: + try: + _, data = conn.search(None, f'(SUBJECT "{term}" SINCE "{since}")') + for uid in (data[0] or b"").split(): + seen_uids[uid] = None + except Exception: + pass + + emails: list[dict] = [] + uids = list(seen_uids.keys())[:limit * 3] # overfetch; filter after dedup + for i, uid in enumerate(uids): + if len(emails) >= limit: + break + if progress_cb: + progress_cb(i / len(uids), f"{name}: {len(emails)} fetched…") + try: + _, raw_data = conn.fetch(uid, "(RFC822)") + if not raw_data or not raw_data[0]: + continue + msg = _email_lib.message_from_bytes(raw_data[0][1]) + subj = _decode_str(msg.get("Subject", "")) + from_addr = _decode_str(msg.get("From", "")) + date = _decode_str(msg.get("Date", "")) + body = _extract_body(msg)[:800] + entry = { + "subject": subj, + "body": body, + "from_addr": from_addr, + "date": date, + "account": name, + } + key = _entry_key(entry) + if key not in known_keys: + known_keys.add(key) + emails.append(entry) + except Exception: + pass + + try: + conn.logout() + except Exception: + pass + return emails + + +# ── Queue / score file helpers ─────────────────────────────────────────────── + +def _entry_key(e: dict) -> str: + return hashlib.md5( + (e.get("subject", "") + (e.get("body") or "")[:100]).encode() + ).hexdigest() + + +def _load_jsonl(path: Path) -> list[dict]: + if not path.exists(): + return [] + rows = [] + with path.open() as f: + for line in f: + line = line.strip() + if line: + try: + rows.append(json.loads(line)) + except Exception: + pass + return rows + + +def _save_jsonl(path: Path, rows: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as f: + for row in rows: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def _append_jsonl(path: Path, row: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a") as f: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +# ── Config ────────────────────────────────────────────────────────────────── + +def _load_config() -> list[dict]: + if not _CFG_FILE.exists(): + return [] + cfg = yaml.safe_load(_CFG_FILE.read_text()) or {} + return cfg.get("accounts", []) + + +# ── Page setup ────────────────────────────────────────────────────────────── + +st.set_page_config( + page_title="Avocet — Email Labeler", + page_icon="📬", + layout="wide", +) + +st.markdown(""" + +""", unsafe_allow_html=True) + +st.title("📬 Avocet — Email Label Tool") +st.caption("Scrape → Store → Process | card-stack edition") + +# ── Session state init ─────────────────────────────────────────────────────── + +if "queue" not in st.session_state: + st.session_state.queue: list[dict] = _load_jsonl(_QUEUE_FILE) + +if "labeled" not in st.session_state: + st.session_state.labeled: list[dict] = _load_jsonl(_SCORE_FILE) + st.session_state.labeled_keys: set[str] = { + _entry_key(r) for r in st.session_state.labeled + } + +if "idx" not in st.session_state: + # Start past already-labeled entries in the queue + labeled_keys = st.session_state.labeled_keys + for i, entry in enumerate(st.session_state.queue): + if _entry_key(entry) not in labeled_keys: + st.session_state.idx = i + break + else: + st.session_state.idx = len(st.session_state.queue) + +if "history" not in st.session_state: + st.session_state.history: list[tuple[int, str]] = [] # (queue_idx, label) + + +# ── Sidebar stats ──────────────────────────────────────────────────────────── + +with st.sidebar: + labeled = st.session_state.labeled + queue = st.session_state.queue + unlabeled = [e for e in queue if _entry_key(e) not in st.session_state.labeled_keys] + + st.metric("✅ Labeled", len(labeled)) + st.metric("📥 Queue", len(unlabeled)) + + if labeled: + st.caption("**Label distribution**") + counts = {lbl: 0 for lbl in LABELS} + for r in labeled: + counts[r.get("label", "")] = counts.get(r.get("label", ""), 0) + 1 + for lbl in LABELS: + m = _LABEL_META[lbl] + st.caption(f"{m['emoji']} {lbl}: **{counts[lbl]}**") + + +# ── Tabs ───────────────────────────────────────────────────────────────────── + +tab_label, tab_fetch, tab_stats = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats"]) + + +# ══════════════════════════════════════════════════════════════════════════════ +# FETCH TAB +# ══════════════════════════════════════════════════════════════════════════════ + +with tab_fetch: + accounts = _load_config() + + if not accounts: + st.warning( + f"No accounts configured. Copy `config/label_tool.yaml.example` → " + f"`config/label_tool.yaml` and add your IMAP accounts.", + icon="⚠️", + ) + else: + st.markdown(f"**{len(accounts)} account(s) configured:**") + for acc in accounts: + st.caption(f"• {acc.get('name', acc.get('username'))} ({acc.get('host')})") + + col_days, col_limit = st.columns(2) + days = col_days.number_input("Days back", min_value=7, max_value=730, value=180) + limit = col_limit.number_input("Max emails per account", min_value=10, max_value=1000, value=150) + + all_accs = [a.get("name", a.get("username")) for a in accounts] + selected = st.multiselect("Accounts to fetch", all_accs, default=all_accs) + + if st.button("📥 Fetch from IMAP", disabled=not accounts or not selected, type="primary"): + existing_keys = {_entry_key(e) for e in st.session_state.queue} + existing_keys.update(st.session_state.labeled_keys) + + fetched_all: list[dict] = [] + status = st.status("Fetching…", expanded=True) + + for acc in accounts: + name = acc.get("name", acc.get("username")) + if name not in selected: + continue + status.write(f"Connecting to **{name}**…") + try: + emails = _fetch_account( + acc, days=int(days), limit=int(limit), + known_keys=existing_keys, + progress_cb=lambda p, msg: status.write(msg), + ) + fetched_all.extend(emails) + status.write(f"✓ {name}: {len(emails)} new emails") + except Exception as e: + status.write(f"✗ {name}: {e}") + + if fetched_all: + _save_jsonl(_QUEUE_FILE, st.session_state.queue + fetched_all) + st.session_state.queue = _load_jsonl(_QUEUE_FILE) + # Reset idx to first unlabeled + labeled_keys = st.session_state.labeled_keys + for i, entry in enumerate(st.session_state.queue): + if _entry_key(entry) not in labeled_keys: + st.session_state.idx = i + break + status.update(label=f"Done — {len(fetched_all)} new emails added to queue", state="complete") + else: + status.update(label="No new emails found (all already in queue or score file)", state="complete") + + +# ══════════════════════════════════════════════════════════════════════════════ +# LABEL TAB +# ══════════════════════════════════════════════════════════════════════════════ + +with tab_label: + queue = st.session_state.queue + labeled_keys = st.session_state.labeled_keys + idx = st.session_state.idx + + # Advance idx past already-labeled entries + while idx < len(queue) and _entry_key(queue[idx]) in labeled_keys: + idx += 1 + st.session_state.idx = idx + + unlabeled = [e for e in queue if _entry_key(e) not in labeled_keys] + total_in_queue = len(queue) + n_labeled = len(st.session_state.labeled) + + if not queue: + st.info("Queue is empty — go to **Fetch** to pull emails from IMAP.", icon="📥") + elif not unlabeled: + st.success( + f"🎉 All {n_labeled} emails labeled! Go to **Stats** to review and export.", + icon="✅", + ) + else: + # Progress + labeled_in_queue = total_in_queue - len(unlabeled) + progress_pct = labeled_in_queue / total_in_queue if total_in_queue else 0 + st.progress(progress_pct, text=f"{labeled_in_queue} / {total_in_queue} labeled in queue") + + # Current email + entry = queue[idx] + + # Card HTML + subj = entry.get("subject", "(no subject)") or "(no subject)" + from_ = entry.get("from_addr", "") or "" + date_ = entry.get("date", "") or "" + acct = entry.get("account", "") or "" + body = (entry.get("body") or "").strip() + + st.markdown( + f"""""", + unsafe_allow_html=True, + ) + if len(body) > 500: + with st.expander("Show full body"): + st.text(body) + + # Stack hint (visual depth) + st.markdown('
', unsafe_allow_html=True) + st.markdown('
', unsafe_allow_html=True) + + st.markdown("") # spacer + + # ── Bucket buttons ──────────────────────────────────────────────── + def _do_label(label: str) -> None: + row = {"subject": entry.get("subject", ""), "body": body[:600], "label": label} + st.session_state.labeled.append(row) + st.session_state.labeled_keys.add(_entry_key(entry)) + _append_jsonl(_SCORE_FILE, row) + st.session_state.history.append((idx, label)) + # Advance + next_idx = idx + 1 + while next_idx < len(queue) and _entry_key(queue[next_idx]) in labeled_keys: + next_idx += 1 + st.session_state.idx = next_idx + + row1_cols = st.columns(3) + row2_cols = st.columns(3) + bucket_pairs = [ + (row1_cols[0], "interview_scheduled"), + (row1_cols[1], "offer_received"), + (row1_cols[2], "rejected"), + (row2_cols[0], "positive_response"), + (row2_cols[1], "survey_received"), + (row2_cols[2], "neutral"), + ] + for col, lbl in bucket_pairs: + m = _LABEL_META[lbl] + counts = {l: 0 for l in LABELS} + for r in st.session_state.labeled: + counts[r.get("label", "")] = counts.get(r.get("label", ""), 0) + 1 + label_display = f"{m['emoji']} **{lbl}** [{counts[lbl]}]\n`{m['key']}`" + if col.button(label_display, key=f"lbl_{lbl}", use_container_width=True): + _do_label(lbl) + st.rerun() + + # ── Navigation ──────────────────────────────────────────────────── + st.markdown("") + nav_cols = st.columns([2, 1, 1]) + + remaining = len(unlabeled) - 1 + nav_cols[0].caption(f"**{remaining}** remaining · Keys: 1–6 = label, S = skip, U = undo") + + if nav_cols[1].button("↩ Undo", disabled=not st.session_state.history, use_container_width=True): + prev_idx, prev_label = st.session_state.history.pop() + # Remove the last labeled entry + if st.session_state.labeled: + removed = st.session_state.labeled.pop() + st.session_state.labeled_keys.discard(_entry_key(removed)) + _save_jsonl(_SCORE_FILE, st.session_state.labeled) + st.session_state.idx = prev_idx + st.rerun() + + if nav_cols[2].button("→ Skip", use_container_width=True): + next_idx = idx + 1 + while next_idx < len(queue) and _entry_key(queue[next_idx]) in labeled_keys: + next_idx += 1 + st.session_state.idx = next_idx + st.rerun() + + # Keyboard shortcut capture (JS → hidden button click) + st.components.v1.html( + """""", + height=0, + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# STATS TAB +# ══════════════════════════════════════════════════════════════════════════════ + +with tab_stats: + labeled = st.session_state.labeled + + if not labeled: + st.info("No labeled emails yet.") + else: + counts = {lbl: 0 for lbl in LABELS} + for r in labeled: + lbl = r.get("label", "") + if lbl in counts: + counts[lbl] += 1 + + st.markdown(f"**{len(labeled)} labeled emails total**") + + for lbl in LABELS: + m = _LABEL_META[lbl] + col_name, col_bar, col_n = st.columns([3, 5, 1]) + col_name.markdown(f"{m['emoji']} {lbl}") + col_bar.progress(counts[lbl] / max(counts.values()) if counts.values() else 0) + col_n.markdown(f"**{counts[lbl]}**") + + st.divider() + + st.caption( + f"Score file: `{_SCORE_FILE.relative_to(_ROOT)}` " + f"({_SCORE_FILE.stat().st_size if _SCORE_FILE.exists() else 0:,} bytes)" + ) + if st.button("🔄 Re-sync from disk"): + st.session_state.labeled = _load_jsonl(_SCORE_FILE) + st.session_state.labeled_keys = {_entry_key(r) for r in st.session_state.labeled} + st.rerun() + + if _SCORE_FILE.exists(): + st.download_button( + "⬇️ Download email_score.jsonl", + data=_SCORE_FILE.read_bytes(), + file_name="email_score.jsonl", + mime="application/jsonlines", + ) diff --git a/config/label_tool.yaml.example b/config/label_tool.yaml.example new file mode 100644 index 0000000..8f80b18 --- /dev/null +++ b/config/label_tool.yaml.example @@ -0,0 +1,23 @@ +# config/label_tool.yaml — Multi-account IMAP config for the email label tool +# Copy to config/label_tool.yaml and fill in your credentials. +# This file is gitignored. + +accounts: + - name: "Gmail" + host: "imap.gmail.com" + port: 993 + username: "you@gmail.com" + password: "your-app-password" # Use an App Password, not your login password + folder: "INBOX" + days_back: 90 + + - name: "Outlook" + host: "outlook.office365.com" + port: 993 + username: "you@outlook.com" + password: "your-app-password" + folder: "INBOX" + days_back: 90 + +# Optional: limit emails fetched per account per run (0 = unlimited) +max_per_account: 500 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/email_score.jsonl.example b/data/email_score.jsonl.example new file mode 100644 index 0000000..d2b313a --- /dev/null +++ b/data/email_score.jsonl.example @@ -0,0 +1,8 @@ +{"subject": "Interview Invitation — Senior Engineer", "body": "Hi Meghan, we'd love to schedule a 30-min phone screen. Are you available Thursday at 2pm? Please reply to confirm.", "label": "interview_scheduled"} +{"subject": "Your application to Acme Corp", "body": "Thank you for your interest in the Senior Engineer role. After careful consideration, we have decided to move forward with other candidates whose experience more closely matches our current needs.", "label": "rejected"} +{"subject": "Offer Letter — Product Manager at Initech", "body": "Dear Meghan, we are thrilled to extend an offer of employment for the Product Manager position. Please find the attached offer letter outlining compensation and start date.", "label": "offer_received"} +{"subject": "Quick question about your background", "body": "Hi Meghan, I came across your profile and would love to connect. We have a few roles that seem like a great match. Would you be open to a brief chat this week?", "label": "positive_response"} +{"subject": "Company Culture Survey — Acme Corp", "body": "Meghan, as part of our evaluation process, we invite all candidates to complete our culture fit assessment. The survey takes approximately 15 minutes. Please click the link below.", "label": "survey_received"} +{"subject": "Application Received — DataCo", "body": "Thank you for submitting your application for the Data Engineer role at DataCo. We have received your materials and will be in touch if your qualifications match our needs.", "label": "neutral"} +{"subject": "Following up on your application", "body": "Hi Meghan, I wanted to follow up on your recent application. Your background looks interesting and we'd like to learn more. Can we set up a quick call?", "label": "positive_response"} +{"subject": "We're moving forward with other candidates", "body": "Dear Meghan, thank you for taking the time to interview with us. After thoughtful consideration, we have decided not to move forward with your candidacy at this time.", "label": "rejected"} diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..eeec142 --- /dev/null +++ b/environment.yml @@ -0,0 +1,25 @@ +name: job-seeker-classifiers +channels: + - conda-forge + - defaults +dependencies: + - python=3.11 + - pip + - pip: + # UI + - streamlit>=1.32 + - pyyaml>=6.0 + + # Classifier backends (heavy — install selectively) + - transformers>=4.40 + - torch>=2.2 + - accelerate>=0.27 + + # Optional: GLiClass adapter + # - gliclass + + # Optional: BGE reranker adapter + # - FlagEmbedding + + # Dev + - pytest>=8.0 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4ecb1ad --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/benchmark_classifier.py b/scripts/benchmark_classifier.py new file mode 100644 index 0000000..3f661a6 --- /dev/null +++ b/scripts/benchmark_classifier.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python +""" +Email classifier benchmark — compare HuggingFace models against our 6 labels. + +Usage: + # List available models + conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --list-models + + # Score against labeled JSONL + conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --score + + # Visual comparison on live IMAP emails (uses first account in label_tool.yaml) + conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --compare --limit 20 + + # Include slow/large models + conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --score --include-slow + + # Export DB-labeled emails (⚠️ LLM-generated labels) + conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --export-db --db /path/to/staging.db +""" +from __future__ import annotations + +import argparse +import email as _email_lib +import imaplib +import json +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from scripts.classifier_adapters import ( + LABELS, + LABEL_DESCRIPTIONS, + ClassifierAdapter, + GLiClassAdapter, + RerankerAdapter, + ZeroShotAdapter, + compute_metrics, +) + +# --------------------------------------------------------------------------- +# Model registry +# --------------------------------------------------------------------------- + +MODEL_REGISTRY: dict[str, dict[str, Any]] = { + "deberta-zeroshot": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/DeBERTa-v3-large-zeroshot-v2.0", + "params": "400M", + "default": True, + }, + "deberta-small": { + "adapter": ZeroShotAdapter, + "model_id": "cross-encoder/nli-deberta-v3-small", + "params": "100M", + "default": True, + }, + "gliclass-large": { + "adapter": GLiClassAdapter, + "model_id": "knowledgator/gliclass-instruct-large-v1.0", + "params": "400M", + "default": True, + }, + "bart-mnli": { + "adapter": ZeroShotAdapter, + "model_id": "facebook/bart-large-mnli", + "params": "400M", + "default": True, + }, + "bge-m3-zeroshot": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/bge-m3-zeroshot-v2.0", + "params": "600M", + "default": True, + }, + "deberta-small-2pass": { + "adapter": ZeroShotAdapter, + "model_id": "cross-encoder/nli-deberta-v3-small", + "params": "100M", + "default": True, + "kwargs": {"two_pass": True}, + }, + "deberta-base-anli": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli", + "params": "200M", + "default": True, + }, + "deberta-large-ling": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/DeBERTa-v3-large-mnli-fever-anli-ling-wanli", + "params": "400M", + "default": False, + }, + "mdeberta-xnli-2m": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7", + "params": "300M", + "default": False, + }, + "bge-reranker": { + "adapter": RerankerAdapter, + "model_id": "BAAI/bge-reranker-v2-m3", + "params": "600M", + "default": False, + }, + "deberta-xlarge": { + "adapter": ZeroShotAdapter, + "model_id": "microsoft/deberta-xlarge-mnli", + "params": "750M", + "default": False, + }, + "mdeberta-mnli": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/mDeBERTa-v3-base-mnli-xnli", + "params": "300M", + "default": False, + }, + "xlm-roberta-anli": { + "adapter": ZeroShotAdapter, + "model_id": "vicgalle/xlm-roberta-large-xnli-anli", + "params": "600M", + "default": False, + }, +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def load_scoring_jsonl(path: str) -> list[dict[str, str]]: + """Load labeled examples from a JSONL file for benchmark scoring.""" + p = Path(path) + if not p.exists(): + raise FileNotFoundError( + f"Scoring file not found: {path}\n" + f"Copy data/email_score.jsonl.example → data/email_score.jsonl " + f"or use the label tool (app/label_tool.py) to label your own emails." + ) + rows = [] + with p.open() as f: + for line in f: + line = line.strip() + if line: + rows.append(json.loads(line)) + return rows + + +def _active_models(include_slow: bool) -> dict[str, dict[str, Any]]: + return {k: v for k, v in MODEL_REGISTRY.items() if v["default"] or include_slow} + + +def run_scoring( + adapters: list[ClassifierAdapter], + score_file: str, +) -> dict[str, Any]: + """Run all adapters against a labeled JSONL. Returns per-adapter metrics.""" + rows = load_scoring_jsonl(score_file) + gold = [r["label"] for r in rows] + results: dict[str, Any] = {} + + for adapter in adapters: + preds: list[str] = [] + t0 = time.monotonic() + for row in rows: + try: + pred = adapter.classify(row["subject"], row["body"]) + except Exception as exc: + print(f" [{adapter.name}] ERROR on '{row['subject'][:40]}': {exc}", flush=True) + pred = "neutral" + preds.append(pred) + elapsed_ms = (time.monotonic() - t0) * 1000 + metrics = compute_metrics(preds, gold, LABELS) + metrics["latency_ms"] = round(elapsed_ms / len(rows), 1) + results[adapter.name] = metrics + adapter.unload() + + return results + + +# --------------------------------------------------------------------------- +# IMAP helpers (stdlib only — reads label_tool.yaml, uses first account) +# --------------------------------------------------------------------------- + +_BROAD_TERMS = [ + "interview", "opportunity", "offer letter", + "job offer", "application", "recruiting", +] + + +def _load_imap_config() -> dict[str, Any]: + """Load IMAP config from label_tool.yaml, returning first account as a flat dict.""" + import yaml + cfg_path = Path(__file__).parent.parent / "config" / "label_tool.yaml" + if not cfg_path.exists(): + raise FileNotFoundError( + f"IMAP config not found: {cfg_path}\n" + f"Copy config/label_tool.yaml.example → config/label_tool.yaml" + ) + cfg = yaml.safe_load(cfg_path.read_text()) or {} + accounts = cfg.get("accounts", []) + if not accounts: + raise ValueError("No accounts configured in config/label_tool.yaml") + return accounts[0] + + +def _imap_connect(cfg: dict[str, Any]) -> imaplib.IMAP4_SSL: + conn = imaplib.IMAP4_SSL(cfg["host"], cfg.get("port", 993)) + conn.login(cfg["username"], cfg["password"]) + return conn + + +def _decode_part(part: Any) -> str: + charset = part.get_content_charset() or "utf-8" + try: + return part.get_payload(decode=True).decode(charset, errors="replace") + except Exception: + return "" + + +def _parse_uid(conn: imaplib.IMAP4_SSL, uid: bytes) -> dict[str, str] | None: + try: + _, data = conn.uid("fetch", uid, "(RFC822)") + raw = data[0][1] + msg = _email_lib.message_from_bytes(raw) + subject = str(msg.get("subject", "")).strip() + body = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + body = _decode_part(part) + break + else: + body = _decode_part(msg) + return {"subject": subject, "body": body} + except Exception: + return None + + +def _fetch_imap_sample(limit: int, days: int) -> list[dict[str, str]]: + cfg = _load_imap_config() + conn = _imap_connect(cfg) + since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y") + conn.select("INBOX") + + seen_uids: dict[bytes, None] = {} + for term in _BROAD_TERMS: + _, data = conn.uid("search", None, f'(SUBJECT "{term}" SINCE {since})') + for uid in (data[0] or b"").split(): + seen_uids[uid] = None + + sample = list(seen_uids.keys())[:limit] + emails = [] + for uid in sample: + parsed = _parse_uid(conn, uid) + if parsed: + emails.append(parsed) + try: + conn.logout() + except Exception: + pass + return emails + + +# --------------------------------------------------------------------------- +# DB export +# --------------------------------------------------------------------------- + +def cmd_export_db(args: argparse.Namespace) -> None: + """Export LLM-labeled emails from a peregrine-style job_contacts table → scoring JSONL.""" + import sqlite3 + + db_path = Path(args.db) + if not db_path.exists(): + print(f"ERROR: Database not found: {args.db}", file=sys.stderr) + sys.exit(1) + + conn = sqlite3.connect(db_path) + cur = conn.cursor() + cur.execute(""" + SELECT subject, body, stage_signal + FROM job_contacts + WHERE stage_signal IS NOT NULL + AND stage_signal != '' + AND direction = 'inbound' + ORDER BY received_at + """) + rows = cur.fetchall() + conn.close() + + if not rows: + print("No labeled emails in job_contacts. Run imap_sync first to populate.") + return + + out_path = Path(args.score_file) + out_path.parent.mkdir(parents=True, exist_ok=True) + + written = 0 + skipped = 0 + label_counts: dict[str, int] = {} + with out_path.open("w") as f: + for subject, body, label in rows: + if label not in LABELS: + print(f" SKIP unknown label '{label}': {subject[:50]}") + skipped += 1 + continue + json.dump({"subject": subject or "", "body": (body or "")[:600], "label": label}, f) + f.write("\n") + label_counts[label] = label_counts.get(label, 0) + 1 + written += 1 + + print(f"\nExported {written} emails → {out_path}" + (f" ({skipped} skipped)" if skipped else "")) + print("\nLabel distribution:") + for label in LABELS: + count = label_counts.get(label, 0) + bar = "█" * count + print(f" {label:<25} {count:>3} {bar}") + print( + "\nNOTE: Labels are LLM predictions from imap_sync — review before treating as ground truth." + ) + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + +def cmd_list_models(_args: argparse.Namespace) -> None: + print(f"\n{'Name':<24} {'Params':<8} {'Default':<20} {'Adapter':<15} Model ID") + print("-" * 104) + for name, entry in MODEL_REGISTRY.items(): + adapter_name = entry["adapter"].__name__ + if entry.get("kwargs", {}).get("two_pass"): + adapter_name += " (2-pass)" + default_flag = "yes" if entry["default"] else "(--include-slow)" + print(f"{name:<24} {entry['params']:<8} {default_flag:<20} {adapter_name:<15} {entry['model_id']}") + print() + + +def cmd_score(args: argparse.Namespace) -> None: + active = _active_models(args.include_slow) + if args.models: + active = {k: v for k, v in active.items() if k in args.models} + + adapters = [ + entry["adapter"](name, entry["model_id"], **entry.get("kwargs", {})) + for name, entry in active.items() + ] + + print(f"\nScoring {len(adapters)} model(s) against {args.score_file} …\n") + results = run_scoring(adapters, args.score_file) + + col = 12 + print(f"{'Model':<22}" + f"{'macro-F1':>{col}} {'Accuracy':>{col}} {'ms/email':>{col}}") + print("-" * (22 + col * 3 + 2)) + for name, m in results.items(): + print( + f"{name:<22}" + f"{m['__macro_f1__']:>{col}.3f}" + f"{m['__accuracy__']:>{col}.3f}" + f"{m['latency_ms']:>{col}.1f}" + ) + + print("\nPer-label F1:") + names = list(results.keys()) + print(f"{'Label':<25}" + "".join(f"{n[:11]:>{col}}" for n in names)) + print("-" * (25 + col * len(names))) + for label in LABELS: + row_str = f"{label:<25}" + for m in results.values(): + row_str += f"{m[label]['f1']:>{col}.3f}" + print(row_str) + print() + + +def cmd_compare(args: argparse.Namespace) -> None: + active = _active_models(args.include_slow) + if args.models: + active = {k: v for k, v in active.items() if k in args.models} + + print(f"Fetching up to {args.limit} emails from IMAP …") + emails = _fetch_imap_sample(args.limit, args.days) + print(f"Fetched {len(emails)} emails. Loading {len(active)} model(s) …\n") + + adapters = [ + entry["adapter"](name, entry["model_id"], **entry.get("kwargs", {})) + for name, entry in active.items() + ] + model_names = [a.name for a in adapters] + + col = 22 + subj_w = 50 + print(f"{'Subject':<{subj_w}}" + "".join(f"{n:<{col}}" for n in model_names)) + print("-" * (subj_w + col * len(model_names))) + + for row in emails: + short_subj = row["subject"][:subj_w - 1] if len(row["subject"]) > subj_w else row["subject"] + line = f"{short_subj:<{subj_w}}" + for adapter in adapters: + try: + label = adapter.classify(row["subject"], row["body"]) + except Exception as exc: + label = f"ERR:{str(exc)[:8]}" + line += f"{label:<{col}}" + print(line, flush=True) + + for adapter in adapters: + adapter.unload() + print() + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Benchmark HuggingFace email classifiers against our 6 labels." + ) + parser.add_argument("--list-models", action="store_true", help="Show model registry and exit") + parser.add_argument("--score", action="store_true", help="Score against labeled JSONL") + parser.add_argument("--compare", action="store_true", help="Visual table on live IMAP emails") + parser.add_argument("--export-db", action="store_true", + help="Export labeled emails from a staging.db → score JSONL") + parser.add_argument("--score-file", default="data/email_score.jsonl", help="Path to labeled JSONL") + parser.add_argument("--db", default="data/staging.db", help="Path to staging.db for --export-db") + parser.add_argument("--limit", type=int, default=20, help="Max emails for --compare") + 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") + + args = parser.parse_args() + + if args.list_models: + cmd_list_models(args) + elif args.score: + cmd_score(args) + elif args.compare: + cmd_compare(args) + elif args.export_db: + cmd_export_db(args) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/scripts/classifier_adapters.py b/scripts/classifier_adapters.py new file mode 100644 index 0000000..a74ea34 --- /dev/null +++ b/scripts/classifier_adapters.py @@ -0,0 +1,257 @@ +"""Classifier adapters for email classification benchmark. + +Each adapter wraps a HuggingFace model and normalizes output to LABELS. +Models load lazily on first classify() call; call unload() to free VRAM. +""" +from __future__ import annotations + +import abc +from collections import defaultdict +from typing import Any + +__all__ = [ + "LABELS", + "LABEL_DESCRIPTIONS", + "compute_metrics", + "ClassifierAdapter", + "ZeroShotAdapter", + "GLiClassAdapter", + "RerankerAdapter", +] + +LABELS: list[str] = [ + "interview_scheduled", + "offer_received", + "rejected", + "positive_response", + "survey_received", + "neutral", +] + +# Natural-language descriptions used by the RerankerAdapter. +LABEL_DESCRIPTIONS: dict[str, str] = { + "interview_scheduled": "scheduling an interview, phone screen, or video call", + "offer_received": "a formal job offer or employment offer letter", + "rejected": "application rejected or not moving forward with candidacy", + "positive_response": "positive recruiter interest or request to connect", + "survey_received": "invitation to complete a culture-fit survey or assessment", + "neutral": "automated ATS confirmation or unrelated email", +} + +# Lazy import shims — allow tests to patch without requiring the libs installed. +try: + from transformers import pipeline # type: ignore[assignment] +except ImportError: + pipeline = None # type: ignore[assignment] + +try: + from gliclass import GLiClassModel, ZeroShotClassificationPipeline # type: ignore + from transformers import AutoTokenizer +except ImportError: + GLiClassModel = None # type: ignore + ZeroShotClassificationPipeline = None # type: ignore + AutoTokenizer = None # type: ignore + +try: + from FlagEmbedding import FlagReranker # type: ignore +except ImportError: + FlagReranker = None # type: ignore + + +def _cuda_available() -> bool: + try: + import torch + return torch.cuda.is_available() + except ImportError: + return False + + +def compute_metrics( + predictions: list[str], + gold: list[str], + labels: list[str], +) -> dict[str, Any]: + """Return per-label precision/recall/F1 + macro_f1 + accuracy.""" + tp: dict[str, int] = defaultdict(int) + fp: dict[str, int] = defaultdict(int) + fn: dict[str, int] = defaultdict(int) + + for pred, true in zip(predictions, gold): + if pred == true: + tp[pred] += 1 + else: + fp[pred] += 1 + fn[true] += 1 + + result: dict[str, Any] = {} + for label in labels: + denom_p = tp[label] + fp[label] + denom_r = tp[label] + fn[label] + p = tp[label] / denom_p if denom_p else 0.0 + r = tp[label] / denom_r if denom_r else 0.0 + f1 = 2 * p * r / (p + r) if (p + r) else 0.0 + result[label] = { + "precision": p, + "recall": r, + "f1": f1, + "support": denom_r, + } + + labels_with_support = [label for label in labels if result[label]["support"] > 0] + if labels_with_support: + result["__macro_f1__"] = ( + sum(result[label]["f1"] for label in labels_with_support) / len(labels_with_support) + ) + else: + result["__macro_f1__"] = 0.0 + result["__accuracy__"] = sum(tp.values()) / len(predictions) if predictions else 0.0 + return result + + +class ClassifierAdapter(abc.ABC): + """Abstract base for all email classifier adapters.""" + + @property + @abc.abstractmethod + def name(self) -> str: ... + + @property + @abc.abstractmethod + def model_id(self) -> str: ... + + @abc.abstractmethod + def load(self) -> None: + """Download/load the model into memory.""" + + @abc.abstractmethod + def unload(self) -> None: + """Release model from memory.""" + + @abc.abstractmethod + def classify(self, subject: str, body: str) -> str: + """Return one of LABELS for the given email.""" + + +class ZeroShotAdapter(ClassifierAdapter): + """Wraps any transformers zero-shot-classification pipeline. + + load() calls pipeline("zero-shot-classification", model=..., device=...) to get + an inference callable, stored as self._pipeline. classify() then calls + self._pipeline(text, LABELS, multi_label=False). In tests, patch + 'scripts.classifier_adapters.pipeline' with a MagicMock whose .return_value is + itself a MagicMock(return_value={...}) to simulate both the factory call and the + inference call. + + two_pass: if True, classify() runs a second pass restricted to the top-2 labels + from the first pass, forcing a binary choice. This typically improves confidence + without the accuracy cost of a full 6-label second run. + """ + + def __init__(self, name: str, model_id: str, two_pass: bool = False) -> None: + self._name = name + self._model_id = model_id + self._pipeline: Any = None + self._two_pass = two_pass + + @property + def name(self) -> str: + return self._name + + @property + def model_id(self) -> str: + return self._model_id + + def load(self) -> None: + import scripts.classifier_adapters as _mod # noqa: PLC0415 + _pipe_fn = _mod.pipeline + if _pipe_fn is None: + raise ImportError("transformers not installed — run: pip install transformers") + device = 0 if _cuda_available() else -1 + # Instantiate the pipeline once; classify() calls the resulting object on each text. + self._pipeline = _pipe_fn("zero-shot-classification", model=self._model_id, device=device) + + def unload(self) -> None: + self._pipeline = None + + def classify(self, subject: str, body: str) -> str: + if self._pipeline is None: + self.load() + text = f"Subject: {subject}\n\n{body[:600]}" + result = self._pipeline(text, LABELS, multi_label=False) + if self._two_pass and len(result["labels"]) >= 2: + top2 = result["labels"][:2] + result = self._pipeline(text, top2, multi_label=False) + return result["labels"][0] + + +class GLiClassAdapter(ClassifierAdapter): + """Wraps knowledgator GLiClass models via the gliclass library.""" + + def __init__(self, name: str, model_id: str) -> None: + self._name = name + self._model_id = model_id + self._pipeline: Any = None + + @property + def name(self) -> str: + return self._name + + @property + def model_id(self) -> str: + return self._model_id + + def load(self) -> None: + if GLiClassModel is None: + raise ImportError("gliclass not installed — run: pip install gliclass") + device = "cuda:0" if _cuda_available() else "cpu" + model = GLiClassModel.from_pretrained(self._model_id) + tokenizer = AutoTokenizer.from_pretrained(self._model_id) + self._pipeline = ZeroShotClassificationPipeline( + model, + tokenizer, + classification_type="single-label", + device=device, + ) + + def unload(self) -> None: + self._pipeline = None + + def classify(self, subject: str, body: str) -> str: + if self._pipeline is None: + self.load() + text = f"Subject: {subject}\n\n{body[:600]}" + results = self._pipeline(text, LABELS, threshold=0.0)[0] + return max(results, key=lambda r: r["score"])["label"] + + +class RerankerAdapter(ClassifierAdapter): + """Uses a BGE reranker to score (email, label_description) pairs.""" + + def __init__(self, name: str, model_id: str) -> None: + self._name = name + self._model_id = model_id + self._reranker: Any = None + + @property + def name(self) -> str: + return self._name + + @property + def model_id(self) -> str: + return self._model_id + + def load(self) -> None: + if FlagReranker is None: + raise ImportError("FlagEmbedding not installed — run: pip install FlagEmbedding") + self._reranker = FlagReranker(self._model_id, use_fp16=_cuda_available()) + + def unload(self) -> None: + self._reranker = None + + def classify(self, subject: str, body: str) -> str: + if self._reranker is None: + self.load() + text = f"Subject: {subject}\n\n{body[:600]}" + pairs = [[text, LABEL_DESCRIPTIONS[label]] for label in LABELS] + scores: list[float] = self._reranker.compute_score(pairs, normalize=True) + return LABELS[scores.index(max(scores))] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_benchmark_classifier.py b/tests/test_benchmark_classifier.py new file mode 100644 index 0000000..299e69c --- /dev/null +++ b/tests/test_benchmark_classifier.py @@ -0,0 +1,94 @@ +"""Tests for benchmark_classifier — no model downloads required.""" +import pytest + + +def test_registry_has_thirteen_models(): + from scripts.benchmark_classifier import MODEL_REGISTRY + assert len(MODEL_REGISTRY) == 13 + + +def test_registry_default_count(): + from scripts.benchmark_classifier import MODEL_REGISTRY + defaults = [k for k, v in MODEL_REGISTRY.items() if v["default"]] + assert len(defaults) == 7 + + +def test_registry_entries_have_required_keys(): + from scripts.benchmark_classifier import MODEL_REGISTRY + from scripts.classifier_adapters import ClassifierAdapter + for name, entry in MODEL_REGISTRY.items(): + assert "adapter" in entry, f"{name} missing 'adapter'" + assert "model_id" in entry, f"{name} missing 'model_id'" + assert "params" in entry, f"{name} missing 'params'" + assert "default" in entry, f"{name} missing 'default'" + assert issubclass(entry["adapter"], ClassifierAdapter), \ + f"{name} adapter must be a ClassifierAdapter subclass" + + +def test_load_scoring_jsonl(tmp_path): + from scripts.benchmark_classifier import load_scoring_jsonl + import json + f = tmp_path / "score.jsonl" + rows = [ + {"subject": "Hi", "body": "Body text", "label": "neutral"}, + {"subject": "Interview", "body": "Schedule a call", "label": "interview_scheduled"}, + ] + f.write_text("\n".join(json.dumps(r) for r in rows)) + result = load_scoring_jsonl(str(f)) + assert len(result) == 2 + assert result[0]["label"] == "neutral" + + +def test_load_scoring_jsonl_missing_file(): + from scripts.benchmark_classifier import load_scoring_jsonl + with pytest.raises(FileNotFoundError): + load_scoring_jsonl("/nonexistent/path.jsonl") + + +def test_run_scoring_with_mock_adapters(tmp_path): + """run_scoring() returns per-model metrics using mock adapters.""" + import json + from unittest.mock import MagicMock + from scripts.benchmark_classifier import run_scoring + + score_file = tmp_path / "score.jsonl" + rows = [ + {"subject": "Interview", "body": "Let's schedule", "label": "interview_scheduled"}, + {"subject": "Sorry", "body": "We went with others", "label": "rejected"}, + {"subject": "Offer", "body": "We are pleased", "label": "offer_received"}, + ] + score_file.write_text("\n".join(json.dumps(r) for r in rows)) + + perfect = MagicMock() + perfect.name = "perfect" + perfect.classify.side_effect = lambda s, b: ( + "interview_scheduled" if "Interview" in s else + "rejected" if "Sorry" in s else "offer_received" + ) + + bad = MagicMock() + bad.name = "bad" + bad.classify.return_value = "neutral" + + results = run_scoring([perfect, bad], str(score_file)) + + assert results["perfect"]["__accuracy__"] == pytest.approx(1.0) + assert results["bad"]["__accuracy__"] == pytest.approx(0.0) + assert "latency_ms" in results["perfect"] + + +def test_run_scoring_handles_classify_error(tmp_path): + """run_scoring() falls back to 'neutral' on exception and continues.""" + import json + from unittest.mock import MagicMock + from scripts.benchmark_classifier import run_scoring + + score_file = tmp_path / "score.jsonl" + score_file.write_text(json.dumps({"subject": "Hi", "body": "Body", "label": "neutral"})) + + broken = MagicMock() + broken.name = "broken" + broken.classify.side_effect = RuntimeError("model crashed") + + results = run_scoring([broken], str(score_file)) + assert "broken" in results diff --git a/tests/test_classifier_adapters.py b/tests/test_classifier_adapters.py new file mode 100644 index 0000000..1e1c36a --- /dev/null +++ b/tests/test_classifier_adapters.py @@ -0,0 +1,177 @@ +"""Tests for classifier_adapters — no model downloads required.""" +import pytest + + +def test_labels_constant_has_six_items(): + from scripts.classifier_adapters import LABELS + assert len(LABELS) == 6 + assert "interview_scheduled" in LABELS + assert "neutral" in LABELS + + +def test_compute_metrics_perfect_predictions(): + from scripts.classifier_adapters import compute_metrics, LABELS + gold = ["rejected", "interview_scheduled", "neutral"] + preds = ["rejected", "interview_scheduled", "neutral"] + m = compute_metrics(preds, gold, LABELS) + assert m["rejected"]["f1"] == pytest.approx(1.0) + assert m["__accuracy__"] == pytest.approx(1.0) + assert m["__macro_f1__"] == pytest.approx(1.0) + + +def test_compute_metrics_all_wrong(): + from scripts.classifier_adapters import compute_metrics, LABELS + gold = ["rejected", "rejected"] + preds = ["neutral", "interview_scheduled"] + m = compute_metrics(preds, gold, LABELS) + assert m["rejected"]["recall"] == pytest.approx(0.0) + assert m["__accuracy__"] == pytest.approx(0.0) + + +def test_compute_metrics_partial(): + from scripts.classifier_adapters import compute_metrics, LABELS + gold = ["rejected", "neutral", "rejected"] + preds = ["rejected", "neutral", "interview_scheduled"] + m = compute_metrics(preds, gold, LABELS) + assert m["rejected"]["precision"] == pytest.approx(1.0) + assert m["rejected"]["recall"] == pytest.approx(0.5) + assert m["neutral"]["f1"] == pytest.approx(1.0) + assert m["__accuracy__"] == pytest.approx(2 / 3) + + +def test_compute_metrics_empty(): + from scripts.classifier_adapters import compute_metrics, LABELS + m = compute_metrics([], [], LABELS) + assert m["__accuracy__"] == pytest.approx(0.0) + + +def test_classifier_adapter_is_abstract(): + from scripts.classifier_adapters import ClassifierAdapter + with pytest.raises(TypeError): + ClassifierAdapter() + + +# ---- ZeroShotAdapter tests ---- + +def test_zeroshot_adapter_classify_mocked(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import ZeroShotAdapter + + # Two-level mock: factory call returns pipeline instance; instance call returns inference result. + mock_pipe_factory = MagicMock() + mock_pipe_factory.return_value = MagicMock(return_value={ + "labels": ["rejected", "neutral", "interview_scheduled"], + "scores": [0.85, 0.10, 0.05], + }) + + with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory): + adapter = ZeroShotAdapter("test-zs", "some/model") + adapter.load() + result = adapter.classify("We went with another candidate", "Thank you for applying.") + + assert result == "rejected" + # Factory was called with the correct task type + assert mock_pipe_factory.call_args[0][0] == "zero-shot-classification" + # Pipeline instance was called with the email text + assert "We went with another candidate" in mock_pipe_factory.return_value.call_args[0][0] + + +def test_zeroshot_adapter_unload_clears_pipeline(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import ZeroShotAdapter + + with patch("scripts.classifier_adapters.pipeline", MagicMock()): + adapter = ZeroShotAdapter("test-zs", "some/model") + adapter.load() + assert adapter._pipeline is not None + adapter.unload() + assert adapter._pipeline is None + + +def test_zeroshot_adapter_lazy_loads(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import ZeroShotAdapter + + mock_pipe_factory = MagicMock() + mock_pipe_factory.return_value = MagicMock(return_value={ + "labels": ["neutral"], "scores": [1.0] + }) + + with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory): + adapter = ZeroShotAdapter("test-zs", "some/model") + adapter.classify("subject", "body") + + mock_pipe_factory.assert_called_once() + + +# ---- GLiClassAdapter tests ---- + +def test_gliclass_adapter_classify_mocked(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import GLiClassAdapter + + mock_pipeline_instance = MagicMock() + mock_pipeline_instance.return_value = [[ + {"label": "interview_scheduled", "score": 0.91}, + {"label": "neutral", "score": 0.05}, + {"label": "rejected", "score": 0.04}, + ]] + + with patch("scripts.classifier_adapters.GLiClassModel") as _mc, \ + patch("scripts.classifier_adapters.AutoTokenizer") as _mt, \ + patch("scripts.classifier_adapters.ZeroShotClassificationPipeline", + return_value=mock_pipeline_instance): + adapter = GLiClassAdapter("test-gli", "some/gliclass-model") + adapter.load() + result = adapter.classify("Interview invitation", "Let's schedule a call.") + + assert result == "interview_scheduled" + + +def test_gliclass_adapter_returns_highest_score(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import GLiClassAdapter + + mock_pipeline_instance = MagicMock() + mock_pipeline_instance.return_value = [[ + {"label": "neutral", "score": 0.02}, + {"label": "offer_received", "score": 0.88}, + {"label": "rejected", "score": 0.10}, + ]] + + with patch("scripts.classifier_adapters.GLiClassModel"), \ + patch("scripts.classifier_adapters.AutoTokenizer"), \ + patch("scripts.classifier_adapters.ZeroShotClassificationPipeline", + return_value=mock_pipeline_instance): + adapter = GLiClassAdapter("test-gli", "some/model") + adapter.load() + result = adapter.classify("Offer letter enclosed", "Dear Meghan, we are pleased to offer...") + + assert result == "offer_received" + + +# ---- RerankerAdapter tests ---- + +def test_reranker_adapter_picks_highest_score(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import RerankerAdapter, LABELS + + mock_reranker = MagicMock() + mock_reranker.compute_score.return_value = [0.1, 0.05, 0.85, 0.05, 0.02, 0.03] + + with patch("scripts.classifier_adapters.FlagReranker", return_value=mock_reranker): + adapter = RerankerAdapter("test-rr", "BAAI/bge-reranker-v2-m3") + adapter.load() + result = adapter.classify( + "We regret to inform you", + "After careful consideration we are moving forward with other candidates.", + ) + + assert result == "rejected" + pairs = mock_reranker.compute_score.call_args[0][0] + assert len(pairs) == len(LABELS) + + +def test_reranker_adapter_descriptions_cover_all_labels(): + from scripts.classifier_adapters import LABEL_DESCRIPTIONS, LABELS + assert set(LABEL_DESCRIPTIONS.keys()) == set(LABELS) -- 2.45.2 From d68754d43271d167b3117c0f1d2651d677be85c0 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:07:38 -0800 Subject: [PATCH 002/103] =?UTF-8?q?feat:=20initial=20avocet=20repo=20?= =?UTF-8?q?=E2=80=94=20email=20classifier=20training=20tool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scrape → Store → Process pipeline for building email classifier benchmark data across the CircuitForge menagerie. - app/label_tool.py — Streamlit card-stack UI, multi-account IMAP fetch, 6-bucket labeling, undo/skip, keyboard shortcuts (1-6/S/U) - scripts/classifier_adapters.py — ZeroShotAdapter (+ two_pass), GLiClassAdapter, RerankerAdapter; ABC with lazy model loading - scripts/benchmark_classifier.py — 13-model registry, --score, --compare, --list-models, --export-db; uses label_tool.yaml for IMAP - tests/ — 20 tests, all passing, zero model downloads required - config/label_tool.yaml.example — multi-account IMAP template - data/email_score.jsonl.example — sample labeled data for CI Labels: interview_scheduled, offer_received, rejected, positive_response, survey_received, neutral --- .gitignore | 16 + app/label_tool.py | 568 +++++++++++++++++++++++++++++ config/label_tool.yaml.example | 23 ++ data/.gitkeep | 0 data/email_score.jsonl.example | 8 + environment.yml | 25 ++ pytest.ini | 5 + scripts/__init__.py | 0 scripts/benchmark_classifier.py | 450 +++++++++++++++++++++++ scripts/classifier_adapters.py | 257 +++++++++++++ tests/__init__.py | 0 tests/test_benchmark_classifier.py | 94 +++++ tests/test_classifier_adapters.py | 177 +++++++++ 13 files changed, 1623 insertions(+) create mode 100644 .gitignore create mode 100644 app/label_tool.py create mode 100644 config/label_tool.yaml.example create mode 100644 data/.gitkeep create mode 100644 data/email_score.jsonl.example create mode 100644 environment.yml create mode 100644 pytest.ini create mode 100644 scripts/__init__.py create mode 100644 scripts/benchmark_classifier.py create mode 100644 scripts/classifier_adapters.py create mode 100644 tests/__init__.py create mode 100644 tests/test_benchmark_classifier.py create mode 100644 tests/test_classifier_adapters.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..353eafc --- /dev/null +++ b/.gitignore @@ -0,0 +1,16 @@ +__pycache__/ +*.pyc +.pytest_cache/ +.coverage +*.egg-info/ + +# Secrets and personal data +config/label_tool.yaml + +# Data files (user-generated, not for version control) +data/email_score.jsonl +data/email_label_queue.jsonl +data/email_compare_sample.jsonl + +# Conda/pip artifacts +.env diff --git a/app/label_tool.py b/app/label_tool.py new file mode 100644 index 0000000..a4a2fdd --- /dev/null +++ b/app/label_tool.py @@ -0,0 +1,568 @@ +"""Email Label Tool — card-stack UI for building classifier benchmark data. + +Philosophy: Scrape → Store → Process + Fetch (IMAP, wide search, multi-account) → data/email_label_queue.jsonl + Label (card stack) → data/email_score.jsonl + +Run: + conda run -n job-seeker streamlit run app/label_tool.py --server.port 8503 + +Config: config/label_tool.yaml (gitignored — see config/label_tool.yaml.example) +""" +from __future__ import annotations + +import email as _email_lib +import hashlib +import imaplib +import json +import sys +from datetime import datetime, timedelta +from email.header import decode_header as _raw_decode +from pathlib import Path +from typing import Any + +import streamlit as st +import yaml + +# ── Path setup ───────────────────────────────────────────────────────────── +_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(_ROOT)) + +_QUEUE_FILE = _ROOT / "data" / "email_label_queue.jsonl" +_SCORE_FILE = _ROOT / "data" / "email_score.jsonl" +_CFG_FILE = _ROOT / "config" / "label_tool.yaml" + +# ── Labels ───────────────────────────────────────────────────────────────── +LABELS = [ + "interview_scheduled", + "offer_received", + "rejected", + "positive_response", + "survey_received", + "neutral", +] + +_LABEL_META: dict[str, dict] = { + "interview_scheduled": {"emoji": "🗓️", "color": "#4CAF50", "key": "1"}, + "offer_received": {"emoji": "🎉", "color": "#2196F3", "key": "2"}, + "rejected": {"emoji": "❌", "color": "#F44336", "key": "3"}, + "positive_response": {"emoji": "👍", "color": "#FF9800", "key": "4"}, + "survey_received": {"emoji": "📋", "color": "#9C27B0", "key": "5"}, + "neutral": {"emoji": "⬜", "color": "#607D8B", "key": "6"}, +} + +# ── Wide IMAP search terms (cast a net across all 6 categories) ───────────── +_WIDE_TERMS = [ + # interview_scheduled + "interview", "phone screen", "video call", "zoom link", "schedule a call", + # offer_received + "offer letter", "job offer", "offer of employment", "pleased to offer", + # rejected + "unfortunately", "not moving forward", "other candidates", "regret to inform", + "no longer", "decided not to", "decided to go with", + # positive_response + "opportunity", "interested in your background", "reached out", "great fit", + "exciting role", "love to connect", + # survey_received + "assessment", "questionnaire", "culture fit", "culture-fit", "online assessment", + # neutral / ATS confirms + "application received", "thank you for applying", "application confirmation", + "you applied", "your application for", + # general recruitment + "application", "recruiter", "recruiting", "hiring", "candidate", +] + + +# ── IMAP helpers ──────────────────────────────────────────────────────────── + +def _decode_str(value: str | None) -> str: + if not value: + return "" + parts = _raw_decode(value) + out = [] + for part, enc in parts: + if isinstance(part, bytes): + out.append(part.decode(enc or "utf-8", errors="replace")) + else: + out.append(str(part)) + return " ".join(out).strip() + + +def _extract_body(msg: Any) -> str: + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + try: + charset = part.get_content_charset() or "utf-8" + return part.get_payload(decode=True).decode(charset, errors="replace") + except Exception: + pass + else: + try: + charset = msg.get_content_charset() or "utf-8" + return msg.get_payload(decode=True).decode(charset, errors="replace") + except Exception: + pass + return "" + + +def _fetch_account(cfg: dict, days: int, limit: int, known_keys: set[str], + progress_cb=None) -> list[dict]: + """Fetch emails from one IMAP account using wide recruitment search terms.""" + since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y") + host = cfg.get("host", "imap.gmail.com") + port = int(cfg.get("port", 993)) + use_ssl = cfg.get("use_ssl", True) + username = cfg["username"] + password = cfg["password"] + name = cfg.get("name", username) + + conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port) + conn.login(username, password) + + seen_uids: dict[bytes, None] = {} + conn.select("INBOX", readonly=True) + for term in _WIDE_TERMS: + try: + _, data = conn.search(None, f'(SUBJECT "{term}" SINCE "{since}")') + for uid in (data[0] or b"").split(): + seen_uids[uid] = None + except Exception: + pass + + emails: list[dict] = [] + uids = list(seen_uids.keys())[:limit * 3] # overfetch; filter after dedup + for i, uid in enumerate(uids): + if len(emails) >= limit: + break + if progress_cb: + progress_cb(i / len(uids), f"{name}: {len(emails)} fetched…") + try: + _, raw_data = conn.fetch(uid, "(RFC822)") + if not raw_data or not raw_data[0]: + continue + msg = _email_lib.message_from_bytes(raw_data[0][1]) + subj = _decode_str(msg.get("Subject", "")) + from_addr = _decode_str(msg.get("From", "")) + date = _decode_str(msg.get("Date", "")) + body = _extract_body(msg)[:800] + entry = { + "subject": subj, + "body": body, + "from_addr": from_addr, + "date": date, + "account": name, + } + key = _entry_key(entry) + if key not in known_keys: + known_keys.add(key) + emails.append(entry) + except Exception: + pass + + try: + conn.logout() + except Exception: + pass + return emails + + +# ── Queue / score file helpers ─────────────────────────────────────────────── + +def _entry_key(e: dict) -> str: + return hashlib.md5( + (e.get("subject", "") + (e.get("body") or "")[:100]).encode() + ).hexdigest() + + +def _load_jsonl(path: Path) -> list[dict]: + if not path.exists(): + return [] + rows = [] + with path.open() as f: + for line in f: + line = line.strip() + if line: + try: + rows.append(json.loads(line)) + except Exception: + pass + return rows + + +def _save_jsonl(path: Path, rows: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w") as f: + for row in rows: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +def _append_jsonl(path: Path, row: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a") as f: + f.write(json.dumps(row, ensure_ascii=False) + "\n") + + +# ── Config ────────────────────────────────────────────────────────────────── + +def _load_config() -> list[dict]: + if not _CFG_FILE.exists(): + return [] + cfg = yaml.safe_load(_CFG_FILE.read_text()) or {} + return cfg.get("accounts", []) + + +# ── Page setup ────────────────────────────────────────────────────────────── + +st.set_page_config( + page_title="Avocet — Email Labeler", + page_icon="📬", + layout="wide", +) + +st.markdown(""" + +""", unsafe_allow_html=True) + +st.title("📬 Avocet — Email Label Tool") +st.caption("Scrape → Store → Process | card-stack edition") + +# ── Session state init ─────────────────────────────────────────────────────── + +if "queue" not in st.session_state: + st.session_state.queue: list[dict] = _load_jsonl(_QUEUE_FILE) + +if "labeled" not in st.session_state: + st.session_state.labeled: list[dict] = _load_jsonl(_SCORE_FILE) + st.session_state.labeled_keys: set[str] = { + _entry_key(r) for r in st.session_state.labeled + } + +if "idx" not in st.session_state: + # Start past already-labeled entries in the queue + labeled_keys = st.session_state.labeled_keys + for i, entry in enumerate(st.session_state.queue): + if _entry_key(entry) not in labeled_keys: + st.session_state.idx = i + break + else: + st.session_state.idx = len(st.session_state.queue) + +if "history" not in st.session_state: + st.session_state.history: list[tuple[int, str]] = [] # (queue_idx, label) + + +# ── Sidebar stats ──────────────────────────────────────────────────────────── + +with st.sidebar: + labeled = st.session_state.labeled + queue = st.session_state.queue + unlabeled = [e for e in queue if _entry_key(e) not in st.session_state.labeled_keys] + + st.metric("✅ Labeled", len(labeled)) + st.metric("📥 Queue", len(unlabeled)) + + if labeled: + st.caption("**Label distribution**") + counts = {lbl: 0 for lbl in LABELS} + for r in labeled: + counts[r.get("label", "")] = counts.get(r.get("label", ""), 0) + 1 + for lbl in LABELS: + m = _LABEL_META[lbl] + st.caption(f"{m['emoji']} {lbl}: **{counts[lbl]}**") + + +# ── Tabs ───────────────────────────────────────────────────────────────────── + +tab_label, tab_fetch, tab_stats = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats"]) + + +# ══════════════════════════════════════════════════════════════════════════════ +# FETCH TAB +# ══════════════════════════════════════════════════════════════════════════════ + +with tab_fetch: + accounts = _load_config() + + if not accounts: + st.warning( + f"No accounts configured. Copy `config/label_tool.yaml.example` → " + f"`config/label_tool.yaml` and add your IMAP accounts.", + icon="⚠️", + ) + else: + st.markdown(f"**{len(accounts)} account(s) configured:**") + for acc in accounts: + st.caption(f"• {acc.get('name', acc.get('username'))} ({acc.get('host')})") + + col_days, col_limit = st.columns(2) + days = col_days.number_input("Days back", min_value=7, max_value=730, value=180) + limit = col_limit.number_input("Max emails per account", min_value=10, max_value=1000, value=150) + + all_accs = [a.get("name", a.get("username")) for a in accounts] + selected = st.multiselect("Accounts to fetch", all_accs, default=all_accs) + + if st.button("📥 Fetch from IMAP", disabled=not accounts or not selected, type="primary"): + existing_keys = {_entry_key(e) for e in st.session_state.queue} + existing_keys.update(st.session_state.labeled_keys) + + fetched_all: list[dict] = [] + status = st.status("Fetching…", expanded=True) + + for acc in accounts: + name = acc.get("name", acc.get("username")) + if name not in selected: + continue + status.write(f"Connecting to **{name}**…") + try: + emails = _fetch_account( + acc, days=int(days), limit=int(limit), + known_keys=existing_keys, + progress_cb=lambda p, msg: status.write(msg), + ) + fetched_all.extend(emails) + status.write(f"✓ {name}: {len(emails)} new emails") + except Exception as e: + status.write(f"✗ {name}: {e}") + + if fetched_all: + _save_jsonl(_QUEUE_FILE, st.session_state.queue + fetched_all) + st.session_state.queue = _load_jsonl(_QUEUE_FILE) + # Reset idx to first unlabeled + labeled_keys = st.session_state.labeled_keys + for i, entry in enumerate(st.session_state.queue): + if _entry_key(entry) not in labeled_keys: + st.session_state.idx = i + break + status.update(label=f"Done — {len(fetched_all)} new emails added to queue", state="complete") + else: + status.update(label="No new emails found (all already in queue or score file)", state="complete") + + +# ══════════════════════════════════════════════════════════════════════════════ +# LABEL TAB +# ══════════════════════════════════════════════════════════════════════════════ + +with tab_label: + queue = st.session_state.queue + labeled_keys = st.session_state.labeled_keys + idx = st.session_state.idx + + # Advance idx past already-labeled entries + while idx < len(queue) and _entry_key(queue[idx]) in labeled_keys: + idx += 1 + st.session_state.idx = idx + + unlabeled = [e for e in queue if _entry_key(e) not in labeled_keys] + total_in_queue = len(queue) + n_labeled = len(st.session_state.labeled) + + if not queue: + st.info("Queue is empty — go to **Fetch** to pull emails from IMAP.", icon="📥") + elif not unlabeled: + st.success( + f"🎉 All {n_labeled} emails labeled! Go to **Stats** to review and export.", + icon="✅", + ) + else: + # Progress + labeled_in_queue = total_in_queue - len(unlabeled) + progress_pct = labeled_in_queue / total_in_queue if total_in_queue else 0 + st.progress(progress_pct, text=f"{labeled_in_queue} / {total_in_queue} labeled in queue") + + # Current email + entry = queue[idx] + + # Card HTML + subj = entry.get("subject", "(no subject)") or "(no subject)" + from_ = entry.get("from_addr", "") or "" + date_ = entry.get("date", "") or "" + acct = entry.get("account", "") or "" + body = (entry.get("body") or "").strip() + + st.markdown( + f"""""", + unsafe_allow_html=True, + ) + if len(body) > 500: + with st.expander("Show full body"): + st.text(body) + + # Stack hint (visual depth) + st.markdown('
', unsafe_allow_html=True) + st.markdown('
', unsafe_allow_html=True) + + st.markdown("") # spacer + + # ── Bucket buttons ──────────────────────────────────────────────── + def _do_label(label: str) -> None: + row = {"subject": entry.get("subject", ""), "body": body[:600], "label": label} + st.session_state.labeled.append(row) + st.session_state.labeled_keys.add(_entry_key(entry)) + _append_jsonl(_SCORE_FILE, row) + st.session_state.history.append((idx, label)) + # Advance + next_idx = idx + 1 + while next_idx < len(queue) and _entry_key(queue[next_idx]) in labeled_keys: + next_idx += 1 + st.session_state.idx = next_idx + + row1_cols = st.columns(3) + row2_cols = st.columns(3) + bucket_pairs = [ + (row1_cols[0], "interview_scheduled"), + (row1_cols[1], "offer_received"), + (row1_cols[2], "rejected"), + (row2_cols[0], "positive_response"), + (row2_cols[1], "survey_received"), + (row2_cols[2], "neutral"), + ] + for col, lbl in bucket_pairs: + m = _LABEL_META[lbl] + counts = {l: 0 for l in LABELS} + for r in st.session_state.labeled: + counts[r.get("label", "")] = counts.get(r.get("label", ""), 0) + 1 + label_display = f"{m['emoji']} **{lbl}** [{counts[lbl]}]\n`{m['key']}`" + if col.button(label_display, key=f"lbl_{lbl}", use_container_width=True): + _do_label(lbl) + st.rerun() + + # ── Navigation ──────────────────────────────────────────────────── + st.markdown("") + nav_cols = st.columns([2, 1, 1]) + + remaining = len(unlabeled) - 1 + nav_cols[0].caption(f"**{remaining}** remaining · Keys: 1–6 = label, S = skip, U = undo") + + if nav_cols[1].button("↩ Undo", disabled=not st.session_state.history, use_container_width=True): + prev_idx, prev_label = st.session_state.history.pop() + # Remove the last labeled entry + if st.session_state.labeled: + removed = st.session_state.labeled.pop() + st.session_state.labeled_keys.discard(_entry_key(removed)) + _save_jsonl(_SCORE_FILE, st.session_state.labeled) + st.session_state.idx = prev_idx + st.rerun() + + if nav_cols[2].button("→ Skip", use_container_width=True): + next_idx = idx + 1 + while next_idx < len(queue) and _entry_key(queue[next_idx]) in labeled_keys: + next_idx += 1 + st.session_state.idx = next_idx + st.rerun() + + # Keyboard shortcut capture (JS → hidden button click) + st.components.v1.html( + """""", + height=0, + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# STATS TAB +# ══════════════════════════════════════════════════════════════════════════════ + +with tab_stats: + labeled = st.session_state.labeled + + if not labeled: + st.info("No labeled emails yet.") + else: + counts = {lbl: 0 for lbl in LABELS} + for r in labeled: + lbl = r.get("label", "") + if lbl in counts: + counts[lbl] += 1 + + st.markdown(f"**{len(labeled)} labeled emails total**") + + for lbl in LABELS: + m = _LABEL_META[lbl] + col_name, col_bar, col_n = st.columns([3, 5, 1]) + col_name.markdown(f"{m['emoji']} {lbl}") + col_bar.progress(counts[lbl] / max(counts.values()) if counts.values() else 0) + col_n.markdown(f"**{counts[lbl]}**") + + st.divider() + + st.caption( + f"Score file: `{_SCORE_FILE.relative_to(_ROOT)}` " + f"({_SCORE_FILE.stat().st_size if _SCORE_FILE.exists() else 0:,} bytes)" + ) + if st.button("🔄 Re-sync from disk"): + st.session_state.labeled = _load_jsonl(_SCORE_FILE) + st.session_state.labeled_keys = {_entry_key(r) for r in st.session_state.labeled} + st.rerun() + + if _SCORE_FILE.exists(): + st.download_button( + "⬇️ Download email_score.jsonl", + data=_SCORE_FILE.read_bytes(), + file_name="email_score.jsonl", + mime="application/jsonlines", + ) diff --git a/config/label_tool.yaml.example b/config/label_tool.yaml.example new file mode 100644 index 0000000..8f80b18 --- /dev/null +++ b/config/label_tool.yaml.example @@ -0,0 +1,23 @@ +# config/label_tool.yaml — Multi-account IMAP config for the email label tool +# Copy to config/label_tool.yaml and fill in your credentials. +# This file is gitignored. + +accounts: + - name: "Gmail" + host: "imap.gmail.com" + port: 993 + username: "you@gmail.com" + password: "your-app-password" # Use an App Password, not your login password + folder: "INBOX" + days_back: 90 + + - name: "Outlook" + host: "outlook.office365.com" + port: 993 + username: "you@outlook.com" + password: "your-app-password" + folder: "INBOX" + days_back: 90 + +# Optional: limit emails fetched per account per run (0 = unlimited) +max_per_account: 500 diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/email_score.jsonl.example b/data/email_score.jsonl.example new file mode 100644 index 0000000..d2b313a --- /dev/null +++ b/data/email_score.jsonl.example @@ -0,0 +1,8 @@ +{"subject": "Interview Invitation — Senior Engineer", "body": "Hi Meghan, we'd love to schedule a 30-min phone screen. Are you available Thursday at 2pm? Please reply to confirm.", "label": "interview_scheduled"} +{"subject": "Your application to Acme Corp", "body": "Thank you for your interest in the Senior Engineer role. After careful consideration, we have decided to move forward with other candidates whose experience more closely matches our current needs.", "label": "rejected"} +{"subject": "Offer Letter — Product Manager at Initech", "body": "Dear Meghan, we are thrilled to extend an offer of employment for the Product Manager position. Please find the attached offer letter outlining compensation and start date.", "label": "offer_received"} +{"subject": "Quick question about your background", "body": "Hi Meghan, I came across your profile and would love to connect. We have a few roles that seem like a great match. Would you be open to a brief chat this week?", "label": "positive_response"} +{"subject": "Company Culture Survey — Acme Corp", "body": "Meghan, as part of our evaluation process, we invite all candidates to complete our culture fit assessment. The survey takes approximately 15 minutes. Please click the link below.", "label": "survey_received"} +{"subject": "Application Received — DataCo", "body": "Thank you for submitting your application for the Data Engineer role at DataCo. We have received your materials and will be in touch if your qualifications match our needs.", "label": "neutral"} +{"subject": "Following up on your application", "body": "Hi Meghan, I wanted to follow up on your recent application. Your background looks interesting and we'd like to learn more. Can we set up a quick call?", "label": "positive_response"} +{"subject": "We're moving forward with other candidates", "body": "Dear Meghan, thank you for taking the time to interview with us. After thoughtful consideration, we have decided not to move forward with your candidacy at this time.", "label": "rejected"} diff --git a/environment.yml b/environment.yml new file mode 100644 index 0000000..eeec142 --- /dev/null +++ b/environment.yml @@ -0,0 +1,25 @@ +name: job-seeker-classifiers +channels: + - conda-forge + - defaults +dependencies: + - python=3.11 + - pip + - pip: + # UI + - streamlit>=1.32 + - pyyaml>=6.0 + + # Classifier backends (heavy — install selectively) + - transformers>=4.40 + - torch>=2.2 + - accelerate>=0.27 + + # Optional: GLiClass adapter + # - gliclass + + # Optional: BGE reranker adapter + # - FlagEmbedding + + # Dev + - pytest>=8.0 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4ecb1ad --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/benchmark_classifier.py b/scripts/benchmark_classifier.py new file mode 100644 index 0000000..3f661a6 --- /dev/null +++ b/scripts/benchmark_classifier.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python +""" +Email classifier benchmark — compare HuggingFace models against our 6 labels. + +Usage: + # List available models + conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --list-models + + # Score against labeled JSONL + conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --score + + # Visual comparison on live IMAP emails (uses first account in label_tool.yaml) + conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --compare --limit 20 + + # Include slow/large models + conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --score --include-slow + + # Export DB-labeled emails (⚠️ LLM-generated labels) + conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --export-db --db /path/to/staging.db +""" +from __future__ import annotations + +import argparse +import email as _email_lib +import imaplib +import json +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from scripts.classifier_adapters import ( + LABELS, + LABEL_DESCRIPTIONS, + ClassifierAdapter, + GLiClassAdapter, + RerankerAdapter, + ZeroShotAdapter, + compute_metrics, +) + +# --------------------------------------------------------------------------- +# Model registry +# --------------------------------------------------------------------------- + +MODEL_REGISTRY: dict[str, dict[str, Any]] = { + "deberta-zeroshot": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/DeBERTa-v3-large-zeroshot-v2.0", + "params": "400M", + "default": True, + }, + "deberta-small": { + "adapter": ZeroShotAdapter, + "model_id": "cross-encoder/nli-deberta-v3-small", + "params": "100M", + "default": True, + }, + "gliclass-large": { + "adapter": GLiClassAdapter, + "model_id": "knowledgator/gliclass-instruct-large-v1.0", + "params": "400M", + "default": True, + }, + "bart-mnli": { + "adapter": ZeroShotAdapter, + "model_id": "facebook/bart-large-mnli", + "params": "400M", + "default": True, + }, + "bge-m3-zeroshot": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/bge-m3-zeroshot-v2.0", + "params": "600M", + "default": True, + }, + "deberta-small-2pass": { + "adapter": ZeroShotAdapter, + "model_id": "cross-encoder/nli-deberta-v3-small", + "params": "100M", + "default": True, + "kwargs": {"two_pass": True}, + }, + "deberta-base-anli": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/DeBERTa-v3-base-mnli-fever-anli", + "params": "200M", + "default": True, + }, + "deberta-large-ling": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/DeBERTa-v3-large-mnli-fever-anli-ling-wanli", + "params": "400M", + "default": False, + }, + "mdeberta-xnli-2m": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7", + "params": "300M", + "default": False, + }, + "bge-reranker": { + "adapter": RerankerAdapter, + "model_id": "BAAI/bge-reranker-v2-m3", + "params": "600M", + "default": False, + }, + "deberta-xlarge": { + "adapter": ZeroShotAdapter, + "model_id": "microsoft/deberta-xlarge-mnli", + "params": "750M", + "default": False, + }, + "mdeberta-mnli": { + "adapter": ZeroShotAdapter, + "model_id": "MoritzLaurer/mDeBERTa-v3-base-mnli-xnli", + "params": "300M", + "default": False, + }, + "xlm-roberta-anli": { + "adapter": ZeroShotAdapter, + "model_id": "vicgalle/xlm-roberta-large-xnli-anli", + "params": "600M", + "default": False, + }, +} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def load_scoring_jsonl(path: str) -> list[dict[str, str]]: + """Load labeled examples from a JSONL file for benchmark scoring.""" + p = Path(path) + if not p.exists(): + raise FileNotFoundError( + f"Scoring file not found: {path}\n" + f"Copy data/email_score.jsonl.example → data/email_score.jsonl " + f"or use the label tool (app/label_tool.py) to label your own emails." + ) + rows = [] + with p.open() as f: + for line in f: + line = line.strip() + if line: + rows.append(json.loads(line)) + return rows + + +def _active_models(include_slow: bool) -> dict[str, dict[str, Any]]: + return {k: v for k, v in MODEL_REGISTRY.items() if v["default"] or include_slow} + + +def run_scoring( + adapters: list[ClassifierAdapter], + score_file: str, +) -> dict[str, Any]: + """Run all adapters against a labeled JSONL. Returns per-adapter metrics.""" + rows = load_scoring_jsonl(score_file) + gold = [r["label"] for r in rows] + results: dict[str, Any] = {} + + for adapter in adapters: + preds: list[str] = [] + t0 = time.monotonic() + for row in rows: + try: + pred = adapter.classify(row["subject"], row["body"]) + except Exception as exc: + print(f" [{adapter.name}] ERROR on '{row['subject'][:40]}': {exc}", flush=True) + pred = "neutral" + preds.append(pred) + elapsed_ms = (time.monotonic() - t0) * 1000 + metrics = compute_metrics(preds, gold, LABELS) + metrics["latency_ms"] = round(elapsed_ms / len(rows), 1) + results[adapter.name] = metrics + adapter.unload() + + return results + + +# --------------------------------------------------------------------------- +# IMAP helpers (stdlib only — reads label_tool.yaml, uses first account) +# --------------------------------------------------------------------------- + +_BROAD_TERMS = [ + "interview", "opportunity", "offer letter", + "job offer", "application", "recruiting", +] + + +def _load_imap_config() -> dict[str, Any]: + """Load IMAP config from label_tool.yaml, returning first account as a flat dict.""" + import yaml + cfg_path = Path(__file__).parent.parent / "config" / "label_tool.yaml" + if not cfg_path.exists(): + raise FileNotFoundError( + f"IMAP config not found: {cfg_path}\n" + f"Copy config/label_tool.yaml.example → config/label_tool.yaml" + ) + cfg = yaml.safe_load(cfg_path.read_text()) or {} + accounts = cfg.get("accounts", []) + if not accounts: + raise ValueError("No accounts configured in config/label_tool.yaml") + return accounts[0] + + +def _imap_connect(cfg: dict[str, Any]) -> imaplib.IMAP4_SSL: + conn = imaplib.IMAP4_SSL(cfg["host"], cfg.get("port", 993)) + conn.login(cfg["username"], cfg["password"]) + return conn + + +def _decode_part(part: Any) -> str: + charset = part.get_content_charset() or "utf-8" + try: + return part.get_payload(decode=True).decode(charset, errors="replace") + except Exception: + return "" + + +def _parse_uid(conn: imaplib.IMAP4_SSL, uid: bytes) -> dict[str, str] | None: + try: + _, data = conn.uid("fetch", uid, "(RFC822)") + raw = data[0][1] + msg = _email_lib.message_from_bytes(raw) + subject = str(msg.get("subject", "")).strip() + body = "" + if msg.is_multipart(): + for part in msg.walk(): + if part.get_content_type() == "text/plain": + body = _decode_part(part) + break + else: + body = _decode_part(msg) + return {"subject": subject, "body": body} + except Exception: + return None + + +def _fetch_imap_sample(limit: int, days: int) -> list[dict[str, str]]: + cfg = _load_imap_config() + conn = _imap_connect(cfg) + since = (datetime.now() - timedelta(days=days)).strftime("%d-%b-%Y") + conn.select("INBOX") + + seen_uids: dict[bytes, None] = {} + for term in _BROAD_TERMS: + _, data = conn.uid("search", None, f'(SUBJECT "{term}" SINCE {since})') + for uid in (data[0] or b"").split(): + seen_uids[uid] = None + + sample = list(seen_uids.keys())[:limit] + emails = [] + for uid in sample: + parsed = _parse_uid(conn, uid) + if parsed: + emails.append(parsed) + try: + conn.logout() + except Exception: + pass + return emails + + +# --------------------------------------------------------------------------- +# DB export +# --------------------------------------------------------------------------- + +def cmd_export_db(args: argparse.Namespace) -> None: + """Export LLM-labeled emails from a peregrine-style job_contacts table → scoring JSONL.""" + import sqlite3 + + db_path = Path(args.db) + if not db_path.exists(): + print(f"ERROR: Database not found: {args.db}", file=sys.stderr) + sys.exit(1) + + conn = sqlite3.connect(db_path) + cur = conn.cursor() + cur.execute(""" + SELECT subject, body, stage_signal + FROM job_contacts + WHERE stage_signal IS NOT NULL + AND stage_signal != '' + AND direction = 'inbound' + ORDER BY received_at + """) + rows = cur.fetchall() + conn.close() + + if not rows: + print("No labeled emails in job_contacts. Run imap_sync first to populate.") + return + + out_path = Path(args.score_file) + out_path.parent.mkdir(parents=True, exist_ok=True) + + written = 0 + skipped = 0 + label_counts: dict[str, int] = {} + with out_path.open("w") as f: + for subject, body, label in rows: + if label not in LABELS: + print(f" SKIP unknown label '{label}': {subject[:50]}") + skipped += 1 + continue + json.dump({"subject": subject or "", "body": (body or "")[:600], "label": label}, f) + f.write("\n") + label_counts[label] = label_counts.get(label, 0) + 1 + written += 1 + + print(f"\nExported {written} emails → {out_path}" + (f" ({skipped} skipped)" if skipped else "")) + print("\nLabel distribution:") + for label in LABELS: + count = label_counts.get(label, 0) + bar = "█" * count + print(f" {label:<25} {count:>3} {bar}") + print( + "\nNOTE: Labels are LLM predictions from imap_sync — review before treating as ground truth." + ) + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + +def cmd_list_models(_args: argparse.Namespace) -> None: + print(f"\n{'Name':<24} {'Params':<8} {'Default':<20} {'Adapter':<15} Model ID") + print("-" * 104) + for name, entry in MODEL_REGISTRY.items(): + adapter_name = entry["adapter"].__name__ + if entry.get("kwargs", {}).get("two_pass"): + adapter_name += " (2-pass)" + default_flag = "yes" if entry["default"] else "(--include-slow)" + print(f"{name:<24} {entry['params']:<8} {default_flag:<20} {adapter_name:<15} {entry['model_id']}") + print() + + +def cmd_score(args: argparse.Namespace) -> None: + active = _active_models(args.include_slow) + if args.models: + active = {k: v for k, v in active.items() if k in args.models} + + adapters = [ + entry["adapter"](name, entry["model_id"], **entry.get("kwargs", {})) + for name, entry in active.items() + ] + + print(f"\nScoring {len(adapters)} model(s) against {args.score_file} …\n") + results = run_scoring(adapters, args.score_file) + + col = 12 + print(f"{'Model':<22}" + f"{'macro-F1':>{col}} {'Accuracy':>{col}} {'ms/email':>{col}}") + print("-" * (22 + col * 3 + 2)) + for name, m in results.items(): + print( + f"{name:<22}" + f"{m['__macro_f1__']:>{col}.3f}" + f"{m['__accuracy__']:>{col}.3f}" + f"{m['latency_ms']:>{col}.1f}" + ) + + print("\nPer-label F1:") + names = list(results.keys()) + print(f"{'Label':<25}" + "".join(f"{n[:11]:>{col}}" for n in names)) + print("-" * (25 + col * len(names))) + for label in LABELS: + row_str = f"{label:<25}" + for m in results.values(): + row_str += f"{m[label]['f1']:>{col}.3f}" + print(row_str) + print() + + +def cmd_compare(args: argparse.Namespace) -> None: + active = _active_models(args.include_slow) + if args.models: + active = {k: v for k, v in active.items() if k in args.models} + + print(f"Fetching up to {args.limit} emails from IMAP …") + emails = _fetch_imap_sample(args.limit, args.days) + print(f"Fetched {len(emails)} emails. Loading {len(active)} model(s) …\n") + + adapters = [ + entry["adapter"](name, entry["model_id"], **entry.get("kwargs", {})) + for name, entry in active.items() + ] + model_names = [a.name for a in adapters] + + col = 22 + subj_w = 50 + print(f"{'Subject':<{subj_w}}" + "".join(f"{n:<{col}}" for n in model_names)) + print("-" * (subj_w + col * len(model_names))) + + for row in emails: + short_subj = row["subject"][:subj_w - 1] if len(row["subject"]) > subj_w else row["subject"] + line = f"{short_subj:<{subj_w}}" + for adapter in adapters: + try: + label = adapter.classify(row["subject"], row["body"]) + except Exception as exc: + label = f"ERR:{str(exc)[:8]}" + line += f"{label:<{col}}" + print(line, flush=True) + + for adapter in adapters: + adapter.unload() + print() + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Benchmark HuggingFace email classifiers against our 6 labels." + ) + parser.add_argument("--list-models", action="store_true", help="Show model registry and exit") + parser.add_argument("--score", action="store_true", help="Score against labeled JSONL") + parser.add_argument("--compare", action="store_true", help="Visual table on live IMAP emails") + parser.add_argument("--export-db", action="store_true", + help="Export labeled emails from a staging.db → score JSONL") + parser.add_argument("--score-file", default="data/email_score.jsonl", help="Path to labeled JSONL") + parser.add_argument("--db", default="data/staging.db", help="Path to staging.db for --export-db") + parser.add_argument("--limit", type=int, default=20, help="Max emails for --compare") + 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") + + args = parser.parse_args() + + if args.list_models: + cmd_list_models(args) + elif args.score: + cmd_score(args) + elif args.compare: + cmd_compare(args) + elif args.export_db: + cmd_export_db(args) + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/scripts/classifier_adapters.py b/scripts/classifier_adapters.py new file mode 100644 index 0000000..a74ea34 --- /dev/null +++ b/scripts/classifier_adapters.py @@ -0,0 +1,257 @@ +"""Classifier adapters for email classification benchmark. + +Each adapter wraps a HuggingFace model and normalizes output to LABELS. +Models load lazily on first classify() call; call unload() to free VRAM. +""" +from __future__ import annotations + +import abc +from collections import defaultdict +from typing import Any + +__all__ = [ + "LABELS", + "LABEL_DESCRIPTIONS", + "compute_metrics", + "ClassifierAdapter", + "ZeroShotAdapter", + "GLiClassAdapter", + "RerankerAdapter", +] + +LABELS: list[str] = [ + "interview_scheduled", + "offer_received", + "rejected", + "positive_response", + "survey_received", + "neutral", +] + +# Natural-language descriptions used by the RerankerAdapter. +LABEL_DESCRIPTIONS: dict[str, str] = { + "interview_scheduled": "scheduling an interview, phone screen, or video call", + "offer_received": "a formal job offer or employment offer letter", + "rejected": "application rejected or not moving forward with candidacy", + "positive_response": "positive recruiter interest or request to connect", + "survey_received": "invitation to complete a culture-fit survey or assessment", + "neutral": "automated ATS confirmation or unrelated email", +} + +# Lazy import shims — allow tests to patch without requiring the libs installed. +try: + from transformers import pipeline # type: ignore[assignment] +except ImportError: + pipeline = None # type: ignore[assignment] + +try: + from gliclass import GLiClassModel, ZeroShotClassificationPipeline # type: ignore + from transformers import AutoTokenizer +except ImportError: + GLiClassModel = None # type: ignore + ZeroShotClassificationPipeline = None # type: ignore + AutoTokenizer = None # type: ignore + +try: + from FlagEmbedding import FlagReranker # type: ignore +except ImportError: + FlagReranker = None # type: ignore + + +def _cuda_available() -> bool: + try: + import torch + return torch.cuda.is_available() + except ImportError: + return False + + +def compute_metrics( + predictions: list[str], + gold: list[str], + labels: list[str], +) -> dict[str, Any]: + """Return per-label precision/recall/F1 + macro_f1 + accuracy.""" + tp: dict[str, int] = defaultdict(int) + fp: dict[str, int] = defaultdict(int) + fn: dict[str, int] = defaultdict(int) + + for pred, true in zip(predictions, gold): + if pred == true: + tp[pred] += 1 + else: + fp[pred] += 1 + fn[true] += 1 + + result: dict[str, Any] = {} + for label in labels: + denom_p = tp[label] + fp[label] + denom_r = tp[label] + fn[label] + p = tp[label] / denom_p if denom_p else 0.0 + r = tp[label] / denom_r if denom_r else 0.0 + f1 = 2 * p * r / (p + r) if (p + r) else 0.0 + result[label] = { + "precision": p, + "recall": r, + "f1": f1, + "support": denom_r, + } + + labels_with_support = [label for label in labels if result[label]["support"] > 0] + if labels_with_support: + result["__macro_f1__"] = ( + sum(result[label]["f1"] for label in labels_with_support) / len(labels_with_support) + ) + else: + result["__macro_f1__"] = 0.0 + result["__accuracy__"] = sum(tp.values()) / len(predictions) if predictions else 0.0 + return result + + +class ClassifierAdapter(abc.ABC): + """Abstract base for all email classifier adapters.""" + + @property + @abc.abstractmethod + def name(self) -> str: ... + + @property + @abc.abstractmethod + def model_id(self) -> str: ... + + @abc.abstractmethod + def load(self) -> None: + """Download/load the model into memory.""" + + @abc.abstractmethod + def unload(self) -> None: + """Release model from memory.""" + + @abc.abstractmethod + def classify(self, subject: str, body: str) -> str: + """Return one of LABELS for the given email.""" + + +class ZeroShotAdapter(ClassifierAdapter): + """Wraps any transformers zero-shot-classification pipeline. + + load() calls pipeline("zero-shot-classification", model=..., device=...) to get + an inference callable, stored as self._pipeline. classify() then calls + self._pipeline(text, LABELS, multi_label=False). In tests, patch + 'scripts.classifier_adapters.pipeline' with a MagicMock whose .return_value is + itself a MagicMock(return_value={...}) to simulate both the factory call and the + inference call. + + two_pass: if True, classify() runs a second pass restricted to the top-2 labels + from the first pass, forcing a binary choice. This typically improves confidence + without the accuracy cost of a full 6-label second run. + """ + + def __init__(self, name: str, model_id: str, two_pass: bool = False) -> None: + self._name = name + self._model_id = model_id + self._pipeline: Any = None + self._two_pass = two_pass + + @property + def name(self) -> str: + return self._name + + @property + def model_id(self) -> str: + return self._model_id + + def load(self) -> None: + import scripts.classifier_adapters as _mod # noqa: PLC0415 + _pipe_fn = _mod.pipeline + if _pipe_fn is None: + raise ImportError("transformers not installed — run: pip install transformers") + device = 0 if _cuda_available() else -1 + # Instantiate the pipeline once; classify() calls the resulting object on each text. + self._pipeline = _pipe_fn("zero-shot-classification", model=self._model_id, device=device) + + def unload(self) -> None: + self._pipeline = None + + def classify(self, subject: str, body: str) -> str: + if self._pipeline is None: + self.load() + text = f"Subject: {subject}\n\n{body[:600]}" + result = self._pipeline(text, LABELS, multi_label=False) + if self._two_pass and len(result["labels"]) >= 2: + top2 = result["labels"][:2] + result = self._pipeline(text, top2, multi_label=False) + return result["labels"][0] + + +class GLiClassAdapter(ClassifierAdapter): + """Wraps knowledgator GLiClass models via the gliclass library.""" + + def __init__(self, name: str, model_id: str) -> None: + self._name = name + self._model_id = model_id + self._pipeline: Any = None + + @property + def name(self) -> str: + return self._name + + @property + def model_id(self) -> str: + return self._model_id + + def load(self) -> None: + if GLiClassModel is None: + raise ImportError("gliclass not installed — run: pip install gliclass") + device = "cuda:0" if _cuda_available() else "cpu" + model = GLiClassModel.from_pretrained(self._model_id) + tokenizer = AutoTokenizer.from_pretrained(self._model_id) + self._pipeline = ZeroShotClassificationPipeline( + model, + tokenizer, + classification_type="single-label", + device=device, + ) + + def unload(self) -> None: + self._pipeline = None + + def classify(self, subject: str, body: str) -> str: + if self._pipeline is None: + self.load() + text = f"Subject: {subject}\n\n{body[:600]}" + results = self._pipeline(text, LABELS, threshold=0.0)[0] + return max(results, key=lambda r: r["score"])["label"] + + +class RerankerAdapter(ClassifierAdapter): + """Uses a BGE reranker to score (email, label_description) pairs.""" + + def __init__(self, name: str, model_id: str) -> None: + self._name = name + self._model_id = model_id + self._reranker: Any = None + + @property + def name(self) -> str: + return self._name + + @property + def model_id(self) -> str: + return self._model_id + + def load(self) -> None: + if FlagReranker is None: + raise ImportError("FlagEmbedding not installed — run: pip install FlagEmbedding") + self._reranker = FlagReranker(self._model_id, use_fp16=_cuda_available()) + + def unload(self) -> None: + self._reranker = None + + def classify(self, subject: str, body: str) -> str: + if self._reranker is None: + self.load() + text = f"Subject: {subject}\n\n{body[:600]}" + pairs = [[text, LABEL_DESCRIPTIONS[label]] for label in LABELS] + scores: list[float] = self._reranker.compute_score(pairs, normalize=True) + return LABELS[scores.index(max(scores))] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_benchmark_classifier.py b/tests/test_benchmark_classifier.py new file mode 100644 index 0000000..299e69c --- /dev/null +++ b/tests/test_benchmark_classifier.py @@ -0,0 +1,94 @@ +"""Tests for benchmark_classifier — no model downloads required.""" +import pytest + + +def test_registry_has_thirteen_models(): + from scripts.benchmark_classifier import MODEL_REGISTRY + assert len(MODEL_REGISTRY) == 13 + + +def test_registry_default_count(): + from scripts.benchmark_classifier import MODEL_REGISTRY + defaults = [k for k, v in MODEL_REGISTRY.items() if v["default"]] + assert len(defaults) == 7 + + +def test_registry_entries_have_required_keys(): + from scripts.benchmark_classifier import MODEL_REGISTRY + from scripts.classifier_adapters import ClassifierAdapter + for name, entry in MODEL_REGISTRY.items(): + assert "adapter" in entry, f"{name} missing 'adapter'" + assert "model_id" in entry, f"{name} missing 'model_id'" + assert "params" in entry, f"{name} missing 'params'" + assert "default" in entry, f"{name} missing 'default'" + assert issubclass(entry["adapter"], ClassifierAdapter), \ + f"{name} adapter must be a ClassifierAdapter subclass" + + +def test_load_scoring_jsonl(tmp_path): + from scripts.benchmark_classifier import load_scoring_jsonl + import json + f = tmp_path / "score.jsonl" + rows = [ + {"subject": "Hi", "body": "Body text", "label": "neutral"}, + {"subject": "Interview", "body": "Schedule a call", "label": "interview_scheduled"}, + ] + f.write_text("\n".join(json.dumps(r) for r in rows)) + result = load_scoring_jsonl(str(f)) + assert len(result) == 2 + assert result[0]["label"] == "neutral" + + +def test_load_scoring_jsonl_missing_file(): + from scripts.benchmark_classifier import load_scoring_jsonl + with pytest.raises(FileNotFoundError): + load_scoring_jsonl("/nonexistent/path.jsonl") + + +def test_run_scoring_with_mock_adapters(tmp_path): + """run_scoring() returns per-model metrics using mock adapters.""" + import json + from unittest.mock import MagicMock + from scripts.benchmark_classifier import run_scoring + + score_file = tmp_path / "score.jsonl" + rows = [ + {"subject": "Interview", "body": "Let's schedule", "label": "interview_scheduled"}, + {"subject": "Sorry", "body": "We went with others", "label": "rejected"}, + {"subject": "Offer", "body": "We are pleased", "label": "offer_received"}, + ] + score_file.write_text("\n".join(json.dumps(r) for r in rows)) + + perfect = MagicMock() + perfect.name = "perfect" + perfect.classify.side_effect = lambda s, b: ( + "interview_scheduled" if "Interview" in s else + "rejected" if "Sorry" in s else "offer_received" + ) + + bad = MagicMock() + bad.name = "bad" + bad.classify.return_value = "neutral" + + results = run_scoring([perfect, bad], str(score_file)) + + assert results["perfect"]["__accuracy__"] == pytest.approx(1.0) + assert results["bad"]["__accuracy__"] == pytest.approx(0.0) + assert "latency_ms" in results["perfect"] + + +def test_run_scoring_handles_classify_error(tmp_path): + """run_scoring() falls back to 'neutral' on exception and continues.""" + import json + from unittest.mock import MagicMock + from scripts.benchmark_classifier import run_scoring + + score_file = tmp_path / "score.jsonl" + score_file.write_text(json.dumps({"subject": "Hi", "body": "Body", "label": "neutral"})) + + broken = MagicMock() + broken.name = "broken" + broken.classify.side_effect = RuntimeError("model crashed") + + results = run_scoring([broken], str(score_file)) + assert "broken" in results diff --git a/tests/test_classifier_adapters.py b/tests/test_classifier_adapters.py new file mode 100644 index 0000000..1e1c36a --- /dev/null +++ b/tests/test_classifier_adapters.py @@ -0,0 +1,177 @@ +"""Tests for classifier_adapters — no model downloads required.""" +import pytest + + +def test_labels_constant_has_six_items(): + from scripts.classifier_adapters import LABELS + assert len(LABELS) == 6 + assert "interview_scheduled" in LABELS + assert "neutral" in LABELS + + +def test_compute_metrics_perfect_predictions(): + from scripts.classifier_adapters import compute_metrics, LABELS + gold = ["rejected", "interview_scheduled", "neutral"] + preds = ["rejected", "interview_scheduled", "neutral"] + m = compute_metrics(preds, gold, LABELS) + assert m["rejected"]["f1"] == pytest.approx(1.0) + assert m["__accuracy__"] == pytest.approx(1.0) + assert m["__macro_f1__"] == pytest.approx(1.0) + + +def test_compute_metrics_all_wrong(): + from scripts.classifier_adapters import compute_metrics, LABELS + gold = ["rejected", "rejected"] + preds = ["neutral", "interview_scheduled"] + m = compute_metrics(preds, gold, LABELS) + assert m["rejected"]["recall"] == pytest.approx(0.0) + assert m["__accuracy__"] == pytest.approx(0.0) + + +def test_compute_metrics_partial(): + from scripts.classifier_adapters import compute_metrics, LABELS + gold = ["rejected", "neutral", "rejected"] + preds = ["rejected", "neutral", "interview_scheduled"] + m = compute_metrics(preds, gold, LABELS) + assert m["rejected"]["precision"] == pytest.approx(1.0) + assert m["rejected"]["recall"] == pytest.approx(0.5) + assert m["neutral"]["f1"] == pytest.approx(1.0) + assert m["__accuracy__"] == pytest.approx(2 / 3) + + +def test_compute_metrics_empty(): + from scripts.classifier_adapters import compute_metrics, LABELS + m = compute_metrics([], [], LABELS) + assert m["__accuracy__"] == pytest.approx(0.0) + + +def test_classifier_adapter_is_abstract(): + from scripts.classifier_adapters import ClassifierAdapter + with pytest.raises(TypeError): + ClassifierAdapter() + + +# ---- ZeroShotAdapter tests ---- + +def test_zeroshot_adapter_classify_mocked(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import ZeroShotAdapter + + # Two-level mock: factory call returns pipeline instance; instance call returns inference result. + mock_pipe_factory = MagicMock() + mock_pipe_factory.return_value = MagicMock(return_value={ + "labels": ["rejected", "neutral", "interview_scheduled"], + "scores": [0.85, 0.10, 0.05], + }) + + with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory): + adapter = ZeroShotAdapter("test-zs", "some/model") + adapter.load() + result = adapter.classify("We went with another candidate", "Thank you for applying.") + + assert result == "rejected" + # Factory was called with the correct task type + assert mock_pipe_factory.call_args[0][0] == "zero-shot-classification" + # Pipeline instance was called with the email text + assert "We went with another candidate" in mock_pipe_factory.return_value.call_args[0][0] + + +def test_zeroshot_adapter_unload_clears_pipeline(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import ZeroShotAdapter + + with patch("scripts.classifier_adapters.pipeline", MagicMock()): + adapter = ZeroShotAdapter("test-zs", "some/model") + adapter.load() + assert adapter._pipeline is not None + adapter.unload() + assert adapter._pipeline is None + + +def test_zeroshot_adapter_lazy_loads(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import ZeroShotAdapter + + mock_pipe_factory = MagicMock() + mock_pipe_factory.return_value = MagicMock(return_value={ + "labels": ["neutral"], "scores": [1.0] + }) + + with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory): + adapter = ZeroShotAdapter("test-zs", "some/model") + adapter.classify("subject", "body") + + mock_pipe_factory.assert_called_once() + + +# ---- GLiClassAdapter tests ---- + +def test_gliclass_adapter_classify_mocked(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import GLiClassAdapter + + mock_pipeline_instance = MagicMock() + mock_pipeline_instance.return_value = [[ + {"label": "interview_scheduled", "score": 0.91}, + {"label": "neutral", "score": 0.05}, + {"label": "rejected", "score": 0.04}, + ]] + + with patch("scripts.classifier_adapters.GLiClassModel") as _mc, \ + patch("scripts.classifier_adapters.AutoTokenizer") as _mt, \ + patch("scripts.classifier_adapters.ZeroShotClassificationPipeline", + return_value=mock_pipeline_instance): + adapter = GLiClassAdapter("test-gli", "some/gliclass-model") + adapter.load() + result = adapter.classify("Interview invitation", "Let's schedule a call.") + + assert result == "interview_scheduled" + + +def test_gliclass_adapter_returns_highest_score(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import GLiClassAdapter + + mock_pipeline_instance = MagicMock() + mock_pipeline_instance.return_value = [[ + {"label": "neutral", "score": 0.02}, + {"label": "offer_received", "score": 0.88}, + {"label": "rejected", "score": 0.10}, + ]] + + with patch("scripts.classifier_adapters.GLiClassModel"), \ + patch("scripts.classifier_adapters.AutoTokenizer"), \ + patch("scripts.classifier_adapters.ZeroShotClassificationPipeline", + return_value=mock_pipeline_instance): + adapter = GLiClassAdapter("test-gli", "some/model") + adapter.load() + result = adapter.classify("Offer letter enclosed", "Dear Meghan, we are pleased to offer...") + + assert result == "offer_received" + + +# ---- RerankerAdapter tests ---- + +def test_reranker_adapter_picks_highest_score(): + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import RerankerAdapter, LABELS + + mock_reranker = MagicMock() + mock_reranker.compute_score.return_value = [0.1, 0.05, 0.85, 0.05, 0.02, 0.03] + + with patch("scripts.classifier_adapters.FlagReranker", return_value=mock_reranker): + adapter = RerankerAdapter("test-rr", "BAAI/bge-reranker-v2-m3") + adapter.load() + result = adapter.classify( + "We regret to inform you", + "After careful consideration we are moving forward with other candidates.", + ) + + assert result == "rejected" + pairs = mock_reranker.compute_score.call_args[0][0] + assert len(pairs) == len(LABELS) + + +def test_reranker_adapter_descriptions_cover_all_labels(): + from scripts.classifier_adapters import LABEL_DESCRIPTIONS, LABELS + assert set(LABEL_DESCRIPTIONS.keys()) == set(LABELS) -- 2.45.2 From e705962f7bbd05b6a3524a5029383728303e60f8 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:10:24 -0800 Subject: [PATCH 003/103] feat: add manage.sh with port collision detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start/stop/restart/status/logs/open for the label tool UI; benchmark/list-models/score/compare shortcuts for the harness. Port scan: checks lsof → ss → /dev/tcp fallback, increments from 8503 until a free port is found. PID tracked in .avocet.pid, assigned port persisted in .avocet.port. --- manage.sh | 262 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100755 manage.sh diff --git a/manage.sh b/manage.sh new file mode 100755 index 0000000..9bcb82e --- /dev/null +++ b/manage.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# manage.sh — Avocet label tool manager +# Usage: ./manage.sh [args] +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +info() { echo -e "${BLUE}[avocet]${NC} $*"; } +success() { echo -e "${GREEN}[avocet]${NC} $*"; } +warn() { echo -e "${YELLOW}[avocet]${NC} $*"; } +error() { echo -e "${RED}[avocet]${NC} $*" >&2; exit 1; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +PID_FILE=".avocet.pid" +PORT_FILE=".avocet.port" +LOG_DIR="log" +LOG_FILE="${LOG_DIR}/label_tool.log" +DEFAULT_PORT=8503 + +CONDA_BASE="${CONDA_BASE:-/devl/miniconda3}" +ENV_UI="job-seeker" +ENV_BM="job-seeker-classifiers" +STREAMLIT="${CONDA_BASE}/envs/${ENV_UI}/bin/streamlit" +PYTHON_BM="${CONDA_BASE}/envs/${ENV_BM}/bin/python" +PYTHON_UI="${CONDA_BASE}/envs/${ENV_UI}/bin/python" + +# ── Port helpers ────────────────────────────────────────────────────────────── + +_port_in_use() { + local port=$1 + # Try lsof first (macOS + most Linux); fall back to ss (systemd Linux) + if command -v lsof &>/dev/null; then + lsof -iTCP:"$port" -sTCP:LISTEN -t &>/dev/null + elif command -v ss &>/dev/null; then + ss -tlnH 2>/dev/null | awk '{print $4}' | grep -q ":${port}$" + else + # Last resort: attempt a connection + (echo "" >/dev/tcp/127.0.0.1/"$port") 2>/dev/null + fi +} + +_find_free_port() { + local port=${1:-$DEFAULT_PORT} + while _port_in_use "$port"; do + warn "Port ${port} is in use — trying $((port + 1))…" + ((port++)) + done + echo "$port" +} + +# ── PID helpers ─────────────────────────────────────────────────────────────── + +_running_pid() { + # Returns the PID if a live avocet process is running, empty string otherwise + if [[ -f "$PID_FILE" ]]; then + local pid + pid=$(<"$PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + echo "$pid" + return 0 + else + rm -f "$PID_FILE" "$PORT_FILE" + fi + fi + echo "" +} + +_running_port() { + [[ -f "$PORT_FILE" ]] && cat "$PORT_FILE" || echo "$DEFAULT_PORT" +} + +# ── Usage ───────────────────────────────────────────────────────────────────── + +usage() { + echo "" + echo -e " ${BLUE}Avocet — Email Classifier Training Tool${NC}" + echo -e " ${YELLOW}Scrape → Store → Process${NC}" + echo "" + echo " Usage: ./manage.sh [args]" + echo "" + echo " Label tool:" + echo -e " ${GREEN}start${NC} Start label tool UI (port collision-safe)" + echo -e " ${GREEN}stop${NC} Stop label tool UI" + echo -e " ${GREEN}restart${NC} Restart label tool UI" + echo -e " ${GREEN}status${NC} Show running state and port" + echo -e " ${GREEN}logs${NC} Tail label tool log output" + echo -e " ${GREEN}open${NC} Open label tool in browser" + echo "" + echo " Benchmark:" + echo -e " ${GREEN}benchmark [args]${NC} Run benchmark_classifier.py (args passed through)" + echo -e " ${GREEN}list-models${NC} Shortcut: --list-models" + echo -e " ${GREEN}score [args]${NC} Shortcut: --score [args]" + echo -e " ${GREEN}compare [args]${NC} Shortcut: --compare [args]" + echo "" + echo " Dev:" + echo -e " ${GREEN}test${NC} Run pytest suite" + echo "" + echo " Port defaults to ${DEFAULT_PORT}; auto-increments if occupied." + echo " Conda envs: UI=${ENV_UI} Benchmark=${ENV_BM}" + echo "" + echo " Examples:" + echo " ./manage.sh start" + echo " ./manage.sh benchmark --list-models" + echo " ./manage.sh score --include-slow" + echo " ./manage.sh compare --limit 30" + echo "" +} + +# ── Commands ────────────────────────────────────────────────────────────────── + +CMD="${1:-help}" +shift || true + +case "$CMD" in + + start) + pid=$(_running_pid) + if [[ -n "$pid" ]]; then + port=$(_running_port) + warn "Already running (PID ${pid}) on port ${port} → http://localhost:${port}" + exit 0 + fi + + if [[ ! -x "$STREAMLIT" ]]; then + error "Streamlit not found at ${STREAMLIT}\nActivate env: conda run -n ${ENV_UI} ..." + fi + + port=$(_find_free_port "$DEFAULT_PORT") + mkdir -p "$LOG_DIR" + + info "Starting label tool on port ${port}…" + nohup "$STREAMLIT" run app/label_tool.py \ + --server.port "$port" \ + --server.headless true \ + --server.fileWatcherType none \ + >"$LOG_FILE" 2>&1 & + + pid=$! + echo "$pid" > "$PID_FILE" + echo "$port" > "$PORT_FILE" + + # Wait briefly and confirm the process survived + sleep 1 + if kill -0 "$pid" 2>/dev/null; then + success "Avocet label tool started → http://localhost:${port} (PID ${pid})" + success "Logs: ${LOG_FILE}" + else + rm -f "$PID_FILE" "$PORT_FILE" + error "Process died immediately. Check ${LOG_FILE} for details." + fi + ;; + + stop) + pid=$(_running_pid) + if [[ -z "$pid" ]]; then + warn "Not running." + exit 0 + fi + info "Stopping label tool (PID ${pid})…" + kill "$pid" + # Wait up to 5 s for clean exit + for _ in $(seq 1 10); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + if kill -0 "$pid" 2>/dev/null; then + warn "Process did not exit cleanly; sending SIGKILL…" + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$PID_FILE" "$PORT_FILE" + success "Stopped." + ;; + + restart) + pid=$(_running_pid) + if [[ -n "$pid" ]]; then + info "Stopping existing process (PID ${pid})…" + kill "$pid" + for _ in $(seq 1 10); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true + rm -f "$PID_FILE" "$PORT_FILE" + fi + exec "$0" start + ;; + + status) + pid=$(_running_pid) + if [[ -n "$pid" ]]; then + port=$(_running_port) + success "Running — PID ${pid} port ${port} → http://localhost:${port}" + else + warn "Not running." + fi + ;; + + logs) + if [[ ! -f "$LOG_FILE" ]]; then + warn "No log file found at ${LOG_FILE}. Has the tool been started?" + exit 0 + fi + info "Tailing ${LOG_FILE} (Ctrl-C to stop)" + tail -f "$LOG_FILE" + ;; + + open) + port=$(_running_port) + pid=$(_running_pid) + [[ -z "$pid" ]] && warn "Label tool does not appear to be running. Start with: ./manage.sh start" + URL="http://localhost:${port}" + info "Opening ${URL}" + if command -v xdg-open &>/dev/null; then + xdg-open "$URL" + elif command -v open &>/dev/null; then + open "$URL" + else + echo "$URL" + fi + ;; + + test) + info "Running test suite…" + PYTEST="${CONDA_BASE}/envs/${ENV_UI}/bin/pytest" + if [[ ! -x "$PYTEST" ]]; then + error "pytest not found in ${ENV_UI} env at ${PYTEST}" + fi + "$PYTEST" tests/ -v "$@" + ;; + + benchmark) + info "Running benchmark (${ENV_BM})…" + if [[ ! -x "$PYTHON_BM" ]]; then + error "Python not found in ${ENV_BM} env at ${PYTHON_BM}\n" \ + "Create it with: conda env create -f environment.yml" + fi + "$PYTHON_BM" scripts/benchmark_classifier.py "$@" + ;; + + list-models) + exec "$0" benchmark --list-models + ;; + + score) + exec "$0" benchmark --score "$@" + ;; + + compare) + exec "$0" benchmark --compare "$@" + ;; + + help|--help|-h) + usage + ;; + + *) + error "Unknown command: ${CMD}. Run './manage.sh help' for usage." + ;; + +esac -- 2.45.2 From bd4911fe581d672d2b6d49cbf0031b3d965d3542 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:10:24 -0800 Subject: [PATCH 004/103] feat: add manage.sh with port collision detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start/stop/restart/status/logs/open for the label tool UI; benchmark/list-models/score/compare shortcuts for the harness. Port scan: checks lsof → ss → /dev/tcp fallback, increments from 8503 until a free port is found. PID tracked in .avocet.pid, assigned port persisted in .avocet.port. --- manage.sh | 262 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100755 manage.sh diff --git a/manage.sh b/manage.sh new file mode 100755 index 0000000..9bcb82e --- /dev/null +++ b/manage.sh @@ -0,0 +1,262 @@ +#!/usr/bin/env bash +# manage.sh — Avocet label tool manager +# Usage: ./manage.sh [args] +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' +info() { echo -e "${BLUE}[avocet]${NC} $*"; } +success() { echo -e "${GREEN}[avocet]${NC} $*"; } +warn() { echo -e "${YELLOW}[avocet]${NC} $*"; } +error() { echo -e "${RED}[avocet]${NC} $*" >&2; exit 1; } + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +PID_FILE=".avocet.pid" +PORT_FILE=".avocet.port" +LOG_DIR="log" +LOG_FILE="${LOG_DIR}/label_tool.log" +DEFAULT_PORT=8503 + +CONDA_BASE="${CONDA_BASE:-/devl/miniconda3}" +ENV_UI="job-seeker" +ENV_BM="job-seeker-classifiers" +STREAMLIT="${CONDA_BASE}/envs/${ENV_UI}/bin/streamlit" +PYTHON_BM="${CONDA_BASE}/envs/${ENV_BM}/bin/python" +PYTHON_UI="${CONDA_BASE}/envs/${ENV_UI}/bin/python" + +# ── Port helpers ────────────────────────────────────────────────────────────── + +_port_in_use() { + local port=$1 + # Try lsof first (macOS + most Linux); fall back to ss (systemd Linux) + if command -v lsof &>/dev/null; then + lsof -iTCP:"$port" -sTCP:LISTEN -t &>/dev/null + elif command -v ss &>/dev/null; then + ss -tlnH 2>/dev/null | awk '{print $4}' | grep -q ":${port}$" + else + # Last resort: attempt a connection + (echo "" >/dev/tcp/127.0.0.1/"$port") 2>/dev/null + fi +} + +_find_free_port() { + local port=${1:-$DEFAULT_PORT} + while _port_in_use "$port"; do + warn "Port ${port} is in use — trying $((port + 1))…" + ((port++)) + done + echo "$port" +} + +# ── PID helpers ─────────────────────────────────────────────────────────────── + +_running_pid() { + # Returns the PID if a live avocet process is running, empty string otherwise + if [[ -f "$PID_FILE" ]]; then + local pid + pid=$(<"$PID_FILE") + if kill -0 "$pid" 2>/dev/null; then + echo "$pid" + return 0 + else + rm -f "$PID_FILE" "$PORT_FILE" + fi + fi + echo "" +} + +_running_port() { + [[ -f "$PORT_FILE" ]] && cat "$PORT_FILE" || echo "$DEFAULT_PORT" +} + +# ── Usage ───────────────────────────────────────────────────────────────────── + +usage() { + echo "" + echo -e " ${BLUE}Avocet — Email Classifier Training Tool${NC}" + echo -e " ${YELLOW}Scrape → Store → Process${NC}" + echo "" + echo " Usage: ./manage.sh [args]" + echo "" + echo " Label tool:" + echo -e " ${GREEN}start${NC} Start label tool UI (port collision-safe)" + echo -e " ${GREEN}stop${NC} Stop label tool UI" + echo -e " ${GREEN}restart${NC} Restart label tool UI" + echo -e " ${GREEN}status${NC} Show running state and port" + echo -e " ${GREEN}logs${NC} Tail label tool log output" + echo -e " ${GREEN}open${NC} Open label tool in browser" + echo "" + echo " Benchmark:" + echo -e " ${GREEN}benchmark [args]${NC} Run benchmark_classifier.py (args passed through)" + echo -e " ${GREEN}list-models${NC} Shortcut: --list-models" + echo -e " ${GREEN}score [args]${NC} Shortcut: --score [args]" + echo -e " ${GREEN}compare [args]${NC} Shortcut: --compare [args]" + echo "" + echo " Dev:" + echo -e " ${GREEN}test${NC} Run pytest suite" + echo "" + echo " Port defaults to ${DEFAULT_PORT}; auto-increments if occupied." + echo " Conda envs: UI=${ENV_UI} Benchmark=${ENV_BM}" + echo "" + echo " Examples:" + echo " ./manage.sh start" + echo " ./manage.sh benchmark --list-models" + echo " ./manage.sh score --include-slow" + echo " ./manage.sh compare --limit 30" + echo "" +} + +# ── Commands ────────────────────────────────────────────────────────────────── + +CMD="${1:-help}" +shift || true + +case "$CMD" in + + start) + pid=$(_running_pid) + if [[ -n "$pid" ]]; then + port=$(_running_port) + warn "Already running (PID ${pid}) on port ${port} → http://localhost:${port}" + exit 0 + fi + + if [[ ! -x "$STREAMLIT" ]]; then + error "Streamlit not found at ${STREAMLIT}\nActivate env: conda run -n ${ENV_UI} ..." + fi + + port=$(_find_free_port "$DEFAULT_PORT") + mkdir -p "$LOG_DIR" + + info "Starting label tool on port ${port}…" + nohup "$STREAMLIT" run app/label_tool.py \ + --server.port "$port" \ + --server.headless true \ + --server.fileWatcherType none \ + >"$LOG_FILE" 2>&1 & + + pid=$! + echo "$pid" > "$PID_FILE" + echo "$port" > "$PORT_FILE" + + # Wait briefly and confirm the process survived + sleep 1 + if kill -0 "$pid" 2>/dev/null; then + success "Avocet label tool started → http://localhost:${port} (PID ${pid})" + success "Logs: ${LOG_FILE}" + else + rm -f "$PID_FILE" "$PORT_FILE" + error "Process died immediately. Check ${LOG_FILE} for details." + fi + ;; + + stop) + pid=$(_running_pid) + if [[ -z "$pid" ]]; then + warn "Not running." + exit 0 + fi + info "Stopping label tool (PID ${pid})…" + kill "$pid" + # Wait up to 5 s for clean exit + for _ in $(seq 1 10); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + if kill -0 "$pid" 2>/dev/null; then + warn "Process did not exit cleanly; sending SIGKILL…" + kill -9 "$pid" 2>/dev/null || true + fi + rm -f "$PID_FILE" "$PORT_FILE" + success "Stopped." + ;; + + restart) + pid=$(_running_pid) + if [[ -n "$pid" ]]; then + info "Stopping existing process (PID ${pid})…" + kill "$pid" + for _ in $(seq 1 10); do + kill -0 "$pid" 2>/dev/null || break + sleep 0.5 + done + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true + rm -f "$PID_FILE" "$PORT_FILE" + fi + exec "$0" start + ;; + + status) + pid=$(_running_pid) + if [[ -n "$pid" ]]; then + port=$(_running_port) + success "Running — PID ${pid} port ${port} → http://localhost:${port}" + else + warn "Not running." + fi + ;; + + logs) + if [[ ! -f "$LOG_FILE" ]]; then + warn "No log file found at ${LOG_FILE}. Has the tool been started?" + exit 0 + fi + info "Tailing ${LOG_FILE} (Ctrl-C to stop)" + tail -f "$LOG_FILE" + ;; + + open) + port=$(_running_port) + pid=$(_running_pid) + [[ -z "$pid" ]] && warn "Label tool does not appear to be running. Start with: ./manage.sh start" + URL="http://localhost:${port}" + info "Opening ${URL}" + if command -v xdg-open &>/dev/null; then + xdg-open "$URL" + elif command -v open &>/dev/null; then + open "$URL" + else + echo "$URL" + fi + ;; + + test) + info "Running test suite…" + PYTEST="${CONDA_BASE}/envs/${ENV_UI}/bin/pytest" + if [[ ! -x "$PYTEST" ]]; then + error "pytest not found in ${ENV_UI} env at ${PYTEST}" + fi + "$PYTEST" tests/ -v "$@" + ;; + + benchmark) + info "Running benchmark (${ENV_BM})…" + if [[ ! -x "$PYTHON_BM" ]]; then + error "Python not found in ${ENV_BM} env at ${PYTHON_BM}\n" \ + "Create it with: conda env create -f environment.yml" + fi + "$PYTHON_BM" scripts/benchmark_classifier.py "$@" + ;; + + list-models) + exec "$0" benchmark --list-models + ;; + + score) + exec "$0" benchmark --score "$@" + ;; + + compare) + exec "$0" benchmark --compare "$@" + ;; + + help|--help|-h) + usage + ;; + + *) + error "Unknown command: ${CMD}. Run './manage.sh help' for usage." + ;; + +esac -- 2.45.2 From 2824fca70cf24b6146c6068e70810db90aa38519 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:18:51 -0800 Subject: [PATCH 005/103] feat: add Settings tab with IMAP account GUI + connection test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⚙️ Settings tab: add/edit/remove accounts without touching YAML - Per-account: name, host, port, SSL, username, password (masked), folder, days back - Test connection button: connect → login → select folder → report message count - Save writes config/label_tool.yaml; Reload discards unsaved changes - _sync_settings_to_state() prevents index-key drift on add/remove - _test_imap_connection() helper shared with fetch tab indirectly - CLAUDE.md: document new tab, Settings UI design notes --- CLAUDE.md | 12 +++- app/label_tool.py | 164 +++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 173 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0790858..95c9e0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -30,13 +30,21 @@ Card-stack Streamlit UI for manually labeling recruitment emails. conda run -n job-seeker streamlit run app/label_tool.py --server.port 8503 ``` -- Config: `config/label_tool.yaml` (gitignored — copy from `.example`) +- Config: `config/label_tool.yaml` (gitignored — copy from `.example`, or use ⚙️ Settings tab) - Queue: `data/email_label_queue.jsonl` (gitignored) - Output: `data/email_score.jsonl` (gitignored) -- Three tabs: 🃏 Label, 📥 Fetch, 📊 Stats +- Four tabs: 🃏 Label, 📥 Fetch, 📊 Stats, ⚙️ Settings - Keyboard shortcuts: 1–6 = label, S = skip, U = undo - Dedup: MD5 of `(subject + body[:100])` — cross-account safe +### Settings Tab (⚙️) +- Add / edit / remove IMAP accounts via form UI — no manual YAML editing required +- Per-account fields: display name, host, port, SSL toggle, username, password (masked), folder, days back +- **🔌 Test connection** button per account — connects, logs in, selects folder, reports message count +- Global: max emails per account per fetch +- **💾 Save** writes `config/label_tool.yaml`; **↩ Reload** discards unsaved changes +- `_sync_settings_to_state()` collects widget values before any add/remove to avoid index-key drift + ## Benchmark (scripts/benchmark_classifier.py) ``` diff --git a/app/label_tool.py b/app/label_tool.py index a4a2fdd..e84147e 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -106,6 +106,27 @@ def _extract_body(msg: Any) -> str: return "" +def _test_imap_connection(acc: dict) -> tuple[bool, str]: + """Try connect → login → select folder. Returns (ok, human message).""" + host = acc.get("host", "") + port = int(acc.get("port", 993)) + use_ssl = acc.get("use_ssl", True) + username = acc.get("username", "") + password = acc.get("password", "") + folder = acc.get("folder", "INBOX") + if not host or not username or not password: + return False, "Host, username, and password are all required." + try: + conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port) + conn.login(username, password) + typ, data = conn.select(folder, readonly=True) + count = data[0].decode() if data and data[0] else "?" + conn.logout() + return True, f"Connected — {count} message(s) in {folder}." + except Exception as exc: + return False, str(exc) + + def _fetch_account(cfg: dict, days: int, limit: int, known_keys: set[str], progress_cb=None) -> list[dict]: """Fetch emails from one IMAP account using wide recruitment search terms.""" @@ -308,7 +329,7 @@ with st.sidebar: # ── Tabs ───────────────────────────────────────────────────────────────────── -tab_label, tab_fetch, tab_stats = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats"]) +tab_label, tab_fetch, tab_stats, tab_settings = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings"]) # ══════════════════════════════════════════════════════════════════════════════ @@ -566,3 +587,144 @@ with tab_stats: file_name="email_score.jsonl", mime="application/jsonlines", ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# SETTINGS TAB +# ══════════════════════════════════════════════════════════════════════════════ + +def _sync_settings_to_state() -> None: + """Collect current widget values back into settings_accounts, then clear + widget keys so the next render picks up freshly from the updated list.""" + accounts = st.session_state.get("settings_accounts", []) + synced = [] + for i in range(len(accounts)): + synced.append({ + "name": st.session_state.get(f"s_name_{i}", accounts[i].get("name", "")), + "host": st.session_state.get(f"s_host_{i}", accounts[i].get("host", "imap.gmail.com")), + "port": int(st.session_state.get(f"s_port_{i}", accounts[i].get("port", 993))), + "use_ssl": bool(st.session_state.get(f"s_ssl_{i}", accounts[i].get("use_ssl", True))), + "username": st.session_state.get(f"s_user_{i}", accounts[i].get("username", "")), + "password": st.session_state.get(f"s_pass_{i}", accounts[i].get("password", "")), + "folder": st.session_state.get(f"s_folder_{i}", accounts[i].get("folder", "INBOX")), + "days_back": int(st.session_state.get(f"s_days_{i}", accounts[i].get("days_back", 90))), + }) + st.session_state.settings_accounts = synced + for key in list(st.session_state.keys()): + if key.startswith("s_"): + del st.session_state[key] + + +with tab_settings: + # ── Init from disk on first load ───────────────────────────────────────── + if "settings_accounts" not in st.session_state: + _cfg_raw = yaml.safe_load(_CFG_FILE.read_text()) or {} if _CFG_FILE.exists() else {} + st.session_state.settings_accounts = [dict(a) for a in _cfg_raw.get("accounts", [])] + st.session_state.settings_max = _cfg_raw.get("max_per_account", 500) + + _accs = st.session_state.settings_accounts + + st.subheader("📧 IMAP Accounts") + st.caption( + "Credentials are saved to `config/label_tool.yaml` (gitignored). " + "Use an **App Password** for Gmail/Outlook — not your login password." + ) + + if not _accs: + st.info("No accounts configured yet. Click **➕ Add account** to get started.", icon="📭") + + _to_remove = None + for _i, _acc in enumerate(_accs): + _label = f"**{_acc.get('name', 'Unnamed')}** — {_acc.get('username', '(no username)')}" + with st.expander(_label, expanded=not _acc.get("username")): + _c1, _c2 = st.columns(2) + _c1.text_input("Display name", key=f"s_name_{_i}", value=_acc.get("name", "")) + _c2.text_input("IMAP host", key=f"s_host_{_i}", value=_acc.get("host", "imap.gmail.com")) + + _c3, _c4, _c5 = st.columns([3, 2, 1]) + _c3.text_input("Username / email", key=f"s_user_{_i}", value=_acc.get("username", "")) + _c4.number_input("Port", key=f"s_port_{_i}", value=int(_acc.get("port", 993)), + min_value=1, max_value=65535, step=1) + _c5.checkbox("SSL", key=f"s_ssl_{_i}", value=bool(_acc.get("use_ssl", True))) + + st.text_input("Password / app password", key=f"s_pass_{_i}", + value=_acc.get("password", ""), type="password") + + _c6, _c7 = st.columns(2) + _c6.text_input("Folder", key=f"s_folder_{_i}", value=_acc.get("folder", "INBOX")) + _c7.number_input("Default days back", key=f"s_days_{_i}", + value=int(_acc.get("days_back", 90)), min_value=1, max_value=730) + + _btn_l, _btn_r = st.columns([1, 3]) + if _btn_l.button("🗑️ Remove", key=f"s_remove_{_i}"): + _to_remove = _i + if _btn_r.button("🔌 Test connection", key=f"s_test_{_i}"): + _test_acc = { + "host": st.session_state.get(f"s_host_{_i}", _acc.get("host", "")), + "port": st.session_state.get(f"s_port_{_i}", _acc.get("port", 993)), + "use_ssl": st.session_state.get(f"s_ssl_{_i}", _acc.get("use_ssl", True)), + "username": st.session_state.get(f"s_user_{_i}", _acc.get("username", "")), + "password": st.session_state.get(f"s_pass_{_i}", _acc.get("password", "")), + "folder": st.session_state.get(f"s_folder_{_i}", _acc.get("folder", "INBOX")), + } + with st.spinner("Connecting…"): + _ok, _msg = _test_imap_connection(_test_acc) + if _ok: + st.success(_msg) + else: + st.error(f"Connection failed: {_msg}") + + if _to_remove is not None: + _sync_settings_to_state() + st.session_state.settings_accounts.pop(_to_remove) + st.rerun() + + if st.button("➕ Add account"): + _sync_settings_to_state() + st.session_state.settings_accounts.append({ + "name": f"Account {len(_accs) + 1}", + "host": "imap.gmail.com", "port": 993, "use_ssl": True, + "username": "", "password": "", "folder": "INBOX", "days_back": 90, + }) + st.rerun() + + st.divider() + st.subheader("⚙️ Global Settings") + st.number_input( + "Max emails per account per fetch (0 = unlimited)", + key="s_max_per_account", + value=st.session_state.settings_max, + min_value=0, max_value=5000, step=50, + ) + + st.divider() + _save_col, _reload_col = st.columns([3, 1]) + if _save_col.button("💾 Save settings", type="primary", use_container_width=True): + _saved_accounts = [] + for _i in range(len(st.session_state.settings_accounts)): + _a = st.session_state.settings_accounts[_i] + _saved_accounts.append({ + "name": st.session_state.get(f"s_name_{_i}", _a.get("name", "")), + "host": st.session_state.get(f"s_host_{_i}", _a.get("host", "imap.gmail.com")), + "port": int(st.session_state.get(f"s_port_{_i}", _a.get("port", 993))), + "use_ssl": bool(st.session_state.get(f"s_ssl_{_i}", _a.get("use_ssl", True))), + "username": st.session_state.get(f"s_user_{_i}", _a.get("username", "")), + "password": st.session_state.get(f"s_pass_{_i}", _a.get("password", "")), + "folder": st.session_state.get(f"s_folder_{_i}", _a.get("folder", "INBOX")), + "days_back": int(st.session_state.get(f"s_days_{_i}", _a.get("days_back", 90))), + }) + _cfg_out = { + "accounts": _saved_accounts, + "max_per_account": int(st.session_state.get("s_max_per_account", 500)), + } + _CFG_FILE.parent.mkdir(parents=True, exist_ok=True) + _CFG_FILE.write_text(yaml.dump(_cfg_out, default_flow_style=False, allow_unicode=True)) + st.session_state.settings_accounts = _saved_accounts + st.session_state.settings_max = _cfg_out["max_per_account"] + st.success(f"Saved {len(_saved_accounts)} account(s) to `config/label_tool.yaml`.") + + if _reload_col.button("↩ Reload", use_container_width=True, help="Discard unsaved changes and reload from disk"): + for _k in list(st.session_state.keys()): + if _k in ("settings_accounts", "settings_max") or _k.startswith("s_"): + del st.session_state[_k] + st.rerun() -- 2.45.2 From 260c7c0f96de0b917103eef436788b7702369abe Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:18:51 -0800 Subject: [PATCH 006/103] feat: add Settings tab with IMAP account GUI + connection test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ⚙️ Settings tab: add/edit/remove accounts without touching YAML - Per-account: name, host, port, SSL, username, password (masked), folder, days back - Test connection button: connect → login → select folder → report message count - Save writes config/label_tool.yaml; Reload discards unsaved changes - _sync_settings_to_state() prevents index-key drift on add/remove - _test_imap_connection() helper shared with fetch tab indirectly - CLAUDE.md: document new tab, Settings UI design notes --- app/label_tool.py | 164 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 163 insertions(+), 1 deletion(-) diff --git a/app/label_tool.py b/app/label_tool.py index a4a2fdd..e84147e 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -106,6 +106,27 @@ def _extract_body(msg: Any) -> str: return "" +def _test_imap_connection(acc: dict) -> tuple[bool, str]: + """Try connect → login → select folder. Returns (ok, human message).""" + host = acc.get("host", "") + port = int(acc.get("port", 993)) + use_ssl = acc.get("use_ssl", True) + username = acc.get("username", "") + password = acc.get("password", "") + folder = acc.get("folder", "INBOX") + if not host or not username or not password: + return False, "Host, username, and password are all required." + try: + conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port) + conn.login(username, password) + typ, data = conn.select(folder, readonly=True) + count = data[0].decode() if data and data[0] else "?" + conn.logout() + return True, f"Connected — {count} message(s) in {folder}." + except Exception as exc: + return False, str(exc) + + def _fetch_account(cfg: dict, days: int, limit: int, known_keys: set[str], progress_cb=None) -> list[dict]: """Fetch emails from one IMAP account using wide recruitment search terms.""" @@ -308,7 +329,7 @@ with st.sidebar: # ── Tabs ───────────────────────────────────────────────────────────────────── -tab_label, tab_fetch, tab_stats = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats"]) +tab_label, tab_fetch, tab_stats, tab_settings = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings"]) # ══════════════════════════════════════════════════════════════════════════════ @@ -566,3 +587,144 @@ with tab_stats: file_name="email_score.jsonl", mime="application/jsonlines", ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# SETTINGS TAB +# ══════════════════════════════════════════════════════════════════════════════ + +def _sync_settings_to_state() -> None: + """Collect current widget values back into settings_accounts, then clear + widget keys so the next render picks up freshly from the updated list.""" + accounts = st.session_state.get("settings_accounts", []) + synced = [] + for i in range(len(accounts)): + synced.append({ + "name": st.session_state.get(f"s_name_{i}", accounts[i].get("name", "")), + "host": st.session_state.get(f"s_host_{i}", accounts[i].get("host", "imap.gmail.com")), + "port": int(st.session_state.get(f"s_port_{i}", accounts[i].get("port", 993))), + "use_ssl": bool(st.session_state.get(f"s_ssl_{i}", accounts[i].get("use_ssl", True))), + "username": st.session_state.get(f"s_user_{i}", accounts[i].get("username", "")), + "password": st.session_state.get(f"s_pass_{i}", accounts[i].get("password", "")), + "folder": st.session_state.get(f"s_folder_{i}", accounts[i].get("folder", "INBOX")), + "days_back": int(st.session_state.get(f"s_days_{i}", accounts[i].get("days_back", 90))), + }) + st.session_state.settings_accounts = synced + for key in list(st.session_state.keys()): + if key.startswith("s_"): + del st.session_state[key] + + +with tab_settings: + # ── Init from disk on first load ───────────────────────────────────────── + if "settings_accounts" not in st.session_state: + _cfg_raw = yaml.safe_load(_CFG_FILE.read_text()) or {} if _CFG_FILE.exists() else {} + st.session_state.settings_accounts = [dict(a) for a in _cfg_raw.get("accounts", [])] + st.session_state.settings_max = _cfg_raw.get("max_per_account", 500) + + _accs = st.session_state.settings_accounts + + st.subheader("📧 IMAP Accounts") + st.caption( + "Credentials are saved to `config/label_tool.yaml` (gitignored). " + "Use an **App Password** for Gmail/Outlook — not your login password." + ) + + if not _accs: + st.info("No accounts configured yet. Click **➕ Add account** to get started.", icon="📭") + + _to_remove = None + for _i, _acc in enumerate(_accs): + _label = f"**{_acc.get('name', 'Unnamed')}** — {_acc.get('username', '(no username)')}" + with st.expander(_label, expanded=not _acc.get("username")): + _c1, _c2 = st.columns(2) + _c1.text_input("Display name", key=f"s_name_{_i}", value=_acc.get("name", "")) + _c2.text_input("IMAP host", key=f"s_host_{_i}", value=_acc.get("host", "imap.gmail.com")) + + _c3, _c4, _c5 = st.columns([3, 2, 1]) + _c3.text_input("Username / email", key=f"s_user_{_i}", value=_acc.get("username", "")) + _c4.number_input("Port", key=f"s_port_{_i}", value=int(_acc.get("port", 993)), + min_value=1, max_value=65535, step=1) + _c5.checkbox("SSL", key=f"s_ssl_{_i}", value=bool(_acc.get("use_ssl", True))) + + st.text_input("Password / app password", key=f"s_pass_{_i}", + value=_acc.get("password", ""), type="password") + + _c6, _c7 = st.columns(2) + _c6.text_input("Folder", key=f"s_folder_{_i}", value=_acc.get("folder", "INBOX")) + _c7.number_input("Default days back", key=f"s_days_{_i}", + value=int(_acc.get("days_back", 90)), min_value=1, max_value=730) + + _btn_l, _btn_r = st.columns([1, 3]) + if _btn_l.button("🗑️ Remove", key=f"s_remove_{_i}"): + _to_remove = _i + if _btn_r.button("🔌 Test connection", key=f"s_test_{_i}"): + _test_acc = { + "host": st.session_state.get(f"s_host_{_i}", _acc.get("host", "")), + "port": st.session_state.get(f"s_port_{_i}", _acc.get("port", 993)), + "use_ssl": st.session_state.get(f"s_ssl_{_i}", _acc.get("use_ssl", True)), + "username": st.session_state.get(f"s_user_{_i}", _acc.get("username", "")), + "password": st.session_state.get(f"s_pass_{_i}", _acc.get("password", "")), + "folder": st.session_state.get(f"s_folder_{_i}", _acc.get("folder", "INBOX")), + } + with st.spinner("Connecting…"): + _ok, _msg = _test_imap_connection(_test_acc) + if _ok: + st.success(_msg) + else: + st.error(f"Connection failed: {_msg}") + + if _to_remove is not None: + _sync_settings_to_state() + st.session_state.settings_accounts.pop(_to_remove) + st.rerun() + + if st.button("➕ Add account"): + _sync_settings_to_state() + st.session_state.settings_accounts.append({ + "name": f"Account {len(_accs) + 1}", + "host": "imap.gmail.com", "port": 993, "use_ssl": True, + "username": "", "password": "", "folder": "INBOX", "days_back": 90, + }) + st.rerun() + + st.divider() + st.subheader("⚙️ Global Settings") + st.number_input( + "Max emails per account per fetch (0 = unlimited)", + key="s_max_per_account", + value=st.session_state.settings_max, + min_value=0, max_value=5000, step=50, + ) + + st.divider() + _save_col, _reload_col = st.columns([3, 1]) + if _save_col.button("💾 Save settings", type="primary", use_container_width=True): + _saved_accounts = [] + for _i in range(len(st.session_state.settings_accounts)): + _a = st.session_state.settings_accounts[_i] + _saved_accounts.append({ + "name": st.session_state.get(f"s_name_{_i}", _a.get("name", "")), + "host": st.session_state.get(f"s_host_{_i}", _a.get("host", "imap.gmail.com")), + "port": int(st.session_state.get(f"s_port_{_i}", _a.get("port", 993))), + "use_ssl": bool(st.session_state.get(f"s_ssl_{_i}", _a.get("use_ssl", True))), + "username": st.session_state.get(f"s_user_{_i}", _a.get("username", "")), + "password": st.session_state.get(f"s_pass_{_i}", _a.get("password", "")), + "folder": st.session_state.get(f"s_folder_{_i}", _a.get("folder", "INBOX")), + "days_back": int(st.session_state.get(f"s_days_{_i}", _a.get("days_back", 90))), + }) + _cfg_out = { + "accounts": _saved_accounts, + "max_per_account": int(st.session_state.get("s_max_per_account", 500)), + } + _CFG_FILE.parent.mkdir(parents=True, exist_ok=True) + _CFG_FILE.write_text(yaml.dump(_cfg_out, default_flow_style=False, allow_unicode=True)) + st.session_state.settings_accounts = _saved_accounts + st.session_state.settings_max = _cfg_out["max_per_account"] + st.success(f"Saved {len(_saved_accounts)} account(s) to `config/label_tool.yaml`.") + + if _reload_col.button("↩ Reload", use_container_width=True, help="Discard unsaved changes and reload from disk"): + for _k in list(st.session_state.keys()): + if _k in ("settings_accounts", "settings_max") or _k.startswith("s_"): + del st.session_state[_k] + st.rerun() -- 2.45.2 From 66f69ff9a2e9e50f56e83b3dd5dc8e0244c90093 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:20:57 -0800 Subject: [PATCH 007/103] =?UTF-8?q?fix:=20fetch=20log=20=E2=80=94=20overwr?= =?UTF-8?q?ite=20per-email=20progress=20instead=20of=20appending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status.write() per email grows the log unboundedly on big pulls. Now uses status.empty() to create one updatable slot; per-email progress overwrites it, cleared after each account completes. Per-account summaries still use status.write() (one line each). --- app/label_tool.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/label_tool.py b/app/label_tool.py index e84147e..83ebbe2 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -363,6 +363,8 @@ with tab_fetch: fetched_all: list[dict] = [] status = st.status("Fetching…", expanded=True) + # Single updatable slot for per-email progress — overwrites instead of appending + _live = status.empty() for acc in accounts: name = acc.get("name", acc.get("username")) @@ -373,11 +375,13 @@ with tab_fetch: emails = _fetch_account( acc, days=int(days), limit=int(limit), known_keys=existing_keys, - progress_cb=lambda p, msg: status.write(msg), + progress_cb=lambda p, msg: _live.markdown(f"⏳ {msg}"), ) + _live.empty() # clear progress line once account is done fetched_all.extend(emails) status.write(f"✓ {name}: {len(emails)} new emails") except Exception as e: + _live.empty() status.write(f"✗ {name}: {e}") if fetched_all: -- 2.45.2 From 4c659033c9f8f9c46cd493880acbe0b52b689a4f Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:20:57 -0800 Subject: [PATCH 008/103] =?UTF-8?q?fix:=20fetch=20log=20=E2=80=94=20overwr?= =?UTF-8?q?ite=20per-email=20progress=20instead=20of=20appending?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit status.write() per email grows the log unboundedly on big pulls. Now uses status.empty() to create one updatable slot; per-email progress overwrites it, cleared after each account completes. Per-account summaries still use status.write() (one line each). --- app/label_tool.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/label_tool.py b/app/label_tool.py index e84147e..83ebbe2 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -363,6 +363,8 @@ with tab_fetch: fetched_all: list[dict] = [] status = st.status("Fetching…", expanded=True) + # Single updatable slot for per-email progress — overwrites instead of appending + _live = status.empty() for acc in accounts: name = acc.get("name", acc.get("username")) @@ -373,11 +375,13 @@ with tab_fetch: emails = _fetch_account( acc, days=int(days), limit=int(limit), known_keys=existing_keys, - progress_cb=lambda p, msg: status.write(msg), + progress_cb=lambda p, msg: _live.markdown(f"⏳ {msg}"), ) + _live.empty() # clear progress line once account is done fetched_all.extend(emails) status.write(f"✓ {name}: {len(emails)} new emails") except Exception as e: + _live.empty() status.write(f"✗ {name}: {e}") if fetched_all: -- 2.45.2 From fd476e4199f57202cd54c26e756bacb72cbffe82 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:34:15 -0800 Subject: [PATCH 009/103] feat: 9 labels (add event_rescheduled/unrelated/digest), wildcard Other label, InvalidCharacterError fix --- CLAUDE.md | 19 +++--- app/label_tool.py | 110 +++++++++++++++++++++++++----- scripts/classifier_adapters.py | 8 ++- tests/test_classifier_adapters.py | 7 +- 4 files changed, 116 insertions(+), 28 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 95c9e0b..3639f64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,14 +66,17 @@ conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --exp ## Labels (peregrine defaults — configurable per product) -| Label | Meaning | -|-------|---------| -| `interview_scheduled` | Phone screen, video call, or on-site invitation | -| `offer_received` | Formal job offer or offer letter | -| `rejected` | Application declined or not moving forward | -| `positive_response` | Recruiter interest or request to connect | -| `survey_received` | Culture-fit survey or assessment invitation | -| `neutral` | ATS confirmation or unrelated email | +| Label | Key | Meaning | +|-------|-----|---------| +| `interview_scheduled` | 1 | Phone screen, video call, or on-site invitation | +| `offer_received` | 2 | Formal job offer or offer letter | +| `rejected` | 3 | Application declined or not moving forward | +| `positive_response` | 4 | Recruiter interest or request to connect | +| `survey_received` | 5 | Culture-fit survey or assessment invitation | +| `neutral` | 6 | ATS confirmation (application received, etc.) | +| `event_rescheduled` | 7 | Interview or event moved to a new time | +| `unrelated` | 8 | Non-job-search email, not classifiable | +| `digest` | 9 | Job digest or multi-listing email (scrapeable) | ## Model Registry (13 models, 7 defaults) diff --git a/app/label_tool.py b/app/label_tool.py index 83ebbe2..30f2fa9 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -13,8 +13,10 @@ from __future__ import annotations import email as _email_lib import hashlib +import html as _html import imaplib import json +import re import sys from datetime import datetime, timedelta from email.header import decode_header as _raw_decode @@ -40,6 +42,9 @@ LABELS = [ "positive_response", "survey_received", "neutral", + "event_rescheduled", + "unrelated", + "digest", ] _LABEL_META: dict[str, dict] = { @@ -49,9 +54,31 @@ _LABEL_META: dict[str, dict] = { "positive_response": {"emoji": "👍", "color": "#FF9800", "key": "4"}, "survey_received": {"emoji": "📋", "color": "#9C27B0", "key": "5"}, "neutral": {"emoji": "⬜", "color": "#607D8B", "key": "6"}, + "event_rescheduled": {"emoji": "🔄", "color": "#FF5722", "key": "7"}, + "unrelated": {"emoji": "🗑️", "color": "#757575", "key": "8"}, + "digest": {"emoji": "📰", "color": "#00BCD4", "key": "9"}, } -# ── Wide IMAP search terms (cast a net across all 6 categories) ───────────── +# ── HTML sanitiser ─────────────────────────────────────────────────────────── +# Valid chars per XML 1.0 §2.2 (same set HTML5 innerHTML enforces): +# #x9 | #xA | #xD | [#x20–#xD7FF] | [#xE000–#xFFFD] | [#x10000–#x10FFFF] +# Anything outside this range causes InvalidCharacterError in the browser. +_INVALID_XML_CHARS = re.compile( + r"[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\U00010000-\U0010FFFF]" +) + +def _to_html(text: str, newlines_to_br: bool = False) -> str: + """Strip invalid XML chars, HTML-escape the result, optionally convert \\n →
.""" + if not text: + return "" + cleaned = _INVALID_XML_CHARS.sub("", text) + escaped = _html.escape(cleaned) + if newlines_to_br: + escaped = escaped.replace("\n", "
") + return escaped + + +# ── Wide IMAP search terms (cast a net across all 9 categories) ───────────── _WIDE_TERMS = [ # interview_scheduled "interview", "phone screen", "video call", "zoom link", "schedule a call", @@ -68,6 +95,11 @@ _WIDE_TERMS = [ # neutral / ATS confirms "application received", "thank you for applying", "application confirmation", "you applied", "your application for", + # event_rescheduled + "reschedule", "rescheduled", "new time", "moved to", "postponed", "new date", + # digest + "job digest", "jobs you may like", "recommended jobs", "jobs for you", + "new jobs", "job alert", # general recruitment "application", "recruiter", "recruiting", "hiring", "candidate", ] @@ -441,9 +473,9 @@ with tab_label: st.markdown( f"""""", unsafe_allow_html=True, ) @@ -470,8 +502,15 @@ with tab_label: next_idx += 1 st.session_state.idx = next_idx + # Pre-compute per-label counts once + _counts: dict[str, int] = {} + for _r in st.session_state.labeled: + _lbl_r = _r.get("label", "") + _counts[_lbl_r] = _counts.get(_lbl_r, 0) + 1 + row1_cols = st.columns(3) row2_cols = st.columns(3) + row3_cols = st.columns(3) bucket_pairs = [ (row1_cols[0], "interview_scheduled"), (row1_cols[1], "offer_received"), @@ -479,23 +518,48 @@ with tab_label: (row2_cols[0], "positive_response"), (row2_cols[1], "survey_received"), (row2_cols[2], "neutral"), + (row3_cols[0], "event_rescheduled"), + (row3_cols[1], "unrelated"), + (row3_cols[2], "digest"), ] for col, lbl in bucket_pairs: m = _LABEL_META[lbl] - counts = {l: 0 for l in LABELS} - for r in st.session_state.labeled: - counts[r.get("label", "")] = counts.get(r.get("label", ""), 0) + 1 - label_display = f"{m['emoji']} **{lbl}** [{counts[lbl]}]\n`{m['key']}`" + cnt = _counts.get(lbl, 0) + label_display = f"{m['emoji']} **{lbl}** [{cnt}]\n`{m['key']}`" if col.button(label_display, key=f"lbl_{lbl}", use_container_width=True): _do_label(lbl) st.rerun() + # ── Wildcard label ───────────────────────────────────────────────── + if "show_custom" not in st.session_state: + st.session_state.show_custom = False + + other_col, _ = st.columns([1, 2]) + if other_col.button("🏷️ Other… `0`", key="lbl_other_toggle", use_container_width=True): + st.session_state.show_custom = not st.session_state.show_custom + st.rerun() + + if st.session_state.get("show_custom"): + custom_cols = st.columns([3, 1]) + custom_val = custom_cols[0].text_input( + "Custom label:", key="custom_label_text", + placeholder="e.g. linkedin_outreach", + label_visibility="collapsed", + ) + if custom_cols[1].button( + "✓ Apply", key="apply_custom", type="primary", + disabled=not (custom_val or "").strip(), + ): + _do_label(custom_val.strip().lower().replace(" ", "_")) + st.session_state.show_custom = False + st.rerun() + # ── Navigation ──────────────────────────────────────────────────── st.markdown("") nav_cols = st.columns([2, 1, 1]) remaining = len(unlabeled) - 1 - nav_cols[0].caption(f"**{remaining}** remaining · Keys: 1–6 = label, S = skip, U = undo") + nav_cols[0].caption(f"**{remaining}** remaining · Keys: 1–9 = label, 0 = other, S = skip, U = undo") if nav_cols[1].button("↩ Undo", disabled=not st.session_state.history, use_container_width=True): prev_idx, prev_label = st.session_state.history.pop() @@ -521,7 +585,8 @@ document.addEventListener('keydown', function(e) { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; const keyToLabel = { '1':'interview_scheduled','2':'offer_received','3':'rejected', - '4':'positive_response','5':'survey_received','6':'neutral' + '4':'positive_response','5':'survey_received','6':'neutral', + '7':'event_rescheduled','8':'unrelated','9':'digest' }; const label = keyToLabel[e.key]; if (label) { @@ -531,6 +596,11 @@ document.addEventListener('keydown', function(e) { btn.click(); break; } } + } else if (e.key === '0') { + const btns = window.parent.document.querySelectorAll('button'); + for (const btn of btns) { + if (btn.innerText.includes('Other')) { btn.click(); break; } + } } else if (e.key.toLowerCase() === 's') { const btns = window.parent.document.querySelectorAll('button'); for (const btn of btns) { @@ -558,19 +628,25 @@ with tab_stats: if not labeled: st.info("No labeled emails yet.") else: - counts = {lbl: 0 for lbl in LABELS} + counts: dict[str, int] = {} for r in labeled: lbl = r.get("label", "") - if lbl in counts: - counts[lbl] += 1 + if lbl: + counts[lbl] = counts.get(lbl, 0) + 1 st.markdown(f"**{len(labeled)} labeled emails total**") - for lbl in LABELS: - m = _LABEL_META[lbl] + # Show known labels first, then any custom labels + all_display_labels = list(LABELS) + [l for l in counts if l not in LABELS] + max_count = max(counts.values()) if counts else 1 + for lbl in all_display_labels: + if lbl not in counts: + continue + m = _LABEL_META.get(lbl) + emoji = m["emoji"] if m else "🏷️" col_name, col_bar, col_n = st.columns([3, 5, 1]) - col_name.markdown(f"{m['emoji']} {lbl}") - col_bar.progress(counts[lbl] / max(counts.values()) if counts.values() else 0) + col_name.markdown(f"{emoji} {lbl}") + col_bar.progress(counts[lbl] / max_count) col_n.markdown(f"**{counts[lbl]}**") st.divider() diff --git a/scripts/classifier_adapters.py b/scripts/classifier_adapters.py index a74ea34..e6020e2 100644 --- a/scripts/classifier_adapters.py +++ b/scripts/classifier_adapters.py @@ -26,6 +26,9 @@ LABELS: list[str] = [ "positive_response", "survey_received", "neutral", + "event_rescheduled", + "unrelated", + "digest", ] # Natural-language descriptions used by the RerankerAdapter. @@ -35,7 +38,10 @@ LABEL_DESCRIPTIONS: dict[str, str] = { "rejected": "application rejected or not moving forward with candidacy", "positive_response": "positive recruiter interest or request to connect", "survey_received": "invitation to complete a culture-fit survey or assessment", - "neutral": "automated ATS confirmation or unrelated email", + "neutral": "automated ATS confirmation such as application received", + "event_rescheduled": "an interview or scheduled event moved to a new time", + "unrelated": "non-job-search email unrelated to any application or recruiter", + "digest": "job digest or multi-listing email with multiple job postings", } # Lazy import shims — allow tests to patch without requiring the libs installed. diff --git a/tests/test_classifier_adapters.py b/tests/test_classifier_adapters.py index 1e1c36a..f50ef3b 100644 --- a/tests/test_classifier_adapters.py +++ b/tests/test_classifier_adapters.py @@ -2,11 +2,14 @@ import pytest -def test_labels_constant_has_six_items(): +def test_labels_constant_has_nine_items(): from scripts.classifier_adapters import LABELS - assert len(LABELS) == 6 + assert len(LABELS) == 9 assert "interview_scheduled" in LABELS assert "neutral" in LABELS + assert "event_rescheduled" in LABELS + assert "unrelated" in LABELS + assert "digest" in LABELS def test_compute_metrics_perfect_predictions(): -- 2.45.2 From 4c346aa32862947651c9c8e212abfeb849bdd7fb Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:34:15 -0800 Subject: [PATCH 010/103] feat: 9 labels (add event_rescheduled/unrelated/digest), wildcard Other label, InvalidCharacterError fix --- app/label_tool.py | 110 +++++++++++++++++++++++++----- scripts/classifier_adapters.py | 8 ++- tests/test_classifier_adapters.py | 7 +- 3 files changed, 105 insertions(+), 20 deletions(-) diff --git a/app/label_tool.py b/app/label_tool.py index 83ebbe2..30f2fa9 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -13,8 +13,10 @@ from __future__ import annotations import email as _email_lib import hashlib +import html as _html import imaplib import json +import re import sys from datetime import datetime, timedelta from email.header import decode_header as _raw_decode @@ -40,6 +42,9 @@ LABELS = [ "positive_response", "survey_received", "neutral", + "event_rescheduled", + "unrelated", + "digest", ] _LABEL_META: dict[str, dict] = { @@ -49,9 +54,31 @@ _LABEL_META: dict[str, dict] = { "positive_response": {"emoji": "👍", "color": "#FF9800", "key": "4"}, "survey_received": {"emoji": "📋", "color": "#9C27B0", "key": "5"}, "neutral": {"emoji": "⬜", "color": "#607D8B", "key": "6"}, + "event_rescheduled": {"emoji": "🔄", "color": "#FF5722", "key": "7"}, + "unrelated": {"emoji": "🗑️", "color": "#757575", "key": "8"}, + "digest": {"emoji": "📰", "color": "#00BCD4", "key": "9"}, } -# ── Wide IMAP search terms (cast a net across all 6 categories) ───────────── +# ── HTML sanitiser ─────────────────────────────────────────────────────────── +# Valid chars per XML 1.0 §2.2 (same set HTML5 innerHTML enforces): +# #x9 | #xA | #xD | [#x20–#xD7FF] | [#xE000–#xFFFD] | [#x10000–#x10FFFF] +# Anything outside this range causes InvalidCharacterError in the browser. +_INVALID_XML_CHARS = re.compile( + r"[^\x09\x0A\x0D\x20-\uD7FF\uE000-\uFFFD\U00010000-\U0010FFFF]" +) + +def _to_html(text: str, newlines_to_br: bool = False) -> str: + """Strip invalid XML chars, HTML-escape the result, optionally convert \\n →
.""" + if not text: + return "" + cleaned = _INVALID_XML_CHARS.sub("", text) + escaped = _html.escape(cleaned) + if newlines_to_br: + escaped = escaped.replace("\n", "
") + return escaped + + +# ── Wide IMAP search terms (cast a net across all 9 categories) ───────────── _WIDE_TERMS = [ # interview_scheduled "interview", "phone screen", "video call", "zoom link", "schedule a call", @@ -68,6 +95,11 @@ _WIDE_TERMS = [ # neutral / ATS confirms "application received", "thank you for applying", "application confirmation", "you applied", "your application for", + # event_rescheduled + "reschedule", "rescheduled", "new time", "moved to", "postponed", "new date", + # digest + "job digest", "jobs you may like", "recommended jobs", "jobs for you", + "new jobs", "job alert", # general recruitment "application", "recruiter", "recruiting", "hiring", "candidate", ] @@ -441,9 +473,9 @@ with tab_label: st.markdown( f"""""", unsafe_allow_html=True, ) @@ -470,8 +502,15 @@ with tab_label: next_idx += 1 st.session_state.idx = next_idx + # Pre-compute per-label counts once + _counts: dict[str, int] = {} + for _r in st.session_state.labeled: + _lbl_r = _r.get("label", "") + _counts[_lbl_r] = _counts.get(_lbl_r, 0) + 1 + row1_cols = st.columns(3) row2_cols = st.columns(3) + row3_cols = st.columns(3) bucket_pairs = [ (row1_cols[0], "interview_scheduled"), (row1_cols[1], "offer_received"), @@ -479,23 +518,48 @@ with tab_label: (row2_cols[0], "positive_response"), (row2_cols[1], "survey_received"), (row2_cols[2], "neutral"), + (row3_cols[0], "event_rescheduled"), + (row3_cols[1], "unrelated"), + (row3_cols[2], "digest"), ] for col, lbl in bucket_pairs: m = _LABEL_META[lbl] - counts = {l: 0 for l in LABELS} - for r in st.session_state.labeled: - counts[r.get("label", "")] = counts.get(r.get("label", ""), 0) + 1 - label_display = f"{m['emoji']} **{lbl}** [{counts[lbl]}]\n`{m['key']}`" + cnt = _counts.get(lbl, 0) + label_display = f"{m['emoji']} **{lbl}** [{cnt}]\n`{m['key']}`" if col.button(label_display, key=f"lbl_{lbl}", use_container_width=True): _do_label(lbl) st.rerun() + # ── Wildcard label ───────────────────────────────────────────────── + if "show_custom" not in st.session_state: + st.session_state.show_custom = False + + other_col, _ = st.columns([1, 2]) + if other_col.button("🏷️ Other… `0`", key="lbl_other_toggle", use_container_width=True): + st.session_state.show_custom = not st.session_state.show_custom + st.rerun() + + if st.session_state.get("show_custom"): + custom_cols = st.columns([3, 1]) + custom_val = custom_cols[0].text_input( + "Custom label:", key="custom_label_text", + placeholder="e.g. linkedin_outreach", + label_visibility="collapsed", + ) + if custom_cols[1].button( + "✓ Apply", key="apply_custom", type="primary", + disabled=not (custom_val or "").strip(), + ): + _do_label(custom_val.strip().lower().replace(" ", "_")) + st.session_state.show_custom = False + st.rerun() + # ── Navigation ──────────────────────────────────────────────────── st.markdown("") nav_cols = st.columns([2, 1, 1]) remaining = len(unlabeled) - 1 - nav_cols[0].caption(f"**{remaining}** remaining · Keys: 1–6 = label, S = skip, U = undo") + nav_cols[0].caption(f"**{remaining}** remaining · Keys: 1–9 = label, 0 = other, S = skip, U = undo") if nav_cols[1].button("↩ Undo", disabled=not st.session_state.history, use_container_width=True): prev_idx, prev_label = st.session_state.history.pop() @@ -521,7 +585,8 @@ document.addEventListener('keydown', function(e) { if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; const keyToLabel = { '1':'interview_scheduled','2':'offer_received','3':'rejected', - '4':'positive_response','5':'survey_received','6':'neutral' + '4':'positive_response','5':'survey_received','6':'neutral', + '7':'event_rescheduled','8':'unrelated','9':'digest' }; const label = keyToLabel[e.key]; if (label) { @@ -531,6 +596,11 @@ document.addEventListener('keydown', function(e) { btn.click(); break; } } + } else if (e.key === '0') { + const btns = window.parent.document.querySelectorAll('button'); + for (const btn of btns) { + if (btn.innerText.includes('Other')) { btn.click(); break; } + } } else if (e.key.toLowerCase() === 's') { const btns = window.parent.document.querySelectorAll('button'); for (const btn of btns) { @@ -558,19 +628,25 @@ with tab_stats: if not labeled: st.info("No labeled emails yet.") else: - counts = {lbl: 0 for lbl in LABELS} + counts: dict[str, int] = {} for r in labeled: lbl = r.get("label", "") - if lbl in counts: - counts[lbl] += 1 + if lbl: + counts[lbl] = counts.get(lbl, 0) + 1 st.markdown(f"**{len(labeled)} labeled emails total**") - for lbl in LABELS: - m = _LABEL_META[lbl] + # Show known labels first, then any custom labels + all_display_labels = list(LABELS) + [l for l in counts if l not in LABELS] + max_count = max(counts.values()) if counts else 1 + for lbl in all_display_labels: + if lbl not in counts: + continue + m = _LABEL_META.get(lbl) + emoji = m["emoji"] if m else "🏷️" col_name, col_bar, col_n = st.columns([3, 5, 1]) - col_name.markdown(f"{m['emoji']} {lbl}") - col_bar.progress(counts[lbl] / max(counts.values()) if counts.values() else 0) + col_name.markdown(f"{emoji} {lbl}") + col_bar.progress(counts[lbl] / max_count) col_n.markdown(f"**{counts[lbl]}**") st.divider() diff --git a/scripts/classifier_adapters.py b/scripts/classifier_adapters.py index a74ea34..e6020e2 100644 --- a/scripts/classifier_adapters.py +++ b/scripts/classifier_adapters.py @@ -26,6 +26,9 @@ LABELS: list[str] = [ "positive_response", "survey_received", "neutral", + "event_rescheduled", + "unrelated", + "digest", ] # Natural-language descriptions used by the RerankerAdapter. @@ -35,7 +38,10 @@ LABEL_DESCRIPTIONS: dict[str, str] = { "rejected": "application rejected or not moving forward with candidacy", "positive_response": "positive recruiter interest or request to connect", "survey_received": "invitation to complete a culture-fit survey or assessment", - "neutral": "automated ATS confirmation or unrelated email", + "neutral": "automated ATS confirmation such as application received", + "event_rescheduled": "an interview or scheduled event moved to a new time", + "unrelated": "non-job-search email unrelated to any application or recruiter", + "digest": "job digest or multi-listing email with multiple job postings", } # Lazy import shims — allow tests to patch without requiring the libs installed. diff --git a/tests/test_classifier_adapters.py b/tests/test_classifier_adapters.py index 1e1c36a..f50ef3b 100644 --- a/tests/test_classifier_adapters.py +++ b/tests/test_classifier_adapters.py @@ -2,11 +2,14 @@ import pytest -def test_labels_constant_has_six_items(): +def test_labels_constant_has_nine_items(): from scripts.classifier_adapters import LABELS - assert len(LABELS) == 6 + assert len(LABELS) == 9 assert "interview_scheduled" in LABELS assert "neutral" in LABELS + assert "event_rescheduled" in LABELS + assert "unrelated" in LABELS + assert "digest" in LABELS def test_compute_metrics_perfect_predictions(): -- 2.45.2 From d0dbc6e787cbed91482621347b32fcd0613ecf5e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:49:24 -0800 Subject: [PATCH 011/103] =?UTF-8?q?docs:=20update=20CLAUDE.md=20=E2=80=94?= =?UTF-8?q?=209=20labels=20table=20with=20keys,=20wildcard=20shortcut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3639f64..bfe9a8b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,7 +34,7 @@ conda run -n job-seeker streamlit run app/label_tool.py --server.port 8503 - Queue: `data/email_label_queue.jsonl` (gitignored) - Output: `data/email_score.jsonl` (gitignored) - Four tabs: 🃏 Label, 📥 Fetch, 📊 Stats, ⚙️ Settings -- Keyboard shortcuts: 1–6 = label, S = skip, U = undo +- Keyboard shortcuts: 1–9 = label, 0 = Other (wildcard, prompts free-text input), S = skip, U = undo - Dedup: MD5 of `(subject + body[:100])` — cross-account safe ### Settings Tab (⚙️) -- 2.45.2 From 1452889d0f3aa975d94287438744356723193627 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:54:31 -0800 Subject: [PATCH 012/103] fix: RerankerAdapter falls back to label name when no LABEL_DESCRIPTIONS entry --- scripts/classifier_adapters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/classifier_adapters.py b/scripts/classifier_adapters.py index e6020e2..2817078 100644 --- a/scripts/classifier_adapters.py +++ b/scripts/classifier_adapters.py @@ -258,6 +258,6 @@ class RerankerAdapter(ClassifierAdapter): if self._reranker is None: self.load() text = f"Subject: {subject}\n\n{body[:600]}" - pairs = [[text, LABEL_DESCRIPTIONS[label]] for label in LABELS] + pairs = [[text, LABEL_DESCRIPTIONS.get(label, label.replace("_", " "))] for label in LABELS] scores: list[float] = self._reranker.compute_score(pairs, normalize=True) return LABELS[scores.index(max(scores))] -- 2.45.2 From f97ef32100fdeb0b4ee130193d156fade7c41ac0 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 14:54:31 -0800 Subject: [PATCH 013/103] fix: RerankerAdapter falls back to label name when no LABEL_DESCRIPTIONS entry --- scripts/classifier_adapters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/classifier_adapters.py b/scripts/classifier_adapters.py index e6020e2..2817078 100644 --- a/scripts/classifier_adapters.py +++ b/scripts/classifier_adapters.py @@ -258,6 +258,6 @@ class RerankerAdapter(ClassifierAdapter): if self._reranker is None: self.load() text = f"Subject: {subject}\n\n{body[:600]}" - pairs = [[text, LABEL_DESCRIPTIONS[label]] for label in LABELS] + pairs = [[text, LABEL_DESCRIPTIONS.get(label, label.replace("_", " "))] for label in LABELS] scores: list[float] = self._reranker.compute_score(pairs, normalize=True) return LABELS[scores.index(max(scores))] -- 2.45.2 From a26a21e71d0c0820553e7f3e50ac9e6b6205f25e Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 15:15:49 -0800 Subject: [PATCH 014/103] =?UTF-8?q?feat:=20targeted=20fetch=20=E2=80=94=20?= =?UTF-8?q?date=20range=20+=20sender/subject=20filter=20for=20historical?= =?UTF-8?q?=20email=20pulls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/label_tool.py | 161 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/app/label_tool.py b/app/label_tool.py index 30f2fa9..e534438 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -220,6 +220,81 @@ def _fetch_account(cfg: dict, days: int, limit: int, known_keys: set[str], return emails +def _fetch_targeted( + cfg: dict, + since_dt: datetime, before_dt: datetime, + term: str, field: str, + limit: int, + known_keys: set[str], + progress_cb=None, +) -> list[dict]: + """Fetch emails within a date range, optionally filtered by sender/subject. + + field: "from" | "subject" | "either" | "none" + """ + since = since_dt.strftime("%d-%b-%Y") + before = before_dt.strftime("%d-%b-%Y") + host = cfg.get("host", "imap.gmail.com") + port = int(cfg.get("port", 993)) + use_ssl = cfg.get("use_ssl", True) + username = cfg["username"] + password = cfg["password"] + name = cfg.get("name", username) + + conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port) + conn.login(username, password) + conn.select("INBOX", readonly=True) + + date_part = f'SINCE "{since}" BEFORE "{before}"' + if term and field == "from": + search_str = f'(FROM "{term}") {date_part}' + elif term and field == "subject": + search_str = f'(SUBJECT "{term}") {date_part}' + elif term and field == "either": + search_str = f'(OR (FROM "{term}") (SUBJECT "{term}")) {date_part}' + else: + search_str = date_part + + try: + _, data = conn.search(None, search_str) + uids = (data[0] or b"").split() + except Exception: + uids = [] + + emails: list[dict] = [] + for i, uid in enumerate(uids): + if len(emails) >= limit: + break + if progress_cb: + progress_cb(i / max(len(uids), 1), f"{name}: {len(emails)} fetched…") + try: + _, raw_data = conn.fetch(uid, "(RFC822)") + if not raw_data or not raw_data[0]: + continue + msg = _email_lib.message_from_bytes(raw_data[0][1]) + subj = _decode_str(msg.get("Subject", "")) + from_addr = _decode_str(msg.get("From", "")) + date = _decode_str(msg.get("Date", "")) + body = _extract_body(msg)[:800] + entry = { + "subject": subj, "body": body, + "from_addr": from_addr, "date": date, + "account": name, + } + key = _entry_key(entry) + if key not in known_keys: + known_keys.add(key) + emails.append(entry) + except Exception: + pass + + try: + conn.logout() + except Exception: + pass + return emails + + # ── Queue / score file helpers ─────────────────────────────────────────────── def _entry_key(e: dict) -> str: @@ -429,6 +504,92 @@ with tab_fetch: else: status.update(label="No new emails found (all already in queue or score file)", state="complete") + # ── Targeted fetch ─────────────────────────────────────────────────────── + st.divider() + with st.expander("🎯 Targeted Fetch — date range + keyword"): + st.caption( + "Pull emails within a specific date window, optionally filtered by " + "sender or subject. Use this to retrieve historical hiring threads." + ) + + _t1, _t2 = st.columns(2) + _one_year_ago = (datetime.now() - timedelta(days=365)).date() + t_since = _t1.date_input("From date", value=_one_year_ago, key="t_since") + t_before = _t2.date_input("To date", value=datetime.now().date(), key="t_before") + + t_term = st.text_input( + "Filter by keyword (optional)", + placeholder="e.g. Stateside", + key="t_term", + ) + _tf1, _tf2 = st.columns(2) + t_field_label = _tf1.selectbox( + "Search in", + ["Either (from or subject)", "Sender/from", "Subject line"], + key="t_field", + ) + t_limit = _tf2.number_input("Max emails", min_value=10, max_value=1000, value=300, key="t_limit") + + t_accs = st.multiselect("Accounts", all_accs, default=all_accs, key="t_accs") + + _field_map = { + "Either (from or subject)": "either", + "Sender/from": "from", + "Subject line": "subject", + } + + _t_invalid = not accounts or not t_accs or t_since >= t_before + if st.button("🎯 Targeted Fetch", disabled=_t_invalid, type="primary", key="btn_targeted"): + _t_since_dt = datetime(t_since.year, t_since.month, t_since.day) + _t_before_dt = datetime(t_before.year, t_before.month, t_before.day) + _t_field = _field_map[t_field_label] + + existing_keys = {_entry_key(e) for e in st.session_state.queue} + existing_keys.update(st.session_state.labeled_keys) + + fetched_all: list[dict] = [] + status = st.status("Fetching…", expanded=True) + _live = status.empty() + + for acc in accounts: + name = acc.get("name", acc.get("username")) + if name not in t_accs: + continue + status.write(f"Connecting to **{name}**…") + try: + emails = _fetch_targeted( + acc, + since_dt=_t_since_dt, before_dt=_t_before_dt, + term=t_term.strip(), field=_t_field, + limit=int(t_limit), + known_keys=existing_keys, + progress_cb=lambda p, msg: _live.markdown(f"⏳ {msg}"), + ) + _live.empty() + fetched_all.extend(emails) + status.write(f"✓ {name}: {len(emails)} new emails") + except Exception as e: + _live.empty() + status.write(f"✗ {name}: {e}") + + if fetched_all: + _save_jsonl(_QUEUE_FILE, st.session_state.queue + fetched_all) + st.session_state.queue = _load_jsonl(_QUEUE_FILE) + labeled_keys = st.session_state.labeled_keys + for i, entry in enumerate(st.session_state.queue): + if _entry_key(entry) not in labeled_keys: + st.session_state.idx = i + break + status.update( + label=f"Done — {len(fetched_all)} new emails added to queue", + state="complete", + ) + else: + status.update( + label="No new emails found in that date range", + state="complete", + ) + # ══════════════════════════════════════════════════════════════════════════════ # LABEL TAB -- 2.45.2 From ab764cb8f6423f10098a049680f15fdf81f6a2f3 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 15:15:49 -0800 Subject: [PATCH 015/103] =?UTF-8?q?feat:=20targeted=20fetch=20=E2=80=94=20?= =?UTF-8?q?date=20range=20+=20sender/subject=20filter=20for=20historical?= =?UTF-8?q?=20email=20pulls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/label_tool.py | 161 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) diff --git a/app/label_tool.py b/app/label_tool.py index 30f2fa9..e534438 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -220,6 +220,81 @@ def _fetch_account(cfg: dict, days: int, limit: int, known_keys: set[str], return emails +def _fetch_targeted( + cfg: dict, + since_dt: datetime, before_dt: datetime, + term: str, field: str, + limit: int, + known_keys: set[str], + progress_cb=None, +) -> list[dict]: + """Fetch emails within a date range, optionally filtered by sender/subject. + + field: "from" | "subject" | "either" | "none" + """ + since = since_dt.strftime("%d-%b-%Y") + before = before_dt.strftime("%d-%b-%Y") + host = cfg.get("host", "imap.gmail.com") + port = int(cfg.get("port", 993)) + use_ssl = cfg.get("use_ssl", True) + username = cfg["username"] + password = cfg["password"] + name = cfg.get("name", username) + + conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port) + conn.login(username, password) + conn.select("INBOX", readonly=True) + + date_part = f'SINCE "{since}" BEFORE "{before}"' + if term and field == "from": + search_str = f'(FROM "{term}") {date_part}' + elif term and field == "subject": + search_str = f'(SUBJECT "{term}") {date_part}' + elif term and field == "either": + search_str = f'(OR (FROM "{term}") (SUBJECT "{term}")) {date_part}' + else: + search_str = date_part + + try: + _, data = conn.search(None, search_str) + uids = (data[0] or b"").split() + except Exception: + uids = [] + + emails: list[dict] = [] + for i, uid in enumerate(uids): + if len(emails) >= limit: + break + if progress_cb: + progress_cb(i / max(len(uids), 1), f"{name}: {len(emails)} fetched…") + try: + _, raw_data = conn.fetch(uid, "(RFC822)") + if not raw_data or not raw_data[0]: + continue + msg = _email_lib.message_from_bytes(raw_data[0][1]) + subj = _decode_str(msg.get("Subject", "")) + from_addr = _decode_str(msg.get("From", "")) + date = _decode_str(msg.get("Date", "")) + body = _extract_body(msg)[:800] + entry = { + "subject": subj, "body": body, + "from_addr": from_addr, "date": date, + "account": name, + } + key = _entry_key(entry) + if key not in known_keys: + known_keys.add(key) + emails.append(entry) + except Exception: + pass + + try: + conn.logout() + except Exception: + pass + return emails + + # ── Queue / score file helpers ─────────────────────────────────────────────── def _entry_key(e: dict) -> str: @@ -429,6 +504,92 @@ with tab_fetch: else: status.update(label="No new emails found (all already in queue or score file)", state="complete") + # ── Targeted fetch ─────────────────────────────────────────────────────── + st.divider() + with st.expander("🎯 Targeted Fetch — date range + keyword"): + st.caption( + "Pull emails within a specific date window, optionally filtered by " + "sender or subject. Use this to retrieve historical hiring threads." + ) + + _t1, _t2 = st.columns(2) + _one_year_ago = (datetime.now() - timedelta(days=365)).date() + t_since = _t1.date_input("From date", value=_one_year_ago, key="t_since") + t_before = _t2.date_input("To date", value=datetime.now().date(), key="t_before") + + t_term = st.text_input( + "Filter by keyword (optional)", + placeholder="e.g. Stateside", + key="t_term", + ) + _tf1, _tf2 = st.columns(2) + t_field_label = _tf1.selectbox( + "Search in", + ["Either (from or subject)", "Sender/from", "Subject line"], + key="t_field", + ) + t_limit = _tf2.number_input("Max emails", min_value=10, max_value=1000, value=300, key="t_limit") + + t_accs = st.multiselect("Accounts", all_accs, default=all_accs, key="t_accs") + + _field_map = { + "Either (from or subject)": "either", + "Sender/from": "from", + "Subject line": "subject", + } + + _t_invalid = not accounts or not t_accs or t_since >= t_before + if st.button("🎯 Targeted Fetch", disabled=_t_invalid, type="primary", key="btn_targeted"): + _t_since_dt = datetime(t_since.year, t_since.month, t_since.day) + _t_before_dt = datetime(t_before.year, t_before.month, t_before.day) + _t_field = _field_map[t_field_label] + + existing_keys = {_entry_key(e) for e in st.session_state.queue} + existing_keys.update(st.session_state.labeled_keys) + + fetched_all: list[dict] = [] + status = st.status("Fetching…", expanded=True) + _live = status.empty() + + for acc in accounts: + name = acc.get("name", acc.get("username")) + if name not in t_accs: + continue + status.write(f"Connecting to **{name}**…") + try: + emails = _fetch_targeted( + acc, + since_dt=_t_since_dt, before_dt=_t_before_dt, + term=t_term.strip(), field=_t_field, + limit=int(t_limit), + known_keys=existing_keys, + progress_cb=lambda p, msg: _live.markdown(f"⏳ {msg}"), + ) + _live.empty() + fetched_all.extend(emails) + status.write(f"✓ {name}: {len(emails)} new emails") + except Exception as e: + _live.empty() + status.write(f"✗ {name}: {e}") + + if fetched_all: + _save_jsonl(_QUEUE_FILE, st.session_state.queue + fetched_all) + st.session_state.queue = _load_jsonl(_QUEUE_FILE) + labeled_keys = st.session_state.labeled_keys + for i, entry in enumerate(st.session_state.queue): + if _entry_key(entry) not in labeled_keys: + st.session_state.idx = i + break + status.update( + label=f"Done — {len(fetched_all)} new emails added to queue", + state="complete", + ) + else: + status.update( + label="No new emails found in that date range", + state="complete", + ) + # ══════════════════════════════════════════════════════════════════════════════ # LABEL TAB -- 2.45.2 From 18c7ed2c5702941a1f791014f52399cfc8826ece Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 15:41:53 -0800 Subject: [PATCH 016/103] =?UTF-8?q?fix:=20manage.sh=20restart=20=E2=80=94?= =?UTF-8?q?=20exec=20bash=20$0=20so=20script=20path=20resolves=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manage.sh b/manage.sh index 9bcb82e..39ea114 100755 --- a/manage.sh +++ b/manage.sh @@ -184,7 +184,7 @@ case "$CMD" in kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true rm -f "$PID_FILE" "$PORT_FILE" fi - exec "$0" start + exec bash "$0" start ;; status) -- 2.45.2 From 732564ae5ea281027a351c76f3b2725deb10b62b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 15:41:53 -0800 Subject: [PATCH 017/103] =?UTF-8?q?fix:=20manage.sh=20restart=20=E2=80=94?= =?UTF-8?q?=20exec=20bash=20$0=20so=20script=20path=20resolves=20correctly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/manage.sh b/manage.sh index 9bcb82e..39ea114 100755 --- a/manage.sh +++ b/manage.sh @@ -184,7 +184,7 @@ case "$CMD" in kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true rm -f "$PID_FILE" "$PORT_FILE" fi - exec "$0" start + exec bash "$0" start ;; status) -- 2.45.2 From 9133cadd6627d62d6f3c27ff91745982f1275830 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Fri, 27 Feb 2026 15:48:40 -0800 Subject: [PATCH 018/103] =?UTF-8?q?feat:=20discard=20button=20=E2=80=94=20?= =?UTF-8?q?removes=20email=20from=20queue=20without=20writing=20to=20score?= =?UTF-8?q?=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/label_tool.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/label_tool.py b/app/label_tool.py index e534438..1340824 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -717,7 +717,7 @@ with tab_label: # ── Navigation ──────────────────────────────────────────────────── st.markdown("") - nav_cols = st.columns([2, 1, 1]) + nav_cols = st.columns([2, 1, 1, 1]) remaining = len(unlabeled) - 1 nav_cols[0].caption(f"**{remaining}** remaining · Keys: 1–9 = label, 0 = other, S = skip, U = undo") @@ -739,6 +739,16 @@ with tab_label: st.session_state.idx = next_idx st.rerun() + if nav_cols[3].button("🗑️ Discard", use_container_width=True): + # Remove from queue entirely — not written to score file + st.session_state.queue = [e for e in queue if _entry_key(e) != _entry_key(entry)] + _save_jsonl(_QUEUE_FILE, st.session_state.queue) + next_idx = min(idx, len(st.session_state.queue) - 1) + while next_idx < len(st.session_state.queue) and _entry_key(st.session_state.queue[next_idx]) in labeled_keys: + next_idx += 1 + st.session_state.idx = max(next_idx, 0) + st.rerun() + # Keyboard shortcut capture (JS → hidden button click) st.components.v1.html( """ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..8ab41c2 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4632 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "@vueuse/core": "^14.2.1", + "@vueuse/integrations": "^14.2.1", + "pinia": "^3.0.4", + "vue": "^3.5.25" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@unocss/preset-attributify": "^66.6.4", + "@unocss/preset-wind": "^66.6.4", + "@vitejs/plugin-vue": "^6.0.2", + "@vue/test-utils": "^2.4.6", + "@vue/tsconfig": "^0.8.1", + "jsdom": "^28.1.0", + "typescript": "~5.9.3", + "unocss": "^66.6.4", + "vite": "^7.3.1", + "vitest": "^4.0.18", + "vue-tsc": "^3.1.5" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@antfu/install-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@antfu/install-pkg/-/install-pkg-1.1.0.tgz", + "integrity": "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "package-manager-detector": "^1.3.0", + "tinyexec": "^1.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", + "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.29", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.29.tgz", + "integrity": "sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.14.1.tgz", + "integrity": "sha512-OhkBFWI6GcRMUroChZiopRiSp2iAMvEBK47NhJooDqz1RERO4QuZIZnjP63TXX8GAiLABkYmX+fuQsdJ1dd2QQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@iconify/types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", + "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@iconify/utils": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@antfu/install-pkg": "^1.1.0", + "@iconify/types": "^2.0.0", + "mlly": "^1.8.0" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@oxc-parser/binding-android-arm-eabi": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.115.0.tgz", + "integrity": "sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.115.0.tgz", + "integrity": "sha512-lWRX75u+gqfB4TF3pWCHuvhaeneAmRl2b2qNBcl4S6yJ0HtnT4VXOMEZrq747i4Zby1ZTxj6mtOe678Bg8gRLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.115.0.tgz", + "integrity": "sha512-ii/oOZjfGY1aszXTy29Z5DRyCEnBOrAXDVCvfdfXFQsOZlbbOa7NMHD7D+06YFe5qdxfmbWAYv4yn6QJi/0d2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.115.0.tgz", + "integrity": "sha512-R/sW/p8l77wglbjpMcF+h/3rWbp9zk1mRP3U14mxTYIC2k3m+aLBpXXgk2zksqf9qKk5mcc4GIYsuCn9l8TgDg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.115.0.tgz", + "integrity": "sha512-CSJ5ldNm9wIGGkhaIJeGmxRMZbgxThRN+X1ufYQQUNi5jZDV/U3C2QDMywpP93fczNBj961hXtcUPO/oVGq4Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.115.0.tgz", + "integrity": "sha512-uWFwssE5dHfQ8lH+ktrsD9JA49+Qa0gtxZHUs62z1e91NgGz6O7jefHGI6aygNyKNS45pnnBSDSP/zV977MsOQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.115.0.tgz", + "integrity": "sha512-fZbqt8y/sKQ+v6bBCuv/mYYFoC0+fZI3mGDDEemmDOhT78+aUs2+4ZMdbd2btlXmnLaScl37r8IRbhnok5Ka9w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.115.0.tgz", + "integrity": "sha512-1ej/MjuTY9tJEunU/hUPIFmgH5PqgMQoRjNOvOkibtJ3Zqlw/+Lc+HGHDNET8sjbgIkWzdhX+p4J96A5CPdbag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.115.0.tgz", + "integrity": "sha512-HjsZbJPH9mMd4swJRywVMsDZsJX0hyKb1iNHo5ijRl5yhtbO3lj7ImSrrL1oZ1VEg0te4iKmDGGz/6YPLd1G8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.115.0.tgz", + "integrity": "sha512-zhhePoBrd7kQx3oClX/W6NldsuCbuMqaN9rRsY+6/WoorAb4j490PG/FjqgAXscWp2uSW2WV9L+ksn0wHrvsrg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.115.0.tgz", + "integrity": "sha512-t/IRojvUE9XrKu+/H1b8YINug+7Q6FLls5rsm2lxB5mnS8GN/eYAYrPgHkcg9/1SueRDSzGpDYu3lGWTObk1zw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-musl": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.115.0.tgz", + "integrity": "sha512-79jBHSSh/YpQRAmvYoaCfpyToRbJ/HBrdB7hxK2ku2JMehjopTVo+xMJss/RV7/ZYqeezgjvKDQzapJbgcjVZA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.115.0.tgz", + "integrity": "sha512-nA1TpxkhNTIOMMyiSSsa7XIVJVoOU/SsVrHIz3gHvWweB5PHCQfO7w+Lb2EP0lBWokv7HtA/KbF7aLDoXzmuMw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.115.0.tgz", + "integrity": "sha512-9iVX789DoC3SaOOG+X6NcF/tVChgLp2vcHffzOC2/Z1JTPlz6bMG2ogvcW6/9s0BG2qvhNQImd+gbWYeQbOwVw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.115.0.tgz", + "integrity": "sha512-RmQmk+mjCB0nMNfEYhaCxwofLo1Z95ebHw1AGvRiWGCd4zhCNOyskgCbMogIcQzSB3SuEKWgkssyaiQYVAA4hQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-openharmony-arm64": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.115.0.tgz", + "integrity": "sha512-viigraWWQhhDvX5aGq+wrQq58k00Xq3MHz/0R4AFMxGlZ8ogNonpEfNc73Q5Ly87Z6sU9BvxEdG0dnYTfVnmew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.115.0.tgz", + "integrity": "sha512-IzGCrMwXhpb4kTXy/8lnqqqwjI7eOvy+r9AhVw+hsr8t1ecBBEHprcNy0aKatFHN6hsX7UMHHQmBAQjVvL/p1A==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.115.0.tgz", + "integrity": "sha512-/ym+Absk/TLFvbhh3se9XYuI1D7BrUVHw4RaG/2dmWKgBenrZHaJsgnRb7NJtaOyjEOLIPtULx1wDdVL0SX2eg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-ia32-msvc": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.115.0.tgz", + "integrity": "sha512-AQSZjIR+b+Te7uaO/hGTMjT8/oxlYrvKrOTi4KTHF/O6osjHEatUQ3y6ZW2+8+lJxy20zIcGz6iQFmFq/qDKkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.115.0.tgz", + "integrity": "sha512-oxUl82N+fIO9jIaXPph8SPPHQXrA08BHokBBJW8ct9F/x6o6bZE6eUAhUtWajbtvFhL8UYcCWRMba+kww6MBlA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", + "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@quansync/fs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@quansync/fs/-/fs-1.0.0.tgz", + "integrity": "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.11.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.11.0.tgz", + "integrity": "sha512-fPxQqz4VTgPI/IQ+lj9r0h+fDR66bzoeMGHp8ASee+32OSGIkeASsoZuJixsQoVef1QJbeubcPBxKk22QVoWdw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@unocss/cli": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/cli/-/cli-66.6.4.tgz", + "integrity": "sha512-jSeGL9a7tchoKvGQAsEdtjmvEu1axdikK5fdvmQnDOnLSM5Vo5wCthGYtsIIpQvb9HFBe0NupAJNwpjRBGiCaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@unocss/config": "66.6.4", + "@unocss/core": "66.6.4", + "@unocss/preset-wind3": "66.6.4", + "@unocss/preset-wind4": "66.6.4", + "@unocss/transformer-directives": "66.6.4", + "cac": "^6.7.14", + "chokidar": "^5.0.0", + "colorette": "^2.0.20", + "consola": "^3.4.2", + "magic-string": "^0.30.21", + "pathe": "^2.0.3", + "perfect-debounce": "^2.1.0", + "tinyglobby": "^0.2.15", + "unplugin-utils": "^0.3.1" + }, + "bin": { + "unocss": "bin/unocss.mjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/cli/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@unocss/config": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/config/-/config-66.6.4.tgz", + "integrity": "sha512-iwHl5FG81cOAMalqigjw21Z2tMa0xjN0doQxnGOLx8KP+BllruXSjBj8CRk3m6Ny9fDxfpFY0ruYbIBA5AGwDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "colorette": "^2.0.20", + "consola": "^3.4.2", + "unconfig": "^7.5.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/core": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/core/-/core-66.6.4.tgz", + "integrity": "sha512-Fii3lhVJVFrKUz6hMGAkq3sXBfNnXB2G8bldNHuBHJpDAoP1F0oO/SU/oSqSjCYvtcD5RtOn8qwzcHuuN3B/mg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/extractor-arbitrary-variants": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/extractor-arbitrary-variants/-/extractor-arbitrary-variants-66.6.4.tgz", + "integrity": "sha512-l827c/UdE2FUBiaXDde5f/IjW41TflhtnjgQr3tJoCw7v9VuokDJFl+iOTyaH6AwMKpMeSBB+DU5Ippj4IOs9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/inspector": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/inspector/-/inspector-66.6.4.tgz", + "integrity": "sha512-q5oplYKCyO6YHN1MFQadkjs4fTTOKgsw0tXoSft6RLXowo8Utv6nBmED4yWb6Y6iYFmFU5RZ8VavxZvfghOlmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "@unocss/rule-utils": "66.6.4", + "colorette": "^2.0.20", + "gzip-size": "^6.0.0", + "sirv": "^3.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/preset-attributify": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/preset-attributify/-/preset-attributify-66.6.4.tgz", + "integrity": "sha512-pksrugV/GqfgyUonHycxDvxUPVI3H9LiRcOEf1mZweD2qAqT6lH9qE1AHHddiZpWAcics4CkUkDpgXRwgt+wJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/preset-icons": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/preset-icons/-/preset-icons-66.6.4.tgz", + "integrity": "sha512-Xz8EQdPkANHlHUmWDw5/ehWTcn4tJeNltB4OnxI5vsi0hiqpLJxxKUE/vLUVH1I4GnVFCF4bBg7fmHanEcL0/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@iconify/utils": "^3.1.0", + "@unocss/core": "66.6.4", + "ofetch": "^1.5.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/preset-mini": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/preset-mini/-/preset-mini-66.6.4.tgz", + "integrity": "sha512-8xUXf/Bbi1/h98ldL56OxOnWUgWy0el0/xCGDLKYtBRUYGvZgrV+ys9UxY1/z+w7q+T+PZi+3qhc0O06nJ8wUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "@unocss/extractor-arbitrary-variants": "66.6.4", + "@unocss/rule-utils": "66.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/preset-tagify": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/preset-tagify/-/preset-tagify-66.6.4.tgz", + "integrity": "sha512-eWu9fH6c6gZH1FswMVPaX0kMS8Jw6dqDvlVLbjZgWraAHTon53lOnB2365bXgsl5zXYg30JGMzP/k171FJQWig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/preset-typography": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/preset-typography/-/preset-typography-66.6.4.tgz", + "integrity": "sha512-APtMRFUPA4e5S1Yyc3LWTqiy+XMq/SEMStkcGM6Rroy8Rzx+ItfqV/UrOWdg8gFYFPK8tVOvNG+40qlZy5Keww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "@unocss/rule-utils": "66.6.4" + } + }, + "node_modules/@unocss/preset-uno": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/preset-uno/-/preset-uno-66.6.4.tgz", + "integrity": "sha512-9BAprWrx6/leMaRBzH91vGYl4mEgIX/BP1h8ucEJ3aAo6dFrfmpC56HG7wOHNGMr4/uxm4aD7uI2SUpN+CBEEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "@unocss/preset-wind3": "66.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/preset-web-fonts": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/preset-web-fonts/-/preset-web-fonts-66.6.4.tgz", + "integrity": "sha512-N2qqeKf0W1mDXDBlXBdN32Dm6pLEbTFQsRe6WpX9SH5pCrEvuJG8cnIBPPpATLC+Qf2EWOepg1fIX+iWoF4Cyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "ofetch": "^1.5.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/preset-wind": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/preset-wind/-/preset-wind-66.6.4.tgz", + "integrity": "sha512-OGeLXvcGQROuFrFmu/WOY8sbBvNBzAyi0firMY5AZhSkGmX/q4aBEJGGs3eiuMwg6JIhPg4QXzLjL7uWZJ0ZgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "@unocss/preset-wind3": "66.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/preset-wind3": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/preset-wind3/-/preset-wind3-66.6.4.tgz", + "integrity": "sha512-RxPR5czvE3RJ+eJoMM2AkPews7z4vSOeqTX8OIILzvEUFG1fRUvxMLaHGb4qstGPtHBJKrwNmvYjMozoiU2EgA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "@unocss/preset-mini": "66.6.4", + "@unocss/rule-utils": "66.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/preset-wind4": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/preset-wind4/-/preset-wind4-66.6.4.tgz", + "integrity": "sha512-MvI3bxoOafEADiFJSHr7WB8nT01ZQvjsfWEuRNtNeRSTBVZ2QuJW8imL2sr9fk1qHoHmzN/3HefpTQoxiQWVcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "@unocss/extractor-arbitrary-variants": "66.6.4", + "@unocss/rule-utils": "66.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/rule-utils": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/rule-utils/-/rule-utils-66.6.4.tgz", + "integrity": "sha512-n/vCodRuzKtRBpZqd4OLVujDEJlPl11Iw5AtxB4GYsRT4AED/JY//XHLb5ubdLa1j3m84OAfnkT9Gr9sMWcwig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "^66.6.4", + "magic-string": "^0.30.21" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/transformer-attributify-jsx": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/transformer-attributify-jsx/-/transformer-attributify-jsx-66.6.4.tgz", + "integrity": "sha512-Rw9g3Ed/Et1W68znIuCod4OTLlOmuPpt2/6ZsylzNPEgGdJCHGYOdNs6Ai5IlbjrlOE4XfwK0O0iJQdk01V6FA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "oxc-parser": "^0.115.0", + "oxc-walker": "^0.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/transformer-compile-class": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/transformer-compile-class/-/transformer-compile-class-66.6.4.tgz", + "integrity": "sha512-sZrPIp28xPnroT+BTX6onHfIXwjBkuPDyO3oKyciuCRZxGgTkV6GXV6lSGSu2EHFRjCmzzuCWgo33gU55TtklA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/transformer-directives": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/transformer-directives/-/transformer-directives-66.6.4.tgz", + "integrity": "sha512-IIczs0NZeEOIa/X28gkJevT6FtCWoMT3OmnMFDRi9plu3d7BYuQuBkBUYVyT7lIspn+iENCaXFl3e1l60e/xpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4", + "@unocss/rule-utils": "66.6.4", + "css-tree": "^3.1.0" + } + }, + "node_modules/@unocss/transformer-variant-group": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/transformer-variant-group/-/transformer-variant-group-66.6.4.tgz", + "integrity": "sha512-evAbg2fKuhJ0en71Y8iHJYbuED0SSiqg7BIajSbk0BQvy8N70wbu19Ljpjfc7JfcWV/vSWgNIklOr/TsYJhU6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/core": "66.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@unocss/vite": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/@unocss/vite/-/vite-66.6.4.tgz", + "integrity": "sha512-qLSfJ2a0iDMhM/d3zpg9RQ7RW22tnP5hXARo430m9UK7bK1SmAbMAS70Wv2/FuRScBGLeMfluIuePghtuzgOLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@unocss/config": "66.6.4", + "@unocss/core": "66.6.4", + "@unocss/inspector": "66.6.4", + "chokidar": "^5.0.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15", + "unplugin-utils": "^0.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz", + "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", + "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "chai": "^6.2.1", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", + "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.0.18", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", + "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", + "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.0.18", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", + "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", + "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", + "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.0.18", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", + "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.29", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", + "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", + "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.29", + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", + "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.5.tgz", + "integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", + "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", + "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/runtime-core": "3.5.29", + "@vue/shared": "3.5.29", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", + "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "vue": "3.5.29" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.2.1.tgz", + "integrity": "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/integrations": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-14.2.1.tgz", + "integrity": "sha512-2LIUpBi/67PoXJGqSDQUF0pgQWpNHh7beiA+KG2AbybcNm+pTGWT6oPGlBgUoDWmYwfeQqM/uzOHqcILpKL7nA==", + "license": "MIT", + "dependencies": { + "@vueuse/core": "14.2.1", + "@vueuse/shared": "14.2.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "async-validator": "^4", + "axios": "^1", + "change-case": "^5", + "drauu": "^0.4", + "focus-trap": "^7 || ^8", + "fuse.js": "^7", + "idb-keyval": "^6", + "jwt-decode": "^4", + "nprogress": "^0.2", + "qrcode": "^1.5", + "sortablejs": "^1", + "universal-cookie": "^7 || ^8", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "async-validator": { + "optional": true + }, + "axios": { + "optional": true + }, + "change-case": { + "optional": true + }, + "drauu": { + "optional": true + }, + "focus-trap": { + "optional": true + }, + "fuse.js": { + "optional": true + }, + "idb-keyval": { + "optional": true + }, + "jwt-decode": { + "optional": true + }, + "nprogress": { + "optional": true + }, + "qrcode": { + "optional": true + }, + "sortablejs": { + "optional": true + }, + "universal-cookie": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.2.1.tgz", + "integrity": "sha512-1ButlVtj5Sb/HDtIy1HFr1VqCP4G6Ypqt5MAo0lCgjokrk2mvQKsK2uuy0vqu/Ks+sHfuHo0B9Y9jn9xKdjZsw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.2.1", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.2.1.tgz", + "integrity": "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssstyle": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", + "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.28", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.6" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true, + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/duplexer": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/jsdom": { + "version": "28.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", + "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.8.1", + "@bramus/specificity": "^2.4.2", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^6.0.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.21.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-regexp": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/magic-regexp/-/magic-regexp-0.10.0.tgz", + "integrity": "sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12", + "mlly": "^1.7.2", + "regexp-tree": "^0.1.27", + "type-level-regexp": "~0.1.17", + "ufo": "^1.5.4", + "unplugin": "^2.0.0" + } + }, + "node_modules/magic-regexp/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", + "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.15.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.1" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/ofetch": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ofetch/-/ofetch-1.5.1.tgz", + "integrity": "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "destr": "^2.0.5", + "node-fetch-native": "^1.6.7", + "ufo": "^1.6.1" + } + }, + "node_modules/oxc-parser": { + "version": "0.115.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.115.0.tgz", + "integrity": "sha512-2w7Xn3CbS/zwzSY82S5WLemrRu3CT57uF7Lx8llrE/2bul6iMTcJE4Rbls7GDNbLn3ttATI68PfOz2Pt3KZ2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.115.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm-eabi": "0.115.0", + "@oxc-parser/binding-android-arm64": "0.115.0", + "@oxc-parser/binding-darwin-arm64": "0.115.0", + "@oxc-parser/binding-darwin-x64": "0.115.0", + "@oxc-parser/binding-freebsd-x64": "0.115.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.115.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.115.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.115.0", + "@oxc-parser/binding-linux-arm64-musl": "0.115.0", + "@oxc-parser/binding-linux-ppc64-gnu": "0.115.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.115.0", + "@oxc-parser/binding-linux-riscv64-musl": "0.115.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.115.0", + "@oxc-parser/binding-linux-x64-gnu": "0.115.0", + "@oxc-parser/binding-linux-x64-musl": "0.115.0", + "@oxc-parser/binding-openharmony-arm64": "0.115.0", + "@oxc-parser/binding-wasm32-wasi": "0.115.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.115.0", + "@oxc-parser/binding-win32-ia32-msvc": "0.115.0", + "@oxc-parser/binding-win32-x64-msvc": "0.115.0" + } + }, + "node_modules/oxc-walker": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/oxc-walker/-/oxc-walker-0.7.0.tgz", + "integrity": "sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-regexp": "^0.10.0" + }, + "peerDependencies": { + "oxc-parser": ">=0.98.0" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/package-manager-detector": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quansync": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-1.0.0.tgz", + "integrity": "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", + "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.24.tgz", + "integrity": "sha512-1r6vQTTt1rUiJkI5vX7KG8PR342Ru/5Oh13kEQP2SMbRSZpOey9SrBe27IDxkoWulx8ShWu4K6C0BkctP8Z1bQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.24" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.24", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.24.tgz", + "integrity": "sha512-pj7yygNMoMRqG7ML2SDQ0xNIOfN3IBDUcPVM2Sg6hP96oFNN2nqnzHreT3z9xLq85IWJyNTvD38O002DdOrPMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-level-regexp": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/type-level-regexp/-/type-level-regexp-0.1.17.tgz", + "integrity": "sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==", + "dev": true, + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/unconfig": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/unconfig/-/unconfig-7.5.0.tgz", + "integrity": "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@quansync/fs": "^1.0.0", + "defu": "^6.1.4", + "jiti": "^2.6.1", + "quansync": "^1.0.0", + "unconfig-core": "7.5.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/unconfig-core": { + "version": "7.5.0", + "resolved": "https://registry.npmjs.org/unconfig-core/-/unconfig-core-7.5.0.tgz", + "integrity": "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@quansync/fs": "^1.0.0", + "quansync": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/undici": { + "version": "7.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", + "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/unocss": { + "version": "66.6.4", + "resolved": "https://registry.npmjs.org/unocss/-/unocss-66.6.4.tgz", + "integrity": "sha512-W7BfUX2pw4cvUB8kq5CZro/TWM0LcXTjgwwmjowK5B/KVs0Sgc8vTaCr5wuyqNcDLLGAe/9oNPGsVgVBJQN6kQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@unocss/cli": "66.6.4", + "@unocss/core": "66.6.4", + "@unocss/preset-attributify": "66.6.4", + "@unocss/preset-icons": "66.6.4", + "@unocss/preset-mini": "66.6.4", + "@unocss/preset-tagify": "66.6.4", + "@unocss/preset-typography": "66.6.4", + "@unocss/preset-uno": "66.6.4", + "@unocss/preset-web-fonts": "66.6.4", + "@unocss/preset-wind": "66.6.4", + "@unocss/preset-wind3": "66.6.4", + "@unocss/preset-wind4": "66.6.4", + "@unocss/transformer-attributify-jsx": "66.6.4", + "@unocss/transformer-compile-class": "66.6.4", + "@unocss/transformer-directives": "66.6.4", + "@unocss/transformer-variant-group": "66.6.4", + "@unocss/vite": "66.6.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@unocss/astro": "66.6.4", + "@unocss/postcss": "66.6.4", + "@unocss/webpack": "66.6.4" + }, + "peerDependenciesMeta": { + "@unocss/astro": { + "optional": true + }, + "@unocss/postcss": { + "optional": true + }, + "@unocss/webpack": { + "optional": true + } + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "dev": true, + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", + "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.0.18", + "@vitest/mocker": "4.0.18", + "@vitest/pretty-format": "4.0.18", + "@vitest/runner": "4.0.18", + "@vitest/snapshot": "4.0.18", + "@vitest/spy": "4.0.18", + "@vitest/utils": "4.0.18", + "es-module-lexer": "^1.7.0", + "expect-type": "^1.2.2", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^3.10.0", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.0.18", + "@vitest/browser-preview": "4.0.18", + "@vitest/browser-webdriverio": "4.0.18", + "@vitest/ui": "4.0.18", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", + "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-sfc": "3.5.29", + "@vue/runtime-dom": "3.5.29", + "@vue/server-renderer": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz", + "integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.5" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..fcc22dd --- /dev/null +++ b/web/package.json @@ -0,0 +1,33 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@vueuse/core": "^14.2.1", + "@vueuse/integrations": "^14.2.1", + "pinia": "^3.0.4", + "vue": "^3.5.25" + }, + "devDependencies": { + "@types/node": "^24.10.1", + "@unocss/preset-attributify": "^66.6.4", + "@unocss/preset-wind": "^66.6.4", + "@vitejs/plugin-vue": "^6.0.2", + "@vue/test-utils": "^2.4.6", + "@vue/tsconfig": "^0.8.1", + "jsdom": "^28.1.0", + "typescript": "~5.9.3", + "unocss": "^66.6.4", + "vite": "^7.3.1", + "vitest": "^4.0.18", + "vue-tsc": "^3.1.5" + } +} diff --git a/web/public/vite.svg b/web/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/web/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..58b0f21 --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/web/src/assets/vue.svg b/web/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/web/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/src/components/HelloWorld.vue b/web/src/components/HelloWorld.vue new file mode 100644 index 0000000..b58e52b --- /dev/null +++ b/web/src/components/HelloWorld.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..2425c0f --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import './style.css' +import App from './App.vue' + +createApp(App).mount('#app') diff --git a/web/src/smoke.test.ts b/web/src/smoke.test.ts new file mode 100644 index 0000000..119b9e0 --- /dev/null +++ b/web/src/smoke.test.ts @@ -0,0 +1,7 @@ +import { describe, it, expect } from 'vitest' + +describe('scaffold', () => { + it('vitest works', () => { + expect(1 + 1).toBe(2) + }) +}) diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..f691315 --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,79 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +.card { + padding: 2em; +} + +#app { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..8d16e42 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,16 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/web/uno.config.ts b/web/uno.config.ts new file mode 100644 index 0000000..29b9a7e --- /dev/null +++ b/web/uno.config.ts @@ -0,0 +1,5 @@ +import { defineConfig, presetWind, presetAttributify } from 'unocss' + +export default defineConfig({ + presets: [presetWind(), presetAttributify()], +}) diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..00529c8 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import UnoCSS from 'unocss/vite' + +export default defineConfig({ + plugins: [vue(), UnoCSS()], + test: { + environment: 'jsdom', + globals: true, + }, +}) -- 2.45.2 From ac1f4b8ba13d79a68849506e7a99af018f036076 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 15:49:07 -0800 Subject: [PATCH 030/103] feat(avocet): CircuitForge base theme + Avocet Slate Teal/Russet colors --- web/src/assets/avocet.css | 8 ++ web/src/assets/theme.css | 268 ++++++++++++++++++++++++++++++++++++++ web/src/main.ts | 9 +- 3 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 web/src/assets/avocet.css create mode 100644 web/src/assets/theme.css diff --git a/web/src/assets/avocet.css b/web/src/assets/avocet.css new file mode 100644 index 0000000..e04cd6c --- /dev/null +++ b/web/src/assets/avocet.css @@ -0,0 +1,8 @@ +/* Avocet app color overrides — Slate Teal + Russet */ +/* These override the base --app-primary/--app-accent from theme.css */ +:root { + --app-primary: #2A6080; /* Slate Teal — "deep water" */ + --app-primary-dark: #5A9DBF; + --app-accent: #B8622A; /* Russet — avocet's orange head */ + --app-accent-dark: #D4854A; +} diff --git a/web/src/assets/theme.css b/web/src/assets/theme.css new file mode 100644 index 0000000..4bf7491 --- /dev/null +++ b/web/src/assets/theme.css @@ -0,0 +1,268 @@ +/* assets/styles/theme.css — CENTRAL THEME FILE + Accessible Solarpunk: warm, earthy, humanist, trustworthy. + Hacker mode: terminal green circuit-trace dark (Konami code). + ALL color/font/spacing tokens live here — nowhere else. +*/ + +/* ── Accessible Solarpunk — light (default) ──────── */ +:root { + /* Brand */ + --color-primary: #2d5a27; + --color-primary-hover: #234820; + --color-primary-light: #e8f2e7; + + /* Surfaces — cool blue-slate, crisp and legible */ + --color-surface: #eaeff8; + --color-surface-alt: #dde4f0; + --color-surface-raised: #f5f7fc; + + /* Borders — cool blue-gray */ + --color-border: #a8b8d0; + --color-border-light: #ccd5e6; + + /* Text — dark navy, cool undertone */ + --color-text: #1a2338; + --color-text-muted: #4a5c7a; + --color-text-inverse: #eaeff8; + + /* Accent — amber/terracotta (action, links, CTAs) */ + --color-accent: #c4732a; + --color-accent-hover: #a85c1f; + --color-accent-light: #fdf0e4; + + /* Semantic */ + --color-success: #3a7a32; + --color-error: #c0392b; + --color-warning: #d4891a; + --color-info: #1e6091; + + /* Typography */ + --font-display: 'Fraunces', Georgia, serif; /* Headings — optical humanist serif */ + --font-body: 'Atkinson Hyperlegible', system-ui, sans-serif; /* Body — designed for accessibility */ + --font-mono: 'JetBrains Mono', 'Fira Code', monospace; /* Code, hacker mode */ + + /* Spacing scale */ + --space-1: 0.25rem; + --space-2: 0.5rem; + --space-3: 0.75rem; + --space-4: 1rem; + --space-6: 1.5rem; + --space-8: 2rem; + --space-12: 3rem; + --space-16: 4rem; + --space-24: 6rem; + + /* Radii */ + --radius-sm: 0.25rem; + --radius-md: 0.5rem; + --radius-lg: 1rem; + --radius-full: 9999px; + + /* Shadows — cool blue-navy base */ + --shadow-sm: 0 1px 3px rgba(26, 35, 56, 0.08), 0 1px 2px rgba(26, 35, 56, 0.04); + --shadow-md: 0 4px 12px rgba(26, 35, 56, 0.1), 0 2px 4px rgba(26, 35, 56, 0.06); + --shadow-lg: 0 10px 30px rgba(26, 35, 56, 0.12), 0 4px 8px rgba(26, 35, 56, 0.06); + + /* Transitions */ + --transition: 200ms ease; + --transition-slow: 400ms ease; + + /* Header */ + --header-height: 4rem; + --header-border: 2px solid var(--color-border); +} + +/* ── Accessible Solarpunk — dark (system dark mode) ─ + Activates when OS/browser is in dark mode. + Uses :not([data-theme="hacker"]) so the Konami easter + egg always wins over the system preference. */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="hacker"]) { + /* Brand — lighter greens readable on dark surfaces */ + --color-primary: #6ab870; + --color-primary-hover: #7ecb84; + --color-primary-light: #162616; + + /* Surfaces — deep blue-slate, not pure black */ + --color-surface: #16202e; + --color-surface-alt: #1e2a3a; + --color-surface-raised: #263547; + + /* Borders */ + --color-border: #2d4060; + --color-border-light: #233352; + + /* Text */ + --color-text: #e4eaf5; + --color-text-muted: #8da0bc; + --color-text-inverse: #16202e; + + /* Accent — lighter amber for dark bg contrast (WCAG AA) */ + --color-accent: #e8a84a; + --color-accent-hover: #f5bc60; + --color-accent-light: #2d1e0a; + + /* Semantic */ + --color-success: #5eb85e; + --color-error: #e05252; + --color-warning: #e8a84a; + --color-info: #4da6e8; + + /* Shadows — darker base for dark bg */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.2); + --shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4), 0 4px 8px rgba(0, 0, 0, 0.2); + } +} + +/* ── Hacker/maker easter egg theme ──────────────── */ +/* Activated by Konami code: ↑↑↓↓←→←→BA */ +/* Stored in localStorage: 'cf-hacker-mode' */ +/* Applied: document.documentElement.dataset.theme */ +[data-theme="hacker"] { + --color-primary: #00ff41; + --color-primary-hover: #00cc33; + --color-primary-light: #001a00; + + --color-surface: #0a0c0a; + --color-surface-alt: #0d120d; + --color-surface-raised: #111811; + + --color-border: #1a3d1a; + --color-border-light: #123012; + + --color-text: #b8f5b8; + --color-text-muted: #5a9a5a; + --color-text-inverse: #0a0c0a; + + --color-accent: #00ff41; + --color-accent-hover: #00cc33; + --color-accent-light: #001a0a; + + --color-success: #00ff41; + --color-error: #ff3333; + --color-warning: #ffaa00; + --color-info: #00aaff; + + /* Hacker mode: mono font everywhere */ + --font-display: 'JetBrains Mono', monospace; + --font-body: 'JetBrains Mono', monospace; + + --shadow-sm: 0 1px 3px rgba(0, 255, 65, 0.08); + --shadow-md: 0 4px 12px rgba(0, 255, 65, 0.12); + --shadow-lg: 0 10px 30px rgba(0, 255, 65, 0.15); + + --header-border: 2px solid var(--color-border); + + /* Hacker glow variants — for box-shadow, text-shadow, bg overlays */ + --color-accent-glow-xs: rgba(0, 255, 65, 0.08); + --color-accent-glow-sm: rgba(0, 255, 65, 0.15); + --color-accent-glow-md: rgba(0, 255, 65, 0.4); + --color-accent-glow-lg: rgba(0, 255, 65, 0.6); +} + +/* ── Base resets ─────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; } + +html { + font-family: var(--font-body); + color: var(--color-text); + background: var(--color-surface); + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { margin: 0; min-height: 100vh; } + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-display); + color: var(--color-primary); + line-height: 1.2; + margin: 0; +} + +/* Focus visible — keyboard nav — accessibility requirement */ +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 3px; + border-radius: var(--radius-sm); +} + +/* Respect reduced motion */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + transition-duration: 0.01ms !important; + } +} + +/* ── Prose — CMS rich text ───────────────────────── */ +.prose { + font-family: var(--font-body); + line-height: 1.75; + color: var(--color-text); + max-width: 65ch; +} +.prose h2 { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 700; + margin: 2rem 0 0.75rem; + color: var(--color-primary); +} +.prose h3 { + font-family: var(--font-display); + font-size: 1.2rem; + font-weight: 600; + margin: 1.5rem 0 0.5rem; + color: var(--color-primary); +} +.prose p { margin: 0 0 1rem; } +.prose ul, .prose ol { margin: 0 0 1rem; padding-left: 1.5rem; } +.prose li { margin-bottom: 0.4rem; } +.prose a { color: var(--color-accent); text-decoration: underline; text-underline-offset: 3px; } +.prose strong { font-weight: 700; } +.prose code { + font-family: var(--font-mono); + font-size: 0.875em; + background: var(--color-surface-alt); + border: 1px solid var(--color-border-light); + padding: 0.1em 0.35em; + border-radius: var(--radius-sm); +} +.prose blockquote { + border-left: 3px solid var(--color-accent); + margin: 1.5rem 0; + padding: 0.5rem 0 0.5rem 1.25rem; + color: var(--color-text-muted); + font-style: italic; +} + +/* ── Utility: screen reader only ────────────────── */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.sr-only:focus-visible { + position: fixed; + top: 0.5rem; + left: 0.5rem; + width: auto; + height: auto; + padding: 0.5rem 1rem; + clip: auto; + white-space: normal; + background: var(--color-accent); + color: var(--color-text-inverse); + border-radius: var(--radius-md); + font-weight: 600; + z-index: 9999; +} diff --git a/web/src/main.ts b/web/src/main.ts index 2425c0f..65ff817 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,5 +1,10 @@ import { createApp } from 'vue' -import './style.css' +import { createPinia } from 'pinia' +import 'virtual:uno.css' +import './assets/theme.css' +import './assets/avocet.css' import App from './App.vue' -createApp(App).mount('#app') +const app = createApp(App) +app.use(createPinia()) +app.mount('#app') -- 2.45.2 From 2abda18b92b8c3001e807ae9102440dafa8b2463 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 15:52:38 -0800 Subject: [PATCH 031/103] =?UTF-8?q?fix(avocet):=20align=20theme=20with=20P?= =?UTF-8?q?eregrine=20design=20system=20=E2=80=94=20full=20token=20set,=20?= =?UTF-8?q?dark=20mode,=20self-hosted=20fonts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/package-lock.json | 30 ++++++++++++++++++++++ web/package.json | 3 +++ web/src/assets/avocet.css | 54 ++++++++++++++++++++++++++++++++++----- web/src/main.ts | 6 +++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index 8ab41c2..f2fbbcd 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,9 @@ "name": "web", "version": "0.0.0", "dependencies": { + "@fontsource/atkinson-hyperlegible": "^5.2.8", + "@fontsource/fraunces": "^5.2.9", + "@fontsource/jetbrains-mono": "^5.2.8", "@vueuse/core": "^14.2.1", "@vueuse/integrations": "^14.2.1", "pinia": "^3.0.4", @@ -772,6 +775,33 @@ } } }, + "node_modules/@fontsource/atkinson-hyperlegible": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/atkinson-hyperlegible/-/atkinson-hyperlegible-5.2.8.tgz", + "integrity": "sha512-HciLcJ5DIK/OVOdo71EbEN4NnvDFlp6/SpAxtcbWf2aAdcsOuPqITxj5KNEXb48qSPSdnnZdGGnSJChPKi3/bA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/fraunces": { + "version": "5.2.9", + "resolved": "https://registry.npmjs.org/@fontsource/fraunces/-/fraunces-5.2.9.tgz", + "integrity": "sha512-XDzuddBtoC7BZgZdBn6b7hsFZY2+V1hgN7yca5fBTKuHjb/lOd45a0Ji8dTUgFhPoL7RdGupo+bC2BFSt6UH8Q==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@iconify/types": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", diff --git a/web/package.json b/web/package.json index fcc22dd..297d155 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,9 @@ "test:watch": "vitest" }, "dependencies": { + "@fontsource/atkinson-hyperlegible": "^5.2.8", + "@fontsource/fraunces": "^5.2.9", + "@fontsource/jetbrains-mono": "^5.2.8", "@vueuse/core": "^14.2.1", "@vueuse/integrations": "^14.2.1", "pinia": "^3.0.4", diff --git a/web/src/assets/avocet.css b/web/src/assets/avocet.css index e04cd6c..3ad8a01 100644 --- a/web/src/assets/avocet.css +++ b/web/src/assets/avocet.css @@ -1,8 +1,50 @@ -/* Avocet app color overrides — Slate Teal + Russet */ -/* These override the base --app-primary/--app-accent from theme.css */ +/* web/src/assets/avocet.css + Avocet token overrides — imports AFTER theme.css. + Only overrides what is genuinely different from the CircuitForge base theme. + Pattern mirrors peregrine.css — see peregrine/docs/plans/2026-03-03-nuxt-design-system.md. + + App colors: + Primary — Slate Teal (#2A6080) — inspired by avocet's slate-blue back plumage + deep water + Accent — Russet (#B8622A) — inspired by avocet's vivid orange-russet head +*/ + +/* ── Light mode (default) ──────────────────────────── */ :root { - --app-primary: #2A6080; /* Slate Teal — "deep water" */ - --app-primary-dark: #5A9DBF; - --app-accent: #B8622A; /* Russet — avocet's orange head */ - --app-accent-dark: #D4854A; + /* Primary — Slate Teal */ + --app-primary: #2A6080; /* 4.8:1 on light surface #eaeff8 — ✅ AA */ + --app-primary-hover: #1E4D66; /* darker for hover */ + --app-primary-light: #E4F0F7; /* subtle bg tint — background use only */ + + /* Accent — Russet */ + --app-accent: #B8622A; /* 4.6:1 on light surface — ✅ AA */ + --app-accent-hover: #9A4E1F; /* darker for hover */ + --app-accent-light: #FAF0E8; /* subtle bg tint — background use only */ + + /* Text on accent buttons — dark navy, NOT white (russet bg only ~2.8:1 with white) */ + --app-accent-text: #1a2338; + + /* Avocet motion tokens */ + --swipe-exit: 300ms; + --swipe-spring: 400ms cubic-bezier(0.34, 1.56, 0.64, 1); /* card gestures */ + --bucket-expand: 250ms cubic-bezier(0.34, 1.56, 0.64, 1); /* label→bucket transform */ + --card-dismiss: 350ms ease-in; /* fileAway / crumple */ + --card-skip: 300ms ease-out; /* slideUnder */ +} + +/* ── Dark mode ─────────────────────────────────────── */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme="hacker"]) { + /* Primary — lighter for legibility on dark surfaces */ + --app-primary: #5A9DBF; /* 6.2:1 on dark surface #16202e — ✅ AA */ + --app-primary-hover: #74B5D8; /* lighter for hover */ + --app-primary-light: #0D1F2D; /* subtle bg tint */ + + /* Accent — lighter russet */ + --app-accent: #D4854A; /* 5.4:1 on dark surface — ✅ AA */ + --app-accent-hover: #E8A060; /* lighter for hover */ + --app-accent-light: #2D1A08; /* subtle bg tint */ + + /* Dark text still needed on accent bg (dark russet bg + dark text ≈ 1.5:1 — use light) */ + --app-accent-text: #1a2338; /* in dark mode, russet is darker so dark text still works */ + } } diff --git a/web/src/main.ts b/web/src/main.ts index 65ff817..4d50956 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,5 +1,11 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' +// Self-hosted fonts — no Google Fonts CDN (privacy requirement) +import '@fontsource/fraunces/400.css' +import '@fontsource/fraunces/700.css' +import '@fontsource/atkinson-hyperlegible/400.css' +import '@fontsource/atkinson-hyperlegible/700.css' +import '@fontsource/jetbrains-mono/400.css' import 'virtual:uno.css' import './assets/theme.css' import './assets/avocet.css' -- 2.45.2 From 209f49f7eaa825094987993dd144a8d61a87a84c Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 15:54:44 -0800 Subject: [PATCH 032/103] feat(avocet): Pinia label store with queue, lastAction, easter egg counter --- web/src/stores/label.test.ts | 62 ++++++++++++++++++++++++++++++++++++ web/src/stores/label.ts | 53 ++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 web/src/stores/label.test.ts create mode 100644 web/src/stores/label.ts diff --git a/web/src/stores/label.test.ts b/web/src/stores/label.test.ts new file mode 100644 index 0000000..63ae543 --- /dev/null +++ b/web/src/stores/label.test.ts @@ -0,0 +1,62 @@ +// src/stores/label.test.ts +import { setActivePinia, createPinia } from 'pinia' +import { useLabelStore } from './label' +import { beforeEach, describe, it, expect } from 'vitest' + +const MOCK_ITEM = { + id: 'abc', subject: 'Test', body: 'Body', from: 'a@b.com', + date: '2026-03-01', source: 'imap:test', +} + +describe('label store', () => { + beforeEach(() => setActivePinia(createPinia())) + + it('starts with empty queue', () => { + const store = useLabelStore() + expect(store.queue).toEqual([]) + expect(store.current).toBeNull() + }) + + it('current returns first item', () => { + const store = useLabelStore() + store.queue = [MOCK_ITEM] + expect(store.current).toEqual(MOCK_ITEM) + }) + + it('removeCurrentFromQueue removes first item', () => { + const store = useLabelStore() + store.queue = [MOCK_ITEM, { ...MOCK_ITEM, id: 'def' }] + store.removeCurrentFromQueue() + expect(store.queue[0].id).toBe('def') + }) + + it('tracks lastAction', () => { + const store = useLabelStore() + store.queue = [MOCK_ITEM] + store.setLastAction('label', MOCK_ITEM, 'interview_scheduled') + expect(store.lastAction?.type).toBe('label') + expect(store.lastAction?.label).toBe('interview_scheduled') + }) + + it('incrementLabeled increases sessionLabeled', () => { + const store = useLabelStore() + store.incrementLabeled() + store.incrementLabeled() + expect(store.sessionLabeled).toBe(2) + }) + + it('restoreItem adds to front of queue', () => { + const store = useLabelStore() + store.queue = [{ ...MOCK_ITEM, id: 'def' }] + store.restoreItem(MOCK_ITEM) + expect(store.queue[0].id).toBe('abc') + expect(store.queue[1].id).toBe('def') + }) + + it('clearLastAction nulls lastAction', () => { + const store = useLabelStore() + store.setLastAction('skip', MOCK_ITEM) + store.clearLastAction() + expect(store.lastAction).toBeNull() + }) +}) diff --git a/web/src/stores/label.ts b/web/src/stores/label.ts new file mode 100644 index 0000000..f5b66f5 --- /dev/null +++ b/web/src/stores/label.ts @@ -0,0 +1,53 @@ +// src/stores/label.ts +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' + +export interface QueueItem { + id: string + subject: string + body: string + from: string + date: string + source: string +} + +export interface LastAction { + type: 'label' | 'skip' | 'discard' + item: QueueItem + label?: string +} + +export const useLabelStore = defineStore('label', () => { + const queue = ref([]) + const totalRemaining = ref(0) + const lastAction = ref(null) + const sessionLabeled = ref(0) // for easter eggs + + const current = computed(() => queue.value[0] ?? null) + + function removeCurrentFromQueue() { + queue.value.shift() + } + + function setLastAction(type: LastAction['type'], item: QueueItem, label?: string) { + lastAction.value = { type, item, label } + } + + function clearLastAction() { + lastAction.value = null + } + + function restoreItem(item: QueueItem) { + queue.value.unshift(item) + } + + function incrementLabeled() { + sessionLabeled.value++ + } + + return { + queue, totalRemaining, lastAction, sessionLabeled, current, + removeCurrentFromQueue, setLastAction, clearLastAction, + restoreItem, incrementLabeled, + } +}) -- 2.45.2 From e823c84196818916afebaac97b56f1ff7ef681a6 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 15:58:43 -0800 Subject: [PATCH 033/103] feat(avocet): useApi, useMotion, useHaptics, useEasterEgg (Konami/hacker mode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useApiFetch: typed fetch wrapper with network/http error discrimination - useMotion: reactive localStorage override for rich-animation toggle, respects OS prefers-reduced-motion - useHaptics: label/discard/skip/undo vibration patterns, gated on rich mode - useKonamiCode + useHackerMode: 10-key Konami sequence → hacker theme, persisted in localStorage - test-setup.ts: jsdom matchMedia stub so useMotion imports cleanly in Vitest - smoke.test.ts: import smoke tests for all 4 composables (12 tests, all passing) --- web/src/composables/useApi.ts | 20 ++++++++++++++ web/src/composables/useEasterEgg.ts | 43 +++++++++++++++++++++++++++++ web/src/composables/useHaptics.ts | 18 ++++++++++++ web/src/composables/useMotion.ts | 28 +++++++++++++++++++ web/src/smoke.test.ts | 23 +++++++++++++++ web/src/test-setup.ts | 17 ++++++++++++ web/vite.config.ts | 1 + 7 files changed, 150 insertions(+) create mode 100644 web/src/composables/useApi.ts create mode 100644 web/src/composables/useEasterEgg.ts create mode 100644 web/src/composables/useHaptics.ts create mode 100644 web/src/composables/useMotion.ts create mode 100644 web/src/test-setup.ts diff --git a/web/src/composables/useApi.ts b/web/src/composables/useApi.ts new file mode 100644 index 0000000..d677091 --- /dev/null +++ b/web/src/composables/useApi.ts @@ -0,0 +1,20 @@ +export type ApiError = + | { kind: 'network'; message: string } + | { kind: 'http'; status: number; detail: string } + +export async function useApiFetch( + url: string, + opts?: RequestInit, +): Promise<{ data: T | null; error: ApiError | null }> { + try { + const res = await fetch(url, opts) + if (!res.ok) { + const detail = await res.text().catch(() => '') + return { data: null, error: { kind: 'http', status: res.status, detail } } + } + const data = await res.json() as T + return { data, error: null } + } catch (e) { + return { data: null, error: { kind: 'network', message: String(e) } } + } +} diff --git a/web/src/composables/useEasterEgg.ts b/web/src/composables/useEasterEgg.ts new file mode 100644 index 0000000..62c48e7 --- /dev/null +++ b/web/src/composables/useEasterEgg.ts @@ -0,0 +1,43 @@ +import { onMounted, onUnmounted } from 'vue' + +const KONAMI = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown','ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'] + +export function useKonamiCode(onActivate: () => void) { + let pos = 0 + + function handler(e: KeyboardEvent) { + if (e.key === KONAMI[pos]) { + pos++ + if (pos === KONAMI.length) { + pos = 0 + onActivate() + } + } else { + pos = 0 + } + } + + onMounted(() => window.addEventListener('keydown', handler)) + onUnmounted(() => window.removeEventListener('keydown', handler)) +} + +export function useHackerMode() { + function toggle() { + const root = document.documentElement + if (root.dataset.theme === 'hacker') { + delete root.dataset.theme + localStorage.removeItem('cf-hacker-mode') + } else { + root.dataset.theme = 'hacker' + localStorage.setItem('cf-hacker-mode', 'true') + } + } + + function restore() { + if (localStorage.getItem('cf-hacker-mode') === 'true') { + document.documentElement.dataset.theme = 'hacker' + } + } + + return { toggle, restore } +} diff --git a/web/src/composables/useHaptics.ts b/web/src/composables/useHaptics.ts new file mode 100644 index 0000000..4406dd9 --- /dev/null +++ b/web/src/composables/useHaptics.ts @@ -0,0 +1,18 @@ +import { useMotion } from './useMotion' + +export function useHaptics() { + const { rich } = useMotion() + + function vibrate(pattern: number | number[]) { + if (rich.value && typeof navigator !== 'undefined' && 'vibrate' in navigator) { + navigator.vibrate(pattern) + } + } + + return { + label: () => vibrate(40), + discard: () => vibrate([40, 30, 40]), + skip: () => vibrate(15), + undo: () => vibrate([20, 20, 60]), + } +} diff --git a/web/src/composables/useMotion.ts b/web/src/composables/useMotion.ts new file mode 100644 index 0000000..eee0ae1 --- /dev/null +++ b/web/src/composables/useMotion.ts @@ -0,0 +1,28 @@ +import { computed, ref } from 'vue' + +const STORAGE_KEY = 'cf-avocet-rich-motion' + +// OS-level prefers-reduced-motion — checked once at module load +const OS_REDUCED = typeof window !== 'undefined' + ? window.matchMedia('(prefers-reduced-motion: reduce)').matches + : false + +// Reactive ref so toggling localStorage triggers re-reads in the same session +const _richOverride = ref( + typeof window !== 'undefined' + ? localStorage.getItem(STORAGE_KEY) + : null +) + +export function useMotion() { + const rich = computed(() => + !OS_REDUCED && _richOverride.value !== 'false' + ) + + function setRich(enabled: boolean) { + localStorage.setItem(STORAGE_KEY, enabled ? 'true' : 'false') + _richOverride.value = enabled ? 'true' : 'false' + } + + return { rich, setRich } +} diff --git a/web/src/smoke.test.ts b/web/src/smoke.test.ts index 119b9e0..a601b38 100644 --- a/web/src/smoke.test.ts +++ b/web/src/smoke.test.ts @@ -5,3 +5,26 @@ describe('scaffold', () => { expect(1 + 1).toBe(2) }) }) + +describe('composable imports', () => { + it('useApi imports', async () => { + const { useApiFetch } = await import('./composables/useApi') + expect(typeof useApiFetch).toBe('function') + }) + + it('useMotion imports', async () => { + const { useMotion } = await import('./composables/useMotion') + expect(typeof useMotion).toBe('function') + }) + + it('useHaptics imports', async () => { + const { useHaptics } = await import('./composables/useHaptics') + expect(typeof useHaptics).toBe('function') + }) + + it('useEasterEgg imports', async () => { + const { useKonamiCode, useHackerMode } = await import('./composables/useEasterEgg') + expect(typeof useKonamiCode).toBe('function') + expect(typeof useHackerMode).toBe('function') + }) +}) diff --git a/web/src/test-setup.ts b/web/src/test-setup.ts new file mode 100644 index 0000000..5021a5e --- /dev/null +++ b/web/src/test-setup.ts @@ -0,0 +1,17 @@ +// jsdom does not implement window.matchMedia — stub it so composables that +// check prefers-reduced-motion can import without throwing. +if (typeof window !== 'undefined' && !window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }) +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 00529c8..c22afdb 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ test: { environment: 'jsdom', globals: true, + setupFiles: ['./src/test-setup.ts'], }, }) -- 2.45.2 From e05ac885d7cef51fa4167a5ae73a9005c7225922 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:03:01 -0800 Subject: [PATCH 034/103] =?UTF-8?q?feat(avocet):=20EmailCard=20component?= =?UTF-8?q?=20=E2=80=94=20subject,=20from/date,=20body=20preview,=20expand?= =?UTF-8?q?/collapse?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/EmailCard.test.ts | 39 +++++++++ web/src/components/EmailCard.vue | 113 +++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 web/src/components/EmailCard.test.ts create mode 100644 web/src/components/EmailCard.vue diff --git a/web/src/components/EmailCard.test.ts b/web/src/components/EmailCard.test.ts new file mode 100644 index 0000000..8d83fa5 --- /dev/null +++ b/web/src/components/EmailCard.test.ts @@ -0,0 +1,39 @@ +import { mount } from '@vue/test-utils' +import EmailCard from './EmailCard.vue' +import { describe, it, expect } from 'vitest' + +const item = { + id: 'abc', subject: 'Interview Invitation', + body: 'Hi there, we would like to schedule a phone screen with you. This will be a 30-minute call.', + from: 'recruiter@acme.com', date: '2026-03-01', source: 'imap:test', +} + +describe('EmailCard', () => { + it('renders subject', () => { + const w = mount(EmailCard, { props: { item } }) + expect(w.text()).toContain('Interview Invitation') + }) + + it('renders from and date', () => { + const w = mount(EmailCard, { props: { item } }) + expect(w.text()).toContain('recruiter@acme.com') + expect(w.text()).toContain('2026-03-01') + }) + + it('renders truncated body by default', () => { + const w = mount(EmailCard, { props: { item } }) + expect(w.text()).toContain('Hi there') + }) + + it('emits expand on button click', async () => { + const w = mount(EmailCard, { props: { item } }) + await w.find('[data-testid="expand-btn"]').trigger('click') + expect(w.emitted('expand')).toBeTruthy() + }) + + it('shows collapse button when expanded', () => { + const w = mount(EmailCard, { props: { item, expanded: true } }) + expect(w.find('[data-testid="collapse-btn"]').exists()).toBe(true) + expect(w.find('[data-testid="expand-btn"]').exists()).toBe(false) + }) +}) diff --git a/web/src/components/EmailCard.vue b/web/src/components/EmailCard.vue new file mode 100644 index 0000000..2180df2 --- /dev/null +++ b/web/src/components/EmailCard.vue @@ -0,0 +1,113 @@ + + + + + -- 2.45.2 From cd6cae20403080adda49d4775566e0733aa66fbf Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:04:31 -0800 Subject: [PATCH 035/103] =?UTF-8?q?feat(avocet):=20LabelBucketGrid=20?= =?UTF-8?q?=E2=80=94=20numpad=20layout,=20bucket-mode=20expansion,=20drag?= =?UTF-8?q?=20drop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/LabelBucketGrid.test.ts | 34 ++++++ web/src/components/LabelBucketGrid.vue | 118 +++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 web/src/components/LabelBucketGrid.test.ts create mode 100644 web/src/components/LabelBucketGrid.vue diff --git a/web/src/components/LabelBucketGrid.test.ts b/web/src/components/LabelBucketGrid.test.ts new file mode 100644 index 0000000..d1d6e27 --- /dev/null +++ b/web/src/components/LabelBucketGrid.test.ts @@ -0,0 +1,34 @@ +import { mount } from '@vue/test-utils' +import LabelBucketGrid from './LabelBucketGrid.vue' +import { describe, it, expect } from 'vitest' + +const labels = [ + { name: 'interview_scheduled', emoji: '🗓️', color: '#4CAF50', key: '1' }, + { name: 'offer_received', emoji: '🎉', color: '#2196F3', key: '2' }, + { name: 'rejected', emoji: '❌', color: '#F44336', key: '3' }, +] + +describe('LabelBucketGrid', () => { + it('renders all labels', () => { + const w = mount(LabelBucketGrid, { props: { labels, isBucketMode: false } }) + expect(w.findAll('[data-testid="label-btn"]')).toHaveLength(3) + }) + + it('emits label event on click', async () => { + const w = mount(LabelBucketGrid, { props: { labels, isBucketMode: false } }) + await w.find('[data-testid="label-btn"]').trigger('click') + expect(w.emitted('label')?.[0]).toEqual(['interview_scheduled']) + }) + + it('applies bucket-mode class when isBucketMode is true', () => { + const w = mount(LabelBucketGrid, { props: { labels, isBucketMode: true } }) + expect(w.find('.label-grid').classes()).toContain('bucket-mode') + }) + + it('shows key hint and emoji', () => { + const w = mount(LabelBucketGrid, { props: { labels, isBucketMode: false } }) + const btn = w.find('[data-testid="label-btn"]') + expect(btn.text()).toContain('1') + expect(btn.text()).toContain('🗓️') + }) +}) diff --git a/web/src/components/LabelBucketGrid.vue b/web/src/components/LabelBucketGrid.vue new file mode 100644 index 0000000..9e6bfa1 --- /dev/null +++ b/web/src/components/LabelBucketGrid.vue @@ -0,0 +1,118 @@ + + + + + -- 2.45.2 From 5114e6ac19ede245dcb9e1e8b9bce86d6fa9f151 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:12:58 -0800 Subject: [PATCH 036/103] =?UTF-8?q?feat(avocet):=20useLabelKeyboard=20?= =?UTF-8?q?=E2=80=94=201-9,=20h,=20S,=20D,=20U,=20=3F=20shortcuts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/composables/useLabelKeyboard.test.ts | 92 ++++++++++++++++++++ web/src/composables/useLabelKeyboard.ts | 40 +++++++++ 2 files changed, 132 insertions(+) create mode 100644 web/src/composables/useLabelKeyboard.test.ts create mode 100644 web/src/composables/useLabelKeyboard.ts diff --git a/web/src/composables/useLabelKeyboard.test.ts b/web/src/composables/useLabelKeyboard.test.ts new file mode 100644 index 0000000..7e7d650 --- /dev/null +++ b/web/src/composables/useLabelKeyboard.test.ts @@ -0,0 +1,92 @@ +import { useLabelKeyboard } from './useLabelKeyboard' +import { describe, it, expect, vi, afterEach } from 'vitest' + +const LABELS = [ + { name: 'interview_scheduled', key: '1', emoji: '🗓️', color: '#4CAF50' }, + { name: 'offer_received', key: '2', emoji: '🎉', color: '#2196F3' }, + { name: 'rejected', key: '3', emoji: '❌', color: '#F44336' }, +] + +describe('useLabelKeyboard', () => { + const cleanups: (() => void)[] = [] + + afterEach(() => { + cleanups.forEach(fn => fn()) + cleanups.length = 0 + }) + + it('calls onLabel when digit key pressed', () => { + const onLabel = vi.fn() + const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() }) + cleanups.push(cleanup) + window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })) + expect(onLabel).toHaveBeenCalledWith('interview_scheduled') + }) + + it('calls onLabel for key 2', () => { + const onLabel = vi.fn() + const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() }) + cleanups.push(cleanup) + window.dispatchEvent(new KeyboardEvent('keydown', { key: '2' })) + expect(onLabel).toHaveBeenCalledWith('offer_received') + }) + + it('calls onLabel("hired") when h pressed', () => { + const onLabel = vi.fn() + const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() }) + cleanups.push(cleanup) + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' })) + expect(onLabel).toHaveBeenCalledWith('hired') + }) + + it('calls onSkip when s pressed', () => { + const onSkip = vi.fn() + const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip, onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() }) + cleanups.push(cleanup) + window.dispatchEvent(new KeyboardEvent('keydown', { key: 's' })) + expect(onSkip).toHaveBeenCalled() + }) + + it('calls onDiscard when d pressed', () => { + const onDiscard = vi.fn() + const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip: vi.fn(), onDiscard, onUndo: vi.fn(), onHelp: vi.fn() }) + cleanups.push(cleanup) + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' })) + expect(onDiscard).toHaveBeenCalled() + }) + + it('calls onUndo when u pressed', () => { + const onUndo = vi.fn() + const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip: vi.fn(), onDiscard: vi.fn(), onUndo, onHelp: vi.fn() }) + cleanups.push(cleanup) + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'u' })) + expect(onUndo).toHaveBeenCalled() + }) + + it('calls onHelp when ? pressed', () => { + const onHelp = vi.fn() + const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel: vi.fn(), onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp }) + cleanups.push(cleanup) + window.dispatchEvent(new KeyboardEvent('keydown', { key: '?' })) + expect(onHelp).toHaveBeenCalled() + }) + + it('ignores keydown when target is an input', () => { + const onLabel = vi.fn() + const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() }) + cleanups.push(cleanup) + const input = document.createElement('input') + document.body.appendChild(input) + input.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true })) + expect(onLabel).not.toHaveBeenCalled() + document.body.removeChild(input) + }) + + it('cleanup removes the listener', () => { + const onLabel = vi.fn() + const { cleanup } = useLabelKeyboard({ labels: LABELS, onLabel, onSkip: vi.fn(), onDiscard: vi.fn(), onUndo: vi.fn(), onHelp: vi.fn() }) + cleanup() + window.dispatchEvent(new KeyboardEvent('keydown', { key: '1' })) + expect(onLabel).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/composables/useLabelKeyboard.ts b/web/src/composables/useLabelKeyboard.ts new file mode 100644 index 0000000..146528b --- /dev/null +++ b/web/src/composables/useLabelKeyboard.ts @@ -0,0 +1,40 @@ +import { onUnmounted, getCurrentInstance } from 'vue' + +interface Label { name: string; key: string; emoji: string; color: string } + +interface Options { + labels: Label[] + onLabel: (name: string) => void + onSkip: () => void + onDiscard: () => void + onUndo: () => void + onHelp: () => void +} + +export function useLabelKeyboard(opts: Options) { + const keyMap = new Map(opts.labels.map(l => [l.key.toLowerCase(), l.name])) + + function handler(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement) return + if (e.target instanceof HTMLTextAreaElement) return + const k = e.key.toLowerCase() + if (keyMap.has(k)) { opts.onLabel(keyMap.get(k)!); return } + if (k === 'h') { opts.onLabel('hired'); return } + if (k === 's') { opts.onSkip(); return } + if (k === 'd') { opts.onDiscard(); return } + if (k === 'u') { opts.onUndo(); return } + if (k === '?') { opts.onHelp(); return } + } + + // Add listener immediately (composable is called in setup, not in onMounted) + window.addEventListener('keydown', handler) + + const cleanup = () => window.removeEventListener('keydown', handler) + + // In component context: auto-cleanup on unmount + if (getCurrentInstance()) { + onUnmounted(cleanup) + } + + return { cleanup } +} -- 2.45.2 From e7f08ce6857e72f00411bd94176f438fb864f5b8 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:13:02 -0800 Subject: [PATCH 037/103] =?UTF-8?q?feat(avocet):=20UndoToast=20=E2=80=94?= =?UTF-8?q?=205-second=20countdown,=20undo=20button,=20accessible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/UndoToast.test.ts | 74 +++++++++++++++++++ web/src/components/UndoToast.vue | 105 +++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 web/src/components/UndoToast.test.ts create mode 100644 web/src/components/UndoToast.vue diff --git a/web/src/components/UndoToast.test.ts b/web/src/components/UndoToast.test.ts new file mode 100644 index 0000000..3378504 --- /dev/null +++ b/web/src/components/UndoToast.test.ts @@ -0,0 +1,74 @@ +import { mount } from '@vue/test-utils' +import UndoToast from './UndoToast.vue' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Mock requestAnimationFrame for jsdom +beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { + // Call with a fake timestamp to simulate one frame + setTimeout(() => fn(16), 0) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const labelAction = { + type: 'label' as const, + item: { id: 'abc', subject: 'Interview at Acme', body: '...', from: 'hr@acme.com', date: '2026-03-01', source: 'imap:test' }, + label: 'interview_scheduled', +} + +const skipAction = { + type: 'skip' as const, + item: { id: 'xyz', subject: 'Cold Outreach', body: '...', from: 'recruiter@x.com', date: '2026-03-01', source: 'imap:test' }, +} + +const discardAction = { + type: 'discard' as const, + item: { id: 'def', subject: 'Spam Email', body: '...', from: 'spam@spam.com', date: '2026-03-01', source: 'imap:test' }, +} + +describe('UndoToast', () => { + it('renders subject for a label action', () => { + const w = mount(UndoToast, { props: { action: labelAction } }) + expect(w.text()).toContain('Interview at Acme') + expect(w.text()).toContain('interview_scheduled') + }) + + it('renders subject for a skip action', () => { + const w = mount(UndoToast, { props: { action: skipAction } }) + expect(w.text()).toContain('Cold Outreach') + expect(w.text()).toContain('Skipped') + }) + + it('renders subject for a discard action', () => { + const w = mount(UndoToast, { props: { action: discardAction } }) + expect(w.text()).toContain('Spam Email') + expect(w.text()).toContain('Discarded') + }) + + it('has undo button', () => { + const w = mount(UndoToast, { props: { action: labelAction } }) + expect(w.find('.undo-btn').exists()).toBe(true) + }) + + it('emits undo when button clicked', async () => { + const w = mount(UndoToast, { props: { action: labelAction } }) + await w.find('.undo-btn').trigger('click') + expect(w.emitted('undo')).toBeTruthy() + }) + + it('has timer bar element', () => { + const w = mount(UndoToast, { props: { action: labelAction } }) + expect(w.find('.timer-bar').exists()).toBe(true) + }) + + it('has accessible role=status', () => { + const w = mount(UndoToast, { props: { action: labelAction } }) + expect(w.find('[role="status"]').exists()).toBe(true) + }) +}) diff --git a/web/src/components/UndoToast.vue b/web/src/components/UndoToast.vue new file mode 100644 index 0000000..18e7f57 --- /dev/null +++ b/web/src/components/UndoToast.vue @@ -0,0 +1,105 @@ + + + + + -- 2.45.2 From 9b5a1ae7527278cc0793e51f5238b90b38ad07d7 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:16:09 -0800 Subject: [PATCH 038/103] =?UTF-8?q?feat(avocet):=20EmailCardStack=20?= =?UTF-8?q?=E2=80=94=20swipe=20gestures,=20depth=20shadows,=20dismissal=20?= =?UTF-8?q?classes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/EmailCardStack.test.ts | 59 +++++++++ web/src/components/EmailCardStack.vue | 138 ++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 web/src/components/EmailCardStack.test.ts create mode 100644 web/src/components/EmailCardStack.vue diff --git a/web/src/components/EmailCardStack.test.ts b/web/src/components/EmailCardStack.test.ts new file mode 100644 index 0000000..6bb0aaa --- /dev/null +++ b/web/src/components/EmailCardStack.test.ts @@ -0,0 +1,59 @@ +import { mount } from '@vue/test-utils' +import EmailCardStack from './EmailCardStack.vue' +import { describe, it, expect } from 'vitest' + +const item = { + id: 'abc', + subject: 'Interview at Acme', + body: 'We would like to schedule...', + from: 'hr@acme.com', + date: '2026-03-01', + source: 'imap:test', +} + +describe('EmailCardStack', () => { + it('renders the email subject', () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + expect(w.text()).toContain('Interview at Acme') + }) + + it('renders shadow cards for depth effect', () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + expect(w.findAll('.card-shadow')).toHaveLength(2) + }) + + it('applies dismiss-label class when dismissType is label', () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'label' } }) + expect(w.find('.card-wrapper').classes()).toContain('dismiss-label') + }) + + it('applies dismiss-discard class when dismissType is discard', () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'discard' } }) + expect(w.find('.card-wrapper').classes()).toContain('dismiss-discard') + }) + + it('applies dismiss-skip class when dismissType is skip', () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: 'skip' } }) + expect(w.find('.card-wrapper').classes()).toContain('dismiss-skip') + }) + + it('no dismiss class when dismissType is null', () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: null } }) + const wrapperClasses = w.find('.card-wrapper').classes() + expect(wrapperClasses).not.toContain('dismiss-label') + expect(wrapperClasses).not.toContain('dismiss-discard') + expect(wrapperClasses).not.toContain('dismiss-skip') + }) + + it('emits drag-start on dragstart event', async () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + await w.find('.card-stack').trigger('dragstart') + expect(w.emitted('drag-start')).toBeTruthy() + }) + + it('emits drag-end on dragend event', async () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + await w.find('.card-stack').trigger('dragend') + expect(w.emitted('drag-end')).toBeTruthy() + }) +}) diff --git a/web/src/components/EmailCardStack.vue b/web/src/components/EmailCardStack.vue new file mode 100644 index 0000000..078d325 --- /dev/null +++ b/web/src/components/EmailCardStack.vue @@ -0,0 +1,138 @@ + + + + + -- 2.45.2 From b623d252d08f1f9af0a64c21bc9bfea9c0fdf735 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:19:29 -0800 Subject: [PATCH 039/103] =?UTF-8?q?feat(avocet):=20LabelBucketGrid=20bucke?= =?UTF-8?q?t-mode=20CSS=20=E2=80=94=20spring=20expansion,=20glow=20on=20dr?= =?UTF-8?q?op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/LabelBucketGrid.vue | 79 +++++++++++++------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/web/src/components/LabelBucketGrid.vue b/web/src/components/LabelBucketGrid.vue index 9e6bfa1..87d2186 100644 --- a/web/src/components/LabelBucketGrid.vue +++ b/web/src/components/LabelBucketGrid.vue @@ -40,11 +40,11 @@ function onDrop(name: string) { .label-grid { display: grid; grid-template-columns: repeat(5, 1fr); - gap: var(--space-2); - transition: all var(--bucket-expand); + gap: 0.5rem; + transition: all var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)); } -/* Mobile: 3×3 numpad layout + hired at bottom */ +/* Mobile: 3-column numpad layout */ @media (max-width: 480px) { .label-grid { grid-template-columns: repeat(3, 1fr); @@ -52,67 +52,70 @@ function onDrop(name: string) { } .label-grid.bucket-mode { - gap: var(--space-4); - padding: var(--space-3); + gap: 1rem; + padding: 1rem; } .label-btn { display: flex; flex-direction: column; align-items: center; - gap: var(--space-1); - padding: var(--space-2) var(--space-1); - border: 2px solid var(--label-color, var(--color-border)); - border-radius: var(--radius-md); + justify-content: center; + gap: 0.25rem; + min-height: 44px; /* Touch target */ + padding: 0.5rem 0.25rem; + border-radius: 0.5rem; + border: 2px solid var(--label-color, #607D8B); background: transparent; - color: var(--color-text); + color: var(--color-text, #1a2338); cursor: pointer; - font-family: var(--font-body); - transition: all var(--bucket-expand); - min-height: 44px; /* touch target */ + transition: all var(--bucket-expand, 250ms cubic-bezier(0.34, 1.56, 0.64, 1)); + font-family: var(--font-body, sans-serif); } .label-grid.bucket-mode .label-btn { - padding: var(--space-6) var(--space-2); - font-size: 1.15rem; -} - -.label-btn:hover, -.label-btn:focus-visible { - background: color-mix(in srgb, var(--label-color) 12%, transparent); -} - -.label-btn:focus-visible { - outline: 2px solid var(--label-color); - outline-offset: 2px; + min-height: 80px; + padding: 1rem 0.5rem; + border-width: 3px; + font-size: 1.1rem; } .label-btn.is-drop-target { - background: var(--label-color); - color: var(--color-text-inverse, #fff); + background: var(--label-color, #607D8B); + color: #fff; transform: scale(1.08); - box-shadow: 0 0 16px color-mix(in srgb, var(--label-color) 60%, transparent); + box-shadow: 0 0 16px color-mix(in srgb, var(--label-color, #607D8B) 60%, transparent); +} + +.label-btn:hover:not(.is-drop-target) { + background: color-mix(in srgb, var(--label-color, #607D8B) 12%, transparent); } .key-hint { - font-size: 0.7rem; - font-weight: 700; - opacity: 0.6; - font-family: var(--font-mono); + font-size: 0.65rem; + font-family: var(--font-mono, monospace); + opacity: 0.55; + line-height: 1; } -.emoji { font-size: 1.2rem; line-height: 1; } +.emoji { + font-size: 1.25rem; + line-height: 1; +} .label-name { - font-size: 0.65rem; + font-size: 0.7rem; text-align: center; line-height: 1.2; word-break: break-word; - color: var(--color-text-muted); + hyphens: auto; } -.label-grid.bucket-mode .label-name { - font-size: 0.75rem; - color: var(--color-text); +/* Reduced-motion fallback */ +@media (prefers-reduced-motion: reduce) { + .label-grid, + .label-btn { + transition: none; + } } -- 2.45.2 From 382bca28a1b4d7afadc606fb09d8f7eae7800ef0 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:21:07 -0800 Subject: [PATCH 040/103] =?UTF-8?q?feat(avocet):=20LabelView=20=E2=80=94?= =?UTF-8?q?=20wires=20store,=20API,=20card=20stack,=20keyboard,=20easter?= =?UTF-8?q?=20eggs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Task 13: LabelView.vue wires together the label store, API fetch, card stack, bucket grid, keyboard shortcuts, haptics, motion preference, and three easter egg badges (on-a-roll, speed round, fifty deep). App.vue updated to mount LabelView and restore hacker-mode theme on load. 3 new LabelView tests; all 48 tests pass, build clean. --- web/src/App.vue | 53 +++--- web/src/views/LabelView.test.ts | 46 +++++ web/src/views/LabelView.vue | 318 ++++++++++++++++++++++++++++++++ 3 files changed, 395 insertions(+), 22 deletions(-) create mode 100644 web/src/views/LabelView.test.ts create mode 100644 web/src/views/LabelView.vue diff --git a/web/src/App.vue b/web/src/App.vue index 58b0f21..8f15aa8 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,30 +1,39 @@ - - - diff --git a/web/src/views/LabelView.test.ts b/web/src/views/LabelView.test.ts new file mode 100644 index 0000000..35a4c35 --- /dev/null +++ b/web/src/views/LabelView.test.ts @@ -0,0 +1,46 @@ +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import LabelView from './LabelView.vue' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mock fetch globally +beforeEach(() => { + setActivePinia(createPinia()) + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ items: [], total: 0 }), + text: async () => '', + })) +}) + +describe('LabelView', () => { + it('shows loading state initially', () => { + const w = mount(LabelView, { + global: { plugins: [createPinia()] }, + }) + // Should show skeleton while loading + expect(w.find('.skeleton-card').exists()).toBe(true) + }) + + it('shows empty state when queue is empty after load', async () => { + const w = mount(LabelView, { + global: { plugins: [createPinia()] }, + }) + // Let all promises resolve + await new Promise(r => setTimeout(r, 0)) + await w.vm.$nextTick() + expect(w.find('.empty-state').exists()).toBe(true) + }) + + it('renders header with action buttons', async () => { + const w = mount(LabelView, { + global: { plugins: [createPinia()] }, + }) + await new Promise(r => setTimeout(r, 0)) + await w.vm.$nextTick() + expect(w.find('.lv-header').exists()).toBe(true) + expect(w.text()).toContain('Undo') + expect(w.text()).toContain('Skip') + expect(w.text()).toContain('Discard') + }) +}) diff --git a/web/src/views/LabelView.vue b/web/src/views/LabelView.vue new file mode 100644 index 0000000..de15f30 --- /dev/null +++ b/web/src/views/LabelView.vue @@ -0,0 +1,318 @@ + + + + + -- 2.45.2 From deddd763ea469b057abaed5cad76979e6e6e8b6a Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:23:56 -0800 Subject: [PATCH 041/103] feat(avocet): manage.sh start-api / stop-api / open-api commands --- manage.sh | 57 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/manage.sh b/manage.sh index 39ea114..0979636 100755 --- a/manage.sh +++ b/manage.sh @@ -93,6 +93,11 @@ usage() { echo -e " ${GREEN}score [args]${NC} Shortcut: --score [args]" echo -e " ${GREEN}compare [args]${NC} Shortcut: --compare [args]" echo "" + echo " Vue API:" + echo -e " ${GREEN}start-api${NC} Build Vue SPA + start FastAPI on port 8503" + echo -e " ${GREEN}stop-api${NC} Stop FastAPI server" + echo -e " ${GREEN}open-api${NC} Open Vue UI in browser (http://localhost:8503)" + echo "" echo " Dev:" echo -e " ${GREEN}test${NC} Run pytest suite" echo "" @@ -251,6 +256,58 @@ case "$CMD" in exec "$0" benchmark --compare "$@" ;; + start-api) + API_PID_FILE=".avocet-api.pid" + API_PORT=8503 + if [[ -f "$API_PID_FILE" ]] && kill -0 "$(<"$API_PID_FILE")" 2>/dev/null; then + warn "API already running (PID $(<"$API_PID_FILE")) → http://localhost:${API_PORT}" + exit 0 + fi + mkdir -p "$LOG_DIR" + API_LOG="${LOG_DIR}/api.log" + info "Building Vue SPA…" + (cd web && npm run build) >> "$API_LOG" 2>&1 + info "Starting FastAPI on port ${API_PORT}…" + nohup "$PYTHON_UI" -m uvicorn app.api:app \ + --host 127.0.0.1 --port "$API_PORT" \ + >> "$API_LOG" 2>&1 & + echo $! > "$API_PID_FILE" + sleep 1 + if kill -0 "$(<"$API_PID_FILE")" 2>/dev/null; then + success "Avocet API started → http://localhost:${API_PORT} (PID $(<"$API_PID_FILE"))" + else + error "API died. Check ${API_LOG}" + fi + ;; + + stop-api) + API_PID_FILE=".avocet-api.pid" + if [[ ! -f "$API_PID_FILE" ]]; then + warn "API not running." + exit 0 + fi + PID="$(<"$API_PID_FILE")" + if kill -0 "$PID" 2>/dev/null; then + kill "$PID" && rm -f "$API_PID_FILE" + success "API stopped (PID ${PID})." + else + warn "Stale PID file (process ${PID} not running). Cleaning up." + rm -f "$API_PID_FILE" + fi + ;; + + open-api) + URL="http://localhost:8503" + info "Opening ${URL}" + if command -v xdg-open &>/dev/null; then + xdg-open "$URL" + elif command -v open &>/dev/null; then + open "$URL" + else + echo "$URL" + fi + ;; + help|--help|-h) usage ;; -- 2.45.2 From 47973aeba60a2a7b27fe393d1d501f4a618fc376 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:24:47 -0800 Subject: [PATCH 042/103] =?UTF-8?q?feat(avocet):=20easter=20eggs=20?= =?UTF-8?q?=E2=80=94=20hired=20confetti,=20century=20mark,=20clean=20sweep?= =?UTF-8?q?,=20midnight=20labeler,=20cursor=20trail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/composables/useEasterEgg.ts | 111 ++++++++++++++++++++++++++++ web/src/views/LabelView.vue | 80 ++++++++++++++++++-- 2 files changed, 184 insertions(+), 7 deletions(-) diff --git a/web/src/composables/useEasterEgg.ts b/web/src/composables/useEasterEgg.ts index 62c48e7..3e3e0ad 100644 --- a/web/src/composables/useEasterEgg.ts +++ b/web/src/composables/useEasterEgg.ts @@ -41,3 +41,114 @@ export function useHackerMode() { return { toggle, restore } } + +/** Fire a confetti burst from a given x,y position. Pure canvas, no dependencies. */ +export function fireConfetti(originX = window.innerWidth / 2, originY = window.innerHeight / 2) { + if (typeof requestAnimationFrame === 'undefined') return + + const canvas = document.createElement('canvas') + canvas.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:9999;' + canvas.width = window.innerWidth + canvas.height = window.innerHeight + document.body.appendChild(canvas) + const ctx = canvas.getContext('2d')! + + const COLORS = ['#2A6080','#B8622A','#5A9DBF','#D4854A','#FFC107','#4CAF50'] + const particles = Array.from({ length: 80 }, () => ({ + x: originX, + y: originY, + vx: (Math.random() - 0.5) * 14, + vy: (Math.random() - 0.6) * 12, + color: COLORS[Math.floor(Math.random() * COLORS.length)], + size: 5 + Math.random() * 6, + angle: Math.random() * Math.PI * 2, + spin: (Math.random() - 0.5) * 0.3, + life: 1.0, + })) + + let raf = 0 + function draw() { + ctx.clearRect(0, 0, canvas.width, canvas.height) + let alive = false + for (const p of particles) { + p.x += p.vx + p.y += p.vy + p.vy += 0.35 // gravity + p.vx *= 0.98 // air friction + p.angle += p.spin + p.life -= 0.016 + if (p.life <= 0) continue + alive = true + ctx.save() + ctx.globalAlpha = p.life + ctx.fillStyle = p.color + ctx.translate(p.x, p.y) + ctx.rotate(p.angle) + ctx.fillRect(-p.size / 2, -p.size / 2, p.size, p.size * 0.6) + ctx.restore() + } + if (alive) { + raf = requestAnimationFrame(draw) + } else { + cancelAnimationFrame(raf) + canvas.remove() + } + } + raf = requestAnimationFrame(draw) +} + +/** Enable cursor trail in hacker mode — returns a cleanup function. */ +export function useCursorTrail() { + const DOT_COUNT = 10 + const dots: HTMLElement[] = [] + let positions: { x: number; y: number }[] = [] + let mouseX = 0 + let mouseY = 0 + let raf = 0 + + for (let i = 0; i < DOT_COUNT; i++) { + const dot = document.createElement('div') + dot.style.cssText = [ + 'position:fixed', + 'pointer-events:none', + 'z-index:9998', + 'border-radius:50%', + 'background:#5A9DBF', + 'transition:opacity 0.1s', + ].join(';') + document.body.appendChild(dot) + dots.push(dot) + } + + function onMouseMove(e: MouseEvent) { + mouseX = e.clientX + mouseY = e.clientY + } + + function animate() { + positions.unshift({ x: mouseX, y: mouseY }) + if (positions.length > DOT_COUNT) positions = positions.slice(0, DOT_COUNT) + + dots.forEach((dot, i) => { + const pos = positions[i] + if (!pos) { dot.style.opacity = '0'; return } + const scale = 1 - i / DOT_COUNT + const size = Math.round(8 * scale) + dot.style.left = `${pos.x - size / 2}px` + dot.style.top = `${pos.y - size / 2}px` + dot.style.width = `${size}px` + dot.style.height = `${size}px` + dot.style.opacity = `${(1 - i / DOT_COUNT) * 0.7}` + }) + raf = requestAnimationFrame(animate) + } + + window.addEventListener('mousemove', onMouseMove) + raf = requestAnimationFrame(animate) + + return function cleanup() { + window.removeEventListener('mousemove', onMouseMove) + cancelAnimationFrame(raf) + dots.forEach(d => d.remove()) + } +} diff --git a/web/src/views/LabelView.vue b/web/src/views/LabelView.vue index de15f30..246bcf7 100644 --- a/web/src/views/LabelView.vue +++ b/web/src/views/LabelView.vue @@ -6,9 +6,12 @@ - 🔥 On a roll! - ⚡ Speed round! - 🎯 Fifty deep! + 🔥 On a roll! + ⚡ Speed round! + 🎯 Fifty deep! + 💯 Century! + 🧹 Clean sweep! + 🦉 Midnight labeler!
@@ -61,12 +64,13 @@ @@ -253,9 +316,12 @@ onMounted(async () => { to { transform: scale(1); opacity: 1; } } -.badge-roll { background: #ff6b35; color: #fff; } -.badge-speed { background: #7c3aed; color: #fff; } -.badge-fifty { background: var(--app-accent, #B8622A); color: var(--app-accent-text, #1a2338); } +.badge-roll { background: #ff6b35; color: #fff; } +.badge-speed { background: #7c3aed; color: #fff; } +.badge-fifty { background: var(--app-accent, #B8622A); color: var(--app-accent-text, #1a2338); } +.badge-century { background: #ffd700; color: #1a2338; } +.badge-sweep { background: var(--app-primary, #2A6080); color: #fff; } +.badge-midnight { background: #1a1a2e; color: #7c9dcf; border: 1px solid #7c9dcf; } .header-actions { display: flex; -- 2.45.2 From 682a958c28aef2ca5c9f0ab0a319dd99e99c7178 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:28:18 -0800 Subject: [PATCH 043/103] =?UTF-8?q?fix(avocet):=20strip=20HTML=20from=20em?= =?UTF-8?q?ail=20bodies=20=E2=80=94=20stdlib=20HTMLParser,=20no=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/label_tool.py | 241 ++++++++++++++++++++++++++++++++++++--- tests/test_label_tool.py | 87 ++++++++++++++ 2 files changed, 310 insertions(+), 18 deletions(-) create mode 100644 tests/test_label_tool.py diff --git a/app/label_tool.py b/app/label_tool.py index 1340824..c86d09b 100644 --- a/app/label_tool.py +++ b/app/label_tool.py @@ -14,6 +14,7 @@ from __future__ import annotations import email as _email_lib import hashlib import html as _html +from html.parser import HTMLParser import imaplib import json import re @@ -23,6 +24,9 @@ from email.header import decode_header as _raw_decode from pathlib import Path from typing import Any +import os +import subprocess + import streamlit as st import yaml @@ -43,8 +47,9 @@ LABELS = [ "survey_received", "neutral", "event_rescheduled", - "unrelated", "digest", + "new_lead", + "hired", ] _LABEL_META: dict[str, dict] = { @@ -55,8 +60,9 @@ _LABEL_META: dict[str, dict] = { "survey_received": {"emoji": "📋", "color": "#9C27B0", "key": "5"}, "neutral": {"emoji": "⬜", "color": "#607D8B", "key": "6"}, "event_rescheduled": {"emoji": "🔄", "color": "#FF5722", "key": "7"}, - "unrelated": {"emoji": "🗑️", "color": "#757575", "key": "8"}, - "digest": {"emoji": "📰", "color": "#00BCD4", "key": "9"}, + "digest": {"emoji": "📰", "color": "#00BCD4", "key": "8"}, + "new_lead": {"emoji": "🤝", "color": "#009688", "key": "9"}, + "hired": {"emoji": "🎊", "color": "#FFC107", "key": "h"}, } # ── HTML sanitiser ─────────────────────────────────────────────────────────── @@ -78,7 +84,50 @@ def _to_html(text: str, newlines_to_br: bool = False) -> str: return escaped -# ── Wide IMAP search terms (cast a net across all 9 categories) ───────────── +# ── HTML → plain-text extractor ───────────────────────────────────────────── + +class _TextExtractor(HTMLParser): + """Extract visible text from an HTML email body, preserving line breaks.""" + _BLOCK = {"p","div","br","li","tr","h1","h2","h3","h4","h5","h6","blockquote"} + _SKIP = {"script","style","head","noscript"} + + def __init__(self): + super().__init__(convert_charrefs=True) + self._parts: list[str] = [] + self._depth_skip = 0 + + def handle_starttag(self, tag, attrs): + tag = tag.lower() + if tag in self._SKIP: + self._depth_skip += 1 + elif tag in self._BLOCK: + self._parts.append("\n") + + def handle_endtag(self, tag): + if tag.lower() in self._SKIP: + self._depth_skip = max(0, self._depth_skip - 1) + + def handle_data(self, data): + if not self._depth_skip: + self._parts.append(data) + + def get_text(self) -> str: + text = "".join(self._parts) + lines = [ln.strip() for ln in text.splitlines()] + return "\n".join(ln for ln in lines if ln) + + +def _strip_html(html_str: str) -> str: + """Convert HTML email body to plain text. Pure stdlib, no dependencies.""" + try: + extractor = _TextExtractor() + extractor.feed(html_str) + return extractor.get_text() + except Exception: + return re.sub(r"<[^>]+>", " ", html_str).strip() + + +# ── Wide IMAP search terms (cast a net across all 10 categories) ──────────── _WIDE_TERMS = [ # interview_scheduled "interview", "phone screen", "video call", "zoom link", "schedule a call", @@ -100,6 +149,11 @@ _WIDE_TERMS = [ # digest "job digest", "jobs you may like", "recommended jobs", "jobs for you", "new jobs", "job alert", + # new_lead + "came across your profile", "reaching out about", "great fit for a role", + "exciting opportunity", "love to connect", + # hired / onboarding + "welcome to the team", "start date", "onboarding", "first day", "we're excited to have you", # general recruitment "application", "recruiter", "recruiting", "hiring", "candidate", ] @@ -121,18 +175,32 @@ def _decode_str(value: str | None) -> str: def _extract_body(msg: Any) -> str: + """Return plain-text body. Strips HTML when no text/plain part exists.""" if msg.is_multipart(): + html_fallback: str | None = None for part in msg.walk(): - if part.get_content_type() == "text/plain": + ct = part.get_content_type() + if ct == "text/plain": try: charset = part.get_content_charset() or "utf-8" return part.get_payload(decode=True).decode(charset, errors="replace") except Exception: pass + elif ct == "text/html" and html_fallback is None: + try: + charset = part.get_content_charset() or "utf-8" + raw = part.get_payload(decode=True).decode(charset, errors="replace") + html_fallback = _strip_html(raw) + except Exception: + pass + return html_fallback or "" else: try: charset = msg.get_content_charset() or "utf-8" - return msg.get_payload(decode=True).decode(charset, errors="replace") + raw = msg.get_payload(decode=True).decode(charset, errors="replace") + if msg.get_content_type() == "text/html": + return _strip_html(raw) + return raw except Exception: pass return "" @@ -436,7 +504,9 @@ with st.sidebar: # ── Tabs ───────────────────────────────────────────────────────────────────── -tab_label, tab_fetch, tab_stats, tab_settings = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings"]) +tab_label, tab_fetch, tab_stats, tab_settings, tab_benchmark = st.tabs( + ["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings", "🔬 Benchmark"] +) # ══════════════════════════════════════════════════════════════════════════════ @@ -669,19 +739,19 @@ with tab_label: _lbl_r = _r.get("label", "") _counts[_lbl_r] = _counts.get(_lbl_r, 0) + 1 - row1_cols = st.columns(3) - row2_cols = st.columns(3) - row3_cols = st.columns(3) + row1_cols = st.columns(5) + row2_cols = st.columns(5) bucket_pairs = [ (row1_cols[0], "interview_scheduled"), (row1_cols[1], "offer_received"), (row1_cols[2], "rejected"), - (row2_cols[0], "positive_response"), - (row2_cols[1], "survey_received"), - (row2_cols[2], "neutral"), - (row3_cols[0], "event_rescheduled"), - (row3_cols[1], "unrelated"), - (row3_cols[2], "digest"), + (row1_cols[3], "positive_response"), + (row1_cols[4], "survey_received"), + (row2_cols[0], "neutral"), + (row2_cols[1], "event_rescheduled"), + (row2_cols[2], "digest"), + (row2_cols[3], "new_lead"), + (row2_cols[4], "hired"), ] for col, lbl in bucket_pairs: m = _LABEL_META[lbl] @@ -720,7 +790,7 @@ with tab_label: nav_cols = st.columns([2, 1, 1, 1]) remaining = len(unlabeled) - 1 - nav_cols[0].caption(f"**{remaining}** remaining · Keys: 1–9 = label, 0 = other, S = skip, U = undo") + nav_cols[0].caption(f"**{remaining}** remaining · Keys: 1–9, H = label, 0 = other, S = skip, U = undo") if nav_cols[1].button("↩ Undo", disabled=not st.session_state.history, use_container_width=True): prev_idx, prev_label = st.session_state.history.pop() @@ -757,7 +827,7 @@ document.addEventListener('keydown', function(e) { const keyToLabel = { '1':'interview_scheduled','2':'offer_received','3':'rejected', '4':'positive_response','5':'survey_received','6':'neutral', - '7':'event_rescheduled','8':'unrelated','9':'digest' + '7':'event_rescheduled','8':'digest','9':'new_lead' }; const label = keyToLabel[e.key]; if (label) { @@ -772,6 +842,11 @@ document.addEventListener('keydown', function(e) { for (const btn of btns) { if (btn.innerText.includes('Other')) { btn.click(); break; } } + } else if (e.key.toLowerCase() === 'h') { + const btns = window.parent.document.querySelectorAll('button'); + for (const btn of btns) { + if (btn.innerText.toLowerCase().includes('hired')) { btn.click(); break; } + } } else if (e.key.toLowerCase() === 's') { const btns = window.parent.document.querySelectorAll('button'); for (const btn of btns) { @@ -979,3 +1054,133 @@ with tab_settings: if _k in ("settings_accounts", "settings_max") or _k.startswith("s_"): del st.session_state[_k] st.rerun() + + +# ══════════════════════════════════════════════════════════════════════════════ +# BENCHMARK TAB +# ══════════════════════════════════════════════════════════════════════════════ + +with tab_benchmark: + # ── Model selection ─────────────────────────────────────────────────────── + _DEFAULT_MODELS = [ + "deberta-zeroshot", "deberta-small", "gliclass-large", + "bart-mnli", "bge-m3-zeroshot", "deberta-small-2pass", "deberta-base-anli", + ] + _SLOW_MODELS = [ + "deberta-large-ling", "mdeberta-xnli-2m", "bge-reranker", + "deberta-xlarge", "mdeberta-mnli", "xlm-roberta-anli", + ] + + st.subheader("🔬 Benchmark Classifier Models") + + _b_include_slow = st.checkbox("Include slow / large models", value=False, key="b_include_slow") + _b_all_models = _DEFAULT_MODELS + (_SLOW_MODELS if _b_include_slow else []) + _b_selected = st.multiselect( + "Models to run", + options=_b_all_models, + default=_b_all_models, + help="Uncheck models to skip them. Slow models require --include-slow.", + ) + + _n_examples = len(st.session_state.labeled) + st.caption( + f"Scoring against `{_SCORE_FILE.name}` · **{_n_examples} labeled examples**" + f" · Est. time: ~{max(1, len(_b_selected))} – {max(2, len(_b_selected) * 2)} min" + ) + + # Direct binary avoids conda's output interception; -u = unbuffered stdout + _CLASSIFIER_PYTHON = "/devl/miniconda3/envs/job-seeker-classifiers/bin/python" + + if st.button("▶ Run Benchmark", type="primary", disabled=not _b_selected, key="b_run"): + _b_cmd = [ + _CLASSIFIER_PYTHON, "-u", + str(_ROOT / "scripts" / "benchmark_classifier.py"), + "--score", "--score-file", str(_SCORE_FILE), + "--models", *_b_selected, + ] + with st.status("Running benchmark…", expanded=True) as _b_status: + _b_proc = subprocess.Popen( + _b_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, cwd=str(_ROOT), + env={**os.environ, "PYTHONUNBUFFERED": "1"}, + ) + _b_lines: list[str] = [] + _b_area = st.empty() + for _b_line in _b_proc.stdout: + _b_lines.append(_b_line) + _b_area.code("".join(_b_lines[-30:]), language="text") + _b_proc.wait() + _b_full = "".join(_b_lines) + st.session_state["bench_output"] = _b_full + if _b_proc.returncode == 0: + _b_status.update(label="Benchmark complete ✓", state="complete", expanded=False) + else: + _b_status.update(label="Benchmark failed", state="error") + + # ── Results display ─────────────────────────────────────────────────────── + if "bench_output" in st.session_state: + _b_out = st.session_state["bench_output"] + + # Parse summary table rows: name f1 accuracy ms + _b_rows = [] + for _b_l in _b_out.splitlines(): + _b_m = re.match(r"^([\w-]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*$", _b_l.strip()) + if _b_m: + _b_rows.append({ + "Model": _b_m.group(1), + "macro-F1": float(_b_m.group(2)), + "Accuracy": float(_b_m.group(3)), + "ms/email": float(_b_m.group(4)), + }) + + if _b_rows: + import pandas as _pd + _b_df = _pd.DataFrame(_b_rows).sort_values("macro-F1", ascending=False).reset_index(drop=True) + st.dataframe( + _b_df, + column_config={ + "macro-F1": st.column_config.ProgressColumn( + "macro-F1", min_value=0, max_value=1, format="%.3f", + ), + "Accuracy": st.column_config.ProgressColumn( + "Accuracy", min_value=0, max_value=1, format="%.3f", + ), + "ms/email": st.column_config.NumberColumn("ms/email", format="%.1f"), + }, + use_container_width=True, hide_index=True, + ) + + with st.expander("Full benchmark output"): + st.code(_b_out, language="text") + + st.divider() + + # ── Tests ───────────────────────────────────────────────────────────────── + st.subheader("🧪 Run Tests") + st.caption("Runs `pytest tests/ -v` in the job-seeker env (no model downloads required).") + + if st.button("▶ Run Tests", key="b_run_tests"): + _t_cmd = [ + "/devl/miniconda3/envs/job-seeker/bin/pytest", "tests/", "-v", "--tb=short", + ] + with st.status("Running tests…", expanded=True) as _t_status: + _t_proc = subprocess.Popen( + _t_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + text=True, cwd=str(_ROOT), + ) + _t_lines: list[str] = [] + _t_area = st.empty() + for _t_line in _t_proc.stdout: + _t_lines.append(_t_line) + _t_area.code("".join(_t_lines[-30:]), language="text") + _t_proc.wait() + _t_full = "".join(_t_lines) + st.session_state["test_output"] = _t_full + _t_summary = [l for l in _t_lines if "passed" in l or "failed" in l or "error" in l.lower()] + _t_label = _t_summary[-1].strip() if _t_summary else "Done" + _t_state = "error" if _t_proc.returncode != 0 else "complete" + _t_status.update(label=_t_label, state=_t_state, expanded=False) + + if "test_output" in st.session_state: + with st.expander("Full test output", expanded=True): + st.code(st.session_state["test_output"], language="text") diff --git a/tests/test_label_tool.py b/tests/test_label_tool.py new file mode 100644 index 0000000..7e5d257 --- /dev/null +++ b/tests/test_label_tool.py @@ -0,0 +1,87 @@ +"""Tests for label_tool HTML extraction utilities. + +These functions are stdlib-only and safe to test without an IMAP connection. +""" +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +from app.label_tool import _extract_body, _strip_html + + +# ── _strip_html ────────────────────────────────────────────────────────────── + +def test_strip_html_removes_tags(): + assert _strip_html("

Hello world

") == "Hello world" + + +def test_strip_html_skips_script_content(): + result = _strip_html("

real

") + assert "doEvil" not in result + assert "real" in result + + +def test_strip_html_skips_style_content(): + result = _strip_html("

visible

") + assert ".foo" not in result + assert "visible" in result + + +def test_strip_html_handles_br_as_newline(): + result = _strip_html("line1
line2") + assert "line1" in result + assert "line2" in result + + +def test_strip_html_decodes_entities(): + # convert_charrefs=True on HTMLParser handles & etc. + result = _strip_html("

Hello & welcome

") + assert "&" not in result + assert "Hello" in result + assert "welcome" in result + + +def test_strip_html_empty_string(): + assert _strip_html("") == "" + + +def test_strip_html_plain_text_passthrough(): + assert _strip_html("no tags here") == "no tags here" + + +# ── _extract_body ──────────────────────────────────────────────────────────── + +def test_extract_body_prefers_plain_over_html(): + msg = MIMEMultipart("alternative") + msg.attach(MIMEText("plain body", "plain")) + msg.attach(MIMEText("html body", "html")) + assert _extract_body(msg) == "plain body" + + +def test_extract_body_falls_back_to_html_when_no_plain(): + msg = MIMEMultipart("alternative") + msg.attach(MIMEText("

HTML only email

", "html")) + result = _extract_body(msg) + assert "HTML only email" in result + assert "<" not in result # no raw HTML tags leaked through + + +def test_extract_body_non_multipart_html_stripped(): + msg = MIMEText("

Solo HTML

", "html") + result = _extract_body(msg) + assert "Solo HTML" in result + assert "" not in result + + +def test_extract_body_non_multipart_plain_unchanged(): + msg = MIMEText("just plain text", "plain") + assert _extract_body(msg) == "just plain text" + + +def test_extract_body_empty_message(): + msg = MIMEText("", "plain") + assert _extract_body(msg) == "" + + +def test_extract_body_multipart_empty_returns_empty(): + msg = MIMEMultipart("alternative") + assert _extract_body(msg) == "" -- 2.45.2 From cd7bbd1dbf728a447961e33a29dc58a8ef13c093 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 18:11:53 -0800 Subject: [PATCH 044/103] =?UTF-8?q?fix(avocet):=20start-api=20polls=20port?= =?UTF-8?q?=20instead=20of=20sleeping=201s=20=E2=80=94=20avoids=20false-su?= =?UTF-8?q?ccess=20on=20slow=20start?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manage.sh | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/manage.sh b/manage.sh index 0979636..5bbfcce 100755 --- a/manage.sh +++ b/manage.sh @@ -272,11 +272,20 @@ case "$CMD" in --host 127.0.0.1 --port "$API_PORT" \ >> "$API_LOG" 2>&1 & echo $! > "$API_PID_FILE" - sleep 1 - if kill -0 "$(<"$API_PID_FILE")" 2>/dev/null; then - success "Avocet API started → http://localhost:${API_PORT} (PID $(<"$API_PID_FILE"))" - else - error "API died. Check ${API_LOG}" + # Poll until port is actually bound (up to 10 s), not just process alive + for _i in $(seq 1 20); do + sleep 0.5 + if (echo "" >/dev/tcp/127.0.0.1/"$API_PORT") 2>/dev/null; then + success "Avocet API started → http://localhost:${API_PORT} (PID $(<"$API_PID_FILE"))" + break + fi + if ! kill -0 "$(<"$API_PID_FILE")" 2>/dev/null; then + rm -f "$API_PID_FILE" + error "API died during startup. Check ${API_LOG}" + fi + done + if ! (echo "" >/dev/tcp/127.0.0.1/"$API_PORT") 2>/dev/null; then + error "API did not bind to port ${API_PORT} within 10 s. Check ${API_LOG}" fi ;; -- 2.45.2 From b54b2a711eff856b77efb7b2979a5c281bb943cd Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 18:43:00 -0800 Subject: [PATCH 045/103] fix(avocet): normalize queue schema + bind to 0.0.0.0 for LAN access MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add _item_id() (content hash) + _normalize() to map legacy JSONL fields (from_addr/account/no-id) to Vue schema (from/source/id) - All mutating endpoints now look up by _normalize(x)[id] — handles both stored-id (test fixtures) and content-hash (real data) transparently - Change uvicorn bind from 127.0.0.1 to 0.0.0.0 so LAN clients can connect --- app/api.py | 47 +++++++++++++++++++++++++++++++++++------------ manage.sh | 2 +- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/app/api.py b/app/api.py index ccbefd1..ef30eda 100644 --- a/app/api.py +++ b/app/api.py @@ -5,6 +5,7 @@ Endpoints and static file serving are added in subsequent tasks. """ from __future__ import annotations +import hashlib import json from pathlib import Path @@ -60,6 +61,29 @@ def _append_jsonl(path: Path, record: dict) -> None: f.write(json.dumps(record, ensure_ascii=False) + "\n") +def _item_id(item: dict) -> str: + """Stable content-hash ID — matches label_tool.py _entry_key dedup logic.""" + key = (item.get("subject", "") + (item.get("body", "") or "")[:100]) + return hashlib.md5(key.encode("utf-8", errors="replace")).hexdigest() + + +def _normalize(item: dict) -> dict: + """Normalize JSONL item to the Vue frontend schema. + + label_tool.py stores: subject, body, from_addr, date, account (no id). + The Vue app expects: id, subject, body, from, date, source. + Both old (from_addr/account) and new (from/source) field names are handled. + """ + return { + "id": item.get("id") or _item_id(item), + "subject": item.get("subject", ""), + "body": item.get("body", ""), + "from": item.get("from") or item.get("from_addr", ""), + "date": item.get("date", ""), + "source": item.get("source") or item.get("account", ""), + } + + app = FastAPI(title="Avocet API") # In-memory last-action store (single user, local tool — in-memory is fine) @@ -69,7 +93,7 @@ _last_action: dict | None = None @app.get("/api/queue") def get_queue(limit: int = Query(default=10, ge=1, le=50)): items = _read_jsonl(_queue_file()) - return {"items": items[:limit], "total": len(items)} + return {"items": [_normalize(x) for x in items[:limit]], "total": len(items)} class LabelRequest(BaseModel): @@ -81,13 +105,13 @@ class LabelRequest(BaseModel): def post_label(req: LabelRequest): global _last_action items = _read_jsonl(_queue_file()) - match = next((x for x in items if x["id"] == req.id), None) + match = next((x for x in items if _normalize(x)["id"] == req.id), None) if not match: raise HTTPException(404, f"Item {req.id!r} not found in queue") record = {**match, "label": req.label, "labeled_at": datetime.now(timezone.utc).isoformat()} _append_jsonl(_score_file(), record) - _write_jsonl(_queue_file(), [x for x in items if x["id"] != req.id]) + _write_jsonl(_queue_file(), [x for x in items if _normalize(x)["id"] != req.id]) _last_action = {"type": "label", "item": match, "label": req.label} return {"ok": True} @@ -100,10 +124,10 @@ class SkipRequest(BaseModel): def post_skip(req: SkipRequest): global _last_action items = _read_jsonl(_queue_file()) - match = next((x for x in items if x["id"] == req.id), None) + match = next((x for x in items if _normalize(x)["id"] == req.id), None) if not match: raise HTTPException(404, f"Item {req.id!r} not found in queue") - reordered = [x for x in items if x["id"] != req.id] + [match] + reordered = [x for x in items if _normalize(x)["id"] != req.id] + [match] _write_jsonl(_queue_file(), reordered) _last_action = {"type": "skip", "item": match} return {"ok": True} @@ -117,14 +141,14 @@ class DiscardRequest(BaseModel): def post_discard(req: DiscardRequest): global _last_action items = _read_jsonl(_queue_file()) - match = next((x for x in items if x["id"] == req.id), None) + match = next((x for x in items if _normalize(x)["id"] == req.id), None) if not match: raise HTTPException(404, f"Item {req.id!r} not found in queue") record = {**match, "label": "__discarded__", "discarded_at": datetime.now(timezone.utc).isoformat()} _append_jsonl(_discarded_file(), record) - _write_jsonl(_queue_file(), [x for x in items if x["id"] != req.id]) - _last_action = {"type": "discard", "item": match} # store ORIGINAL match, not enriched record + _write_jsonl(_queue_file(), [x for x in items if _normalize(x)["id"] != req.id]) + _last_action = {"type": "discard", "item": match} return {"ok": True} @@ -153,14 +177,13 @@ def delete_undo(): _write_jsonl(_queue_file(), [item] + items) elif action["type"] == "skip": items = _read_jsonl(_queue_file()) - # Remove the item wherever it sits (guards against duplicate insertion), - # then prepend it to the front — restoring it to position 0. - items = [item] + [x for x in items if x["id"] != item["id"]] + item_id = _normalize(item)["id"] + items = [item] + [x for x in items if _normalize(x)["id"] != item_id] _write_jsonl(_queue_file(), items) # Clear AFTER all file operations succeed _last_action = None - return {"undone": {"type": action["type"], "item": item}} + return {"undone": {"type": action["type"], "item": _normalize(item)}} # Label metadata — 10 labels matching label_tool.py diff --git a/manage.sh b/manage.sh index 5bbfcce..7ea900d 100755 --- a/manage.sh +++ b/manage.sh @@ -269,7 +269,7 @@ case "$CMD" in (cd web && npm run build) >> "$API_LOG" 2>&1 info "Starting FastAPI on port ${API_PORT}…" nohup "$PYTHON_UI" -m uvicorn app.api:app \ - --host 127.0.0.1 --port "$API_PORT" \ + --host 0.0.0.0 --port "$API_PORT" \ >> "$API_LOG" 2>&1 & echo $! > "$API_PID_FILE" # Poll until port is actually bound (up to 10 s), not just process alive -- 2.45.2 From a06b133a6eccbf0b1fdffe801bfe75de27bd9bd7 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 18:43:41 -0800 Subject: [PATCH 046/103] docs(avocet): document email field schemas and normalization layer --- CLAUDE.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bfe9a8b..565a484 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,8 +75,9 @@ conda run -n job-seeker-classifiers python scripts/benchmark_classifier.py --exp | `survey_received` | 5 | Culture-fit survey or assessment invitation | | `neutral` | 6 | ATS confirmation (application received, etc.) | | `event_rescheduled` | 7 | Interview or event moved to a new time | -| `unrelated` | 8 | Non-job-search email, not classifiable | -| `digest` | 9 | Job digest or multi-listing email (scrapeable) | +| `digest` | 8 | Job digest or multi-listing email (scrapeable) | +| `new_lead` | 9 | Unsolicited recruiter outreach or cold contact | +| `hired` | h | Offer accepted, onboarding, welcome email, start date | ## Model Registry (13 models, 7 defaults) @@ -105,6 +106,67 @@ Add `--models deberta-small deberta-small-2pass` to test a specific subset. - `--compare` uses the first account in `label_tool.yaml` for live IMAP emails. - DB export labels are llama3.1:8b-generated — treat as noisy, not gold truth. +## Vue Label UI (app/api.py + web/) + +FastAPI on port 8503 serves both the REST API and the built Vue SPA (`web/dist/`). + +``` +./manage.sh start-api # build Vue SPA + start FastAPI (binds 0.0.0.0:8503 — LAN accessible) +./manage.sh stop-api +./manage.sh open-api # xdg-open http://localhost:8503 +``` + +Logs: `log/api.log` + +## Email Field Schema — IMPORTANT + +Two schemas exist. The normalization layer in `app/api.py` bridges them automatically. + +### JSONL on-disk schema (written by `label_tool.py` and `label_tool.py`'s IMAP fetch) + +| Field | Type | Notes | +|-------|------|-------| +| `subject` | str | Email subject line | +| `body` | str | Plain-text body, truncated at 800 chars; HTML stripped by `_strip_html()` | +| `from_addr` | str | Sender address string (`"Name "`) | +| `date` | str | Raw RFC 2822 date string | +| `account` | str | Display name of the IMAP account that fetched it | +| *(no `id`)* | — | Dedup key is MD5 of `(subject + body[:100])` — never stored on disk | + +### Vue API schema (returned by `GET /api/queue`, required by POST endpoints) + +| Field | Type | Notes | +|-------|------|-------| +| `id` | str | MD5 content hash, or stored `id` if item has one | +| `subject` | str | Unchanged | +| `body` | str | Unchanged | +| `from` | str | Mapped from `from_addr` (or `from` if already present) | +| `date` | str | Unchanged | +| `source` | str | Mapped from `account` (or `source` if already present) | + +### Normalization layer (`_normalize()` in `app/api.py`) + +`_normalize(item)` handles the mapping and ID generation. All `GET /api/queue` responses +pass through it. Mutating endpoints (`/api/label`, `/api/skip`, `/api/discard`) look up +items via `_normalize(x)["id"]`, so both real data (no `id`, uses content hash) and test +fixtures (explicit `id` field) work transparently. + +### Peregrine integration + +Peregrine's `staging.db` uses different field names again: + +| staging.db column | Maps to avocet JSONL field | +|-------------------|---------------------------| +| `subject` | `subject` | +| `body` | `body` (may contain HTML — run through `_strip_html()` before queuing) | +| `from_address` | `from_addr` | +| `received_date` | `date` | +| `account` or source context | `account` | + +When exporting from Peregrine's DB for avocet labeling, transform to the JSONL schema above +(not the Vue API schema). The `--export-db` flag in `benchmark_classifier.py` does this. +Any new export path should also call `_strip_html()` on the body before writing. + ## Relationship to Peregrine Avocet started as `peregrine/tools/label_tool.py` + `peregrine/scripts/classifier_adapters.py`. -- 2.45.2 From 82eeb4defc9a51c5466fff8cb3b4fc219224bf9b Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 19:26:34 -0800 Subject: [PATCH 047/103] fix: prevent blank page on rebuild and queue drain on skip/discard Two bugs fixed: 1. Blank white page after vue SPA rebuild: browsers cached old index.html referencing old asset hashes. Assets are deleted on rebuild, causing 404s for JS/CSS -> blank page. Fix: serve index.html with Cache-Control: no-cache so browsers always fetch fresh HTML. Hashed assets (/assets/chunk-abc123.js) remain cacheable forever. 2. Queue draining to empty on skip/discard: handleSkip and handleDiscard never refilled the local queue buffer. After enough skips, store.current went null and the empty state showed (blank-looking). Fix: both handlers now call fetchBatch() when queue drops below 3, matching handleLabel. Also: sync classifier_adapters LABELS to match current 10-label schema (new_lead + hired, remove unrelated). 48 Python tests pass, 48 frontend tests pass. --- app/api.py | 11 +++++++++++ scripts/classifier_adapters.py | 6 ++++-- tests/test_classifier_adapters.py | 8 +++++--- web/src/views/LabelView.vue | 2 ++ 4 files changed, 22 insertions(+), 5 deletions(-) diff --git a/app/api.py b/app/api.py index ef30eda..30ddc56 100644 --- a/app/api.py +++ b/app/api.py @@ -209,5 +209,16 @@ def get_labels(): # Static SPA — MUST be last (catches all unmatched paths) _DIST = _ROOT / "web" / "dist" if _DIST.exists(): + from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles + + # Serve index.html with no-cache so browsers always fetch fresh HTML after rebuilds. + # Hashed assets (/assets/index-abc123.js) can be cached forever — they change names + # when content changes (standard Vite cache-busting strategy). + _NO_CACHE = {"Cache-Control": "no-cache, no-store, must-revalidate", "Pragma": "no-cache"} + + @app.get("/") + def get_spa_root(): + return FileResponse(str(_DIST / "index.html"), headers=_NO_CACHE) + app.mount("/", StaticFiles(directory=str(_DIST), html=True), name="spa") diff --git a/scripts/classifier_adapters.py b/scripts/classifier_adapters.py index 2817078..5704de1 100644 --- a/scripts/classifier_adapters.py +++ b/scripts/classifier_adapters.py @@ -27,8 +27,9 @@ LABELS: list[str] = [ "survey_received", "neutral", "event_rescheduled", - "unrelated", "digest", + "new_lead", + "hired", ] # Natural-language descriptions used by the RerankerAdapter. @@ -40,8 +41,9 @@ LABEL_DESCRIPTIONS: dict[str, str] = { "survey_received": "invitation to complete a culture-fit survey or assessment", "neutral": "automated ATS confirmation such as application received", "event_rescheduled": "an interview or scheduled event moved to a new time", - "unrelated": "non-job-search email unrelated to any application or recruiter", "digest": "job digest or multi-listing email with multiple job postings", + "new_lead": "unsolicited recruiter outreach or cold contact about a new opportunity", + "hired": "job offer accepted, onboarding logistics, welcome email, or start date confirmation", } # Lazy import shims — allow tests to patch without requiring the libs installed. diff --git a/tests/test_classifier_adapters.py b/tests/test_classifier_adapters.py index f50ef3b..85f9dc1 100644 --- a/tests/test_classifier_adapters.py +++ b/tests/test_classifier_adapters.py @@ -2,14 +2,16 @@ import pytest -def test_labels_constant_has_nine_items(): +def test_labels_constant_has_ten_items(): from scripts.classifier_adapters import LABELS - assert len(LABELS) == 9 + assert len(LABELS) == 10 assert "interview_scheduled" in LABELS assert "neutral" in LABELS assert "event_rescheduled" in LABELS - assert "unrelated" in LABELS assert "digest" in LABELS + assert "new_lead" in LABELS + assert "hired" in LABELS + assert "unrelated" not in LABELS def test_compute_metrics_perfect_predictions(): diff --git a/web/src/views/LabelView.vue b/web/src/views/LabelView.vue index 246bcf7..1925bfc 100644 --- a/web/src/views/LabelView.vue +++ b/web/src/views/LabelView.vue @@ -211,6 +211,7 @@ async function handleSkip() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }), }) + if (store.queue.length < 3) await fetchBatch() } async function handleDiscard() { @@ -228,6 +229,7 @@ async function handleDiscard() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: item.id }), }) + if (store.queue.length < 3) await fetchBatch() } async function handleUndo() { -- 2.45.2 From 92da5902ba563c347209b823aed950c27c185561 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Wed, 4 Mar 2026 11:29:03 -0800 Subject: [PATCH 048/103] fix: UndoToast now emits expire after 5s so toast self-dismisses --- web/src/components/UndoToast.test.ts | 15 ++++++++++ web/src/components/UndoToast.vue | 7 +++-- web/src/views/LabelView.vue | 43 ++++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 5 deletions(-) diff --git a/web/src/components/UndoToast.test.ts b/web/src/components/UndoToast.test.ts index 3378504..b5a2b30 100644 --- a/web/src/components/UndoToast.test.ts +++ b/web/src/components/UndoToast.test.ts @@ -71,4 +71,19 @@ describe('UndoToast', () => { const w = mount(UndoToast, { props: { action: labelAction } }) expect(w.find('[role="status"]').exists()).toBe(true) }) + + it('emits expire when tick fires with timestamp beyond DURATION', async () => { + let capturedTick: FrameRequestCallback | null = null + vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { + capturedTick = fn + return 1 + }) + vi.spyOn(performance, 'now').mockReturnValue(0) + const w = mount(UndoToast, { props: { action: labelAction } }) + await import('vue').then(v => v.nextTick()) + // Simulate a tick timestamp 6 seconds in — beyond the 5-second DURATION + if (capturedTick) capturedTick(6000) + await import('vue').then(v => v.nextTick()) + expect(w.emitted('expire')).toBeTruthy() + }) }) diff --git a/web/src/components/UndoToast.vue b/web/src/components/UndoToast.vue index 18e7f57..aa02a04 100644 --- a/web/src/components/UndoToast.vue +++ b/web/src/components/UndoToast.vue @@ -13,7 +13,7 @@ import { ref, onMounted, onUnmounted, computed } from 'vue' import type { LastAction } from '../stores/label' const props = defineProps<{ action: LastAction }>() -defineEmits<{ undo: [] }>() +const emit = defineEmits<{ undo: []; expire: [] }>() const DURATION = 5000 const elapsed = ref(0) @@ -30,14 +30,15 @@ const label = computed(() => { }) function tick(ts: number) { - if (!start) start = ts elapsed.value = ts - start if (elapsed.value < DURATION) { raf = requestAnimationFrame(tick) + } else { + emit('expire') } } -onMounted(() => { raf = requestAnimationFrame(tick) }) +onMounted(() => { start = performance.now(); raf = requestAnimationFrame(tick) }) onUnmounted(() => cancelAnimationFrame(raf)) diff --git a/web/src/views/LabelView.vue b/web/src/views/LabelView.vue index 1925bfc..cc7df66 100644 --- a/web/src/views/LabelView.vue +++ b/web/src/views/LabelView.vue @@ -1,11 +1,19 @@