From 3e978c59485c71c2dfb1ce19af1c4049d433b26b Mon Sep 17 00:00:00 2001 From: Kunthawat Greethong Date: Sun, 23 Aug 2026 07:41:31 +0700 Subject: [PATCH] [verified] build tourism signal dashboard --- .gitignore | 9 + README.md | 67 ++ backend/app/__init__.py | 182 ++++ backend/app/paper.py | 41 + backend/app/tourism.py | 96 ++ backend/fixtures/tourism_snapshot.json | 44 + backend/requirements.txt | 1 + backend/run.py | 17 + backend/tests/test_api.py | 87 ++ backend/tests/test_tourism.py | 65 ++ docs/HANDOFF.md | 47 + docs/engineering-log.md | 32 + frontend/index.html | 13 + frontend/package-lock.json | 1382 ++++++++++++++++++++++++ frontend/package.json | 18 + frontend/src/App.vue | 307 ++++++ frontend/src/main.js | 5 + frontend/src/style.css | 172 +++ frontend/vite.config.js | 12 + 19 files changed, 2597 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/app/__init__.py create mode 100644 backend/app/paper.py create mode 100644 backend/app/tourism.py create mode 100644 backend/fixtures/tourism_snapshot.json create mode 100644 backend/requirements.txt create mode 100644 backend/run.py create mode 100644 backend/tests/test_api.py create mode 100644 backend/tests/test_tourism.py create mode 100644 docs/HANDOFF.md create mode 100644 docs/engineering-log.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/main.js create mode 100644 frontend/src/style.css create mode 100644 frontend/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16ae5d4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +.venv/ +.env +.DS_Store +backend/.pytest_cache/ +frontend/node_modules/ +frontend/dist/ +reports/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c01d67d --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# SET50 Alternative Data Platform + +Tourism-first vertical slice for a deterministic SET50 alternative-data research system. + +Current scope: + +```text +fixture observation + → Tourism Pulse surprise + → versioned exposure score + → ranked target weights + → English dashboard + → internal paper ledger +``` + +No external webhook receiver and no live MT5 execution are enabled. + +## Run the backend + +```bash +python -m venv .venv +.venv/bin/pip install -r backend/requirements.txt +PAPER_WRITE_TOKEN=local-paper-token PYTHONPATH=backend .venv/bin/python backend/run.py +``` + +Health check: + +```bash +curl http://127.0.0.1:5000/api/v1/health +``` + +## Run the dashboard + +In a second terminal: + +```bash +cd frontend +npm install +npm run dev -- --host 127.0.0.1 +``` + +Open `http://127.0.0.1:5173`. + +The frontend reads the live API through Vite's `/api` proxy. Paper writes require the operator to unlock an HttpOnly browser session using the backend `PAPER_WRITE_TOKEN`; the token is never embedded in the frontend bundle. The paper-entry action records an assumed fill in the in-memory paper ledger only. + +For HTTPS/non-local deployment, set `PAPER_COOKIE_SECURE=1`. The M0 session store is intentionally in-memory and single-process; use a shared session store before running multiple workers or replicas. + +## Tests and build + +```bash +PYTHONPATH=backend .venv/bin/python -m unittest discover -s backend/tests -v +cd frontend && npm run build +``` + +## Current M0 boundary + +- English UI and analysis vocabulary +- Research mode and paper mode only +- Tourism Pulse fixture adapter +- Data lineage: source, publication time, retrieval time, vintage +- Deterministic surprise × exposure × confidence score +- Paper ledger endpoint +- No LLM call yet; the deterministic result is the source of truth +- No webhook receiver yet +- No MT5 bridge yet + +The next implementation step is replacing the fixture with one replayable Tourism source adapter while preserving the same snapshot contract. diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..67372d9 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,182 @@ +"""Flask application factory for the SET50 alternative-data platform.""" + +from __future__ import annotations + +import hmac +import json +import os +import secrets +import time +from pathlib import Path +from typing import Any + +from flask import Flask, jsonify, request + +from .paper import PaperLedger +from .tourism import compute_tourism_signal + +APP_VERSION = "0.1.0" + + +def _load_default_snapshot() -> dict[str, Any]: + fixture_path = Path(__file__).resolve().parents[1] / "fixtures" / "tourism_snapshot.json" + return json.loads(fixture_path.read_text(encoding="utf-8")) + + +def _signal_summary(result: dict[str, Any]) -> dict[str, int]: + signals = result["signals"] + return { + "total": len(signals), + "long": sum(item["side"] == "LONG" for item in signals), + "short": sum(item["side"] == "SHORT" for item in signals), + "neutral": sum(item["side"] == "NEUTRAL" for item in signals), + } + + +def create_app(config: dict[str, Any] | None = None) -> Flask: + app = Flask(__name__) + app.config.from_mapping( + TESTING=False, + MODE=os.getenv("APP_MODE", "research"), + PAPER_WRITE_TOKEN=os.getenv("PAPER_WRITE_TOKEN", ""), + PAPER_COOKIE_SECURE=os.getenv("PAPER_COOKIE_SECURE", "0") == "1", + PAPER_SESSION_SECONDS=int(os.getenv("PAPER_SESSION_SECONDS", "3600")), + SNAPSHOT=_load_default_snapshot(), + ) + if config: + app.config.update(config) + + result = compute_tourism_signal(app.config["SNAPSHOT"]) + ledger = PaperLedger() + paper_sessions: dict[str, float] = {} + allowed_symbols = {item["symbol"] for item in result["signals"]} + app.extensions["tourism_result"] = result + app.extensions["paper_ledger"] = ledger + app.extensions["paper_sessions"] = paper_sessions + app.extensions["allowed_symbols"] = allowed_symbols + + @app.after_request + def add_cors_headers(response): + origin = request.headers.get("Origin") + allowed_origin = os.getenv("CORS_ORIGIN", "http://localhost:5173") + if origin == allowed_origin: + response.headers["Access-Control-Allow-Origin"] = origin + response.headers["Vary"] = "Origin" + response.headers["Access-Control-Allow-Headers"] = "Content-Type" + response.headers["Access-Control-Allow-Credentials"] = "true" + response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS" + return response + + def _paper_write_authorized() -> tuple[bool, tuple[dict[str, str], int] | None]: + configured = str(app.config.get("PAPER_WRITE_TOKEN", "")) + if not configured: + return False, ({"error": "paper writes disabled: PAPER_WRITE_TOKEN is not configured"}, 503) + sessions: dict[str, float] = app.extensions["paper_sessions"] + now = time.time() + expired = [session_id for session_id, expiry in sessions.items() if expiry <= now] + for session_id in expired: + sessions.pop(session_id, None) + session_id = request.cookies.get("paper_session", "") + if session_id and session_id in sessions and sessions[session_id] > now: + return True, None + return False, ({"error": "paper session required"}, 401) + + @app.post("/api/v1/auth/paper") + def authenticate_paper(): + configured = str(app.config.get("PAPER_WRITE_TOKEN", "")) + if not configured: + return jsonify({"error": "paper writes disabled: PAPER_WRITE_TOKEN is not configured"}), 503 + payload = request.get_json(silent=True) + candidate = str(payload.get("token", "")) if isinstance(payload, dict) else "" + if not hmac.compare_digest(candidate, configured): + return jsonify({"error": "invalid paper token"}), 401 + session_id = secrets.token_urlsafe(32) + app.extensions["paper_sessions"][session_id] = time.time() + int(app.config["PAPER_SESSION_SECONDS"]) + response = jsonify({"authenticated": True, "mode": "paper"}) + response.set_cookie( + "paper_session", + session_id, + max_age=int(app.config["PAPER_SESSION_SECONDS"]), + httponly=True, + secure=bool(app.config["PAPER_COOKIE_SECURE"]), + samesite="Strict", + ) + return response + + @app.get("/api/v1/auth/paper") + def paper_session_status(): + authorized, _ = _paper_write_authorized() + return jsonify({"authenticated": authorized}) + + @app.get("/api/v1/health") + def health(): + return jsonify({"status": "ok", "mode": app.config["MODE"], "version": APP_VERSION}) + + @app.get("/api/v1/dashboard/summary") + def dashboard_summary(): + current = app.extensions["tourism_result"] + entries = app.extensions["paper_ledger"].entries() + return jsonify( + { + "as_of": current["as_of"], + "theme": current["theme"], + "strategy_version": current["strategy_version"], + "theme_surprise": current["theme_surprise"], + "data_health": { + "status": current["data_quality"], + "source_id": current["source"].get("source_id"), + "source_url": current["source"].get("source_url"), + "published_at": current["source"].get("published_at"), + "retrieved_at": current["source"].get("retrieved_at"), + "vintage_id": current["source"].get("vintage_id"), + }, + "signal_summary": _signal_summary(current), + "top_signals": current["signals"][:5], + "paper_ledger": {"entries": len(entries), "mode": "paper"}, + } + ) + + @app.get("/api/v1/factors/tourism/observations") + def tourism_observations(): + current = app.extensions["tourism_result"] + return jsonify( + { + "theme": current["theme"], + "as_of": current["as_of"], + "theme_surprise": current["theme_surprise"], + "source": current["source"], + "observations": current["observations"], + } + ) + + @app.get("/api/v1/signals") + def signals(): + current = app.extensions["tourism_result"] + return jsonify( + { + "theme": current["theme"], + "as_of": current["as_of"], + "strategy_version": current["strategy_version"], + "signals": current["signals"], + } + ) + + @app.route("/api/v1/paper/ledger", methods=["GET", "POST"]) + def paper_ledger(): + current_ledger = app.extensions["paper_ledger"] + if request.method == "GET": + return jsonify({"mode": "paper", "entries": current_ledger.entries()}) + authorized, error = _paper_write_authorized() + if not authorized: + body, status = error + return jsonify(body), status + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return jsonify({"error": "JSON object required"}), 400 + try: + entry = current_ledger.record(payload, app.extensions["allowed_symbols"]) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify({"mode": "paper", "entry": entry}), 201 + + return app diff --git a/backend/app/paper.py b/backend/app/paper.py new file mode 100644 index 0000000..e993ede --- /dev/null +++ b/backend/app/paper.py @@ -0,0 +1,41 @@ +"""Paper portfolio ledger for the first vertical slice.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from math import isfinite +from uuid import uuid4 + + +class PaperLedger: + def __init__(self) -> None: + self._entries: list[dict] = [] + + def record(self, payload: dict, allowed_symbols: set[str]) -> dict: + symbol = str(payload.get("symbol", "")).strip().upper() + if symbol not in allowed_symbols: + raise ValueError(f"unknown signal symbol: {symbol}") + try: + target_weight = float(payload["target_weight"]) + assumed_price = float(payload["assumed_price"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("target_weight and assumed_price must be numeric") from exc + if not isfinite(target_weight) or not isfinite(assumed_price): + raise ValueError("target_weight and assumed_price must be finite") + if not -1.0 <= target_weight <= 1.0: + raise ValueError("target_weight must be between -1 and 1") + if assumed_price <= 0: + raise ValueError("assumed_price must be positive") + entry = { + "entry_id": f"paper_{uuid4().hex}", + "created_at": datetime.now(timezone.utc).isoformat(), + "symbol": symbol, + "target_weight": target_weight, + "assumed_price": assumed_price, + "status": "PAPER_RECORDED", + } + self._entries.append(entry) + return entry + + def entries(self) -> list[dict]: + return list(self._entries) diff --git a/backend/app/tourism.py b/backend/app/tourism.py new file mode 100644 index 0000000..de3397c --- /dev/null +++ b/backend/app/tourism.py @@ -0,0 +1,96 @@ +"""Tourism Pulse deterministic signal engine.""" + +from __future__ import annotations + +import math +from statistics import fmean +from typing import Any + + +def _validate_observation(observation: dict[str, Any]) -> None: + required = {"metric_key", "value", "expected", "scale", "unit"} + missing = required.difference(observation) + if missing: + raise ValueError(f"observation missing fields: {sorted(missing)}") + scale = float(observation["scale"]) + if not math.isfinite(scale) or scale <= 0: + raise ValueError(f"observation scale must be positive: {observation['metric_key']}") + for key in ("value", "expected"): + value = float(observation[key]) + if not math.isfinite(value): + raise ValueError(f"observation {key} must be finite: {observation['metric_key']}") + + +def _data_quality(snapshot: dict[str, Any], observations: list[dict[str, Any]]) -> str: + source = snapshot.get("source", {}) + source_id = str(source.get("source_id", "")) + if source_id.startswith("fixture"): + return "fixture" + required_source = {"source_id", "source_url", "published_at", "retrieved_at", "vintage_id"} + if not required_source.issubset(source): + return "low" + if not observations: + return "low" + return "high" + + +def compute_tourism_signal(snapshot: dict[str, Any]) -> dict[str, Any]: + """Compute a replayable Tourism Pulse signal from a frozen snapshot.""" + + observations = list(snapshot.get("observations", [])) + if not observations: + raise ValueError("tourism snapshot must contain observations") + for observation in observations: + _validate_observation(observation) + + standardized = [] + observation_output = [] + for observation in observations: + surprise = (float(observation["value"]) - float(observation["expected"])) / float(observation["scale"]) + surprise = round(surprise, 8) + standardized.append(surprise) + observation_output.append({**observation, "surprise": surprise}) + theme_surprise = round(fmean(standardized), 8) + + signal_rows = [] + for exposure in snapshot.get("exposures", []): + symbol = str(exposure.get("symbol", "")).strip().upper() + coefficient = float(exposure.get("coefficient", 0)) + confidence = float(exposure.get("confidence", 1.0)) + if not symbol: + raise ValueError("exposure symbol must not be empty") + if not math.isfinite(coefficient) or not math.isfinite(confidence): + raise ValueError(f"exposure must be finite: {symbol}") + if confidence < 0 or confidence > 1: + raise ValueError(f"exposure confidence must be between 0 and 1: {symbol}") + score = round(theme_surprise * coefficient * confidence, 8) + evidence = str(exposure.get("evidence", "exposure")).strip().upper().replace(" ", "_") + side = "LONG" if score > 0.15 else "SHORT" if score < -0.15 else "NEUTRAL" + signal_rows.append( + { + "symbol": symbol, + "score": score, + "coefficient": coefficient, + "confidence": confidence, + "side": side, + "reason_codes": ["TOURISM_SURPRISE", evidence], + "evidence": exposure.get("evidence", ""), + } + ) + + signal_rows.sort(key=lambda row: (-row["score"], row["symbol"])) + total_abs = sum(abs(row["score"]) for row in signal_rows) + for rank, row in enumerate(signal_rows, start=1): + row["rank"] = rank + row["target_weight"] = round((row["score"] / total_abs) * 0.5, 8) if total_abs else 0.0 + + return { + "theme": "tourism", + "as_of": snapshot.get("as_of"), + "theme_surprise": theme_surprise, + "data_quality": _data_quality(snapshot, observations), + "source": snapshot.get("source", {}), + "observations": observation_output, + "signals": signal_rows, + "strategy_version": snapshot.get("strategy_version", "tourism-v0.1"), + } diff --git a/backend/fixtures/tourism_snapshot.json b/backend/fixtures/tourism_snapshot.json new file mode 100644 index 0000000..9345a58 --- /dev/null +++ b/backend/fixtures/tourism_snapshot.json @@ -0,0 +1,44 @@ +{ + "as_of": "2026-08-21", + "strategy_version": "tourism-v0.1", + "source": { + "source_id": "fixture.tourism_pulse", + "source_url": "https://example.invalid/fixture/tourism-pulse", + "published_at": "2026-08-21T08:00:00Z", + "retrieved_at": "2026-08-21T08:05:00Z", + "vintage_id": "fixture-tourism-2026-08-21-v1" + }, + "observations": [ + { + "metric_key": "foreign_arrivals_yoy", + "value": 12.5, + "expected": 8.0, + "scale": 2.5, + "unit": "percent" + }, + { + "metric_key": "airport_passengers_yoy", + "value": 9.0, + "expected": 6.0, + "scale": 2.0, + "unit": "percent" + }, + { + "metric_key": "hotel_occupancy_change", + "value": 3.2, + "expected": 1.0, + "scale": 1.5, + "unit": "percentage_points" + } + ], + "exposures": [ + {"symbol": "AOT", "coefficient": 1.00, "confidence": 0.95, "evidence": "airport"}, + {"symbol": "MINT", "coefficient": 0.80, "confidence": 0.80, "evidence": "hotel"}, + {"symbol": "AWC", "coefficient": 0.70, "confidence": 0.75, "evidence": "hotel"}, + {"symbol": "CPN", "coefficient": 0.45, "confidence": 0.65, "evidence": "retail"}, + {"symbol": "CPALL", "coefficient": 0.35, "confidence": 0.60, "evidence": "consumer"}, + {"symbol": "CRC", "coefficient": 0.35, "confidence": 0.60, "evidence": "consumer"}, + {"symbol": "BEM", "coefficient": 0.20, "confidence": 0.50, "evidence": "transit"}, + {"symbol": "PTT", "coefficient": -0.15, "confidence": 0.35, "evidence": "control"} + ] +} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..05cf542 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1 @@ +Flask>=3.1,<4 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 0000000..7133f0f --- /dev/null +++ b/backend/run.py @@ -0,0 +1,17 @@ +"""Run the local Flask API.""" + +from __future__ import annotations + +import os + +from app import create_app + +app = create_app() + + +if __name__ == "__main__": + app.run( + host=os.getenv("HOST", "127.0.0.1"), + port=int(os.getenv("PORT", "5000")), + debug=False, + ) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py new file mode 100644 index 0000000..9f33fcd --- /dev/null +++ b/backend/tests/test_api.py @@ -0,0 +1,87 @@ +import unittest + +from app import create_app + + +class ApiTests(unittest.TestCase): + def setUp(self): + self.snapshot = { + "as_of": "2026-08-21", + "source": { + "source_id": "fixture.tourism", + "source_url": "https://example.invalid/tourism", + "published_at": "2026-08-21T08:00:00Z", + "retrieved_at": "2026-08-21T08:05:00Z", + "vintage_id": "fixture-1", + }, + "observations": [ + {"metric_key": "arrivals_yoy", "value": 12, "expected": 8, "scale": 2, "unit": "percent"}, + ], + "exposures": [ + {"symbol": "AOT", "coefficient": 1.0, "confidence": 0.95, "evidence": "airport"}, + {"symbol": "PTT", "coefficient": -0.2, "confidence": 0.60, "evidence": "control"}, + ], + } + self.app = create_app({"TESTING": True, "SNAPSHOT": self.snapshot, "PAPER_WRITE_TOKEN": "test-token"}) + self.client = self.app.test_client() + + def _login_paper(self): + response = self.client.post("/api/v1/auth/paper", json={"token": "test-token"}) + self.assertEqual(response.status_code, 200) + + def test_health_reports_research_mode(self): + response = self.client.get("/api/v1/health") + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_json()["mode"], "research") + + def test_summary_contains_lineage_and_signal_counts(self): + response = self.client.get("/api/v1/dashboard/summary") + body = response.get_json() + self.assertEqual(response.status_code, 200) + self.assertEqual(body["data_health"]["vintage_id"], "fixture-1") + self.assertEqual(body["data_health"]["status"], "fixture") + self.assertEqual(body["signal_summary"]["total"], 2) + self.assertEqual(body["signal_summary"]["long"], 1) + self.assertEqual(body["signal_summary"]["short"], 1) + + def test_paper_ledger_requires_token(self): + response = self.client.post( + "/api/v1/paper/ledger", + json={"symbol": "AOT", "target_weight": 0.1, "assumed_price": 10}, + ) + self.assertEqual(response.status_code, 401) + + def test_paper_ledger_rejects_invalid_paper_token(self): + response = self.client.post("/api/v1/auth/paper", json={"token": "wrong-token"}) + self.assertEqual(response.status_code, 401) + + def test_paper_ledger_rejects_non_finite_price(self): + self._login_paper() + response = self.client.post( + "/api/v1/paper/ledger", + json={"symbol": "AOT", "target_weight": 0.1, "assumed_price": "NaN"}, + ) + self.assertEqual(response.status_code, 400) + + def test_paper_ledger_rejects_unknown_symbol(self): + self._login_paper() + response = self.client.post( + "/api/v1/paper/ledger", + json={"symbol": "UNKNOWN", "target_weight": 0.1, "assumed_price": 10}, + ) + self.assertEqual(response.status_code, 400) + + def test_paper_ledger_records_valid_entry(self): + self._login_paper() + response = self.client.post( + "/api/v1/paper/ledger", + json={"symbol": "AOT", "target_weight": 0.1, "assumed_price": 60}, + ) + self.assertEqual(response.status_code, 201) + self.assertEqual(response.get_json()["entry"]["symbol"], "AOT") + ledger = self.client.get("/api/v1/paper/ledger").get_json()["entries"] + self.assertEqual(len(ledger), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/backend/tests/test_tourism.py b/backend/tests/test_tourism.py new file mode 100644 index 0000000..171a801 --- /dev/null +++ b/backend/tests/test_tourism.py @@ -0,0 +1,65 @@ +import unittest + +from app.tourism import compute_tourism_signal + + +class TourismSignalTests(unittest.TestCase): + def test_theme_surprise_is_mean_of_standardized_observations(self): + snapshot = { + "as_of": "2026-08-21", + "source": { + "source_id": "fixture.tourism", + "source_url": "https://example.invalid/tourism", + "published_at": "2026-08-21T08:00:00Z", + "retrieved_at": "2026-08-21T08:05:00Z", + "vintage_id": "fixture-1", + }, + "observations": [ + {"metric_key": "arrivals_yoy", "value": 12, "expected": 8, "scale": 2, "unit": "percent"}, + {"metric_key": "airport_passengers_yoy", "value": 8, "expected": 6, "scale": 2, "unit": "percent"}, + ], + "exposures": [], + } + result = compute_tourism_signal(snapshot) + self.assertAlmostEqual(result["theme_surprise"], 1.5) + self.assertEqual(result["data_quality"], "fixture") + + def test_exposure_score_and_rank_are_deterministic(self): + snapshot = { + "as_of": "2026-08-21", + "source": { + "source_id": "fixture.tourism", + "source_url": "https://example.invalid/tourism", + "published_at": "2026-08-21T08:00:00Z", + "retrieved_at": "2026-08-21T08:05:00Z", + "vintage_id": "fixture-1", + }, + "observations": [ + {"metric_key": "arrivals_yoy", "value": 12, "expected": 8, "scale": 2, "unit": "percent"}, + ], + "exposures": [ + {"symbol": "AOT", "coefficient": 1.0, "confidence": 0.95, "evidence": "airport"}, + {"symbol": "MINT", "coefficient": 0.6, "confidence": 0.80, "evidence": "hotel"}, + {"symbol": "PTT", "coefficient": -0.2, "confidence": 0.60, "evidence": "control"}, + ], + } + result = compute_tourism_signal(snapshot) + self.assertEqual([item["symbol"] for item in result["signals"]], ["AOT", "MINT", "PTT"]) + self.assertEqual(result["signals"][0]["side"], "LONG") + self.assertEqual(result["signals"][-1]["side"], "SHORT") + self.assertGreater(result["signals"][0]["score"], result["signals"][1]["score"]) + self.assertEqual(result["signals"][0]["reason_codes"], ["TOURISM_SURPRISE", "AIRPORT"]) + + def test_missing_scale_is_rejected(self): + snapshot = { + "as_of": "2026-08-21", + "source": {}, + "observations": [{"metric_key": "arrivals_yoy", "value": 10, "expected": 8}], + "exposures": [], + } + with self.assertRaises(ValueError): + compute_tourism_signal(snapshot) + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md new file mode 100644 index 0000000..7d5c504 --- /dev/null +++ b/docs/HANDOFF.md @@ -0,0 +1,47 @@ +# Handoff — Tourism Vertical Slice + +## Project + +- Path: `/Users/kunthawat/Gitea/set50-alternative-data-platform` +- Mode: research + paper only +- Frontend: Vue 3 + Vite +- Backend: Flask +- Current data: deterministic fixture with lineage metadata + +## Completed + +- Tourism snapshot schema with source, publication, retrieval and vintage fields. +- Deterministic Tourism Pulse score: standardized surprise × exposure × confidence. +- Ranked target weights and LONG/SHORT/NEUTRAL classification. +- Flask endpoints for health, summary, observations, signals and paper ledger. +- English dashboard with live API data, lineage panel, signal table and paper-entry form. +- No external webhook or MT5 integration. + +## Verified commands + +```text +PYTHONPATH=backend .venv/bin/python -m unittest discover -s backend/tests -v +Ran 10 tests ... OK + +Paper writes use a server-side token exchange and HttpOnly `paper_session` cookie; the token is not embedded in the frontend bundle. + +npm run build +Vite build completed successfully. + +GET /api/v1/health +{"mode":"research","status":"ok","version":"0.1.0"} + +POST /api/v1/paper/ledger + GET /api/v1/paper/ledger +PAPER_RECORDED and readback verified. + +Independent review +PASSED — no concrete security or logic blockers. +``` + +## Known limitation + +The data adapter is a fixture. It demonstrates the contract and calculation, not live tourism data quality. Browser visual screenshot verification was blocked by a Chrome remote-debugging permission prompt; served HTML, API response and frontend build were verified instead. + +## Next action + +Implement one replayable Tourism source adapter without changing the snapshot contract. Add parser fixture tests, publication timestamp handling, raw snapshot hash, and a data-health failure state before adding LLM analysis. diff --git a/docs/engineering-log.md b/docs/engineering-log.md new file mode 100644 index 0000000..21eede5 --- /dev/null +++ b/docs/engineering-log.md @@ -0,0 +1,32 @@ +# Engineering Log — SET50 Alternative Data Platform + +## Current status + +| Milestone | Status | Evidence | Next action | +|---|---|---|---| +| M0 repo foundation | complete | Flask API, Vue/Vite shell | replace fixture with source adapter | +| Tourism deterministic signal | complete | 7 backend tests pass | add replayable real source | +| Internal paper ledger | complete | POST/readback through live API | persist in PostgreSQL later | +| Dashboard | complete | Vite build + live HTML/API checks | visual browser capture after permission is available | +| LLM analysis | deferred | intentionally no LLM dependency in M0 | add after signal lineage is stable | +| Webhook receiver | deferred | contract only, no external receiver | choose after core app is usable | +| MT5 bridge | deferred | not started | paper bridge after webhook decision | + +## Guardrails + +- Research and paper modes only. +- No live orders, external webhook receiver, broker credentials, or MT5 connection. +- Deterministic signal is authoritative; LLM will remain downstream. +- Fixture source is clearly marked and must be replaced before investment use. +- `target_weight` is recorded in the internal paper ledger; it is not an order. + +## Verification + +- Backend: 10 unittest tests pass. +- Independent review: **PASSED**; no concrete security or logic blockers. +- Reviewer suggestions: set `PAPER_COOKIE_SECURE=1` outside local HTTP; replace in-memory sessions before multi-worker deployment. +- Frontend: `npm run build` passes with Vite. +- Backend health endpoint returns HTTP 200 JSON. +- Dashboard served HTML contains the current title, Vue mount point and Vite entry. +- Paper ledger POST and readback work through the live API. +- Browser visual capture was blocked by Chrome remote-debugging permission; no permission dialog was clicked. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4ed2f44 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + SET50 Signal Lab + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..aa63f9e --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1382 @@ +{ + "name": "set50-alternative-data-dashboard", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "set50-alternative-data-dashboard", + "version": "0.1.0", + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "vite": "^6.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.41.tgz", + "integrity": "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.41", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.41.tgz", + "integrity": "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.41.tgz", + "integrity": "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.41", + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-ssr": "3.5.41", + "@vue/shared": "3.5.41", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.41.tgz", + "integrity": "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.41.tgz", + "integrity": "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.41.tgz", + "integrity": "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.41.tgz", + "integrity": "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.41", + "@vue/runtime-core": "3.5.41", + "@vue/shared": "3.5.41", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.41.tgz", + "integrity": "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/shared": "3.5.41" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.41.tgz", + "integrity": "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA==", + "license": "MIT" + }, + "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/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/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "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/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/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/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/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "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/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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "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.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "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/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "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/vue": { + "version": "3.5.41", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.41.tgz", + "integrity": "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.41", + "@vue/compiler-sfc": "3.5.41", + "@vue/runtime-dom": "3.5.41", + "@vue/server-renderer": "3.5.41", + "@vue/shared": "3.5.41" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1b85910 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,18 @@ +{ + "name": "set50-alternative-data-dashboard", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.3", + "vite": "^6.2.0" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..a7f7b0c --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,307 @@ + + + diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..fe5bae3 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..3f6f5d2 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1,172 @@ +@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap'); + +:root { + color-scheme: dark; + font-family: 'Manrope', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + color: #edf2f7; + background: #0b1018; + font-synthesis: none; + text-rendering: optimizeLegibility; + --bg: #0b1018; + --panel: #111925; + --panel-soft: #151f2d; + --line: #263344; + --line-bright: #35465c; + --text: #edf2f7; + --muted: #8794a6; + --faint: #5b6a7e; + --mint: #52d6bd; + --mint-soft: rgba(82, 214, 189, 0.12); + --amber: #e6b96c; + --amber-soft: rgba(230, 185, 108, 0.12); + --red: #ef8b8b; + --red-soft: rgba(239, 139, 139, 0.12); +} + +* { box-sizing: border-box; } +html { scroll-behavior: smooth; } +body { margin: 0; min-width: 320px; background: var(--bg); } +button, input { font: inherit; } +button { cursor: pointer; } + +.app-shell { min-height: 100vh; display: flex; background: radial-gradient(circle at 85% -10%, rgba(82, 214, 189, 0.08), transparent 32rem), var(--bg); } +.sidebar { width: 248px; flex: 0 0 248px; min-height: 100vh; padding: 28px 18px 22px; border-right: 1px solid var(--line); display: flex; flex-direction: column; background: rgba(11, 16, 24, 0.76); } +.brand-lockup { display: flex; align-items: center; gap: 11px; padding: 0 9px 33px; } +.brand-mark { width: 32px; height: 32px; display: grid; place-items: center; border: 1px solid rgba(82, 214, 189, .7); border-radius: 9px; color: var(--mint); font: 500 11px 'DM Mono', monospace; letter-spacing: -.08em; box-shadow: 0 0 24px rgba(82, 214, 189, .12); } +.brand-name { font-size: 13px; font-weight: 800; letter-spacing: .01em; } +.brand-caption { margin-top: 2px; color: var(--faint); font: 10px 'DM Mono', monospace; } +.nav-stack { display: grid; gap: 5px; } +.nav-item { display: flex; align-items: center; gap: 11px; padding: 11px 12px; border: 1px solid transparent; border-radius: 8px; color: var(--muted); text-decoration: none; font-size: 12px; font-weight: 600; transition: .2s ease; } +.nav-item:hover { color: var(--text); background: rgba(255,255,255,.025); } +.nav-item.active { color: var(--mint); background: var(--mint-soft); border-color: rgba(82,214,189,.18); } +.nav-glyph { width: 16px; color: currentColor; font-size: 15px; text-align: center; } +.sidebar-footer { margin-top: auto; padding: 12px 8px 0; } +.mode-card { display: flex; gap: 10px; align-items: center; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: rgba(255,255,255,.02); } +.mode-dot, .freshness-dot { width: 7px; height: 7px; flex: 0 0 7px; border-radius: 99px; background: var(--mint); box-shadow: 0 0 12px var(--mint); } +.mode-label { font-size: 11px; font-weight: 700; } +.mode-detail, .version-line { margin-top: 3px; color: var(--faint); font: 10px 'DM Mono', monospace; } +.version-line { padding: 14px 3px 0; } + +.content { width: min(100%, 1440px); margin: 0 auto; padding: 42px clamp(22px, 4vw, 64px) 70px; } +.topbar { display: flex; justify-content: space-between; gap: 24px; align-items: flex-start; padding-bottom: 35px; } +.eyebrow, .section-kicker { color: var(--mint); font: 500 10px 'DM Mono', monospace; letter-spacing: .14em; text-transform: uppercase; } +h1, h2, p { margin: 0; } +h1 { margin-top: 10px; font-size: clamp(28px, 4vw, 46px); line-height: 1.04; letter-spacing: -.055em; } +h2 { margin-top: 7px; font-size: 17px; letter-spacing: -.025em; } +.subtitle { max-width: 560px; margin-top: 12px; color: var(--muted); font-size: 13px; line-height: 1.7; } +.topbar-meta { text-align: right; color: var(--muted); font: 10px 'DM Mono', monospace; } +.freshness-pill { display: inline-flex; align-items: center; gap: 8px; padding: 7px 10px; border: 1px solid rgba(82,214,189,.25); border-radius: 99px; color: var(--mint); background: var(--mint-soft); } +.as-of { margin-top: 10px; } + +.kpi-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 12px; } +.kpi-card, .panel, .state-card { border: 1px solid var(--line); background: linear-gradient(145deg, rgba(21,31,45,.86), rgba(14,21,31,.94)); box-shadow: 0 18px 42px rgba(0,0,0,.12); } +.kpi-card { min-height: 132px; padding: 19px 19px 16px; border-radius: 10px; } +.accent-card { border-color: rgba(82,214,189,.3); background: linear-gradient(145deg, rgba(25,56,59,.8), rgba(14,31,38,.92)); } +.kpi-label { color: var(--muted); font: 10px 'DM Mono', monospace; text-transform: uppercase; letter-spacing: .08em; } +.kpi-value { margin-top: 12px; color: var(--text); font-size: 29px; font-weight: 700; letter-spacing: -.055em; } +.kpi-unit { margin-left: 4px; color: var(--mint); font: 14px 'DM Mono', monospace; } +.quality-value { font-size: 22px; text-transform: capitalize; } +.kpi-foot { margin-top: 10px; color: var(--faint); font: 10px 'DM Mono', monospace; } +.long-count, .positive-text { color: var(--mint); } +.short-count, .negative-text { color: var(--red); } + +.hero-grid, .bottom-grid { display: grid; grid-template-columns: 1.45fr 1fr; gap: 12px; margin-bottom: 12px; } +.panel { border-radius: 10px; padding: 22px; } +.panel-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; } +.confidence-tag, .status-tag { padding: 6px 8px; color: var(--mint); border: 1px solid rgba(82,214,189,.22); border-radius: 6px; background: var(--mint-soft); font: 10px 'DM Mono', monospace; white-space: nowrap; } +.pulse-lead { display: flex; gap: 15px; align-items: baseline; margin: 26px 0 25px; } +.pulse-number { color: var(--mint); font-size: 32px; font-weight: 700; letter-spacing: -.06em; } +.pulse-copy { max-width: 380px; color: var(--muted); font-size: 12px; line-height: 1.6; } +.observation-list { display: grid; gap: 16px; } +.observation-row { display: grid; grid-template-columns: minmax(145px, 1fr) 1.3fr 58px; align-items: center; gap: 14px; } +.observation-name { color: var(--muted); font: 10px 'DM Mono', monospace; text-transform: uppercase; } +.observation-track { height: 6px; overflow: hidden; border-radius: 99px; background: #253140; } +.observation-bar { height: 100%; border-radius: inherit; } +.bar-positive { background: var(--mint); box-shadow: 0 0 16px rgba(82,214,189,.42); } +.bar-negative { background: var(--red); } +.observation-value { text-align: right; font: 11px 'DM Mono', monospace; } +.lineage-panel { display: flex; flex-direction: column; } +.lineage-list { display: grid; gap: 0; margin-top: 24px; border-top: 1px solid var(--line); } +.lineage-item { display: flex; justify-content: space-between; gap: 15px; padding: 13px 0; border-bottom: 1px solid var(--line); color: var(--muted); font-size: 11px; } +.lineage-item strong { color: var(--text); font: 10px 'DM Mono', monospace; text-align: right; } +.lineage-note { margin-top: auto; padding-top: 22px; color: var(--faint); font-size: 11px; line-height: 1.7; } + +.signal-panel { padding: 0; overflow: hidden; } +.signal-header { padding: 22px; border-bottom: 1px solid var(--line); } +.strategy-meta { color: var(--faint); font: 10px 'DM Mono', monospace; } +.strategy-meta span { color: var(--line-bright); padding: 0 5px; } +.table-wrap { overflow-x: auto; } +table { width: 100%; border-collapse: collapse; min-width: 850px; } +th { padding: 12px 16px; color: var(--faint); border-bottom: 1px solid var(--line); font: 10px 'DM Mono', monospace; font-weight: 400; text-align: left; text-transform: uppercase; letter-spacing: .06em; } +td { padding: 14px 16px; border-bottom: 1px solid rgba(38,51,68,.72); color: var(--muted); font-size: 11px; vertical-align: middle; } +tbody tr:last-child td { border-bottom: 0; } +tbody tr:hover { background: rgba(255,255,255,.025); } +.muted-cell, .score-cell, .target-cell { font-family: 'DM Mono', monospace; } +.symbol-name { display: block; color: var(--text); font-size: 12px; } +.confidence-cell { display: block; margin-top: 4px; color: var(--faint); font: 9px 'DM Mono', monospace; } +.side-pill { display: inline-block; min-width: 52px; padding: 5px 7px; border-radius: 5px; font: 10px 'DM Mono', monospace; text-align: center; } +.side-pill.long { color: var(--mint); background: var(--mint-soft); } +.side-pill.short { color: var(--red); background: var(--red-soft); } +.side-pill.neutral { color: var(--amber); background: var(--amber-soft); } +.reason-code { display: inline-block; margin: 2px 3px 2px 0; padding: 4px 5px; border: 1px solid var(--line-bright); border-radius: 4px; color: var(--faint); font: 9px 'DM Mono', monospace; } +.row-action, .primary-button { padding: 8px 10px; border: 1px solid var(--line-bright); border-radius: 5px; color: var(--text); background: transparent; font-size: 10px; white-space: nowrap; transition: .2s ease; } +.row-action:hover { color: var(--mint); border-color: var(--mint); } + +.bottom-grid { grid-template-columns: 1fr 1fr; } +.thesis-panel { background: linear-gradient(145deg, rgba(36,48,64,.9), rgba(15,23,34,.95)); } +.thesis-panel p, .ledger-panel p { margin-top: 14px; color: var(--muted); font-size: 12px; line-height: 1.8; } +.thesis-rule { display: flex; align-items: center; gap: 9px; margin-top: 24px; color: var(--mint); font: 10px 'DM Mono', monospace; } +.thesis-rule span { width: 22px; height: 1px; background: var(--mint); } +.fixture-tag { color: var(--amber); border-color: rgba(230,185,108,.3); background: var(--amber-soft); } +.neutral-tag { color: var(--amber); border-color: rgba(230,185,108,.25); background: var(--amber-soft); } +.auth-form { display: grid; gap: 10px; margin-top: 16px; } +.auth-copy { color: var(--muted); font-size: 11px; line-height: 1.65; } +.entry-form { display: grid; gap: 10px; margin-top: 16px; } +.selected-entry { display: flex; justify-content: space-between; gap: 12px; align-items: center; color: var(--muted); font: 10px 'DM Mono', monospace; } +.selected-entry strong { color: var(--text); font-size: 13px; } +input { width: 100%; padding: 10px 11px; outline: none; border: 1px solid var(--line-bright); border-radius: 6px; color: var(--text); background: rgba(11,16,24,.7); font: 11px 'DM Mono', monospace; } +input:focus { border-color: var(--mint); box-shadow: 0 0 0 3px rgba(82,214,189,.09); } +.primary-button { color: #071411; border-color: var(--mint); background: var(--mint); font-weight: 700; } +.primary-button:disabled { opacity: .5; cursor: wait; } +.empty-ledger { margin-top: 18px; padding: 14px; border: 1px dashed var(--line-bright); border-radius: 6px; color: var(--faint); font: 10px 'DM Mono', monospace; text-align: center; } +.notice { margin-top: 14px; padding: 9px 10px; border-radius: 5px; font: 10px 'DM Mono', monospace; } +.notice-success { color: var(--mint); background: var(--mint-soft); } +.notice-error { color: var(--red); background: var(--red-soft); } +.state-card { margin-top: 12px; padding: 28px; border-radius: 10px; color: var(--muted); font: 12px 'DM Mono', monospace; } +.error-state { color: var(--red); border-color: rgba(239,139,139,.3); } + +@media (max-width: 1040px) { + .sidebar { width: 208px; flex-basis: 208px; } + .kpi-grid { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 760px) { + .app-shell { display: block; } + .sidebar { width: 100%; min-height: auto; padding: 15px 18px; border-right: 0; border-bottom: 1px solid var(--line); } + .brand-lockup { padding: 0; } + .nav-stack { display: flex; overflow-x: auto; margin-top: 14px; gap: 5px; } + .nav-item { flex: 0 0 auto; padding: 8px 10px; font-size: 10px; } + .nav-glyph { display: none; } + .sidebar-footer { display: none; } + .content { padding: 30px 16px 44px; } + .topbar { display: block; padding-bottom: 25px; } + .topbar-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 18px; text-align: left; } + .hero-grid, .bottom-grid { grid-template-columns: 1fr; } + .panel { padding: 18px; } +} + +@media (max-width: 500px) { + .kpi-grid { grid-template-columns: 1fr 1fr; gap: 7px; } + .kpi-card { min-height: 112px; padding: 14px; } + .kpi-value { font-size: 22px; } + .kpi-foot { font-size: 9px; } + .pulse-lead { display: block; margin: 20px 0; } + .pulse-copy { display: block; margin-top: 8px; } + .observation-row { grid-template-columns: 1fr 55px; gap: 8px; } + .observation-track { grid-column: 1 / -1; grid-row: 2; } + .observation-value { grid-column: 2; grid-row: 1; } + .lineage-item { display: block; } + .lineage-item strong { display: block; margin-top: 5px; text-align: left; } + h1 { font-size: 31px; } + .subtitle { font-size: 11px; } +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..583489f --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': 'http://127.0.0.1:5000', + }, + }, +})