diff --git a/.gitignore b/.gitignore index 06104ed..fc48446 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ config/label_tool.yaml data/email_score.jsonl data/email_label_queue.jsonl data/email_compare_sample.jsonl +data/sft_candidates.jsonl +data/sft_approved.jsonl # Conda/pip artifacts .env diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..afc7b9f --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,7 @@ +# Privacy Policy + +CircuitForge LLC's privacy policy applies to this product and is published at: + +**** + +Last reviewed: March 2026. diff --git a/app/api.py b/app/api.py new file mode 100644 index 0000000..3ce74c3 --- /dev/null +++ b/app/api.py @@ -0,0 +1,572 @@ +"""Avocet — FastAPI REST layer. + +JSONL read/write helpers and FastAPI app instance. +Endpoints and static file serving are added in subsequent tasks. +""" +from __future__ import annotations + +import hashlib +import json +import os +import subprocess as _subprocess +import yaml +from pathlib import Path + +from datetime import datetime, timezone + +from fastapi import FastAPI, HTTPException, Query +from pydantic import BaseModel + +_ROOT = Path(__file__).parent.parent +_DATA_DIR: Path = _ROOT / "data" # overridable in tests via set_data_dir() +_MODELS_DIR: Path = _ROOT / "models" # overridable in tests via set_models_dir() +_CONFIG_DIR: Path | None = None # None = use real path + +# Process registry for running jobs — used by cancel endpoints. +# Keys: "benchmark" | "finetune". Values: the live Popen object. +_running_procs: dict = {} +_cancelled_jobs: set = set() + + +def set_data_dir(path: Path) -> None: + """Override data directory — used by tests.""" + global _DATA_DIR + _DATA_DIR = path + + +def _best_cuda_device() -> str: + """Return the index of the GPU with the most free VRAM as a string. + + Uses nvidia-smi so it works in the job-seeker env (no torch). Returns "" + if nvidia-smi is unavailable or no GPUs are found. Restricting the + training subprocess to a single GPU via CUDA_VISIBLE_DEVICES prevents + PyTorch DataParallel from replicating the model across all GPUs, which + would OOM the GPU with less headroom. + """ + try: + out = _subprocess.check_output( + ["nvidia-smi", "--query-gpu=index,memory.free", + "--format=csv,noheader,nounits"], + text=True, + timeout=5, + ) + best_idx, best_free = "", 0 + for line in out.strip().splitlines(): + parts = line.strip().split(", ") + if len(parts) == 2: + idx, free = parts[0].strip(), int(parts[1].strip()) + if free > best_free: + best_free, best_idx = free, idx + return best_idx + except Exception: + return "" + + +def set_models_dir(path: Path) -> None: + """Override models directory — used by tests.""" + global _MODELS_DIR + _MODELS_DIR = path + + +def set_config_dir(path: Path | None) -> None: + """Override config directory — used by tests.""" + global _CONFIG_DIR + _CONFIG_DIR = path + + +def _config_file() -> Path: + if _CONFIG_DIR is not None: + return _CONFIG_DIR / "label_tool.yaml" + return _ROOT / "config" / "label_tool.yaml" + + +def reset_last_action() -> None: + """Reset undo state — used by tests.""" + global _last_action + _last_action = None + + +def _queue_file() -> Path: + return _DATA_DIR / "email_label_queue.jsonl" + + +def _score_file() -> Path: + return _DATA_DIR / "email_score.jsonl" + + +def _discarded_file() -> Path: + return _DATA_DIR / "discarded.jsonl" + + +def _read_jsonl(path: Path) -> list[dict]: + if not path.exists(): + return [] + lines = path.read_text(encoding="utf-8").splitlines() + return [json.loads(l) for l in lines if l.strip()] + + +def _write_jsonl(path: Path, records: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + text = "\n".join(json.dumps(r, ensure_ascii=False) for r in records) + path.write_text(text + "\n" if records else "", encoding="utf-8") + + +def _append_jsonl(path: Path, record: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as f: + 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") + +from app.sft import router as sft_router +app.include_router(sft_router, prefix="/api/sft") + +# In-memory last-action store (single user, local tool — in-memory is fine) +_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": [_normalize(x) for x in items[:limit]], "total": len(items)} + + +class LabelRequest(BaseModel): + id: str + label: str + + +@app.post("/api/label") +def post_label(req: LabelRequest): + global _last_action + items = _read_jsonl(_queue_file()) + 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 _normalize(x)["id"] != req.id]) + _last_action = {"type": "label", "item": match, "label": req.label} + return {"ok": True} + + +class SkipRequest(BaseModel): + id: str + + +@app.post("/api/skip") +def post_skip(req: SkipRequest): + global _last_action + items = _read_jsonl(_queue_file()) + 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 _normalize(x)["id"] != req.id] + [match] + _write_jsonl(_queue_file(), reordered) + _last_action = {"type": "skip", "item": match} + return {"ok": True} + + +class DiscardRequest(BaseModel): + id: str + + +@app.post("/api/discard") +def post_discard(req: DiscardRequest): + global _last_action + items = _read_jsonl(_queue_file()) + 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 _normalize(x)["id"] != req.id]) + _last_action = {"type": "discard", "item": match} + return {"ok": True} + + +@app.delete("/api/label/undo") +def delete_undo(): + global _last_action + if not _last_action: + raise HTTPException(404, "No action to undo") + action = _last_action + item = action["item"] # always the original clean queue item + + # Perform file operations FIRST — only clear _last_action on success + if action["type"] == "label": + records = _read_jsonl(_score_file()) + if not records: + raise HTTPException(409, "Score file is empty — cannot undo label") + _write_jsonl(_score_file(), records[:-1]) + items = _read_jsonl(_queue_file()) + _write_jsonl(_queue_file(), [item] + items) + elif action["type"] == "discard": + records = _read_jsonl(_discarded_file()) + if not records: + raise HTTPException(409, "Discarded file is empty — cannot undo discard") + _write_jsonl(_discarded_file(), records[:-1]) + items = _read_jsonl(_queue_file()) + _write_jsonl(_queue_file(), [item] + items) + elif action["type"] == "skip": + items = _read_jsonl(_queue_file()) + 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": _normalize(item)}} + + +# Label metadata — 10 labels matching label_tool.py +_LABEL_META = [ + {"name": "interview_scheduled", "emoji": "\U0001f4c5", "color": "#4CAF50", "key": "1"}, + {"name": "offer_received", "emoji": "\U0001f389", "color": "#2196F3", "key": "2"}, + {"name": "rejected", "emoji": "\u274c", "color": "#F44336", "key": "3"}, + {"name": "positive_response", "emoji": "\U0001f44d", "color": "#FF9800", "key": "4"}, + {"name": "survey_received", "emoji": "\U0001f4cb", "color": "#9C27B0", "key": "5"}, + {"name": "neutral", "emoji": "\u2b1c", "color": "#607D8B", "key": "6"}, + {"name": "event_rescheduled", "emoji": "\U0001f504", "color": "#FF5722", "key": "7"}, + {"name": "digest", "emoji": "\U0001f4f0", "color": "#00BCD4", "key": "8"}, + {"name": "new_lead", "emoji": "\U0001f91d", "color": "#009688", "key": "9"}, + {"name": "hired", "emoji": "\U0001f38a", "color": "#FFC107", "key": "h"}, +] + + +@app.get("/api/config/labels") +def get_labels(): + return _LABEL_META + + +@app.get("/api/config") +def get_config(): + f = _config_file() + if not f.exists(): + return {"accounts": [], "max_per_account": 500} + raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + return {"accounts": raw.get("accounts", []), "max_per_account": raw.get("max_per_account", 500)} + + +class ConfigPayload(BaseModel): + accounts: list[dict] + max_per_account: int = 500 + + +@app.post("/api/config") +def post_config(payload: ConfigPayload): + f = _config_file() + f.parent.mkdir(parents=True, exist_ok=True) + tmp = f.with_suffix(".tmp") + tmp.write_text(yaml.dump(payload.model_dump(), allow_unicode=True, sort_keys=False), + encoding="utf-8") + tmp.rename(f) + return {"ok": True} + + +@app.get("/api/stats") +def get_stats(): + records = _read_jsonl(_score_file()) + counts: dict[str, int] = {} + for r in records: + lbl = r.get("label", "") + if lbl: + counts[lbl] = counts.get(lbl, 0) + 1 + return { + "total": len(records), + "counts": counts, + "score_file_bytes": _score_file().stat().st_size if _score_file().exists() else 0, + } + + +@app.get("/api/stats/download") +def download_stats(): + from fastapi.responses import FileResponse + if not _score_file().exists(): + raise HTTPException(404, "No score file") + return FileResponse( + str(_score_file()), + filename="email_score.jsonl", + media_type="application/jsonlines", + headers={"Content-Disposition": 'attachment; filename="email_score.jsonl"'}, + ) + + +class AccountTestRequest(BaseModel): + account: dict + + +@app.post("/api/accounts/test") +def test_account(req: AccountTestRequest): + from app.imap_fetch import test_connection + ok, message, count = test_connection(req.account) + return {"ok": ok, "message": message, "count": count} + + +from fastapi.responses import StreamingResponse + + +# --------------------------------------------------------------------------- +# Benchmark endpoints +# --------------------------------------------------------------------------- + +@app.get("/api/benchmark/results") +def get_benchmark_results(): + """Return the most recently saved benchmark results, or an empty envelope.""" + path = _DATA_DIR / "benchmark_results.json" + if not path.exists(): + return {"models": {}, "sample_count": 0, "timestamp": None} + return json.loads(path.read_text()) + + +@app.get("/api/benchmark/run") +def run_benchmark(include_slow: bool = False): + """Spawn the benchmark script and stream stdout as SSE progress events.""" + python_bin = "/devl/miniconda3/envs/job-seeker-classifiers/bin/python" + script = str(_ROOT / "scripts" / "benchmark_classifier.py") + cmd = [python_bin, script, "--score", "--save"] + if include_slow: + cmd.append("--include-slow") + + def generate(): + try: + proc = _subprocess.Popen( + cmd, + stdout=_subprocess.PIPE, + stderr=_subprocess.STDOUT, + text=True, + bufsize=1, + cwd=str(_ROOT), + ) + _running_procs["benchmark"] = proc + _cancelled_jobs.discard("benchmark") # clear any stale flag from a prior run + try: + for line in proc.stdout: + line = line.rstrip() + if line: + yield f"data: {json.dumps({'type': 'progress', 'message': line})}\n\n" + proc.wait() + if proc.returncode == 0: + yield f"data: {json.dumps({'type': 'complete'})}\n\n" + elif "benchmark" in _cancelled_jobs: + _cancelled_jobs.discard("benchmark") + yield f"data: {json.dumps({'type': 'cancelled'})}\n\n" + else: + yield f"data: {json.dumps({'type': 'error', 'message': f'Process exited with code {proc.returncode}'})}\n\n" + finally: + _running_procs.pop("benchmark", None) + except Exception as exc: + yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +# --------------------------------------------------------------------------- +# Finetune endpoints +# --------------------------------------------------------------------------- + +@app.get("/api/finetune/status") +def get_finetune_status(): + """Scan models/ for training_info.json files. Returns [] if none exist.""" + models_dir = _MODELS_DIR + if not models_dir.exists(): + return [] + results = [] + for sub in models_dir.iterdir(): + if not sub.is_dir(): + continue + info_path = sub / "training_info.json" + if not info_path.exists(): + continue + try: + info = json.loads(info_path.read_text(encoding="utf-8")) + results.append(info) + except Exception: + pass + return results + + +@app.get("/api/finetune/run") +def run_finetune_endpoint( + model: str = "deberta-small", + epochs: int = 5, + score: list[str] = Query(default=[]), +): + """Spawn finetune_classifier.py and stream stdout as SSE progress events.""" + python_bin = "/devl/miniconda3/envs/job-seeker-classifiers/bin/python" + script = str(_ROOT / "scripts" / "finetune_classifier.py") + cmd = [python_bin, script, "--model", model, "--epochs", str(epochs)] + data_root = _DATA_DIR.resolve() + for score_file in score: + resolved = (_DATA_DIR / score_file).resolve() + if not str(resolved).startswith(str(data_root)): + raise HTTPException(400, f"Invalid score path: {score_file!r}") + cmd.extend(["--score", str(resolved)]) + + # Pick the GPU with the most free VRAM. Setting CUDA_VISIBLE_DEVICES to a + # single device prevents DataParallel from replicating the model across all + # GPUs, which would force a full copy onto the more memory-constrained device. + proc_env = {**os.environ, "PYTORCH_ALLOC_CONF": "expandable_segments:True"} + best_gpu = _best_cuda_device() + if best_gpu: + proc_env["CUDA_VISIBLE_DEVICES"] = best_gpu + + gpu_note = f"GPU {best_gpu}" if best_gpu else "CPU (no GPU found)" + + def generate(): + yield f"data: {json.dumps({'type': 'progress', 'message': f'[api] Using {gpu_note} (most free VRAM)'})}\n\n" + try: + proc = _subprocess.Popen( + cmd, + stdout=_subprocess.PIPE, + stderr=_subprocess.STDOUT, + text=True, + bufsize=1, + cwd=str(_ROOT), + env=proc_env, + ) + _running_procs["finetune"] = proc + _cancelled_jobs.discard("finetune") # clear any stale flag from a prior run + try: + for line in proc.stdout: + line = line.rstrip() + if line: + yield f"data: {json.dumps({'type': 'progress', 'message': line})}\n\n" + proc.wait() + if proc.returncode == 0: + yield f"data: {json.dumps({'type': 'complete'})}\n\n" + elif "finetune" in _cancelled_jobs: + _cancelled_jobs.discard("finetune") + yield f"data: {json.dumps({'type': 'cancelled'})}\n\n" + else: + yield f"data: {json.dumps({'type': 'error', 'message': f'Process exited with code {proc.returncode}'})}\n\n" + finally: + _running_procs.pop("finetune", None) + except Exception as exc: + yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" + + return StreamingResponse( + generate(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@app.post("/api/benchmark/cancel") +def cancel_benchmark(): + """Kill the running benchmark subprocess. 404 if none is running.""" + proc = _running_procs.get("benchmark") + if proc is None: + raise HTTPException(404, "No benchmark is running") + _cancelled_jobs.add("benchmark") + proc.terminate() + try: + proc.wait(timeout=3) + except _subprocess.TimeoutExpired: + proc.kill() + return {"status": "cancelled"} + + +@app.post("/api/finetune/cancel") +def cancel_finetune(): + """Kill the running fine-tune subprocess. 404 if none is running.""" + proc = _running_procs.get("finetune") + if proc is None: + raise HTTPException(404, "No finetune is running") + _cancelled_jobs.add("finetune") + proc.terminate() + try: + proc.wait(timeout=3) + except _subprocess.TimeoutExpired: + proc.kill() + return {"status": "cancelled"} + + +@app.get("/api/fetch/stream") +def fetch_stream( + accounts: str = Query(default=""), + days_back: int = Query(default=90, ge=1, le=365), + limit: int = Query(default=150, ge=1, le=1000), + mode: str = Query(default="wide"), +): + from app.imap_fetch import fetch_account_stream + + selected_names = {n.strip() for n in accounts.split(",") if n.strip()} + config = get_config() # reuse existing endpoint logic + selected = [a for a in config["accounts"] if a.get("name") in selected_names] + + def generate(): + known_keys = {_item_id(x) for x in _read_jsonl(_queue_file())} + total_added = 0 + + for acc in selected: + try: + batch_emails: list[dict] = [] + for event in fetch_account_stream(acc, days_back, limit, known_keys): + if event["type"] == "done": + batch_emails = event.pop("emails", []) + total_added += event["added"] + yield f"data: {json.dumps(event)}\n\n" + # Write new emails to queue after each account + if batch_emails: + existing = _read_jsonl(_queue_file()) + _write_jsonl(_queue_file(), existing + batch_emails) + except Exception as exc: + error_event = {"type": "error", "account": acc.get("name", "?"), + "message": str(exc)} + yield f"data: {json.dumps(error_event)}\n\n" + + queue_size = len(_read_jsonl(_queue_file())) + complete = {"type": "complete", "total_added": total_added, "queue_size": queue_size} + yield f"data: {json.dumps(complete)}\n\n" + + return StreamingResponse(generate(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +# 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/app/imap_fetch.py b/app/imap_fetch.py new file mode 100644 index 0000000..1e15119 --- /dev/null +++ b/app/imap_fetch.py @@ -0,0 +1,158 @@ +"""Avocet — IMAP fetch utilities. + +Shared between app/api.py (FastAPI SSE endpoint) and the label UI. +No Streamlit imports here — stdlib + imaplib only. +""" +from __future__ import annotations + +import email as _email_lib +import hashlib +import imaplib +from datetime import datetime, timedelta +from email.header import decode_header as _raw_decode +from typing import Any, Iterator + +from app.utils import extract_body, strip_html # noqa: F401 (strip_html re-exported for callers) + + +# ── IMAP decode 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 entry_key(e: dict) -> str: + """Stable MD5 content-hash for dedup — matches label_tool.py _entry_key.""" + key = (e.get("subject", "") + (e.get("body", "") or "")[:100]) + return hashlib.md5(key.encode("utf-8", errors="replace")).hexdigest() + + +# ── Wide search terms ──────────────────────────────────────────────────────── + +_WIDE_TERMS = [ + "interview", "phone screen", "video call", "zoom link", "schedule a call", + "offer letter", "job offer", "offer of employment", "pleased to offer", + "unfortunately", "not moving forward", "other candidates", "regret to inform", + "no longer", "decided not to", "decided to go with", + "opportunity", "interested in your background", "reached out", "great fit", + "exciting role", "love to connect", + "assessment", "questionnaire", "culture fit", "culture-fit", "online assessment", + "application received", "thank you for applying", "application confirmation", + "you applied", "your application for", + "reschedule", "rescheduled", "new time", "moved to", "postponed", "new date", + "job digest", "jobs you may like", "recommended jobs", "jobs for you", + "new jobs", "job alert", + "came across your profile", "reaching out about", "great fit for a role", + "exciting opportunity", + "welcome to the team", "start date", "onboarding", "first day", "we're excited to have you", + "application", "recruiter", "recruiting", "hiring", "candidate", +] + + +# ── Public API ──────────────────────────────────────────────────────────────── + +def test_connection(acc: dict) -> tuple[bool, str, int | None]: + """Connect, login, select folder. Returns (ok, human_message, message_count|None).""" + 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.", None + try: + conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port) + conn.login(username, password) + _, data = conn.select(folder, readonly=True) + count_raw = data[0].decode() if data and data[0] else "0" + count = int(count_raw) if count_raw.isdigit() else 0 + conn.logout() + return True, f"Connected — {count:,} message(s) in {folder}.", count + except Exception as exc: + return False, str(exc), None + + +def fetch_account_stream( + acc: dict, + days_back: int, + limit: int, + known_keys: set[str], +) -> Iterator[dict]: + """Generator — yields progress dicts while fetching emails via IMAP. + + Mutates `known_keys` in place for cross-account dedup within one fetch session. + + Yields event dicts with "type" key: + {"type": "start", "account": str, "total_uids": int} + {"type": "progress", "account": str, "fetched": int, "total_uids": int} + {"type": "done", "account": str, "added": int, "skipped": int, "emails": list} + """ + name = acc.get("name", acc.get("username", "?")) + host = acc.get("host", "imap.gmail.com") + port = int(acc.get("port", 993)) + use_ssl = acc.get("use_ssl", True) + username = acc["username"] + password = acc["password"] + folder = acc.get("folder", "INBOX") + since = (datetime.now() - timedelta(days=days_back)).strftime("%d-%b-%Y") + + conn = (imaplib.IMAP4_SSL if use_ssl else imaplib.IMAP4)(host, port) + conn.login(username, password) + conn.select(folder, readonly=True) + + seen_uids: dict[bytes, None] = {} + 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 + + uids = list(seen_uids.keys())[: limit * 3] + yield {"type": "start", "account": name, "total_uids": len(uids)} + + emails: list[dict] = [] + skipped = 0 + for i, uid in enumerate(uids): + if len(emails) >= limit: + break + if i % 5 == 0: + yield {"type": "progress", "account": name, "fetched": len(emails), "total_uids": len(uids)} + 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} + k = entry_key(entry) + if k not in known_keys: + known_keys.add(k) + emails.append(entry) + else: + skipped += 1 + except Exception: + skipped += 1 + + try: + conn.logout() + except Exception: + pass + + yield {"type": "done", "account": name, "added": len(emails), "skipped": skipped, + "emails": emails} diff --git a/app/label_tool.py b/app/label_tool.py deleted file mode 100644 index 1340824..0000000 --- a/app/label_tool.py +++ /dev/null @@ -1,981 +0,0 @@ -"""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 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 -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", - "event_rescheduled", - "unrelated", - "digest", -] - -_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"}, - "event_rescheduled": {"emoji": "🔄", "color": "#FF5722", "key": "7"}, - "unrelated": {"emoji": "🗑️", "color": "#757575", "key": "8"}, - "digest": {"emoji": "📰", "color": "#00BCD4", "key": "9"}, -} - -# ── 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", - # 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", - # 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", -] - - -# ── 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 _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.""" - 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 - - -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: - 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, tab_settings = st.tabs(["🃏 Label", "📥 Fetch", "📊 Stats", "⚙️ Settings"]) - - -# ══════════════════════════════════════════════════════════════════════════════ -# 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) - # 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")) - 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: _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: - _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") - - # ── 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 -# ══════════════════════════════════════════════════════════════════════════════ - -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"""
-
{_to_html(from_)}  ·  {_to_html(date_[:16])}  ·  {_to_html(acct)}
-
{_to_html(subj)}
-
{_to_html(body[:500], newlines_to_br=True)}
-
""", - 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 - - # 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"), - (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"), - ] - for col, lbl in bucket_pairs: - m = _LABEL_META[lbl] - 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, 1]) - - remaining = len(unlabeled) - 1 - 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() - # 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() - - 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( - """""", - height=0, - ) - - -# ══════════════════════════════════════════════════════════════════════════════ -# STATS TAB -# ══════════════════════════════════════════════════════════════════════════════ - -with tab_stats: - labeled = st.session_state.labeled - - if not labeled: - st.info("No labeled emails yet.") - else: - counts: dict[str, int] = {} - for r in labeled: - lbl = r.get("label", "") - if lbl: - counts[lbl] = counts.get(lbl, 0) + 1 - - st.markdown(f"**{len(labeled)} labeled emails total**") - - # 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"{emoji} {lbl}") - col_bar.progress(counts[lbl] / max_count) - 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", - ) - - -# ══════════════════════════════════════════════════════════════════════════════ -# 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() diff --git a/app/sft.py b/app/sft.py new file mode 100644 index 0000000..929b98a --- /dev/null +++ b/app/sft.py @@ -0,0 +1,310 @@ +"""Avocet — SFT candidate import and correction API. + +All endpoints are registered on `router` (a FastAPI APIRouter). +api.py includes this router with prefix="/api/sft". + +Module-level globals (_SFT_DATA_DIR, _SFT_CONFIG_DIR) follow the same +testability pattern as api.py — override them via set_sft_data_dir() and +set_sft_config_dir() in test fixtures. +""" +from __future__ import annotations + +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Literal + +import yaml +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel + +from app.utils import append_jsonl, read_jsonl, write_jsonl + +logger = logging.getLogger(__name__) + +_ROOT = Path(__file__).parent.parent +_SFT_DATA_DIR: Path = _ROOT / "data" +_SFT_CONFIG_DIR: Path | None = None + +router = APIRouter() + + +# ── Testability seams ────────────────────────────────────────────────────── + +def set_sft_data_dir(path: Path) -> None: + global _SFT_DATA_DIR + _SFT_DATA_DIR = path + + +def set_sft_config_dir(path: Path | None) -> None: + global _SFT_CONFIG_DIR + _SFT_CONFIG_DIR = path + + +# ── Internal helpers ─────────────────────────────────────────────────────── + +def _config_file() -> Path: + if _SFT_CONFIG_DIR is not None: + return _SFT_CONFIG_DIR / "label_tool.yaml" + return _ROOT / "config" / "label_tool.yaml" + + +def _get_bench_results_dir() -> Path: + f = _config_file() + if not f.exists(): + return Path("/nonexistent-bench-results") + try: + raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as exc: + logger.warning("Failed to parse SFT config %s: %s", f, exc) + return Path("/nonexistent-bench-results") + d = raw.get("sft", {}).get("bench_results_dir", "") + return Path(d) if d else Path("/nonexistent-bench-results") + + +def _candidates_file() -> Path: + return _SFT_DATA_DIR / "sft_candidates.jsonl" + + +def _approved_file() -> Path: + return _SFT_DATA_DIR / "sft_approved.jsonl" + + +def _read_candidates() -> list[dict]: + return read_jsonl(_candidates_file()) + + +def _write_candidates(records: list[dict]) -> None: + write_jsonl(_candidates_file(), records) + + +def _is_exportable(r: dict) -> bool: + """Return True if an approved record is ready to include in SFT export.""" + return ( + r.get("status") == "approved" + and bool(r.get("corrected_response")) + and str(r["corrected_response"]).strip() != "" + ) + + +# ── GET /runs ────────────────────────────────────────────────────────────── + +@router.get("/runs") +def get_runs(): + """List available benchmark runs in the configured bench_results_dir.""" + from scripts.sft_import import discover_runs + bench_dir = _get_bench_results_dir() + existing = _read_candidates() + # benchmark_run_id in each record equals the run's directory name by cf-orch convention + imported_run_ids = { + r["benchmark_run_id"] + for r in existing + if r.get("benchmark_run_id") is not None + } + runs = discover_runs(bench_dir) + return [ + { + "run_id": r["run_id"], + "timestamp": r["timestamp"], + "candidate_count": r["candidate_count"], + "already_imported": r["run_id"] in imported_run_ids, + } + for r in runs + ] + + +# ── POST /import ─────────────────────────────────────────────────────────── + +class ImportRequest(BaseModel): + run_id: str + + +@router.post("/import") +def post_import(req: ImportRequest): + """Import one benchmark run's sft_candidates.jsonl into the local data dir.""" + from scripts.sft_import import discover_runs, import_run + bench_dir = _get_bench_results_dir() + runs = discover_runs(bench_dir) + run = next((r for r in runs if r["run_id"] == req.run_id), None) + if run is None: + raise HTTPException(404, f"Run {req.run_id!r} not found in bench_results_dir") + return import_run(run["sft_path"], _SFT_DATA_DIR) + + +# ── GET /queue ───────────────────────────────────────────────────────────── + +@router.get("/queue") +def get_queue(page: int = 1, per_page: int = 20): + """Return paginated needs_review candidates.""" + records = _read_candidates() + pending = [r for r in records if r.get("status") == "needs_review"] + start = (page - 1) * per_page + return { + "items": pending[start:start + per_page], + "total": len(pending), + "page": page, + "per_page": per_page, + } + + +# ── POST /submit ─────────────────────────────────────────────────────────── + +class SubmitRequest(BaseModel): + id: str + action: Literal["correct", "discard", "flag"] + corrected_response: str | None = None + + +@router.post("/submit") +def post_submit(req: SubmitRequest): + """Record a reviewer decision for one SFT candidate.""" + if req.action == "correct": + if not req.corrected_response or not req.corrected_response.strip(): + raise HTTPException(422, "corrected_response must be non-empty when action is 'correct'") + + records = _read_candidates() + idx = next((i for i, r in enumerate(records) if r.get("id") == req.id), None) + if idx is None: + raise HTTPException(404, f"Record {req.id!r} not found") + + record = records[idx] + if record.get("status") != "needs_review": + raise HTTPException(409, f"Record is not in needs_review state (current: {record.get('status')})") + + if req.action == "correct": + records[idx] = {**record, "status": "approved", "corrected_response": req.corrected_response} + _write_candidates(records) + append_jsonl(_approved_file(), records[idx]) + elif req.action == "discard": + records[idx] = {**record, "status": "discarded"} + _write_candidates(records) + else: # flag + records[idx] = {**record, "status": "model_rejected"} + _write_candidates(records) + + return {"ok": True} + + +# ── POST /undo ───────────────────────────────────────────────────────────── + +class UndoRequest(BaseModel): + id: str + + +@router.post("/undo") +def post_undo(req: UndoRequest): + """Restore a previously actioned candidate back to needs_review.""" + records = _read_candidates() + idx = next((i for i, r in enumerate(records) if r.get("id") == req.id), None) + if idx is None: + raise HTTPException(404, f"Record {req.id!r} not found") + + record = records[idx] + old_status = record.get("status") + if old_status == "needs_review": + raise HTTPException(409, "Record is already in needs_review state") + + records[idx] = {**record, "status": "needs_review", "corrected_response": None} + _write_candidates(records) + + # If it was approved, remove from the approved file too + if old_status == "approved": + approved = read_jsonl(_approved_file()) + write_jsonl(_approved_file(), [r for r in approved if r.get("id") != req.id]) + + return {"ok": True} + + +# ── GET /export ───────────────────────────────────────────────────────────── + +@router.get("/export") +def get_export() -> StreamingResponse: + """Stream approved records as SFT-ready JSONL for download.""" + exportable = [r for r in read_jsonl(_approved_file()) if _is_exportable(r)] + + def generate(): + for r in exportable: + record = { + "messages": r.get("prompt_messages", []) + [ + {"role": "assistant", "content": r["corrected_response"]} + ] + } + yield json.dumps(record) + "\n" + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S") + return StreamingResponse( + generate(), + media_type="application/x-ndjson", + headers={ + "Content-Disposition": f'attachment; filename="sft_export_{timestamp}.jsonl"' + }, + ) + + +# ── GET /stats ────────────────────────────────────────────────────────────── + +@router.get("/stats") +def get_stats() -> dict[str, object]: + """Return counts by status, model, and task type.""" + records = _read_candidates() + by_status: dict[str, int] = {} + by_model: dict[str, int] = {} + by_task_type: dict[str, int] = {} + + for r in records: + status = r.get("status", "unknown") + by_status[status] = by_status.get(status, 0) + 1 + model = r.get("model_name", "unknown") + by_model[model] = by_model.get(model, 0) + 1 + task_type = r.get("task_type", "unknown") + by_task_type[task_type] = by_task_type.get(task_type, 0) + 1 + + approved = read_jsonl(_approved_file()) + export_ready = sum(1 for r in approved if _is_exportable(r)) + + return { + "total": len(records), + "by_status": by_status, + "by_model": by_model, + "by_task_type": by_task_type, + "export_ready": export_ready, + } + + +# ── GET /config ───────────────────────────────────────────────────────────── + +@router.get("/config") +def get_sft_config() -> dict: + """Return the current SFT configuration (bench_results_dir).""" + f = _config_file() + if not f.exists(): + return {"bench_results_dir": ""} + try: + raw = yaml.safe_load(f.read_text(encoding="utf-8")) or {} + except yaml.YAMLError: + return {"bench_results_dir": ""} + sft_section = raw.get("sft") or {} + return {"bench_results_dir": sft_section.get("bench_results_dir", "")} + + +class SftConfigPayload(BaseModel): + bench_results_dir: str + + +@router.post("/config") +def post_sft_config(payload: SftConfigPayload) -> dict: + """Write the bench_results_dir setting to the config file.""" + f = _config_file() + f.parent.mkdir(parents=True, exist_ok=True) + try: + raw = yaml.safe_load(f.read_text(encoding="utf-8")) if f.exists() else {} + raw = raw or {} + except yaml.YAMLError: + raw = {} + raw["sft"] = {"bench_results_dir": payload.bench_results_dir} + tmp = f.with_suffix(".tmp") + tmp.write_text(yaml.dump(raw, allow_unicode=True, sort_keys=False), encoding="utf-8") + tmp.rename(f) + return {"ok": True} diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..4b40ddd --- /dev/null +++ b/app/utils.py @@ -0,0 +1,117 @@ +"""Shared email utility functions for Avocet. + +Pure-stdlib helpers extracted from the retired label_tool.py Streamlit app. +These are reused by the FastAPI backend and the test suite. +""" +from __future__ import annotations + +import json +import re +from html.parser import HTMLParser +from pathlib import Path +from typing import Any + + +# ── 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() + + +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(): + 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" + 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 "" + + +def read_jsonl(path: Path) -> list[dict]: + """Read a JSONL file, returning valid records. Skips blank lines and malformed JSON.""" + if not path.exists(): + return [] + records: list[dict] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + pass + return records + + +def write_jsonl(path: Path, records: list[dict]) -> None: + """Write records to a JSONL file, overwriting any existing content.""" + path.parent.mkdir(parents=True, exist_ok=True) + content = "\n".join(json.dumps(r) for r in records) + path.write_text(content + ("\n" if records else ""), encoding="utf-8") + + +def append_jsonl(path: Path, record: dict) -> None: + """Append a single record to a JSONL file.""" + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as fh: + fh.write(json.dumps(record) + "\n") diff --git a/config/label_tool.yaml.example b/config/label_tool.yaml.example index 8f80b18..9310d21 100644 --- a/config/label_tool.yaml.example +++ b/config/label_tool.yaml.example @@ -21,3 +21,8 @@ accounts: # Optional: limit emails fetched per account per run (0 = unlimited) max_per_account: 500 + +# cf-orch SFT candidate import — path to the bench_results/ directory +# produced by circuitforge-orch's benchmark harness. +sft: + bench_results_dir: /path/to/circuitforge-orch/scripts/bench_results diff --git a/environment.yml b/environment.yml index eeec142..73f1941 100644 --- a/environment.yml +++ b/environment.yml @@ -14,6 +14,7 @@ dependencies: - transformers>=4.40 - torch>=2.2 - accelerate>=0.27 + - scikit-learn>=1.4 # Optional: GLiClass adapter # - gliclass diff --git a/manage.sh b/manage.sh index 39ea114..847e8c3 100755 --- a/manage.sh +++ b/manage.sh @@ -19,9 +19,8 @@ LOG_FILE="${LOG_DIR}/label_tool.log" DEFAULT_PORT=8503 CONDA_BASE="${CONDA_BASE:-/devl/miniconda3}" -ENV_UI="job-seeker" +ENV_UI="${AVOCET_ENV:-cf}" 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" @@ -79,13 +78,11 @@ usage() { 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 " Vue UI + FastAPI:" + echo -e " ${GREEN}start${NC} Build Vue SPA + start FastAPI on port 8503" + echo -e " ${GREEN}stop${NC} Stop FastAPI server" + echo -e " ${GREEN}restart${NC} Stop + rebuild + restart FastAPI server" + echo -e " ${GREEN}open${NC} Open Vue UI in browser (http://localhost:8503)" echo "" echo " Benchmark:" echo -e " ${GREEN}benchmark [args]${NC} Run benchmark_classifier.py (args passed through)" @@ -94,6 +91,7 @@ usage() { echo -e " ${GREEN}compare [args]${NC} Shortcut: --compare [args]" echo "" echo " Dev:" + echo -e " ${GREEN}dev${NC} Hot-reload: uvicorn --reload (:8503) + Vite HMR (:5173)" echo -e " ${GREEN}test${NC} Run pytest suite" echo "" echo " Port defaults to ${DEFAULT_PORT}; auto-increments if occupied." @@ -115,102 +113,102 @@ 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}" + 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 - - 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." + 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 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 + 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 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 "Server died during startup. Check ${API_LOG}" + fi + done + if ! (echo "" >/dev/tcp/127.0.0.1/"$API_PORT") 2>/dev/null; then + error "Server did not bind to port ${API_PORT} within 10 s. Check ${API_LOG}" fi ;; stop) - pid=$(_running_pid) - if [[ -z "$pid" ]]; then + API_PID_FILE=".avocet-api.pid" + if [[ ! -f "$API_PID_FILE" ]]; 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 + PID="$(<"$API_PID_FILE")" + if kill -0 "$PID" 2>/dev/null; then + kill "$PID" && rm -f "$API_PID_FILE" + success "Stopped (PID ${PID})." + else + warn "Stale PID file (process ${PID} not running). Cleaning up." + rm -f "$API_PID_FILE" 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 + bash "$0" stop exec bash "$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 - ;; + dev) + API_PORT=8503 + VITE_PORT=5173 + DEV_API_PID_FILE=".avocet-dev-api.pid" + mkdir -p "$LOG_DIR" + DEV_API_LOG="${LOG_DIR}/dev-api.log" - logs) - if [[ ! -f "$LOG_FILE" ]]; then - warn "No log file found at ${LOG_FILE}. Has the tool been started?" - exit 0 + if [[ -f "$DEV_API_PID_FILE" ]] && kill -0 "$(<"$DEV_API_PID_FILE")" 2>/dev/null; then + warn "Dev API already running (PID $(<"$DEV_API_PID_FILE"))" + else + info "Starting uvicorn with --reload on port ${API_PORT}…" + nohup "$PYTHON_UI" -m uvicorn app.api:app \ + --host 0.0.0.0 --port "$API_PORT" --reload \ + >> "$DEV_API_LOG" 2>&1 & + echo $! > "$DEV_API_PID_FILE" + # Wait for API to bind + for _i in $(seq 1 20); do + sleep 0.5 + (echo "" >/dev/tcp/127.0.0.1/"$API_PORT") 2>/dev/null && break + if ! kill -0 "$(<"$DEV_API_PID_FILE")" 2>/dev/null; then + rm -f "$DEV_API_PID_FILE" + error "Dev API died during startup. Check ${DEV_API_LOG}" + fi + done + success "API (hot-reload) → http://localhost:${API_PORT}" fi - info "Tailing ${LOG_FILE} (Ctrl-C to stop)" - tail -f "$LOG_FILE" + + # Kill API on exit (Ctrl+C or Vite exits) + _cleanup_dev() { + local pid + pid=$(<"$DEV_API_PID_FILE" 2>/dev/null || true) + [[ -n "$pid" ]] && kill "$pid" 2>/dev/null && rm -f "$DEV_API_PID_FILE" + info "Dev servers stopped." + } + trap _cleanup_dev EXIT INT TERM + + info "Starting Vite HMR on port ${VITE_PORT} (proxy /api → :${API_PORT})…" + success "Frontend (HMR) → http://localhost:${VITE_PORT}" + (cd web && npm run dev -- --host 0.0.0.0 --port "$VITE_PORT") ;; 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}" + URL="http://localhost:8503" info "Opening ${URL}" if command -v xdg-open &>/dev/null; then xdg-open "$URL" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b1b82c2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.100.0 +pydantic>=2.0.0 +uvicorn[standard]>=0.20.0 +httpx>=0.24.0 +pytest>=7.0.0 diff --git a/scripts/benchmark_classifier.py b/scripts/benchmark_classifier.py index 3f661a6..b3c5b49 100644 --- a/scripts/benchmark_classifier.py +++ b/scripts/benchmark_classifier.py @@ -32,10 +32,14 @@ from typing import Any sys.path.insert(0, str(Path(__file__).parent.parent)) +_ROOT = Path(__file__).parent.parent +_MODELS_DIR = _ROOT / "models" + from scripts.classifier_adapters import ( LABELS, LABEL_DESCRIPTIONS, ClassifierAdapter, + FineTunedAdapter, GLiClassAdapter, RerankerAdapter, ZeroShotAdapter, @@ -150,8 +154,55 @@ def load_scoring_jsonl(path: str) -> list[dict[str, str]]: 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 discover_finetuned_models(models_dir: Path | None = None) -> list[dict]: + """Scan models/ for subdirs containing training_info.json. + + Returns a list of training_info dicts, each with an added 'model_dir' key. + Returns [] silently if models_dir does not exist. + """ + if models_dir is None: + models_dir = _MODELS_DIR + if not models_dir.exists(): + return [] + found = [] + for sub in models_dir.iterdir(): + if not sub.is_dir(): + continue + info_path = sub / "training_info.json" + if not info_path.exists(): + continue + try: + info = json.loads(info_path.read_text(encoding="utf-8")) + except Exception as exc: + print(f"[discover] WARN: skipping {info_path}: {exc}", flush=True) + continue + if "name" not in info: + print(f"[discover] WARN: skipping {info_path}: missing 'name' key", flush=True) + continue + info["model_dir"] = str(sub) + found.append(info) + return found + + +def _active_models(include_slow: bool = False) -> dict[str, dict[str, Any]]: + """Return the active model registry, merged with any discovered fine-tuned models.""" + active: dict[str, dict[str, Any]] = { + key: {**entry, "adapter_instance": entry["adapter"]( + key, + entry["model_id"], + **entry.get("kwargs", {}), + )} + for key, entry in MODEL_REGISTRY.items() + if include_slow or entry.get("default", False) + } + for info in discover_finetuned_models(): + name = info["name"] + active[name] = { + "adapter_instance": FineTunedAdapter(name, info["model_dir"]), + "params": "fine-tuned", + "default": True, + } + return active def run_scoring( @@ -163,7 +214,8 @@ def run_scoring( gold = [r["label"] for r in rows] results: dict[str, Any] = {} - for adapter in adapters: + for i, adapter in enumerate(adapters, 1): + print(f"[{i}/{len(adapters)}] Running {adapter.name} ({len(rows)} samples) …", flush=True) preds: list[str] = [] t0 = time.monotonic() for row in rows: @@ -177,6 +229,7 @@ def run_scoring( metrics = compute_metrics(preds, gold, LABELS) metrics["latency_ms"] = round(elapsed_ms / len(rows), 1) results[adapter.name] = metrics + print(f" → macro-F1 {metrics['__macro_f1__']:.3f} accuracy {metrics['__accuracy__']:.3f} {metrics['latency_ms']:.1f} ms/email", flush=True) adapter.unload() return results @@ -345,10 +398,7 @@ def cmd_score(args: argparse.Namespace) -> None: 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() - ] + adapters = [entry["adapter_instance"] for entry in active.values()] print(f"\nScoring {len(adapters)} model(s) against {args.score_file} …\n") results = run_scoring(adapters, args.score_file) @@ -375,6 +425,31 @@ def cmd_score(args: argparse.Namespace) -> None: print(row_str) print() + if args.save: + import datetime + rows = load_scoring_jsonl(args.score_file) + save_data = { + "timestamp": datetime.datetime.utcnow().isoformat() + "Z", + "sample_count": len(rows), + "models": { + name: { + "macro_f1": round(m["__macro_f1__"], 4), + "accuracy": round(m["__accuracy__"], 4), + "latency_ms": m["latency_ms"], + "per_label": { + label: {k: round(v, 4) for k, v in m[label].items()} + for label in LABELS + if label in m + }, + } + for name, m in results.items() + }, + } + save_path = Path(args.score_file).parent / "benchmark_results.json" + with open(save_path, "w") as f: + json.dump(save_data, f, indent=2) + print(f"Results saved → {save_path}", flush=True) + def cmd_compare(args: argparse.Namespace) -> None: active = _active_models(args.include_slow) @@ -385,10 +460,7 @@ def cmd_compare(args: argparse.Namespace) -> None: 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() - ] + adapters = [entry["adapter_instance"] for entry in active.values()] model_names = [a.name for a in adapters] col = 22 @@ -431,6 +503,8 @@ def main() -> None: parser.add_argument("--days", type=int, default=90, help="Days back for IMAP search") parser.add_argument("--include-slow", action="store_true", help="Include non-default heavy models") parser.add_argument("--models", nargs="+", help="Override: run only these model names") + parser.add_argument("--save", action="store_true", + help="Save results to data/benchmark_results.json (for the web UI)") args = parser.parse_args() diff --git a/scripts/classifier_adapters.py b/scripts/classifier_adapters.py index 2817078..f2b4fff 100644 --- a/scripts/classifier_adapters.py +++ b/scripts/classifier_adapters.py @@ -17,6 +17,7 @@ __all__ = [ "ZeroShotAdapter", "GLiClassAdapter", "RerankerAdapter", + "FineTunedAdapter", ] LABELS: list[str] = [ @@ -27,8 +28,9 @@ LABELS: list[str] = [ "survey_received", "neutral", "event_rescheduled", - "unrelated", "digest", + "new_lead", + "hired", ] # Natural-language descriptions used by the RerankerAdapter. @@ -40,8 +42,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. @@ -261,3 +264,43 @@ class RerankerAdapter(ClassifierAdapter): 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))] + + +class FineTunedAdapter(ClassifierAdapter): + """Loads a fine-tuned checkpoint from a local models/ directory. + + Uses pipeline("text-classification") for a single forward pass. + Input format: 'subject [SEP] body[:400]' — must match training format exactly. + Expected inference speed: ~10–20ms/email vs 111–338ms for zero-shot. + """ + + def __init__(self, name: str, model_dir: str) -> None: + self._name = name + self._model_dir = model_dir + self._pipeline: Any = None + + @property + def name(self) -> str: + return self._name + + @property + def model_id(self) -> str: + return self._model_dir + + 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 + self._pipeline = _pipe_fn("text-classification", model=self._model_dir, 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} [SEP] {body[:400]}" + result = self._pipeline(text) + return result[0]["label"] diff --git a/scripts/finetune_classifier.py b/scripts/finetune_classifier.py new file mode 100644 index 0000000..e936466 --- /dev/null +++ b/scripts/finetune_classifier.py @@ -0,0 +1,416 @@ +"""Fine-tune email classifiers on the labeled dataset. + +CLI entry point. All prints use flush=True so stdout is SSE-streamable. + +Usage: + python scripts/finetune_classifier.py --model deberta-small [--epochs 5] + +Supported --model values: deberta-small, bge-m3 +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from collections import Counter +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import torch +import torch.nn.functional as F +from torch.utils.data import Dataset as TorchDataset +from sklearn.model_selection import train_test_split +from sklearn.metrics import f1_score, accuracy_score +from transformers import ( + AutoTokenizer, + AutoModelForSequenceClassification, + EvalPrediction, + Trainer, + TrainingArguments, + EarlyStoppingCallback, +) + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from scripts.classifier_adapters import LABELS + +_ROOT = Path(__file__).parent.parent + +_MODEL_CONFIG: dict[str, dict[str, Any]] = { + "deberta-small": { + "base_model_id": "cross-encoder/nli-deberta-v3-small", + "max_tokens": 512, + # fp16 must stay OFF — DeBERTa-v3 disentangled attention overflows fp16. + "fp16": False, + # batch_size=8 + grad_accum=2 keeps effective batch of 16 while halving + # per-step activation memory. gradient_checkpointing recomputes activations + # on backward instead of storing them — ~60% less activation VRAM. + "batch_size": 8, + "grad_accum": 2, + "gradient_checkpointing": True, + }, + "bge-m3": { + "base_model_id": "MoritzLaurer/bge-m3-zeroshot-v2.0", + "max_tokens": 512, + "fp16": True, + "batch_size": 4, + "grad_accum": 4, + "gradient_checkpointing": True, + }, +} + + +def load_and_prepare_data(score_files: Path | list[Path]) -> tuple[list[str], list[str]]: + """Load labeled JSONL and return (texts, labels) filtered to canonical LABELS. + + score_files: a single Path or a list of Paths. When multiple files are given, + rows are merged with last-write-wins deduplication keyed by content hash + (MD5 of subject + body[:100]). + + Drops rows with non-canonical labels (with warning), and drops entire classes + that have fewer than 2 total samples (required for stratified split). + Warns (but continues) for classes with fewer than 5 samples. + """ + # Normalise to list — backwards compatible with single-Path callers. + if isinstance(score_files, Path): + score_files = [score_files] + + for score_file in score_files: + if not score_file.exists(): + raise FileNotFoundError( + f"Labeled data not found: {score_file}\n" + "Run the label tool first to generate email_score.jsonl." + ) + + label_set = set(LABELS) + # Use a plain dict keyed by content hash; later entries overwrite earlier ones + # (last-write wins), which lets later labeling runs correct earlier labels. + seen: dict[str, dict] = {} + total = 0 + + for score_file in score_files: + with score_file.open() as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + r = json.loads(line) + except json.JSONDecodeError: + continue + lbl = r.get("label", "") + if lbl not in label_set: + print( + f"[data] WARNING: Dropping row with non-canonical label {lbl!r}", + flush=True, + ) + continue + content_hash = hashlib.md5( + (r.get("subject", "") + (r.get("body", "") or "")[:100]).encode( + "utf-8", errors="replace" + ) + ).hexdigest() + seen[content_hash] = r + total += 1 + + kept = len(seen) + dropped = total - kept + if dropped > 0: + print( + f"[data] Deduped: kept {kept} of {total} rows (dropped {dropped} duplicates)", + flush=True, + ) + + rows = list(seen.values()) + + # Count samples per class + counts: Counter = Counter(r["label"] for r in rows) + + # Drop classes with < 2 total samples (cannot stratify-split) + drop_classes: set[str] = set() + for lbl, cnt in counts.items(): + if cnt < 2: + print( + f"[data] WARNING: Dropping class {lbl!r} — only {counts[lbl]} total " + f"sample(s). Need at least 2 for stratified split.", + flush=True, + ) + drop_classes.add(lbl) + + # Warn for classes with < 5 samples (unreliable eval F1) + for lbl, cnt in counts.items(): + if lbl not in drop_classes and cnt < 5: + print( + f"[data] WARNING: Class {lbl!r} has only {cnt} sample(s). " + f"Eval F1 for this class will be unreliable.", + flush=True, + ) + + # Filter rows + rows = [r for r in rows if r["label"] not in drop_classes] + + texts = [f"{r['subject']} [SEP] {r['body'][:400]}" for r in rows] + labels = [r["label"] for r in rows] + + return texts, labels + + +def compute_class_weights(label_ids: list[int], n_classes: int) -> torch.Tensor: + """Compute inverse-frequency class weights. + + Formula: total / (n_classes * class_count) per class. + Unseen classes (count=0) use count=1 to avoid division by zero. + + Returns a CPU float32 tensor of shape (n_classes,). + """ + counts = Counter(label_ids) + total = len(label_ids) + weights = [] + for cls in range(n_classes): + cnt = counts.get(cls, 1) # use 1 for unseen to avoid div-by-zero + weights.append(total / (n_classes * cnt)) + return torch.tensor(weights, dtype=torch.float32) + + +def compute_metrics_for_trainer(eval_pred: EvalPrediction) -> dict: + """Compute macro F1 and accuracy from EvalPrediction. + + Called by Hugging Face Trainer at each evaluation step. + """ + logits, label_ids = eval_pred.predictions, eval_pred.label_ids + preds = logits.argmax(axis=-1) + macro_f1 = f1_score(label_ids, preds, average="macro", zero_division=0) + acc = accuracy_score(label_ids, preds) + return {"macro_f1": float(macro_f1), "accuracy": float(acc)} + + +class WeightedTrainer(Trainer): + """Trainer subclass that applies per-class weights to the cross-entropy loss.""" + + def compute_loss(self, model, inputs, return_outputs=False, **kwargs): + # **kwargs is required — absorbs num_items_in_batch added in Transformers 4.38. + # Do not remove it; removing it causes TypeError on the first training step. + labels = inputs.pop("labels") + outputs = model(**inputs) + # Move class_weights to the same device as logits — required for GPU training. + # class_weights is created on CPU; logits are on cuda:0 during training. + weight = self.class_weights.to(outputs.logits.device) + loss = F.cross_entropy(outputs.logits, labels, weight=weight) + return (loss, outputs) if return_outputs else loss + + +# --------------------------------------------------------------------------- +# Training dataset wrapper +# --------------------------------------------------------------------------- + + +class _EmailDataset(TorchDataset): + def __init__(self, encodings: dict, label_ids: list[int]) -> None: + self.encodings = encodings + self.label_ids = label_ids + + def __len__(self) -> int: + return len(self.label_ids) + + def __getitem__(self, idx: int) -> dict: + item = {k: torch.tensor(v[idx]) for k, v in self.encodings.items()} + item["labels"] = torch.tensor(self.label_ids[idx], dtype=torch.long) + return item + + +# --------------------------------------------------------------------------- +# Main training function +# --------------------------------------------------------------------------- + +def run_finetune(model_key: str, epochs: int = 5, score_files: list[Path] | None = None) -> None: + """Fine-tune the specified model on labeled data. + + score_files: list of score JSONL paths to merge. Defaults to [_ROOT / "data" / "email_score.jsonl"]. + Saves model + tokenizer + training_info.json to models/avocet-{model_key}/. + All prints use flush=True for SSE streaming. + """ + if model_key not in _MODEL_CONFIG: + raise ValueError(f"Unknown model key: {model_key!r}. Choose from: {list(_MODEL_CONFIG)}") + + if score_files is None: + score_files = [_ROOT / "data" / "email_score.jsonl"] + + config = _MODEL_CONFIG[model_key] + base_model_id = config["base_model_id"] + output_dir = _ROOT / "models" / f"avocet-{model_key}" + + print(f"[finetune] Model: {model_key} ({base_model_id})", flush=True) + print(f"[finetune] Score files: {[str(f) for f in score_files]}", flush=True) + print(f"[finetune] Output: {output_dir}", flush=True) + if output_dir.exists(): + print(f"[finetune] WARNING: {output_dir} already exists — will overwrite.", flush=True) + + # --- Data --- + print(f"[finetune] Loading data ...", flush=True) + texts, str_labels = load_and_prepare_data(score_files) + + present_labels = sorted(set(str_labels)) + label2id = {l: i for i, l in enumerate(present_labels)} + id2label = {i: l for l, i in label2id.items()} + n_classes = len(present_labels) + label_ids = [label2id[l] for l in str_labels] + + print(f"[finetune] {len(texts)} samples, {n_classes} classes", flush=True) + + # Stratified 80/20 split — ensure val set has at least n_classes samples. + # For very small datasets (e.g. example data) we may need to give the val set + # more than 20% so every class appears at least once in eval. + desired_test = max(int(len(texts) * 0.2), n_classes) + # test_size must leave at least n_classes samples for train too + desired_test = min(desired_test, len(texts) - n_classes) + (train_texts, val_texts, + train_label_ids, val_label_ids) = train_test_split( + texts, label_ids, + test_size=desired_test, + stratify=label_ids, + random_state=42, + ) + print(f"[finetune] Train: {len(train_texts)}, Val: {len(val_texts)}", flush=True) + + # Warn for classes with < 5 training samples + train_counts = Counter(train_label_ids) + for cls_id, cnt in train_counts.items(): + if cnt < 5: + print( + f"[finetune] WARNING: Class {id2label[cls_id]!r} has {cnt} training sample(s). " + "Eval F1 for this class will be unreliable.", + flush=True, + ) + + # --- Tokenize --- + print(f"[finetune] Loading tokenizer ...", flush=True) + tokenizer = AutoTokenizer.from_pretrained(base_model_id) + + train_enc = tokenizer(train_texts, truncation=True, + max_length=config["max_tokens"], padding=True) + val_enc = tokenizer(val_texts, truncation=True, + max_length=config["max_tokens"], padding=True) + + train_dataset = _EmailDataset(train_enc, train_label_ids) + val_dataset = _EmailDataset(val_enc, val_label_ids) + + # --- Class weights --- + class_weights = compute_class_weights(train_label_ids, n_classes) + print(f"[finetune] Class weights computed", flush=True) + + # --- Model --- + print(f"[finetune] Loading model ...", flush=True) + model = AutoModelForSequenceClassification.from_pretrained( + base_model_id, + num_labels=n_classes, + ignore_mismatched_sizes=True, # NLI head (3-class) → new head (n_classes) + id2label=id2label, + label2id=label2id, + ) + if config["gradient_checkpointing"]: + # use_reentrant=False avoids "backward through graph a second time" errors + # when Accelerate's gradient accumulation context is layered on top. + # Reentrant checkpointing (the default) conflicts with Accelerate ≥ 0.27. + model.gradient_checkpointing_enable( + gradient_checkpointing_kwargs={"use_reentrant": False} + ) + + # --- TrainingArguments --- + training_args = TrainingArguments( + output_dir=str(output_dir), + num_train_epochs=epochs, + per_device_train_batch_size=config["batch_size"], + per_device_eval_batch_size=config["batch_size"], + gradient_accumulation_steps=config["grad_accum"], + learning_rate=2e-5, + lr_scheduler_type="linear", + warmup_ratio=0.1, + fp16=config["fp16"], + eval_strategy="epoch", + save_strategy="epoch", + load_best_model_at_end=True, + metric_for_best_model="macro_f1", + greater_is_better=True, + logging_steps=10, + report_to="none", + save_total_limit=2, + ) + + trainer = WeightedTrainer( + model=model, + args=training_args, + train_dataset=train_dataset, + eval_dataset=val_dataset, + compute_metrics=compute_metrics_for_trainer, + callbacks=[EarlyStoppingCallback(early_stopping_patience=3)], + ) + trainer.class_weights = class_weights + + # --- Train --- + print(f"[finetune] Starting training ({epochs} epochs) ...", flush=True) + train_result = trainer.train() + print(f"[finetune] Training complete. Steps: {train_result.global_step}", flush=True) + + # --- Evaluate --- + print(f"[finetune] Evaluating best checkpoint ...", flush=True) + metrics = trainer.evaluate() + val_macro_f1 = metrics.get("eval_macro_f1", 0.0) + val_accuracy = metrics.get("eval_accuracy", 0.0) + print(f"[finetune] Val macro-F1: {val_macro_f1:.4f}, Accuracy: {val_accuracy:.4f}", flush=True) + + # --- Save model + tokenizer --- + print(f"[finetune] Saving model to {output_dir} ...", flush=True) + trainer.save_model(str(output_dir)) + tokenizer.save_pretrained(str(output_dir)) + + # --- Write training_info.json --- + label_counts = dict(Counter(str_labels)) + info = { + "name": f"avocet-{model_key}", + "base_model_id": base_model_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "epochs_run": epochs, + "val_macro_f1": round(val_macro_f1, 4), + "val_accuracy": round(val_accuracy, 4), + "sample_count": len(texts), + "train_sample_count": len(train_texts), + "label_counts": label_counts, + "score_files": [str(f) for f in score_files], + } + info_path = output_dir / "training_info.json" + info_path.write_text(json.dumps(info, indent=2), encoding="utf-8") + print(f"[finetune] Saved training_info.json: val_macro_f1={val_macro_f1:.4f}", flush=True) + print(f"[finetune] Done.", flush=True) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Fine-tune an email classifier") + parser.add_argument( + "--model", + choices=list(_MODEL_CONFIG), + required=True, + help="Model key to fine-tune", + ) + parser.add_argument( + "--epochs", + type=int, + default=5, + help="Number of training epochs (default: 5)", + ) + parser.add_argument( + "--score", + dest="score_files", + type=Path, + action="append", + metavar="FILE", + help="Score JSONL file to include (repeatable; defaults to data/email_score.jsonl)", + ) + args = parser.parse_args() + score_files = args.score_files or None # None → run_finetune uses default + run_finetune(args.model, args.epochs, score_files=score_files) diff --git a/scripts/sft_import.py b/scripts/sft_import.py new file mode 100644 index 0000000..76e1d73 --- /dev/null +++ b/scripts/sft_import.py @@ -0,0 +1,110 @@ +"""Avocet — SFT candidate run discovery and JSONL import. + +No FastAPI dependency — pure Python file operations. +Used by app/sft.py endpoints and can be run standalone. +""" +from __future__ import annotations + +import json +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +_CANDIDATES_FILENAME = "sft_candidates.jsonl" + + +def discover_runs(bench_results_dir: Path) -> list[dict]: + """Return one entry per run subdirectory that contains sft_candidates.jsonl. + + Sorted newest-first by directory name (directories are named YYYY-MM-DD-HHMMSS + by the cf-orch benchmark harness, so lexicographic order is chronological). + + Each entry: {run_id, timestamp, candidate_count, sft_path} + """ + if not bench_results_dir.exists() or not bench_results_dir.is_dir(): + return [] + runs = [] + for subdir in bench_results_dir.iterdir(): + if not subdir.is_dir(): + continue + sft_path = subdir / _CANDIDATES_FILENAME + if not sft_path.exists(): + continue + records = _read_jsonl(sft_path) + runs.append({ + "run_id": subdir.name, + "timestamp": subdir.name, + "candidate_count": len(records), + "sft_path": sft_path, + }) + runs.sort(key=lambda r: r["run_id"], reverse=True) + return runs + + +def import_run(sft_path: Path, data_dir: Path) -> dict[str, int]: + """Append records from sft_path into data_dir/sft_candidates.jsonl. + + Deduplicates on the `id` field — records whose id already exists in the + destination file are skipped silently. Records missing an `id` field are + also skipped (malformed input from a partial benchmark write). + + Returns {imported: N, skipped: M}. + """ + dest = data_dir / _CANDIDATES_FILENAME + existing_ids = _read_existing_ids(dest) + + new_records: list[dict] = [] + skipped = 0 + for record in _read_jsonl(sft_path): + if "id" not in record: + logger.warning("Skipping record missing 'id' field in %s", sft_path) + continue # malformed — skip without crashing + if record["id"] in existing_ids: + skipped += 1 + continue + new_records.append(record) + existing_ids.add(record["id"]) + + if new_records: + dest.parent.mkdir(parents=True, exist_ok=True) + with open(dest, "a", encoding="utf-8") as fh: + for r in new_records: + fh.write(json.dumps(r) + "\n") + + return {"imported": len(new_records), "skipped": skipped} + + +def _read_jsonl(path: Path) -> list[dict]: + """Read a JSONL file, returning valid records. Skips blank lines and malformed JSON.""" + if not path.exists(): + return [] + records: list[dict] = [] + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError as exc: + logger.warning("Skipping malformed JSON line in %s: %s", path, exc) + return records + + +def _read_existing_ids(path: Path) -> set[str]: + """Read only the id field from each line of a JSONL file.""" + if not path.exists(): + return set() + ids: set[str] = set() + with path.open() as f: + for line in f: + line = line.strip() + if not line: + continue + try: + record = json.loads(line) + if "id" in record: + ids.add(record["id"]) + except json.JSONDecodeError: + pass # corrupt line, skip silently (ids file is our own output) + return ids diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..2360b02 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,561 @@ +import json + +import pytest +from app import api as api_module # noqa: F401 + + +@pytest.fixture(autouse=True) +def reset_globals(tmp_path): + from app import api + api.set_data_dir(tmp_path) + api.reset_last_action() + yield + api.reset_last_action() + + +def test_import(): + from app import api # noqa: F401 + + +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + from app.api import app + return TestClient(app) + + +@pytest.fixture +def queue_with_items(): + """Write 3 test emails to the queue file.""" + from app import api as api_module + items = [ + {"id": f"id{i}", "subject": f"Subject {i}", "body": f"Body {i}", + "from": "test@example.com", "date": "2026-03-01", "source": "imap:test"} + for i in range(3) + ] + queue_path = api_module._DATA_DIR / "email_label_queue.jsonl" + queue_path.write_text("\n".join(json.dumps(x) for x in items) + "\n") + return items + + +def test_queue_returns_items(client, queue_with_items): + r = client.get("/api/queue?limit=2") + assert r.status_code == 200 + data = r.json() + assert len(data["items"]) == 2 + assert data["total"] == 3 + + +def test_queue_empty_when_no_file(client): + r = client.get("/api/queue") + assert r.status_code == 200 + assert r.json() == {"items": [], "total": 0} + + +def test_label_appends_to_score(client, queue_with_items): + from app import api as api_module + r = client.post("/api/label", json={"id": "id0", "label": "interview_scheduled"}) + assert r.status_code == 200 + records = api_module._read_jsonl(api_module._score_file()) + assert len(records) == 1 + assert records[0]["id"] == "id0" + assert records[0]["label"] == "interview_scheduled" + assert "labeled_at" in records[0] + +def test_label_removes_from_queue(client, queue_with_items): + from app import api as api_module + client.post("/api/label", json={"id": "id0", "label": "rejected"}) + queue = api_module._read_jsonl(api_module._queue_file()) + assert not any(x["id"] == "id0" for x in queue) + +def test_label_unknown_id_returns_404(client, queue_with_items): + r = client.post("/api/label", json={"id": "unknown", "label": "neutral"}) + assert r.status_code == 404 + +def test_skip_moves_to_back(client, queue_with_items): + from app import api as api_module + r = client.post("/api/skip", json={"id": "id0"}) + assert r.status_code == 200 + queue = api_module._read_jsonl(api_module._queue_file()) + assert queue[-1]["id"] == "id0" + assert queue[0]["id"] == "id1" + +def test_skip_unknown_id_returns_404(client, queue_with_items): + r = client.post("/api/skip", json={"id": "nope"}) + assert r.status_code == 404 + + +# --- Part A: POST /api/discard --- + +def test_discard_writes_to_discarded_file(client, queue_with_items): + from app import api as api_module + r = client.post("/api/discard", json={"id": "id1"}) + assert r.status_code == 200 + discarded = api_module._read_jsonl(api_module._discarded_file()) + assert len(discarded) == 1 + assert discarded[0]["id"] == "id1" + assert discarded[0]["label"] == "__discarded__" + +def test_discard_removes_from_queue(client, queue_with_items): + from app import api as api_module + client.post("/api/discard", json={"id": "id1"}) + queue = api_module._read_jsonl(api_module._queue_file()) + assert not any(x["id"] == "id1" for x in queue) + + +# --- Part B: DELETE /api/label/undo --- + +def test_undo_label_removes_from_score(client, queue_with_items): + from app import api as api_module + client.post("/api/label", json={"id": "id0", "label": "neutral"}) + r = client.delete("/api/label/undo") + assert r.status_code == 200 + data = r.json() + assert data["undone"]["type"] == "label" + score = api_module._read_jsonl(api_module._score_file()) + assert score == [] + # Item should be restored to front of queue + queue = api_module._read_jsonl(api_module._queue_file()) + assert queue[0]["id"] == "id0" + +def test_undo_discard_removes_from_discarded(client, queue_with_items): + from app import api as api_module + client.post("/api/discard", json={"id": "id0"}) + r = client.delete("/api/label/undo") + assert r.status_code == 200 + discarded = api_module._read_jsonl(api_module._discarded_file()) + assert discarded == [] + +def test_undo_skip_restores_to_front(client, queue_with_items): + from app import api as api_module + client.post("/api/skip", json={"id": "id0"}) + r = client.delete("/api/label/undo") + assert r.status_code == 200 + queue = api_module._read_jsonl(api_module._queue_file()) + assert queue[0]["id"] == "id0" + +def test_undo_with_no_action_returns_404(client): + r = client.delete("/api/label/undo") + assert r.status_code == 404 + + +# --- Part C: GET /api/config/labels --- + +def test_config_labels_returns_metadata(client): + r = client.get("/api/config/labels") + assert r.status_code == 200 + labels = r.json() + assert len(labels) == 10 + assert labels[0]["key"] == "1" + assert "emoji" in labels[0] + assert "color" in labels[0] + assert "name" in labels[0] + + +# ── /api/config ────────────────────────────────────────────────────────────── + +@pytest.fixture +def config_dir(tmp_path): + """Give the API a writable config directory.""" + from app import api as api_module + api_module.set_config_dir(tmp_path) + yield tmp_path + api_module.set_config_dir(None) # reset to default + + +@pytest.fixture +def data_dir(): + """Expose the current _DATA_DIR set by the autouse reset_globals fixture.""" + from app import api as api_module + return api_module._DATA_DIR + + +def test_get_config_returns_empty_when_no_file(client, config_dir): + r = client.get("/api/config") + assert r.status_code == 200 + data = r.json() + assert data["accounts"] == [] + assert data["max_per_account"] == 500 + + +def test_post_config_writes_yaml(client, config_dir): + import yaml + payload = { + "accounts": [{"name": "Test", "host": "imap.test.com", "port": 993, + "use_ssl": True, "username": "u@t.com", "password": "pw", + "folder": "INBOX", "days_back": 30}], + "max_per_account": 200, + } + r = client.post("/api/config", json=payload) + assert r.status_code == 200 + assert r.json()["ok"] is True + cfg_file = config_dir / "label_tool.yaml" + assert cfg_file.exists() + saved = yaml.safe_load(cfg_file.read_text()) + assert saved["max_per_account"] == 200 + assert saved["accounts"][0]["name"] == "Test" + + +def test_get_config_round_trips(client, config_dir): + payload = {"accounts": [{"name": "R", "host": "h", "port": 993, "use_ssl": True, + "username": "u", "password": "p", "folder": "INBOX", + "days_back": 90}], "max_per_account": 300} + client.post("/api/config", json=payload) + r = client.get("/api/config") + data = r.json() + assert data["max_per_account"] == 300 + assert data["accounts"][0]["name"] == "R" + + +# ── /api/stats ─────────────────────────────────────────────────────────────── + +@pytest.fixture +def score_with_labels(tmp_path, data_dir): + """Write a score file with 3 labels for stats tests.""" + score_path = data_dir / "email_score.jsonl" + records = [ + {"id": "a", "label": "interview_scheduled"}, + {"id": "b", "label": "interview_scheduled"}, + {"id": "c", "label": "rejected"}, + ] + score_path.write_text("\n".join(json.dumps(r) for r in records) + "\n") + return records + + +def test_stats_returns_counts(client, score_with_labels): + r = client.get("/api/stats") + assert r.status_code == 200 + data = r.json() + assert data["total"] == 3 + assert data["counts"]["interview_scheduled"] == 2 + assert data["counts"]["rejected"] == 1 + + +def test_stats_empty_when_no_file(client, data_dir): + r = client.get("/api/stats") + assert r.status_code == 200 + data = r.json() + assert data["total"] == 0 + assert data["counts"] == {} + assert data["score_file_bytes"] == 0 + + +def test_stats_download_returns_file(client, score_with_labels): + r = client.get("/api/stats/download") + assert r.status_code == 200 + assert "jsonlines" in r.headers.get("content-type", "") + + +def test_stats_download_404_when_no_file(client, data_dir): + r = client.get("/api/stats/download") + assert r.status_code == 404 + + +# ── /api/accounts/test ─────────────────────────────────────────────────────── + +def test_account_test_missing_fields(client): + r = client.post("/api/accounts/test", json={"account": {"host": "", "username": "", "password": ""}}) + assert r.status_code == 200 + data = r.json() + assert data["ok"] is False + assert "required" in data["message"].lower() + + +def test_account_test_success(client): + from unittest.mock import MagicMock, patch + mock_conn = MagicMock() + mock_conn.select.return_value = ("OK", [b"99"]) + with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn): + r = client.post("/api/accounts/test", json={"account": { + "host": "imap.example.com", "port": 993, "use_ssl": True, + "username": "u@example.com", "password": "pw", "folder": "INBOX", + }}) + assert r.status_code == 200 + data = r.json() + assert data["ok"] is True + assert data["count"] == 99 + + +# ── /api/fetch/stream (SSE) ────────────────────────────────────────────────── + +def _parse_sse(content: bytes) -> list[dict]: + """Parse SSE response body into list of event dicts.""" + events = [] + for line in content.decode().splitlines(): + if line.startswith("data: "): + events.append(json.loads(line[6:])) + return events + + +def test_fetch_stream_no_accounts_configured(client, config_dir): + """With no config, stream should immediately complete with 0 added.""" + r = client.get("/api/fetch/stream?accounts=NoSuchAccount&days_back=30&limit=10") + assert r.status_code == 200 + events = _parse_sse(r.content) + complete = next((e for e in events if e["type"] == "complete"), None) + assert complete is not None + assert complete["total_added"] == 0 + + +def test_fetch_stream_with_mock_imap(client, config_dir, data_dir): + """With one configured account, stream should yield start/done/complete events.""" + import yaml + from unittest.mock import MagicMock, patch + + # Write a config with one account + cfg = {"accounts": [{"name": "Mock", "host": "h", "port": 993, "use_ssl": True, + "username": "u", "password": "p", "folder": "INBOX", + "days_back": 30}], "max_per_account": 50} + (config_dir / "label_tool.yaml").write_text(yaml.dump(cfg)) + + raw_msg = (b"Subject: Interview\r\nFrom: a@b.com\r\n" + b"Date: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nBody") + mock_conn = MagicMock() + mock_conn.search.return_value = ("OK", [b"1"]) + mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)]) + + with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn): + r = client.get("/api/fetch/stream?accounts=Mock&days_back=30&limit=50") + + assert r.status_code == 200 + events = _parse_sse(r.content) + types = [e["type"] for e in events] + assert "start" in types + assert "done" in types + assert "complete" in types + + +# ---- /api/finetune/status tests ---- + +def test_finetune_status_returns_empty_when_no_models_dir(client): + """GET /api/finetune/status must return [] if models/ does not exist.""" + r = client.get("/api/finetune/status") + assert r.status_code == 200 + assert r.json() == [] + + +def test_finetune_status_returns_training_info(client, tmp_path): + """GET /api/finetune/status must return one entry per training_info.json found.""" + import json as _json + from app import api as api_module + + models_dir = tmp_path / "models" / "avocet-deberta-small" + models_dir.mkdir(parents=True) + info = { + "name": "avocet-deberta-small", + "base_model_id": "cross-encoder/nli-deberta-v3-small", + "val_macro_f1": 0.712, + "timestamp": "2026-03-15T12:00:00Z", + "sample_count": 401, + } + (models_dir / "training_info.json").write_text(_json.dumps(info)) + + api_module.set_models_dir(tmp_path / "models") + try: + r = client.get("/api/finetune/status") + assert r.status_code == 200 + data = r.json() + assert any(d["name"] == "avocet-deberta-small" for d in data) + finally: + api_module.set_models_dir(api_module._ROOT / "models") + + +def test_finetune_run_streams_sse_events(client): + """GET /api/finetune/run must return text/event-stream content type.""" + from unittest.mock import patch, MagicMock + + mock_proc = MagicMock() + mock_proc.stdout = iter(["Training epoch 1\n", "Done\n"]) + mock_proc.returncode = 0 + mock_proc.wait = MagicMock() + + with patch("app.api._subprocess.Popen",return_value=mock_proc): + r = client.get("/api/finetune/run?model=deberta-small&epochs=1") + + assert r.status_code == 200 + assert "text/event-stream" in r.headers.get("content-type", "") + + +def test_finetune_run_emits_complete_on_success(client): + """GET /api/finetune/run must emit a complete event on clean exit.""" + from unittest.mock import patch, MagicMock + + mock_proc = MagicMock() + mock_proc.stdout = iter(["progress line\n"]) + mock_proc.returncode = 0 + mock_proc.wait = MagicMock() + + with patch("app.api._subprocess.Popen",return_value=mock_proc): + r = client.get("/api/finetune/run?model=deberta-small&epochs=1") + + assert '{"type": "complete"}' in r.text + + +def test_finetune_run_emits_error_on_nonzero_exit(client): + """GET /api/finetune/run must emit an error event on non-zero exit.""" + from unittest.mock import patch, MagicMock + + mock_proc = MagicMock() + mock_proc.stdout = iter([]) + mock_proc.returncode = 1 + mock_proc.wait = MagicMock() + + with patch("app.api._subprocess.Popen",return_value=mock_proc): + r = client.get("/api/finetune/run?model=deberta-small&epochs=1") + + assert '"type": "error"' in r.text + + +def test_finetune_run_passes_score_files_to_subprocess(client): + """GET /api/finetune/run?score=file1&score=file2 must pass --score args to subprocess.""" + from unittest.mock import patch, MagicMock + + captured_cmd = [] + + def mock_popen(cmd, **kwargs): + captured_cmd.extend(cmd) + m = MagicMock() + m.stdout = iter([]) + m.returncode = 0 + m.wait = MagicMock() + return m + + with patch("app.api._subprocess.Popen",side_effect=mock_popen): + client.get("/api/finetune/run?model=deberta-small&epochs=1&score=run1.jsonl&score=run2.jsonl") + + assert "--score" in captured_cmd + assert captured_cmd.count("--score") == 2 + # Paths are resolved to absolute — check filenames are present as substrings + assert any("run1.jsonl" in arg for arg in captured_cmd) + assert any("run2.jsonl" in arg for arg in captured_cmd) + + +# ---- Cancel endpoint tests ---- + +def test_benchmark_cancel_returns_404_when_not_running(client): + """POST /api/benchmark/cancel must return 404 if no benchmark is running.""" + from app import api as api_module + api_module._running_procs.pop("benchmark", None) + r = client.post("/api/benchmark/cancel") + assert r.status_code == 404 + + +def test_finetune_cancel_returns_404_when_not_running(client): + """POST /api/finetune/cancel must return 404 if no finetune is running.""" + from app import api as api_module + api_module._running_procs.pop("finetune", None) + r = client.post("/api/finetune/cancel") + assert r.status_code == 404 + + +def test_benchmark_cancel_terminates_running_process(client): + """POST /api/benchmark/cancel must call terminate() on the running process.""" + from unittest.mock import MagicMock + from app import api as api_module + + mock_proc = MagicMock() + mock_proc.wait = MagicMock() + api_module._running_procs["benchmark"] = mock_proc + + try: + r = client.post("/api/benchmark/cancel") + assert r.status_code == 200 + assert r.json()["status"] == "cancelled" + mock_proc.terminate.assert_called_once() + finally: + api_module._running_procs.pop("benchmark", None) + api_module._cancelled_jobs.discard("benchmark") + + +def test_finetune_cancel_terminates_running_process(client): + """POST /api/finetune/cancel must call terminate() on the running process.""" + from unittest.mock import MagicMock + from app import api as api_module + + mock_proc = MagicMock() + mock_proc.wait = MagicMock() + api_module._running_procs["finetune"] = mock_proc + + try: + r = client.post("/api/finetune/cancel") + assert r.status_code == 200 + assert r.json()["status"] == "cancelled" + mock_proc.terminate.assert_called_once() + finally: + api_module._running_procs.pop("finetune", None) + api_module._cancelled_jobs.discard("finetune") + + +def test_benchmark_cancel_kills_process_on_timeout(client): + """POST /api/benchmark/cancel must call kill() if the process does not exit within 3 s.""" + import subprocess + from unittest.mock import MagicMock + from app import api as api_module + + mock_proc = MagicMock() + mock_proc.wait.side_effect = subprocess.TimeoutExpired(cmd="benchmark", timeout=3) + api_module._running_procs["benchmark"] = mock_proc + + try: + r = client.post("/api/benchmark/cancel") + assert r.status_code == 200 + mock_proc.kill.assert_called_once() + finally: + api_module._running_procs.pop("benchmark", None) + api_module._cancelled_jobs.discard("benchmark") + + +def test_finetune_run_emits_cancelled_event(client): + """GET /api/finetune/run must emit cancelled (not error) when job was cancelled.""" + from unittest.mock import patch, MagicMock + from app import api as api_module + + mock_proc = MagicMock() + mock_proc.stdout = iter([]) + mock_proc.returncode = -15 # SIGTERM + + def mock_wait(): + # Simulate cancel being called while the process is running (after discard clears stale flag) + api_module._cancelled_jobs.add("finetune") + + mock_proc.wait = mock_wait + + def mock_popen(cmd, **kwargs): + return mock_proc + + try: + with patch("app.api._subprocess.Popen",side_effect=mock_popen): + r = client.get("/api/finetune/run?model=deberta-small&epochs=1") + assert '{"type": "cancelled"}' in r.text + assert '"type": "error"' not in r.text + finally: + api_module._cancelled_jobs.discard("finetune") + + +def test_benchmark_run_emits_cancelled_event(client): + """GET /api/benchmark/run must emit cancelled (not error) when job was cancelled.""" + from unittest.mock import patch, MagicMock + from app import api as api_module + + mock_proc = MagicMock() + mock_proc.stdout = iter([]) + mock_proc.returncode = -15 + + def mock_wait(): + # Simulate cancel being called while the process is running (after discard clears stale flag) + api_module._cancelled_jobs.add("benchmark") + + mock_proc.wait = mock_wait + + def mock_popen(cmd, **kwargs): + return mock_proc + + try: + with patch("app.api._subprocess.Popen",side_effect=mock_popen): + r = client.get("/api/benchmark/run") + assert '{"type": "cancelled"}' in r.text + assert '"type": "error"' not in r.text + finally: + api_module._cancelled_jobs.discard("benchmark") diff --git a/tests/test_benchmark_classifier.py b/tests/test_benchmark_classifier.py index 299e69c..1b1a71b 100644 --- a/tests/test_benchmark_classifier.py +++ b/tests/test_benchmark_classifier.py @@ -92,3 +92,77 @@ def test_run_scoring_handles_classify_error(tmp_path): results = run_scoring([broken], str(score_file)) assert "broken" in results + + +# ---- Auto-discovery tests ---- + +def test_discover_finetuned_models_finds_training_info_files(tmp_path): + """discover_finetuned_models() must return one entry per training_info.json found.""" + import json + from scripts.benchmark_classifier import discover_finetuned_models + + # Create two fake model directories + for name in ("avocet-deberta-small", "avocet-bge-m3"): + model_dir = tmp_path / name + model_dir.mkdir() + info = { + "name": name, + "base_model_id": "cross-encoder/nli-deberta-v3-small", + "timestamp": "2026-03-15T12:00:00Z", + "val_macro_f1": 0.72, + "val_accuracy": 0.80, + "sample_count": 401, + } + (model_dir / "training_info.json").write_text(json.dumps(info)) + + results = discover_finetuned_models(tmp_path) + assert len(results) == 2 + names = {r["name"] for r in results} + assert "avocet-deberta-small" in names + assert "avocet-bge-m3" in names + for r in results: + assert "model_dir" in r, "discover_finetuned_models must inject model_dir key" + assert r["model_dir"].endswith(r["name"]) + + +def test_discover_finetuned_models_returns_empty_when_no_models_dir(): + """discover_finetuned_models() must return [] silently if models/ doesn't exist.""" + from pathlib import Path + from scripts.benchmark_classifier import discover_finetuned_models + + results = discover_finetuned_models(Path("/nonexistent/path/models")) + assert results == [] + + +def test_discover_finetuned_models_skips_dirs_without_training_info(tmp_path): + """Subdirs without training_info.json are silently skipped.""" + from scripts.benchmark_classifier import discover_finetuned_models + + # A dir WITHOUT training_info.json + (tmp_path / "some-other-dir").mkdir() + + results = discover_finetuned_models(tmp_path) + assert results == [] + + +def test_active_models_includes_discovered_finetuned(tmp_path): + """The active models dict must include FineTunedAdapter entries for discovered models.""" + import json + from unittest.mock import patch + from scripts.benchmark_classifier import _active_models + from scripts.classifier_adapters import FineTunedAdapter + + model_dir = tmp_path / "avocet-deberta-small" + model_dir.mkdir() + (model_dir / "training_info.json").write_text(json.dumps({ + "name": "avocet-deberta-small", + "base_model_id": "cross-encoder/nli-deberta-v3-small", + "val_macro_f1": 0.72, + "sample_count": 401, + })) + + with patch("scripts.benchmark_classifier._MODELS_DIR", tmp_path): + models = _active_models(include_slow=False) + + assert "avocet-deberta-small" in models + assert isinstance(models["avocet-deberta-small"]["adapter_instance"], FineTunedAdapter) diff --git a/tests/test_classifier_adapters.py b/tests/test_classifier_adapters.py index f50ef3b..13f8a94 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(): @@ -178,3 +180,91 @@ def test_reranker_adapter_picks_highest_score(): def test_reranker_adapter_descriptions_cover_all_labels(): from scripts.classifier_adapters import LABEL_DESCRIPTIONS, LABELS assert set(LABEL_DESCRIPTIONS.keys()) == set(LABELS) + + +# ---- FineTunedAdapter tests ---- + +def test_finetuned_adapter_classify_calls_pipeline_with_sep_format(tmp_path): + """classify() must format input as 'subject [SEP] body[:400]' — not the zero-shot format.""" + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import FineTunedAdapter + + mock_result = [{"label": "digest", "score": 0.95}] + mock_pipe_instance = MagicMock(return_value=mock_result) + mock_pipe_factory = MagicMock(return_value=mock_pipe_instance) + + adapter = FineTunedAdapter("avocet-deberta-small", str(tmp_path)) + with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory): + result = adapter.classify("Test subject", "Test body") + + assert result == "digest" + call_args = mock_pipe_instance.call_args[0][0] + assert "[SEP]" in call_args + assert "Test subject" in call_args + assert "Test body" in call_args + + +def test_finetuned_adapter_truncates_body_to_400(): + """Body must be truncated to 400 chars in the [SEP] format.""" + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import FineTunedAdapter, LABELS + + long_body = "x" * 800 + mock_result = [{"label": "neutral", "score": 0.9}] + mock_pipe_instance = MagicMock(return_value=mock_result) + mock_pipe_factory = MagicMock(return_value=mock_pipe_instance) + + adapter = FineTunedAdapter("avocet-deberta-small", "/fake/path") + with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory): + adapter.classify("Subject", long_body) + + call_text = mock_pipe_instance.call_args[0][0] + parts = call_text.split(" [SEP] ", 1) + assert len(parts) == 2, "Input must contain ' [SEP] ' separator" + assert len(parts[1]) == 400, f"Body must be exactly 400 chars, got {len(parts[1])}" + + +def test_finetuned_adapter_returns_label_string(): + """classify() must return a plain string, not a dict.""" + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import FineTunedAdapter + + mock_result = [{"label": "interview_scheduled", "score": 0.87}] + mock_pipe_instance = MagicMock(return_value=mock_result) + mock_pipe_factory = MagicMock(return_value=mock_pipe_instance) + + adapter = FineTunedAdapter("avocet-deberta-small", "/fake/path") + with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory): + result = adapter.classify("S", "B") + + assert isinstance(result, str) + assert result == "interview_scheduled" + + +def test_finetuned_adapter_lazy_loads_pipeline(): + """Pipeline factory must not be called until classify() is first called.""" + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import FineTunedAdapter + + mock_pipe_factory = MagicMock(return_value=MagicMock(return_value=[{"label": "neutral", "score": 0.9}])) + + with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory): + adapter = FineTunedAdapter("avocet-deberta-small", "/fake/path") + assert not mock_pipe_factory.called + adapter.classify("s", "b") + assert mock_pipe_factory.called + + +def test_finetuned_adapter_unload_clears_pipeline(): + """unload() must set _pipeline to None so memory is released.""" + from unittest.mock import MagicMock, patch + from scripts.classifier_adapters import FineTunedAdapter + + mock_pipe_factory = MagicMock(return_value=MagicMock(return_value=[{"label": "neutral", "score": 0.9}])) + + with patch("scripts.classifier_adapters.pipeline", mock_pipe_factory): + adapter = FineTunedAdapter("avocet-deberta-small", "/fake/path") + adapter.classify("s", "b") + assert adapter._pipeline is not None + adapter.unload() + assert adapter._pipeline is None diff --git a/tests/test_finetune.py b/tests/test_finetune.py new file mode 100644 index 0000000..29c59d6 --- /dev/null +++ b/tests/test_finetune.py @@ -0,0 +1,371 @@ +"""Tests for finetune_classifier — no model downloads required.""" +from __future__ import annotations + +import json +import pytest + + +# ---- Data loading tests ---- + +def test_load_and_prepare_data_drops_non_canonical_labels(tmp_path): + """Rows with labels not in LABELS must be silently dropped.""" + from scripts.finetune_classifier import load_and_prepare_data + from scripts.classifier_adapters import LABELS + + # Two samples per canonical label so they survive the < 2 class-drop rule. + rows = [ + {"subject": "s1", "body": "b1", "label": "digest"}, + {"subject": "s2", "body": "b2", "label": "digest"}, + {"subject": "s3", "body": "b3", "label": "profile_alert"}, # non-canonical + {"subject": "s4", "body": "b4", "label": "neutral"}, + {"subject": "s5", "body": "b5", "label": "neutral"}, + ] + score_file = tmp_path / "email_score.jsonl" + score_file.write_text("\n".join(json.dumps(r) for r in rows)) + + texts, labels = load_and_prepare_data(score_file) + assert len(texts) == 4 + assert all(l in LABELS for l in labels) + + +def test_load_and_prepare_data_formats_input_as_sep(tmp_path): + """Input text must be 'subject [SEP] body[:400]'.""" + # Two samples with the same label so the class survives the < 2 drop rule. + rows = [ + {"subject": "Hello", "body": "World" * 100, "label": "neutral"}, + {"subject": "Hello2", "body": "World" * 100, "label": "neutral"}, + ] + score_file = tmp_path / "email_score.jsonl" + score_file.write_text("\n".join(json.dumps(r) for r in rows)) + + from scripts.finetune_classifier import load_and_prepare_data + texts, labels = load_and_prepare_data(score_file) + + assert texts[0].startswith("Hello [SEP] ") + parts = texts[0].split(" [SEP] ", 1) + assert len(parts[1]) == 400, f"Body must be exactly 400 chars, got {len(parts[1])}" + + +def test_load_and_prepare_data_raises_on_missing_file(): + """FileNotFoundError must be raised with actionable message.""" + from pathlib import Path + from scripts.finetune_classifier import load_and_prepare_data + + with pytest.raises(FileNotFoundError, match="email_score.jsonl"): + load_and_prepare_data(Path("/nonexistent/email_score.jsonl")) + + +def test_load_and_prepare_data_drops_class_with_fewer_than_2_samples(tmp_path, capsys): + """Classes with < 2 total samples must be dropped with a warning.""" + from scripts.finetune_classifier import load_and_prepare_data + + rows = [ + {"subject": "s1", "body": "b", "label": "digest"}, + {"subject": "s2", "body": "b", "label": "digest"}, + {"subject": "s3", "body": "b", "label": "new_lead"}, # only 1 sample — drop + ] + score_file = tmp_path / "email_score.jsonl" + score_file.write_text("\n".join(json.dumps(r) for r in rows)) + + texts, labels = load_and_prepare_data(score_file) + captured = capsys.readouterr() + + assert "new_lead" not in labels + assert "new_lead" in captured.out # warning printed + + +# ---- Class weights tests ---- + +def test_compute_class_weights_returns_tensor_for_each_class(): + """compute_class_weights must return a float tensor of length n_classes.""" + import torch + from scripts.finetune_classifier import compute_class_weights + + label_ids = [0, 0, 0, 1, 1, 2] # 3 classes, imbalanced + weights = compute_class_weights(label_ids, n_classes=3) + + assert isinstance(weights, torch.Tensor) + assert weights.shape == (3,) + assert all(w > 0 for w in weights) + + +def test_compute_class_weights_upweights_minority(): + """Minority classes must receive higher weight than majority classes.""" + from scripts.finetune_classifier import compute_class_weights + + # Class 0: 10 samples, Class 1: 2 samples + label_ids = [0] * 10 + [1] * 2 + weights = compute_class_weights(label_ids, n_classes=2) + + assert weights[1] > weights[0] + + +# ---- compute_metrics_for_trainer tests ---- + +def test_compute_metrics_for_trainer_returns_macro_f1_key(): + """Must return a dict with 'macro_f1' key.""" + import numpy as np + from scripts.finetune_classifier import compute_metrics_for_trainer + from transformers import EvalPrediction + + logits = np.array([[2.0, 0.1], [0.1, 2.0], [2.0, 0.1]]) + labels = np.array([0, 1, 0]) + pred = EvalPrediction(predictions=logits, label_ids=labels) + + result = compute_metrics_for_trainer(pred) + assert "macro_f1" in result + assert result["macro_f1"] == pytest.approx(1.0) + + +def test_compute_metrics_for_trainer_returns_accuracy_key(): + """Must also return 'accuracy' key.""" + import numpy as np + from scripts.finetune_classifier import compute_metrics_for_trainer + from transformers import EvalPrediction + + logits = np.array([[2.0, 0.1], [0.1, 2.0]]) + labels = np.array([0, 1]) + pred = EvalPrediction(predictions=logits, label_ids=labels) + + result = compute_metrics_for_trainer(pred) + assert "accuracy" in result + assert result["accuracy"] == pytest.approx(1.0) + + +# ---- WeightedTrainer tests ---- + +def test_weighted_trainer_compute_loss_returns_scalar(): + """compute_loss must return a scalar tensor when return_outputs=False.""" + import torch + from unittest.mock import MagicMock + from scripts.finetune_classifier import WeightedTrainer + + n_classes = 3 + batch = 4 + logits = torch.randn(batch, n_classes) + + mock_outputs = MagicMock() + mock_outputs.logits = logits + mock_model = MagicMock(return_value=mock_outputs) + + trainer = WeightedTrainer.__new__(WeightedTrainer) + trainer.class_weights = torch.ones(n_classes) + + inputs = { + "input_ids": torch.zeros(batch, 10, dtype=torch.long), + "labels": torch.randint(0, n_classes, (batch,)), + } + + loss = trainer.compute_loss(mock_model, inputs, return_outputs=False) + assert isinstance(loss, torch.Tensor) + assert loss.ndim == 0 # scalar + + +def test_weighted_trainer_compute_loss_accepts_kwargs(): + """compute_loss must not raise TypeError when called with num_items_in_batch kwarg.""" + import torch + from unittest.mock import MagicMock + from scripts.finetune_classifier import WeightedTrainer + + n_classes = 3 + batch = 2 + logits = torch.randn(batch, n_classes) + + mock_outputs = MagicMock() + mock_outputs.logits = logits + mock_model = MagicMock(return_value=mock_outputs) + + trainer = WeightedTrainer.__new__(WeightedTrainer) + trainer.class_weights = torch.ones(n_classes) + + inputs = { + "input_ids": torch.zeros(batch, 5, dtype=torch.long), + "labels": torch.randint(0, n_classes, (batch,)), + } + + loss = trainer.compute_loss(mock_model, inputs, return_outputs=False, + num_items_in_batch=batch) + assert isinstance(loss, torch.Tensor) + + +def test_weighted_trainer_weighted_loss_differs_from_unweighted(): + """Weighted loss must differ from uniform-weight loss for imbalanced inputs.""" + import torch + from unittest.mock import MagicMock + from scripts.finetune_classifier import WeightedTrainer + + n_classes = 2 + batch = 4 + # Mixed labels: 3× class-0, 1× class-1. + # Asymmetric logits (class-0 samples predicted well, class-1 predicted poorly) + # ensure per-class CE values differ, so re-weighting changes the weighted mean. + labels = torch.tensor([0, 0, 0, 1], dtype=torch.long) + logits = torch.tensor([[3.0, -1.0], [3.0, -1.0], [3.0, -1.0], [0.5, 0.5]]) + + mock_outputs = MagicMock() + mock_outputs.logits = logits + + trainer_uniform = WeightedTrainer.__new__(WeightedTrainer) + trainer_uniform.class_weights = torch.ones(n_classes) + inputs_uniform = {"input_ids": torch.zeros(batch, 5, dtype=torch.long), "labels": labels.clone()} + loss_uniform = trainer_uniform.compute_loss(MagicMock(return_value=mock_outputs), + inputs_uniform) + + trainer_weighted = WeightedTrainer.__new__(WeightedTrainer) + trainer_weighted.class_weights = torch.tensor([0.1, 10.0]) + inputs_weighted = {"input_ids": torch.zeros(batch, 5, dtype=torch.long), "labels": labels.clone()} + + mock_outputs2 = MagicMock() + mock_outputs2.logits = logits.clone() + loss_weighted = trainer_weighted.compute_loss(MagicMock(return_value=mock_outputs2), + inputs_weighted) + + assert not torch.isclose(loss_uniform, loss_weighted) + + +def test_weighted_trainer_compute_loss_returns_outputs_when_requested(): + """compute_loss with return_outputs=True must return (loss, outputs) tuple.""" + import torch + from unittest.mock import MagicMock + from scripts.finetune_classifier import WeightedTrainer + + n_classes = 3 + batch = 2 + logits = torch.randn(batch, n_classes) + + mock_outputs = MagicMock() + mock_outputs.logits = logits + mock_model = MagicMock(return_value=mock_outputs) + + trainer = WeightedTrainer.__new__(WeightedTrainer) + trainer.class_weights = torch.ones(n_classes) + + inputs = { + "input_ids": torch.zeros(batch, 5, dtype=torch.long), + "labels": torch.randint(0, n_classes, (batch,)), + } + + result = trainer.compute_loss(mock_model, inputs, return_outputs=True) + assert isinstance(result, tuple) + loss, outputs = result + assert isinstance(loss, torch.Tensor) + + +# ---- Multi-file merge / dedup tests ---- + +def test_load_and_prepare_data_merges_multiple_files(tmp_path): + """Multiple score files must be merged into a single dataset.""" + from scripts.finetune_classifier import load_and_prepare_data + + file1 = tmp_path / "run1.jsonl" + file2 = tmp_path / "run2.jsonl" + file1.write_text( + json.dumps({"subject": "s1", "body": "b1", "label": "digest"}) + "\n" + + json.dumps({"subject": "s2", "body": "b2", "label": "digest"}) + "\n" + ) + file2.write_text( + json.dumps({"subject": "s3", "body": "b3", "label": "neutral"}) + "\n" + + json.dumps({"subject": "s4", "body": "b4", "label": "neutral"}) + "\n" + ) + + texts, labels = load_and_prepare_data([file1, file2]) + assert len(texts) == 4 + assert labels.count("digest") == 2 + assert labels.count("neutral") == 2 + + +def test_load_and_prepare_data_deduplicates_last_write_wins(tmp_path, capsys): + """Duplicate rows (same content hash) keep the last occurrence.""" + from scripts.finetune_classifier import load_and_prepare_data + + # Same subject+body[:100] = same hash + row_early = {"subject": "Hello", "body": "World", "label": "neutral"} + row_late = {"subject": "Hello", "body": "World", "label": "digest"} # relabeled + + file1 = tmp_path / "run1.jsonl" + file2 = tmp_path / "run2.jsonl" + # Add a second row with different content so class count >= 2 for both classes + file1.write_text( + json.dumps(row_early) + "\n" + + json.dumps({"subject": "Other1", "body": "Other", "label": "neutral"}) + "\n" + ) + file2.write_text( + json.dumps(row_late) + "\n" + + json.dumps({"subject": "Other2", "body": "Stuff", "label": "digest"}) + "\n" + ) + + texts, labels = load_and_prepare_data([file1, file2]) + captured = capsys.readouterr() + + # The duplicate row should be counted as dropped + assert "Deduped" in captured.out + # The relabeled row should have "digest" (last-write wins), not "neutral" + hello_idx = next(i for i, t in enumerate(texts) if t.startswith("Hello [SEP]")) + assert labels[hello_idx] == "digest" + + +def test_load_and_prepare_data_single_path_still_works(tmp_path): + """Passing a single Path (not a list) must still work — backwards compatibility.""" + from scripts.finetune_classifier import load_and_prepare_data + + rows = [ + {"subject": "s1", "body": "b1", "label": "digest"}, + {"subject": "s2", "body": "b2", "label": "digest"}, + ] + score_file = tmp_path / "email_score.jsonl" + score_file.write_text("\n".join(json.dumps(r) for r in rows)) + + texts, labels = load_and_prepare_data(score_file) # single Path, not list + assert len(texts) == 2 + + +# ---- Integration test ---- + +def test_integration_finetune_on_example_data(tmp_path): + """Fine-tune deberta-small on example data for 1 epoch. + + Uses data/email_score.jsonl.example (8 samples, 5 labels represented). + The 5 missing labels must trigger the < 2 samples drop warning. + Verifies training_info.json is written with correct keys. + + Requires job-seeker-classifiers env and downloads deberta-small (~100MB on first run). + """ + import shutil + from scripts import finetune_classifier as ft_mod + from scripts.finetune_classifier import run_finetune + + example_file = ft_mod._ROOT / "data" / "email_score.jsonl.example" + if not example_file.exists(): + pytest.skip("email_score.jsonl.example not found") + + orig_root = ft_mod._ROOT + ft_mod._ROOT = tmp_path + (tmp_path / "data").mkdir() + shutil.copy(example_file, tmp_path / "data" / "email_score.jsonl") + + try: + import io + from contextlib import redirect_stdout + captured = io.StringIO() + with redirect_stdout(captured): + run_finetune("deberta-small", epochs=1) + output = captured.getvalue() + finally: + ft_mod._ROOT = orig_root + + # Missing labels should trigger the < 2 samples drop warning + assert "WARNING: Dropping class" in output + + # training_info.json must exist with correct keys + info_path = tmp_path / "models" / "avocet-deberta-small" / "training_info.json" + assert info_path.exists(), "training_info.json not written" + + info = json.loads(info_path.read_text()) + for key in ("name", "base_model_id", "timestamp", "epochs_run", + "val_macro_f1", "val_accuracy", "sample_count", "train_sample_count", + "label_counts", "score_files"): + assert key in info, f"Missing key: {key}" + + assert info["name"] == "avocet-deberta-small" + assert info["epochs_run"] == 1 + assert isinstance(info["score_files"], list) diff --git a/tests/test_imap_fetch.py b/tests/test_imap_fetch.py new file mode 100644 index 0000000..e33dac7 --- /dev/null +++ b/tests/test_imap_fetch.py @@ -0,0 +1,86 @@ +"""Tests for imap_fetch — IMAP calls mocked.""" +from unittest.mock import MagicMock, patch + + +def test_test_connection_missing_fields(): + from app.imap_fetch import test_connection + ok, msg, count = test_connection({"host": "", "username": "", "password": ""}) + assert ok is False + assert "required" in msg.lower() + + +def test_test_connection_success(): + from app.imap_fetch import test_connection + + mock_conn = MagicMock() + mock_conn.select.return_value = ("OK", [b"42"]) + + with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn): + ok, msg, count = test_connection({ + "host": "imap.example.com", "port": 993, "use_ssl": True, + "username": "u@example.com", "password": "secret", "folder": "INBOX", + }) + assert ok is True + assert count == 42 + assert "42" in msg + + +def test_test_connection_auth_failure(): + from app.imap_fetch import test_connection + import imaplib + + with patch("app.imap_fetch.imaplib.IMAP4_SSL", side_effect=imaplib.IMAP4.error("auth failed")): + ok, msg, count = test_connection({ + "host": "imap.example.com", "port": 993, "use_ssl": True, + "username": "u@example.com", "password": "wrong", "folder": "INBOX", + }) + assert ok is False + assert count is None + + +def test_fetch_account_stream_yields_start_done(tmp_path): + from app.imap_fetch import fetch_account_stream + + mock_conn = MagicMock() + mock_conn.search.return_value = ("OK", [b"1 2"]) + raw_msg = b"Subject: Test\r\nFrom: a@b.com\r\nDate: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nHello" + mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)]) + + with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn): + events = list(fetch_account_stream( + acc={"host": "h", "port": 993, "use_ssl": True, + "username": "u", "password": "p", "folder": "INBOX", "name": "Test"}, + days_back=30, limit=10, known_keys=set(), + )) + + types = [e["type"] for e in events] + assert "start" in types + assert "done" in types + + +def test_fetch_account_stream_deduplicates(tmp_path): + from app.imap_fetch import fetch_account_stream + + raw_msg = b"Subject: Dupe\r\nFrom: a@b.com\r\nDate: Mon, 1 Mar 2026 12:00:00 +0000\r\n\r\nBody" + mock_conn = MagicMock() + mock_conn.search.return_value = ("OK", [b"1"]) + mock_conn.fetch.return_value = ("OK", [(b"1 (RFC822 {N})", raw_msg)]) + + known = set() + with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn): + events1 = list(fetch_account_stream( + {"host": "h", "port": 993, "use_ssl": True, "username": "u", + "password": "p", "folder": "INBOX", "name": "T"}, + 30, 10, known, + )) + done1 = next(e for e in events1 if e["type"] == "done") + + with patch("app.imap_fetch.imaplib.IMAP4_SSL", return_value=mock_conn): + events2 = list(fetch_account_stream( + {"host": "h", "port": 993, "use_ssl": True, "username": "u", + "password": "p", "folder": "INBOX", "name": "T"}, + 30, 10, known, + )) + done2 = next(e for e in events2 if e["type"] == "done") + assert done1["added"] == 1 + assert done2["added"] == 0 diff --git a/tests/test_label_tool.py b/tests/test_label_tool.py new file mode 100644 index 0000000..01f6f4d --- /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.utils 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) == "" diff --git a/tests/test_sft.py b/tests/test_sft.py new file mode 100644 index 0000000..ed808b1 --- /dev/null +++ b/tests/test_sft.py @@ -0,0 +1,342 @@ +"""API integration tests for app/sft.py — /api/sft/* endpoints.""" +import json +import pytest +from fastapi.testclient import TestClient +from pathlib import Path + + +@pytest.fixture(autouse=True) +def reset_sft_globals(tmp_path): + from app import sft as sft_module + _prev_data = sft_module._SFT_DATA_DIR + _prev_cfg = sft_module._SFT_CONFIG_DIR + sft_module.set_sft_data_dir(tmp_path) + sft_module.set_sft_config_dir(tmp_path) + yield + sft_module.set_sft_data_dir(_prev_data) + sft_module.set_sft_config_dir(_prev_cfg) + + +@pytest.fixture +def client(): + from app.api import app + return TestClient(app) + + +def _make_record(id: str, run_id: str = "2026-04-07-143022") -> dict: + return { + "id": id, "source": "cf-orch-benchmark", + "benchmark_run_id": run_id, "timestamp": "2026-04-07T10:00:00Z", + "status": "needs_review", + "prompt_messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Write a Python function that adds two numbers."}, + ], + "model_response": "def add(a, b): return a - b", + "corrected_response": None, + "quality_score": 0.2, "failure_reason": "pattern_match: 0/2 matched", + "task_id": "code-fn", "task_type": "code", + "task_name": "Code: Write a Python function", + "model_id": "Qwen/Qwen2.5-3B", "model_name": "Qwen2.5-3B", + "node_id": "heimdall", "gpu_id": 0, "tokens_per_sec": 38.4, + } + + +def _write_run(tmp_path, run_id: str, records: list[dict]) -> Path: + run_dir = tmp_path / "bench_results" / run_id + run_dir.mkdir(parents=True) + sft_path = run_dir / "sft_candidates.jsonl" + sft_path.write_text( + "\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8" + ) + return sft_path + + +def _write_config(tmp_path, bench_results_dir: Path) -> None: + import yaml + cfg = {"sft": {"bench_results_dir": str(bench_results_dir)}} + (tmp_path / "label_tool.yaml").write_text( + yaml.dump(cfg, allow_unicode=True), encoding="utf-8" + ) + + +# ── /api/sft/runs ────────────────────────────────────────────────────────── + +def test_runs_returns_empty_when_no_config(client): + r = client.get("/api/sft/runs") + assert r.status_code == 200 + assert r.json() == [] + + +def test_runs_returns_available_runs(client, tmp_path): + _write_run(tmp_path, "2026-04-07-143022", [_make_record("a"), _make_record("b")]) + _write_config(tmp_path, tmp_path / "bench_results") + r = client.get("/api/sft/runs") + assert r.status_code == 200 + data = r.json() + assert len(data) == 1 + assert data[0]["run_id"] == "2026-04-07-143022" + assert data[0]["candidate_count"] == 2 + assert data[0]["already_imported"] is False + + +def test_runs_marks_already_imported(client, tmp_path): + _write_run(tmp_path, "2026-04-07-143022", [_make_record("a")]) + _write_config(tmp_path, tmp_path / "bench_results") + from app import sft as sft_module + candidates = sft_module._candidates_file() + candidates.parent.mkdir(parents=True, exist_ok=True) + candidates.write_text( + json.dumps(_make_record("a", run_id="2026-04-07-143022")) + "\n", + encoding="utf-8" + ) + r = client.get("/api/sft/runs") + assert r.json()[0]["already_imported"] is True + + +# ── /api/sft/import ───────────────────────────────────────────────────────── + +def test_import_adds_records(client, tmp_path): + _write_run(tmp_path, "2026-04-07-143022", [_make_record("a"), _make_record("b")]) + _write_config(tmp_path, tmp_path / "bench_results") + r = client.post("/api/sft/import", json={"run_id": "2026-04-07-143022"}) + assert r.status_code == 200 + assert r.json() == {"imported": 2, "skipped": 0} + + +def test_import_is_idempotent(client, tmp_path): + _write_run(tmp_path, "2026-04-07-143022", [_make_record("a")]) + _write_config(tmp_path, tmp_path / "bench_results") + client.post("/api/sft/import", json={"run_id": "2026-04-07-143022"}) + r = client.post("/api/sft/import", json={"run_id": "2026-04-07-143022"}) + assert r.json() == {"imported": 0, "skipped": 1} + + +def test_import_unknown_run_returns_404(client, tmp_path): + _write_config(tmp_path, tmp_path / "bench_results") + r = client.post("/api/sft/import", json={"run_id": "nonexistent"}) + assert r.status_code == 404 + + +# ── /api/sft/queue ────────────────────────────────────────────────────────── + +def _populate_candidates(tmp_path, records: list[dict]) -> None: + from app import sft as sft_module + path = sft_module._candidates_file() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + "\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8" + ) + + +def test_queue_returns_needs_review_only(client, tmp_path): + records = [ + _make_record("a"), # needs_review + {**_make_record("b"), "status": "approved"}, # should not appear + {**_make_record("c"), "status": "discarded"}, # should not appear + ] + _populate_candidates(tmp_path, records) + r = client.get("/api/sft/queue") + assert r.status_code == 200 + data = r.json() + assert data["total"] == 1 + assert len(data["items"]) == 1 + assert data["items"][0]["id"] == "a" + + +def test_queue_pagination(client, tmp_path): + records = [_make_record(str(i)) for i in range(25)] + _populate_candidates(tmp_path, records) + r = client.get("/api/sft/queue?page=1&per_page=10") + data = r.json() + assert data["total"] == 25 + assert len(data["items"]) == 10 + r2 = client.get("/api/sft/queue?page=3&per_page=10") + assert len(r2.json()["items"]) == 5 + + +def test_queue_empty_when_no_file(client): + r = client.get("/api/sft/queue") + assert r.status_code == 200 + assert r.json() == {"items": [], "total": 0, "page": 1, "per_page": 20} + + +# ── /api/sft/submit ───────────────────────────────────────────────────────── + +def test_submit_correct_sets_approved(client, tmp_path): + _populate_candidates(tmp_path, [_make_record("a")]) + r = client.post("/api/sft/submit", json={ + "id": "a", "action": "correct", + "corrected_response": "def add(a, b): return a + b", + }) + assert r.status_code == 200 + from app import sft as sft_module + records = sft_module._read_candidates() + assert records[0]["status"] == "approved" + assert records[0]["corrected_response"] == "def add(a, b): return a + b" + + +def test_submit_correct_also_appends_to_approved_file(client, tmp_path): + _populate_candidates(tmp_path, [_make_record("a")]) + client.post("/api/sft/submit", json={ + "id": "a", "action": "correct", + "corrected_response": "def add(a, b): return a + b", + }) + from app import sft as sft_module + from app.utils import read_jsonl + approved = read_jsonl(sft_module._approved_file()) + assert len(approved) == 1 + assert approved[0]["id"] == "a" + + +def test_submit_discard_sets_discarded(client, tmp_path): + _populate_candidates(tmp_path, [_make_record("a")]) + r = client.post("/api/sft/submit", json={"id": "a", "action": "discard"}) + assert r.status_code == 200 + from app import sft as sft_module + assert sft_module._read_candidates()[0]["status"] == "discarded" + + +def test_submit_flag_sets_model_rejected(client, tmp_path): + _populate_candidates(tmp_path, [_make_record("a")]) + r = client.post("/api/sft/submit", json={"id": "a", "action": "flag"}) + assert r.status_code == 200 + from app import sft as sft_module + assert sft_module._read_candidates()[0]["status"] == "model_rejected" + + +def test_submit_correct_empty_response_returns_422(client, tmp_path): + _populate_candidates(tmp_path, [_make_record("a")]) + r = client.post("/api/sft/submit", json={ + "id": "a", "action": "correct", "corrected_response": " ", + }) + assert r.status_code == 422 + + +def test_submit_correct_null_response_returns_422(client, tmp_path): + _populate_candidates(tmp_path, [_make_record("a")]) + r = client.post("/api/sft/submit", json={ + "id": "a", "action": "correct", "corrected_response": None, + }) + assert r.status_code == 422 + + +def test_submit_unknown_id_returns_404(client, tmp_path): + r = client.post("/api/sft/submit", json={"id": "nope", "action": "discard"}) + assert r.status_code == 404 + + +def test_submit_already_approved_returns_409(client, tmp_path): + _populate_candidates(tmp_path, [{**_make_record("a"), "status": "approved"}]) + r = client.post("/api/sft/submit", json={"id": "a", "action": "discard"}) + assert r.status_code == 409 + + +# ── /api/sft/undo ──────────────────────────────────────────────────────────── + +def test_undo_restores_discarded_to_needs_review(client, tmp_path): + _populate_candidates(tmp_path, [_make_record("a")]) + client.post("/api/sft/submit", json={"id": "a", "action": "discard"}) + r = client.post("/api/sft/undo", json={"id": "a"}) + assert r.status_code == 200 + from app import sft as sft_module + assert sft_module._read_candidates()[0]["status"] == "needs_review" + + +def test_undo_removes_approved_from_approved_file(client, tmp_path): + _populate_candidates(tmp_path, [_make_record("a")]) + client.post("/api/sft/submit", json={ + "id": "a", "action": "correct", + "corrected_response": "def add(a, b): return a + b", + }) + client.post("/api/sft/undo", json={"id": "a"}) + from app import sft as sft_module + from app.utils import read_jsonl + approved = read_jsonl(sft_module._approved_file()) + assert not any(r["id"] == "a" for r in approved) + + +def test_undo_already_needs_review_returns_409(client, tmp_path): + _populate_candidates(tmp_path, [_make_record("a")]) + r = client.post("/api/sft/undo", json={"id": "a"}) + assert r.status_code == 409 + + +# ── /api/sft/export ────────────────────────────────────────────────────────── + +def test_export_returns_approved_as_sft_jsonl(client, tmp_path): + from app import sft as sft_module + from app.utils import write_jsonl + approved = { + **_make_record("a"), + "status": "approved", + "corrected_response": "def add(a, b): return a + b", + "prompt_messages": [ + {"role": "system", "content": "You are a coding assistant."}, + {"role": "user", "content": "Write a Python add function."}, + ], + } + write_jsonl(sft_module._approved_file(), [approved]) + _populate_candidates(tmp_path, [approved]) + + r = client.get("/api/sft/export") + assert r.status_code == 200 + assert "application/x-ndjson" in r.headers["content-type"] + lines = [l for l in r.text.splitlines() if l.strip()] + assert len(lines) == 1 + record = json.loads(lines[0]) + assert record["messages"][-1] == { + "role": "assistant", "content": "def add(a, b): return a + b" + } + assert record["messages"][0]["role"] == "system" + assert record["messages"][1]["role"] == "user" + + +def test_export_excludes_non_approved(client, tmp_path): + from app import sft as sft_module + from app.utils import write_jsonl + records = [ + {**_make_record("a"), "status": "discarded", "corrected_response": None}, + {**_make_record("b"), "status": "needs_review", "corrected_response": None}, + ] + write_jsonl(sft_module._approved_file(), records) + r = client.get("/api/sft/export") + assert r.text.strip() == "" + + +def test_export_empty_when_no_approved_file(client): + r = client.get("/api/sft/export") + assert r.status_code == 200 + assert r.text.strip() == "" + + +# ── /api/sft/stats ─────────────────────────────────────────────────────────── + +def test_stats_counts_by_status(client, tmp_path): + from app import sft as sft_module + from app.utils import write_jsonl + records = [ + _make_record("a"), + {**_make_record("b"), "status": "approved", "corrected_response": "ok"}, + {**_make_record("c"), "status": "discarded"}, + {**_make_record("d"), "status": "model_rejected"}, + ] + _populate_candidates(tmp_path, records) + write_jsonl(sft_module._approved_file(), [records[1]]) + r = client.get("/api/sft/stats") + assert r.status_code == 200 + data = r.json() + assert data["total"] == 4 + assert data["by_status"]["needs_review"] == 1 + assert data["by_status"]["approved"] == 1 + assert data["by_status"]["discarded"] == 1 + assert data["by_status"]["model_rejected"] == 1 + assert data["export_ready"] == 1 + + +def test_stats_empty_when_no_data(client): + r = client.get("/api/sft/stats") + assert r.status_code == 200 + data = r.json() + assert data["total"] == 0 + assert data["export_ready"] == 0 diff --git a/tests/test_sft_import.py b/tests/test_sft_import.py new file mode 100644 index 0000000..c63fb3e --- /dev/null +++ b/tests/test_sft_import.py @@ -0,0 +1,95 @@ +"""Unit tests for scripts/sft_import.py — run discovery and JSONL deduplication.""" +import json +import pytest +from pathlib import Path + + +def _write_candidates(path: Path, records: list[dict]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(json.dumps(r) for r in records) + "\n", encoding="utf-8") + + +def _make_record(id: str, run_id: str = "run1") -> dict: + return { + "id": id, "source": "cf-orch-benchmark", + "benchmark_run_id": run_id, "timestamp": "2026-04-07T10:00:00Z", + "status": "needs_review", "prompt_messages": [], + "model_response": "bad", "corrected_response": None, + "quality_score": 0.3, "failure_reason": "missing patterns", + "task_id": "code-fn", "task_type": "code", "task_name": "Code: fn", + "model_id": "Qwen/Qwen2.5-3B", "model_name": "Qwen2.5-3B", + "node_id": "heimdall", "gpu_id": 0, "tokens_per_sec": 38.4, + } + + +def test_discover_runs_empty_when_dir_missing(tmp_path): + from scripts.sft_import import discover_runs + result = discover_runs(tmp_path / "nonexistent") + assert result == [] + + +def test_discover_runs_returns_runs(tmp_path): + from scripts.sft_import import discover_runs + run_dir = tmp_path / "2026-04-07-143022" + _write_candidates(run_dir / "sft_candidates.jsonl", [_make_record("a"), _make_record("b")]) + result = discover_runs(tmp_path) + assert len(result) == 1 + assert result[0]["run_id"] == "2026-04-07-143022" + assert result[0]["candidate_count"] == 2 + assert "sft_path" in result[0] + + +def test_discover_runs_skips_dirs_without_sft_file(tmp_path): + from scripts.sft_import import discover_runs + (tmp_path / "2026-04-07-no-sft").mkdir() + result = discover_runs(tmp_path) + assert result == [] + + +def test_discover_runs_sorted_newest_first(tmp_path): + from scripts.sft_import import discover_runs + for name in ["2026-04-05-120000", "2026-04-07-143022", "2026-04-06-090000"]: + run_dir = tmp_path / name + _write_candidates(run_dir / "sft_candidates.jsonl", [_make_record("x")]) + result = discover_runs(tmp_path) + assert [r["run_id"] for r in result] == [ + "2026-04-07-143022", "2026-04-06-090000", "2026-04-05-120000" + ] + + +def test_import_run_imports_new_records(tmp_path): + from scripts.sft_import import import_run + sft_path = tmp_path / "run1" / "sft_candidates.jsonl" + _write_candidates(sft_path, [_make_record("a"), _make_record("b")]) + result = import_run(sft_path, tmp_path) + assert result == {"imported": 2, "skipped": 0} + dest = tmp_path / "sft_candidates.jsonl" + lines = [json.loads(l) for l in dest.read_text().splitlines() if l.strip()] + assert len(lines) == 2 + + +def test_import_run_deduplicates_on_id(tmp_path): + from scripts.sft_import import import_run + sft_path = tmp_path / "run1" / "sft_candidates.jsonl" + _write_candidates(sft_path, [_make_record("a"), _make_record("b")]) + import_run(sft_path, tmp_path) + result = import_run(sft_path, tmp_path) # second import + assert result == {"imported": 0, "skipped": 2} + dest = tmp_path / "sft_candidates.jsonl" + lines = [l for l in dest.read_text().splitlines() if l.strip()] + assert len(lines) == 2 # no duplicates + + +def test_import_run_skips_records_missing_id(tmp_path, caplog): + import logging + from scripts.sft_import import import_run + sft_path = tmp_path / "run1" / "sft_candidates.jsonl" + sft_path.parent.mkdir() + sft_path.write_text( + json.dumps({"model_response": "bad", "status": "needs_review"}) + "\n" + + json.dumps({"id": "abc123", "model_response": "good", "status": "needs_review"}) + "\n" + ) + with caplog.at_level(logging.WARNING, logger="scripts.sft_import"): + result = import_run(sft_path, tmp_path) + assert result == {"imported": 1, "skipped": 0} + assert "missing 'id'" in caplog.text diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/.vscode/extensions.json b/web/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/web/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/web/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..e16603c --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,4939 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "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", + "animejs": "^4.3.6", + "pinia": "^3.0.4", + "vue": "^3.5.25", + "vue-router": "^5.0.3" + }, + "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/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "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/@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", + "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==", + "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==", + "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==", + "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==", + "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-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "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==", + "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/animejs": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/animejs/-/animejs-4.3.6.tgz", + "integrity": "sha512-rzZ4bDc8JAtyx6hYwxj7s5M/yWfnM5qqY4hZDnhy1cWFvMb6H5/necHS2sbCY3WQTDbRLuZL10dPXSxSCFOr/w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/juliangarnier" + } + }, + "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/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "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==", + "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==", + "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/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "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/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/local-pkg/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/local-pkg/node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/local-pkg/node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "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/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "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==", + "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==", + "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==", + "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==", + "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==", + "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==", + "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/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "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==", + "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==", + "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==", + "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-router": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.3.tgz", + "integrity": "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.7.tgz", + "integrity": "sha512-tc1TXAxclsn55JblLkFVcIRG7MeSJC4fWsPjfM7qu/IcmPUYnQ5Q8vzWwBpyDY24ZjmZTUCCwjRSNbx58IhlAA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.0.7" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-kit": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.7.tgz", + "integrity": "sha512-H6esJGHGl5q0E9iV3m2EoBQHJ+V83WMW83A0/+Fn95eZ2iIvdsq4+UCS6yT/Fdd4cGZSchx/MdWDreM3WqMsDw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.0.7", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-shared": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.7.tgz", + "integrity": "sha512-CgAb9oJH5NUmbQRdYDj/1zMiaICYSLtm+B1kxcP72LBrifGAjUmt8bx52dDH1gWRPlQgxGPqpAMKavzVirAEhA==", + "license": "MIT" + }, + "node_modules/vue-router/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==", + "license": "MIT" + }, + "node_modules/vue-router/node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "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==", + "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" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8d3abec --- /dev/null +++ b/web/package.json @@ -0,0 +1,38 @@ +{ + "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": { + "@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", + "animejs": "^4.3.6", + "pinia": "^3.0.4", + "vue": "^3.5.25", + "vue-router": "^5.0.3" + }, + "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..f15fb5c --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/web/src/assets/avocet.css b/web/src/assets/avocet.css new file mode 100644 index 0000000..7a91dac --- /dev/null +++ b/web/src/assets/avocet.css @@ -0,0 +1,71 @@ +/* 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 +*/ + +/* ── Page-level overrides — must be in avocet.css (applied after theme.css base) ── */ +html { + /* Prevent Mac Chrome's horizontal swipe-to-navigate page animation + from triggering when the user scrolls near the viewport edge */ + overscroll-behavior-x: none; + /* clip (not hidden) — prevents overflowing content from expanding the html layout + width beyond the viewport. Without this, body's overflow-x:hidden propagates to + the viewport and body has no BFC, so long email URLs inflate the layout and + margin:0 auto centering drifts rightward as fonts load. */ + overflow-x: clip; +} + +body { + /* Prevent horizontal scroll from card swipe animations */ + overflow-x: hidden; +} + + +/* ── Light mode (default) ──────────────────────────── */ +:root { + /* Aliases bridging avocet component vars to CircuitForge base theme vars */ + --color-bg: var(--color-surface); /* App.vue body bg → #eaeff8 in light */ + --color-text-secondary: var(--color-text-muted); /* muted label text */ + /* Primary — Slate Teal */ + --app-primary: #2A6080; /* 4.8:1 on light surface #eaeff8 — ✅ AA */ + --app-primary-hover: #1E4D66; /* darker for hover */ + --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/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/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/AppSidebar.vue b/web/src/components/AppSidebar.vue new file mode 100644 index 0000000..051feda --- /dev/null +++ b/web/src/components/AppSidebar.vue @@ -0,0 +1,276 @@ + + + + + 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..beb994d --- /dev/null +++ b/web/src/components/EmailCard.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/web/src/components/EmailCardStack.test.ts b/web/src/components/EmailCardStack.test.ts new file mode 100644 index 0000000..1ffa968 --- /dev/null +++ b/web/src/components/EmailCardStack.test.ts @@ -0,0 +1,183 @@ +import { mount } from '@vue/test-utils' +import EmailCardStack from './EmailCardStack.vue' +import { describe, it, expect, vi } from 'vitest' + +vi.mock('../composables/useCardAnimation', () => ({ + useCardAnimation: vi.fn(() => ({ + pickup: vi.fn(), + setDragPosition: vi.fn(), + snapBack: vi.fn(), + animateDismiss: vi.fn(), + updateAura: vi.fn(), + reset: vi.fn(), + })), +})) + +import { useCardAnimation } from '../composables/useCardAnimation' +import { nextTick } from 'vue' + +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('calls animateDismiss with type when dismissType prop changes', async () => { + ;(useCardAnimation as ReturnType).mockClear() + const w = mount(EmailCardStack, { props: { item, isBucketMode: false, dismissType: null } }) + const { animateDismiss } = (useCardAnimation as ReturnType).mock.results[0].value + await w.setProps({ dismissType: 'label' }) + await nextTick() + expect(animateDismiss).toHaveBeenCalledWith('label') + }) + + // JSDOM doesn't implement setPointerCapture — mock it on the element. + // Also use dispatchEvent(new PointerEvent) directly because @vue/test-utils + // .trigger() tries to assign clientX on a MouseEvent (read-only in JSDOM). + function mockPointerCapture(element: Element) { + ;(element as any).setPointerCapture = vi.fn() + ;(element as any).releasePointerCapture = vi.fn() + } + + function fire(element: Element, type: string, init: PointerEventInit) { + element.dispatchEvent(new PointerEvent(type, { bubbles: true, ...init })) + } + + it('emits drag-start on pointerdown', async () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 }) + await w.vm.$nextTick() + expect(w.emitted('drag-start')).toBeTruthy() + }) + + it('emits drag-end on pointerup', async () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + fire(el, 'pointerdown', { pointerId: 1, clientX: 200, clientY: 300 }) + fire(el, 'pointerup', { pointerId: 1, clientX: 200, clientY: 300 }) + await w.vm.$nextTick() + expect(w.emitted('drag-end')).toBeTruthy() + }) + + it('emits discard when released in left zone (x < 7% viewport)', async () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + // JSDOM window.innerWidth defaults to 1024; 7% = 71.7px + fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + fire(el, 'pointermove', { pointerId: 1, clientX: 30, clientY: 300 }) + fire(el, 'pointerup', { pointerId: 1, clientX: 30, clientY: 300 }) + await w.vm.$nextTick() + expect(w.emitted('discard')).toBeTruthy() + }) + + it('emits skip when released in right zone (x > 93% viewport)', async () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + // JSDOM window.innerWidth defaults to 1024; 93% = 952px + fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + fire(el, 'pointermove', { pointerId: 1, clientX: 1000, clientY: 300 }) + fire(el, 'pointerup', { pointerId: 1, clientX: 1000, clientY: 300 }) + await w.vm.$nextTick() + expect(w.emitted('skip')).toBeTruthy() + }) + + it('does not emit action on pointerup without movement past zone', async () => { + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + fire(el, 'pointerup', { pointerId: 1, clientX: 512, clientY: 300 }) + await w.vm.$nextTick() + expect(w.emitted('discard')).toBeFalsy() + expect(w.emitted('skip')).toBeFalsy() + expect(w.emitted('label')).toBeFalsy() + }) + + // Fling tests — mock performance.now() to control timestamps between events + it('emits discard on fast leftward fling (option B: speed + alignment)', async () => { + let mockTime = 0 + vi.spyOn(performance, 'now').mockImplementation(() => mockTime) + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 400, clientY: 310 }) + mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 288, clientY: 320 }) + // vx = (288-400)/(50-30)*1000 = -5600 px/s, vy ≈ 500 px/s + // speed ≈ 5622 px/s > 600, alignment = 5600/5622 ≈ 0.996 > 0.707 ✓ + fire(el, 'pointerup', { pointerId: 1, clientX: 288, clientY: 320 }) + await w.vm.$nextTick() + expect(w.emitted('discard')).toBeTruthy() + vi.restoreAllMocks() + }) + + it('emits skip on fast rightward fling', async () => { + let mockTime = 0 + vi.spyOn(performance, 'now').mockImplementation(() => mockTime) + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 624, clientY: 310 }) + mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 736, clientY: 320 }) + // vx = (736-624)/(50-30)*1000 = 5600 px/s — mirror of discard case + fire(el, 'pointerup', { pointerId: 1, clientX: 736, clientY: 320 }) + await w.vm.$nextTick() + expect(w.emitted('skip')).toBeTruthy() + vi.restoreAllMocks() + }) + + it('does not fling on diagonal swipe (alignment < 0.707)', async () => { + let mockTime = 0 + vi.spyOn(performance, 'now').mockImplementation(() => mockTime) + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + mockTime = 30; fire(el, 'pointermove', { pointerId: 1, clientX: 400, clientY: 150 }) + mockTime = 50; fire(el, 'pointermove', { pointerId: 1, clientX: 288, clientY: 0 }) + // vx = -5600 px/s, vy = -7500 px/s, speed ≈ 9356 px/s + // alignment = 5600/9356 ≈ 0.598 < 0.707 — too diagonal ✓ + fire(el, 'pointerup', { pointerId: 1, clientX: 288, clientY: 0 }) + await w.vm.$nextTick() + expect(w.emitted('discard')).toBeFalsy() + expect(w.emitted('skip')).toBeFalsy() + vi.restoreAllMocks() + }) + + it('does not fling on slow movement (speed < threshold)', async () => { + let mockTime = 0 + vi.spyOn(performance, 'now').mockImplementation(() => mockTime) + const w = mount(EmailCardStack, { props: { item, isBucketMode: false } }) + const el = w.find('.card-wrapper').element + mockPointerCapture(el) + mockTime = 0; fire(el, 'pointerdown', { pointerId: 1, clientX: 512, clientY: 300 }) + mockTime = 100; fire(el, 'pointermove', { pointerId: 1, clientX: 480, clientY: 300 }) + mockTime = 200; fire(el, 'pointermove', { pointerId: 1, clientX: 450, clientY: 300 }) + // vx = (450-480)/(200-100)*1000 = -300 px/s < 600 threshold + fire(el, 'pointerup', { pointerId: 1, clientX: 450, clientY: 300 }) + await w.vm.$nextTick() + expect(w.emitted('discard')).toBeFalsy() + expect(w.emitted('skip')).toBeFalsy() + vi.restoreAllMocks() + }) +}) diff --git a/web/src/components/EmailCardStack.vue b/web/src/components/EmailCardStack.vue new file mode 100644 index 0000000..2abc6c3 --- /dev/null +++ b/web/src/components/EmailCardStack.vue @@ -0,0 +1,272 @@ + + + + + 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/components/LabelBucketGrid.test.ts b/web/src/components/LabelBucketGrid.test.ts new file mode 100644 index 0000000..fb09e8c --- /dev/null +++ b/web/src/components/LabelBucketGrid.test.ts @@ -0,0 +1,52 @@ +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('🗓️') + }) + + it('marks button as drop-target when hoveredBucket matches label name', () => { + const w = mount(LabelBucketGrid, { + props: { labels, isBucketMode: true, hoveredBucket: 'interview_scheduled' }, + }) + const btns = w.findAll('[data-testid="label-btn"]') + expect(btns[0].classes()).toContain('is-drop-target') + expect(btns[1].classes()).not.toContain('is-drop-target') + }) + + it('no button marked as drop-target when hoveredBucket is null', () => { + const w = mount(LabelBucketGrid, { + props: { labels, isBucketMode: false, hoveredBucket: null }, + }) + w.findAll('[data-testid="label-btn"]').forEach(btn => { + expect(btn.classes()).not.toContain('is-drop-target') + }) + }) +}) diff --git a/web/src/components/LabelBucketGrid.vue b/web/src/components/LabelBucketGrid.vue new file mode 100644 index 0000000..ebd1500 --- /dev/null +++ b/web/src/components/LabelBucketGrid.vue @@ -0,0 +1,132 @@ + + + + + diff --git a/web/src/components/SftCard.test.ts b/web/src/components/SftCard.test.ts new file mode 100644 index 0000000..6834987 --- /dev/null +++ b/web/src/components/SftCard.test.ts @@ -0,0 +1,98 @@ +import { mount } from '@vue/test-utils' +import SftCard from './SftCard.vue' +import type { SftQueueItem } from '../stores/sft' +import { describe, it, expect } from 'vitest' + +const LOW_QUALITY_ITEM: SftQueueItem = { + id: 'abc', source: 'cf-orch-benchmark', benchmark_run_id: 'run1', + timestamp: '2026-04-07T10:00:00Z', status: 'needs_review', + prompt_messages: [ + { role: 'system', content: 'You are a coding assistant.' }, + { role: 'user', content: 'Write a Python add function.' }, + ], + model_response: 'def add(a, b): return a - b', + corrected_response: null, quality_score: 0.2, + failure_reason: 'pattern_match: 0/2 matched', + task_id: 'code-fn', task_type: 'code', task_name: 'Code: Write a function', + model_id: 'Qwen/Qwen2.5-3B', model_name: 'Qwen2.5-3B', + node_id: 'heimdall', gpu_id: 0, tokens_per_sec: 38.4, +} + +const MID_QUALITY_ITEM: SftQueueItem = { ...LOW_QUALITY_ITEM, id: 'mid', quality_score: 0.55 } +const HIGH_QUALITY_ITEM: SftQueueItem = { ...LOW_QUALITY_ITEM, id: 'hi', quality_score: 0.72 } + +describe('SftCard', () => { + it('renders model name chip', () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } }) + expect(w.text()).toContain('Qwen2.5-3B') + }) + + it('renders task type chip', () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } }) + expect(w.text()).toContain('code') + }) + + it('renders failure reason', () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } }) + expect(w.text()).toContain('pattern_match: 0/2 matched') + }) + + it('renders model response', () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } }) + expect(w.text()).toContain('def add(a, b): return a - b') + }) + + it('quality chip shows numeric value for low quality', () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } }) + expect(w.text()).toContain('0.20') + }) + + it('quality chip has low-quality class when score < 0.4', () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } }) + expect(w.find('[data-testid="quality-chip"]').classes()).toContain('quality-low') + }) + + it('quality chip has mid-quality class when score is 0.4 to <0.7', () => { + const w = mount(SftCard, { props: { item: MID_QUALITY_ITEM } }) + expect(w.find('[data-testid="quality-chip"]').classes()).toContain('quality-mid') + }) + + it('quality chip has acceptable class when score >= 0.7', () => { + const w = mount(SftCard, { props: { item: HIGH_QUALITY_ITEM } }) + expect(w.find('[data-testid="quality-chip"]').classes()).toContain('quality-ok') + }) + + it('clicking Correct button emits correct', async () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } }) + await w.find('[data-testid="correct-btn"]').trigger('click') + expect(w.emitted('correct')).toBeTruthy() + }) + + it('clicking Discard button emits discard', async () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } }) + await w.find('[data-testid="discard-btn"]').trigger('click') + expect(w.emitted('discard')).toBeTruthy() + }) + + it('clicking Flag Model button emits flag', async () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } }) + await w.find('[data-testid="flag-btn"]').trigger('click') + expect(w.emitted('flag')).toBeTruthy() + }) + + it('correction area hidden initially', () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM } }) + expect(w.find('[data-testid="correction-area"]').exists()).toBe(false) + }) + + it('correction area shown when correcting prop is true', () => { + const w = mount(SftCard, { props: { item: LOW_QUALITY_ITEM, correcting: true } }) + expect(w.find('[data-testid="correction-area"]').exists()).toBe(true) + }) + + it('renders nothing for failure reason when null', () => { + const item = { ...LOW_QUALITY_ITEM, failure_reason: null } + const w = mount(SftCard, { props: { item } }) + expect(w.find('.failure-reason').exists()).toBe(false) + }) +}) diff --git a/web/src/components/SftCard.vue b/web/src/components/SftCard.vue new file mode 100644 index 0000000..f0ac2d1 --- /dev/null +++ b/web/src/components/SftCard.vue @@ -0,0 +1,246 @@ + + + + + diff --git a/web/src/components/SftCorrectionArea.test.ts b/web/src/components/SftCorrectionArea.test.ts new file mode 100644 index 0000000..8ecc409 --- /dev/null +++ b/web/src/components/SftCorrectionArea.test.ts @@ -0,0 +1,68 @@ +import { mount } from '@vue/test-utils' +import SftCorrectionArea from './SftCorrectionArea.vue' +import { describe, it, expect } from 'vitest' + +describe('SftCorrectionArea', () => { + it('renders a textarea', () => { + const w = mount(SftCorrectionArea) + expect(w.find('textarea').exists()).toBe(true) + }) + + it('submit button is disabled when textarea is empty', () => { + const w = mount(SftCorrectionArea) + const btn = w.find('[data-testid="submit-btn"]') + expect((btn.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('submit button is disabled when textarea is whitespace only', async () => { + const w = mount(SftCorrectionArea) + await w.find('textarea').setValue(' ') + const btn = w.find('[data-testid="submit-btn"]') + expect((btn.element as HTMLButtonElement).disabled).toBe(true) + }) + + it('submit button is enabled when textarea has content', async () => { + const w = mount(SftCorrectionArea) + await w.find('textarea').setValue('def add(a, b): return a + b') + const btn = w.find('[data-testid="submit-btn"]') + expect((btn.element as HTMLButtonElement).disabled).toBe(false) + }) + + it('clicking submit emits submit with trimmed text', async () => { + const w = mount(SftCorrectionArea) + await w.find('textarea').setValue(' def add(a, b): return a + b ') + await w.find('[data-testid="submit-btn"]').trigger('click') + expect(w.emitted('submit')?.[0]).toEqual(['def add(a, b): return a + b']) + }) + + it('clicking cancel emits cancel', async () => { + const w = mount(SftCorrectionArea) + await w.find('[data-testid="cancel-btn"]').trigger('click') + expect(w.emitted('cancel')).toBeTruthy() + }) + + it('Escape key emits cancel', async () => { + const w = mount(SftCorrectionArea) + await w.find('textarea').trigger('keydown', { key: 'Escape' }) + expect(w.emitted('cancel')).toBeTruthy() + }) + + it('Ctrl+Enter emits submit when text is non-empty', async () => { + const w = mount(SftCorrectionArea) + await w.find('textarea').setValue('correct answer') + await w.find('textarea').trigger('keydown', { key: 'Enter', ctrlKey: true }) + expect(w.emitted('submit')?.[0]).toEqual(['correct answer']) + }) + + it('Ctrl+Enter does not emit submit when text is empty', async () => { + const w = mount(SftCorrectionArea) + await w.find('textarea').trigger('keydown', { key: 'Enter', ctrlKey: true }) + expect(w.emitted('submit')).toBeFalsy() + }) + + it('omits aria-describedby when describedBy prop is not provided', () => { + const w = mount(SftCorrectionArea) + const textarea = w.find('textarea') + expect(textarea.attributes('aria-describedby')).toBeUndefined() + }) +}) diff --git a/web/src/components/SftCorrectionArea.vue b/web/src/components/SftCorrectionArea.vue new file mode 100644 index 0000000..44dfd2e --- /dev/null +++ b/web/src/components/SftCorrectionArea.vue @@ -0,0 +1,130 @@ +