From e1992fa049c2532d72f2df003db6d7c4e07ff49f Mon Sep 17 00:00:00 2001 From: PeninsulaInd Date: Thu, 19 Feb 2026 21:53:51 -0600 Subject: [PATCH] Add data-driven dashboard with API backend - New cheddahbot/api.py: FastAPI router with endpoints for tasks, link building, press releases, agents, system health, notifications, KV states, and cache management (all cached 5min) - Rewrote dashboard/index.html: replaces all hardcoded data with JS that fetches from /api/ endpoints. Tabs: Overview, Link Building, Press Releases, By Company, System Health, Agents, Notifications - Updated __main__.py: mounts API router, removes old inline /api/linkbuilding/status endpoint - Fixed run_link_building to reject empty LB Method instead of defaulting to Cora Backlinks Co-Authored-By: Claude Opus 4.6 --- cheddahbot/__main__.py | 78 +-- cheddahbot/api.py | 297 ++++++++ cheddahbot/tools/linkbuilding.py | 8 +- dashboard/index.html | 816 ++++++++++++++++++++++ dashboard/styles.css | 1113 ++++++++++++++++++++++++++++++ 5 files changed, 2240 insertions(+), 72 deletions(-) create mode 100644 cheddahbot/api.py create mode 100644 dashboard/index.html create mode 100644 dashboard/styles.css diff --git a/cheddahbot/__main__.py b/cheddahbot/__main__.py index 7d8099e..b324ed1 100644 --- a/cheddahbot/__main__.py +++ b/cheddahbot/__main__.py @@ -1,6 +1,5 @@ """Entry point: python -m cheddahbot""" -import json import logging from pathlib import Path @@ -146,6 +145,13 @@ def main(): fastapi_app = FastAPI() + # Mount API endpoints + from .api import create_api_router + + api_router = create_api_router(config, db, registry) + fastapi_app.include_router(api_router) + log.info("API router mounted at /api/") + # Mount the dashboard as static files (must come before Gradio's catch-all) dashboard_dir = Path(__file__).resolve().parent.parent / "dashboard" if dashboard_dir.is_dir(): @@ -161,76 +167,6 @@ def main(): ) log.info("Dashboard mounted at /dashboard/ (serving %s)", dashboard_dir) - # Link building status API endpoint (for dashboard consumption) - @fastapi_app.get("/api/linkbuilding/status") - async def linkbuilding_status(): - """Return link building pipeline status for dashboard consumption.""" - result = { - "pending_cora_runs": [], - "in_progress": [], - "completed": [], - "failed": [], - } - - # Query KV store for tracked link building states - try: - for key, value in db.kv_scan("linkbuilding:watched:"): - try: - state = json.loads(value) - entry = { - "filename": state.get("filename", key.split(":")[-1]), - "status": state.get("status", "unknown"), - "task_id": state.get("task_id", ""), - } - if state.get("status") == "completed": - entry["completed_at"] = state.get("completed_at", "") - result["completed"].append(entry) - elif state.get("status") == "failed": - entry["error"] = state.get("error", "") - entry["failed_at"] = state.get("failed_at", "") - result["failed"].append(entry) - elif state.get("status") == "processing": - entry["started_at"] = state.get("started_at", "") - result["in_progress"].append(entry) - except json.JSONDecodeError: - pass - except Exception as e: - log.warning("Error reading linkbuilding KV state: %s", e) - - # Query ClickUp for pending tasks (to do + Link Building + Cora Backlinks) - if config.clickup.enabled: - try: - from .clickup import ClickUpClient - - cu = ClickUpClient( - api_token=config.clickup.api_token, - workspace_id=config.clickup.workspace_id, - task_type_field_name=config.clickup.task_type_field_name, - ) - try: - tasks = cu.get_tasks_from_space(config.clickup.space_id, statuses=["to do"]) - for task in tasks: - if task.task_type != "Link Building": - continue - lb_method = task.custom_fields.get("LB Method", "") - if lb_method and lb_method != "Cora Backlinks": - continue - result["pending_cora_runs"].append( - { - "keyword": task.custom_fields.get("Keyword", ""), - "url": task.custom_fields.get("IMSURL", ""), - "client": task.custom_fields.get("Client", ""), - "task_id": task.id, - "task_name": task.name, - } - ) - finally: - cu.close() - except Exception as e: - log.warning("Error querying ClickUp for pending link building: %s", e) - - return result - # Mount Gradio at the root gr.mount_gradio_app(fastapi_app, blocks, path="/", pwa=True, show_error=True) diff --git a/cheddahbot/api.py b/cheddahbot/api.py new file mode 100644 index 0000000..7b2c354 --- /dev/null +++ b/cheddahbot/api.py @@ -0,0 +1,297 @@ +"""Dashboard API endpoints. + +Provides JSON data for the standalone HTML dashboard. +All ClickUp data is cached for 5 minutes to avoid hammering the API. +""" + +from __future__ import annotations + +import json +import logging +import shutil +import threading +import time +from typing import TYPE_CHECKING + +from fastapi import APIRouter + +if TYPE_CHECKING: + from .agent_registry import AgentRegistry + from .config import Config + from .db import Database + +log = logging.getLogger(__name__) + +router = APIRouter(prefix="/api") + +# Module-level refs wired at startup by create_api_router() +_config: Config | None = None +_db: Database | None = None +_registry: AgentRegistry | None = None + +# Simple in-memory cache for ClickUp data +_cache: dict[str, dict] = {} +_cache_lock = threading.Lock() +CACHE_TTL = 300 # 5 minutes + + +def create_api_router( + config: Config, db: Database, registry: AgentRegistry +) -> APIRouter: + """Wire dependencies and return the router.""" + global _config, _db, _registry + _config = config + _db = db + _registry = registry + return router + + +def _get_cached(key: str) -> dict | None: + with _cache_lock: + entry = _cache.get(key) + if entry and time.time() - entry["ts"] < CACHE_TTL: + return entry["data"] + return None + + +def _set_cached(key: str, data): + with _cache_lock: + _cache[key] = {"data": data, "ts": time.time()} + + +def _get_clickup_client(): + from .clickup import ClickUpClient + + return ClickUpClient( + api_token=_config.clickup.api_token, + workspace_id=_config.clickup.workspace_id, + task_type_field_name=_config.clickup.task_type_field_name, + ) + + +# ── Tasks ── + + +@router.get("/tasks") +async def get_tasks(): + """All ClickUp tasks grouped by work category.""" + cached = _get_cached("tasks") + if cached is not None: + return cached + + if not _config or not _config.clickup.enabled: + return {"tasks": [], "error": "ClickUp not configured"} + + client = _get_clickup_client() + try: + raw_tasks = client.get_tasks_from_space(_config.clickup.space_id) + tasks = [] + for t in raw_tasks: + tasks.append( + { + "id": t.id, + "name": t.name, + "status": t.status, + "task_type": t.task_type, + "url": t.url, + "due_date": t.due_date, + "list_name": t.list_name, + "custom_fields": t.custom_fields, + } + ) + result = {"tasks": tasks, "count": len(tasks)} + _set_cached("tasks", result) + return result + except Exception as e: + log.error("Failed to fetch tasks for dashboard: %s", e) + return {"tasks": [], "error": str(e)} + finally: + client.close() + + +@router.get("/tasks/by-company") +async def get_tasks_by_company(): + """Tasks grouped by Client custom field.""" + data = await get_tasks() + by_company: dict[str, list] = {} + for task in data.get("tasks", []): + company = task["custom_fields"].get("Client") or "Unassigned" + by_company.setdefault(company, []).append(task) + + # Sort companies by task count descending + sorted_companies = sorted(by_company.items(), key=lambda x: -len(x[1])) + return { + "companies": [ + {"name": name, "tasks": tasks, "count": len(tasks)} + for name, tasks in sorted_companies + ] + } + + +@router.get("/tasks/link-building") +async def get_link_building_tasks(): + """Link building tasks with KV state merged in.""" + data = await get_tasks() + lb_tasks = [t for t in data.get("tasks", []) if t["task_type"] == "Link Building"] + + # Merge KV state + if _db: + for task in lb_tasks: + kv_key = f"clickup:task:{task['id']}:state" + raw = _db.kv_get(kv_key) + if raw: + try: + task["kv_state"] = json.loads(raw) + except json.JSONDecodeError: + task["kv_state"] = None + else: + task["kv_state"] = None + + # Group by company + by_company: dict[str, list] = {} + for task in lb_tasks: + company = task["custom_fields"].get("Client") or "Unassigned" + by_company.setdefault(company, []).append(task) + + return { + "total": len(lb_tasks), + "companies": [ + {"name": name, "tasks": tasks, "count": len(tasks)} + for name, tasks in sorted(by_company.items(), key=lambda x: -len(x[1])) + ], + "status_counts": _count_statuses(lb_tasks), + } + + +@router.get("/tasks/press-releases") +async def get_press_release_tasks(): + """Press release tasks with KV state merged in.""" + data = await get_tasks() + pr_tasks = [t for t in data.get("tasks", []) if t["task_type"] == "Press Release"] + + if _db: + for task in pr_tasks: + kv_key = f"clickup:task:{task['id']}:state" + raw = _db.kv_get(kv_key) + if raw: + try: + task["kv_state"] = json.loads(raw) + except json.JSONDecodeError: + task["kv_state"] = None + else: + task["kv_state"] = None + + by_company: dict[str, list] = {} + for task in pr_tasks: + company = task["custom_fields"].get("Client") or "Unassigned" + by_company.setdefault(company, []).append(task) + + return { + "total": len(pr_tasks), + "companies": [ + {"name": name, "tasks": tasks, "count": len(tasks)} + for name, tasks in sorted(by_company.items(), key=lambda x: -len(x[1])) + ], + "status_counts": _count_statuses(pr_tasks), + } + + +def _count_statuses(tasks: list[dict]) -> dict[str, int]: + counts: dict[str, int] = {} + for t in tasks: + s = t.get("status", "unknown") + counts[s] = counts.get(s, 0) + 1 + return counts + + +# ── Agents ── + + +@router.get("/agents") +async def get_agents(): + """Agent configurations.""" + if not _config: + return {"agents": []} + + agents = [] + for ac in _config.agents: + agents.append( + { + "name": ac.name, + "display_name": ac.display_name, + "personality_file": ac.personality_file, + "model": ac.model or _config.chat_model, + "tools": ac.tools, + "skills": ac.skills, + "memory_scope": ac.memory_scope or "shared", + } + ) + return {"agents": agents} + + +# ── System ── + + +@router.get("/system/health") +async def get_system_health(): + """System health: disk space, brains, integrations.""" + health = { + "execution_brain": shutil.which("claude") is not None, + "clickup_enabled": _config.clickup.enabled if _config else False, + "chat_model": _config.chat_model if _config else "unknown", + "execution_model": _config.default_model if _config else "unknown", + "disks": [], + } + + # Disk space (Windows drives) + for drive in ["C:", "D:", "E:", "Z:"]: + try: + usage = shutil.disk_usage(drive + "/") + health["disks"].append( + { + "drive": drive, + "total_gb": round(usage.total / (1024**3), 1), + "free_gb": round(usage.free / (1024**3), 1), + "percent_free": round(usage.free / usage.total * 100, 1), + } + ) + except (FileNotFoundError, OSError): + pass + + return health + + +@router.get("/system/notifications") +async def get_notifications(): + """Recent notifications from the DB.""" + if not _db: + return {"notifications": []} + + notes = _db.get_notifications_after(0, limit=100) + return {"notifications": notes} + + +@router.get("/system/kv-states") +async def get_kv_states(): + """All ClickUp task states from KV store.""" + if not _db: + return {"states": []} + + pairs = _db.kv_scan("clickup:task:") + states = [] + for _key, val in pairs: + try: + state = json.loads(val) + states.append(state) + except json.JSONDecodeError: + pass + + return {"states": states} + + +@router.post("/cache/clear") +async def clear_cache(): + """Clear the ClickUp data cache.""" + with _cache_lock: + _cache.clear() + return {"status": "cleared"} diff --git a/cheddahbot/tools/linkbuilding.py b/cheddahbot/tools/linkbuilding.py index a48dd42..e66a96e 100644 --- a/cheddahbot/tools/linkbuilding.py +++ b/cheddahbot/tools/linkbuilding.py @@ -413,7 +413,13 @@ def run_link_building( ctx: dict | None = None, ) -> str: """Dispatch to the correct link building pipeline based on lb_method.""" - method = (lb_method or "Cora Backlinks").strip() + method = (lb_method or "").strip() + + if not method: + return ( + "Skipped: 'LB Method' field is empty. Each Link Building task must have " + "an LB Method set (e.g. 'Cora Backlinks') before processing can begin." + ) if method == "Cora Backlinks": # For Cora Backlinks, xlsx_path is required diff --git a/dashboard/index.html b/dashboard/index.html new file mode 100644 index 0000000..6639ef7 --- /dev/null +++ b/dashboard/index.html @@ -0,0 +1,816 @@ + + + + + + CheddahBot Command Dashboard + + + + + + + +
+ + +
+
+ + + CheddahBot + Command Dashboard +
+
+
+ + Loading... +
+ + +
+
+ + + + + +
+ + +
+
+
+

Good Morning, Bryan.

+

+
+
+ “Continuous effort — not strength or intelligence — is the key to unlocking our potential.” + — Churchill +
+
+ + +
+
+
-
+
Total Tasks
+
Loading...
+
+
+
-
+
Link Building
+
Loading...
+
+
+
-
+
Press Releases
+
Loading...
+
+
+
-
+
Companies
+
Loading...
+
+
+
-
+
Agents
+
Loading...
+
+
+ + +
+
+

🔗 Link Building Tasks

+ View all +
+
+

Loading...

+
+
+ + +
+
+

📰 Press Release Tasks

+ View all +
+
+

Loading...

+
+
+ + +
+
+ + Loading system health... + +
+
+
+ + +
+ + +
+ + +
+
+

🏢 By Company

+
+
+
+ + +
+
+

📋 Full Task List

+
+
+

Loading...

+
+
+
+ + +
+ + +
+ +
+
+

📰 All Press Releases

+
+
+

Loading...

+
+
+
+ + +
+ + +
+

Loading...

+
+
+ + +
+ + +
+ +
+
+

💾 Disk Space

+
+
+
+
+ + +
+ + +
+

Loading...

+
+
+ + +
+ + +
+

Loading...

+
+
+ +
+
+ + + + + diff --git a/dashboard/styles.css b/dashboard/styles.css new file mode 100644 index 0000000..eebd0c4 --- /dev/null +++ b/dashboard/styles.css @@ -0,0 +1,1113 @@ +/* ============================================================ + CheddahBot Command Dashboard + Churchill-esque: deep navy, rich slate, burnished gold + ============================================================ */ + +/* --- Foundations --- */ +:root { + --navy: #0b1a2e; + --navy-mid: #122340; + --navy-light: #1a3050; + --slate: #2c3e50; + --slate-light:#3d5166; + --gold: #c9a84c; + --gold-light: #e0c872; + --gold-dim: #a68930; + --cream: #f0e9d8; + --cream-light:#faf6ed; + --white: #ffffff; + --red: #d9534f; + --green: #4caf50; + --amber: #f0ad4e; + --text: #e8e0d0; + --text-muted: #8a9bb5; + --border: rgba(201,168,76,0.18); + --shadow: 0 2px 16px rgba(0,0,0,0.35); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.45); + --radius: 8px; + --radius-lg: 12px; + --transition: 0.25s ease; + --font: 'Inter', 'Segoe UI', system-ui, -apple-system, sans-serif; + --font-serif: 'Playfair Display', 'Georgia', serif; +} + +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 15px; + scroll-behavior: smooth; +} + +body { + font-family: var(--font); + background: var(--navy); + color: var(--text); + line-height: 1.6; + min-height: 100vh; + -webkit-font-smoothing: antialiased; +} + +/* --- Layout Shell --- */ +.dashboard { + display: grid; + grid-template-columns: 240px 1fr; + grid-template-rows: auto 1fr; + min-height: 100vh; +} + +/* --- Top Bar --- */ +.topbar { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 2rem; + height: 64px; + background: linear-gradient(135deg, var(--navy) 0%, var(--navy-mid) 100%); + border-bottom: 1px solid var(--border); +} + +.topbar__brand { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.topbar__logo { + width: 36px; + height: 36px; + background: var(--gold); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: 1rem; + color: var(--navy); + flex-shrink: 0; +} + +.topbar__title { + font-family: var(--font-serif); + font-size: 1.4rem; + font-weight: 700; + color: var(--cream); + letter-spacing: 0.02em; +} + +.topbar__subtitle { + font-size: 0.75rem; + color: var(--text-muted); + margin-left: 0.5rem; +} + +.topbar__right { + display: flex; + align-items: center; + gap: 1.5rem; +} + +.topbar__status { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: var(--green); +} + +.topbar__status .dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--green); + animation: pulse 2s infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +.topbar__time { + font-size: 0.85rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +/* --- Sidebar --- */ +.sidebar { + background: var(--navy-mid); + border-right: 1px solid var(--border); + padding: 1.5rem 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.sidebar__section { + padding: 0 1rem; + margin-bottom: 0.5rem; +} + +.sidebar__label { + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--gold-dim); + font-weight: 700; + margin-bottom: 0.5rem; + padding: 0 0.75rem; +} + +.sidebar__link { + display: flex; + align-items: center; + gap: 0.65rem; + padding: 0.6rem 0.75rem; + border-radius: var(--radius); + color: var(--text-muted); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + transition: all var(--transition); + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; +} + +.sidebar__link:hover { + background: rgba(201,168,76,0.08); + color: var(--cream); +} + +.sidebar__link.active { + background: rgba(201,168,76,0.15); + color: var(--gold-light); +} + +.sidebar__link .icon { + width: 18px; + text-align: center; + flex-shrink: 0; + font-size: 0.95rem; +} + +.sidebar__link .badge { + margin-left: auto; + background: var(--gold); + color: var(--navy); + font-size: 0.65rem; + font-weight: 700; + padding: 0.1rem 0.45rem; + border-radius: 10px; + min-width: 20px; + text-align: center; +} + +.sidebar__divider { + height: 1px; + background: var(--border); + margin: 0.75rem 1rem; +} + +/* --- Main Content --- */ +.main { + padding: 1.75rem 2rem; + overflow-y: auto; + background: linear-gradient(180deg, var(--navy) 0%, #0d1f35 100%); +} + +/* --- Tab Panels --- */ +.tab-panel { display: none; } +.tab-panel.active { display: block; } + +/* --- Page Header (non-briefing tabs) --- */ +.page-header { + margin-bottom: 1.75rem; +} + +.page-header__greeting { + font-family: var(--font-serif); + font-size: 1.75rem; + color: var(--cream); + font-weight: 700; + margin-bottom: 0.15rem; +} + +.page-header__date { + font-size: 0.85rem; + color: var(--text-muted); +} + +.page-header__quote { + margin-top: 1rem; + padding: 0.85rem 1.25rem; + background: rgba(201,168,76,0.06); + border-left: 3px solid var(--gold); + border-radius: 0 var(--radius) var(--radius) 0; + font-style: italic; + color: var(--gold-light); + font-size: 0.9rem; + line-height: 1.55; +} + +.page-header__quote cite { + display: block; + margin-top: 0.35rem; + font-style: normal; + font-size: 0.75rem; + color: var(--gold-dim); +} + +/* ============================================================ + BRIEFING-SPECIFIC STYLES (data-dense layout) + ============================================================ */ + +/* --- Compact Briefing Header --- */ +.briefing-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 2rem; + margin-bottom: 1.25rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border); +} + +.briefing-header__left { + flex-shrink: 0; +} + +.briefing-header__greeting { + font-family: var(--font-serif); + font-size: 1.5rem; + color: var(--cream); + font-weight: 700; + line-height: 1.2; +} + +.briefing-header__date { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.15rem; +} + +.briefing-header__quote { + margin: 0; + padding: 0.6rem 1rem; + background: rgba(201,168,76,0.05); + border-left: 2px solid var(--gold-dim); + border-radius: 0 var(--radius) var(--radius) 0; + font-style: italic; + color: var(--gold-dim); + font-size: 0.78rem; + line-height: 1.45; + max-width: 420px; + flex-shrink: 1; +} + +.briefing-header__quote cite { + font-style: normal; + font-size: 0.7rem; + opacity: 0.7; +} + +/* --- User Reminders Section --- */ +.reminders-section { + margin-bottom: 1.25rem; +} + +.reminders-section__header { + margin-bottom: 0.6rem; +} + +.reminders-section__title { + font-family: var(--font-serif); + font-size: 1rem; + font-weight: 700; + color: var(--gold-light); +} + +.reminders-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 0; +} + +.reminder-item { + display: flex; + align-items: flex-start; + gap: 0.65rem; + padding: 0.65rem 1rem; + border-left: 3px solid transparent; + font-size: 0.82rem; + line-height: 1.45; + color: var(--text); + background: var(--navy-mid); + border-bottom: 1px solid rgba(201,168,76,0.06); +} + +.reminder-item:first-child { + border-radius: var(--radius-lg) var(--radius-lg) 0 0; +} + +.reminder-item:last-child { + border-radius: 0 0 var(--radius-lg) var(--radius-lg); + border-bottom: none; +} + +.reminder-item:only-child { + border-radius: var(--radius-lg); +} + +.reminder-item--urgent { + border-left-color: var(--red); + background: linear-gradient(90deg, rgba(217,83,79,0.08) 0%, var(--navy-mid) 30%); +} + +.reminder-item--warning { + border-left-color: var(--amber); + background: linear-gradient(90deg, rgba(240,173,78,0.06) 0%, var(--navy-mid) 30%); +} + +.reminder-item--info { + border-left-color: var(--gold-dim); +} + +.reminder-item__icon { + flex-shrink: 0; + font-size: 0.55rem; + margin-top: 0.35rem; +} + +.reminder-item--urgent .reminder-item__icon { color: var(--red); } +.reminder-item--warning .reminder-item__icon { color: var(--amber); } +.reminder-item--info .reminder-item__icon { color: var(--gold-dim); } + +.reminder-item__body strong { + color: var(--cream); +} + +.reminder-item__body em { + color: var(--gold-light); + font-style: italic; +} + +/* --- Action Items --- */ +.action-items { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 0.75rem; +} + +.action-card { + display: flex; + gap: 0.75rem; + padding: 0.85rem 1rem; + background: var(--navy-mid); + border: 1px solid var(--border); + border-radius: var(--radius); +} + +.action-card__indicator { + width: 3px; + align-self: stretch; + border-radius: 2px; + flex-shrink: 0; +} + +.action-card--blocked .action-card__indicator { background: var(--red); } +.action-card--hold .action-card__indicator { background: var(--amber); } + +.action-card__label { + font-size: 0.6rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 0.15rem; +} + +.action-card--blocked .action-card__label { color: var(--red); } +.action-card--hold .action-card__label { color: var(--amber); } + +.action-card__title { + font-size: 0.85rem; + font-weight: 600; + color: var(--cream); + margin-bottom: 0.15rem; +} + +.action-card__detail { + font-size: 0.75rem; + color: var(--text-muted); + line-height: 1.4; +} + +/* --- Compact Stats Row (briefing) --- */ +.stats-row--compact { + grid-template-columns: repeat(5, 1fr); + gap: 0.65rem; + margin-bottom: 1.25rem; +} + +.stat-card--mini { + padding: 0.75rem 1rem; + text-align: center; +} + +.stat-card--mini .stat-card__value { + font-size: 1.5rem; + margin-bottom: 0.1rem; +} + +.stat-card--mini .stat-card__label { + margin-bottom: 0.15rem; +} + +.stat-card--mini .stat-card__detail { + margin-top: 0.1rem; + font-size: 0.68rem; +} + +/* --- Compact Task Table (Top 5) --- */ +.task-table-wrap--compact { + max-height: none; +} + +.task-table--dense th { + padding: 0.5rem 0.75rem; + font-size: 0.65rem; +} + +.task-table--dense td { + padding: 0.5rem 0.75rem; + font-size: 0.8rem; +} + +/* --- Health Inline (exception-based in briefing) --- */ +.health-inline { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.6rem 1rem; + background: var(--navy-mid); + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 0.8rem; +} + +.health-inline__dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.health-inline__dot--ok { background: var(--green); } +.health-inline__dot--warn { background: var(--amber); } +.health-inline__dot--error { background: var(--red); } + +.health-inline__text { + font-weight: 600; + color: var(--cream); +} + +.health-inline__detail { + color: var(--text-muted); + font-size: 0.75rem; + margin-left: auto; +} + +.health-inline--warn { + border-color: rgba(240,173,78,0.3); + background: linear-gradient(90deg, rgba(240,173,78,0.08) 0%, var(--navy-mid) 40%); +} + +.health-inline--error { + border-color: rgba(217,83,79,0.3); + background: linear-gradient(90deg, rgba(217,83,79,0.08) 0%, var(--navy-mid) 40%); +} + +/* --- Section (tighter variant for briefing) --- */ +.section--tight { + margin-bottom: 1.25rem; +} + +.section--tight .section__header { + margin-bottom: 0.65rem; +} + +/* --- Section link inside badge --- */ +.section__link { + color: var(--gold-light); + text-decoration: none; + font-weight: 600; +} + +.section__link:hover { + text-decoration: underline; +} + +/* ============================================================ + SHARED COMPONENT STYLES (all tabs) + ============================================================ */ + +/* --- Stat Cards Row --- */ +.stats-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--navy-mid); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem 1.5rem; + position: relative; + overflow: hidden; + transition: transform var(--transition), box-shadow var(--transition); +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; +} + +.stat-card--gold::before { background: var(--gold); } +.stat-card--green::before { background: var(--green); } +.stat-card--amber::before { background: var(--amber); } +.stat-card--red::before { background: var(--red); } +.stat-card--blue::before { background: #5b9bd5; } + +.stat-card__label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.stat-card__value { + font-family: var(--font-serif); + font-size: 2rem; + font-weight: 700; + color: var(--cream); + line-height: 1; +} + +.stat-card__detail { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 0.35rem; +} + +/* --- Section --- */ +.section { + margin-bottom: 2rem; +} + +.section__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.section__title { + font-family: var(--font-serif); + font-size: 1.15rem; + font-weight: 700; + color: var(--cream); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.section__title .icon { + color: var(--gold); +} + +.section__badge { + background: rgba(201,168,76,0.15); + color: var(--gold); + font-size: 0.7rem; + font-weight: 700; + padding: 0.25rem 0.7rem; + border-radius: 20px; + border: 1px solid var(--border); +} + +/* --- Task Table --- */ +.task-table-wrap { + background: var(--navy-mid); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + overflow: hidden; +} + +.task-table { + width: 100%; + border-collapse: collapse; +} + +.task-table thead { + background: rgba(201,168,76,0.08); +} + +.task-table th { + text-align: left; + padding: 0.75rem 1rem; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--gold); + font-weight: 700; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +.task-table td { + padding: 0.7rem 1rem; + font-size: 0.85rem; + border-bottom: 1px solid rgba(201,168,76,0.06); + color: var(--text); + vertical-align: middle; +} + +.task-table tbody tr { + transition: background var(--transition); +} + +.task-table tbody tr:hover { + background: rgba(201,168,76,0.04); +} + +.task-table tbody tr:last-child td { + border-bottom: none; +} + +.task-table__num { + color: var(--text-muted); + font-variant-numeric: tabular-nums; + width: 40px; +} + +.task-table__title { + font-weight: 600; + color: var(--cream-light); +} + +.task-table__company { + color: var(--gold-light); + font-weight: 500; +} + +.task-table__keyword { + color: var(--text-muted); + font-style: italic; +} + +.task-table__status { + width: 100px; +} + +.status-pill { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 12px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.status-pill--todo { + background: rgba(91,155,213,0.15); + color: #5b9bd5; + border: 1px solid rgba(91,155,213,0.25); +} + +.status-pill--progress { + background: rgba(240,173,78,0.15); + color: var(--amber); + border: 1px solid rgba(240,173,78,0.25); +} + +.status-pill--done { + background: rgba(76,175,80,0.15); + color: var(--green); + border: 1px solid rgba(76,175,80,0.25); +} + +/* --- System Health Panel --- */ +.health-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + gap: 1rem; +} + +.health-card { + background: var(--navy-mid); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem; +} + +.health-card__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.health-card__title { + font-size: 0.85rem; + font-weight: 600; + color: var(--cream); +} + +.health-card__status { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; +} + +.health-card__status--ok { color: var(--green); } +.health-card__status--warn { color: var(--amber); } +.health-card__status--error { color: var(--red); } + +.health-bar { + height: 6px; + background: rgba(255,255,255,0.06); + border-radius: 3px; + overflow: hidden; + margin-bottom: 0.5rem; +} + +.health-bar__fill { + height: 100%; + border-radius: 3px; + transition: width 0.6s ease; +} + +.health-bar__fill--green { background: var(--green); } +.health-bar__fill--amber { background: var(--amber); } +.health-bar__fill--red { background: var(--red); } + +.health-card__meta { + font-size: 0.75rem; + color: var(--text-muted); + display: flex; + justify-content: space-between; +} + +/* --- Log Panel --- */ +.log-list { + list-style: none; +} + +.log-item { + display: flex; + gap: 1rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid rgba(201,168,76,0.06); + font-size: 0.82rem; + transition: background var(--transition); +} + +.log-item:hover { + background: rgba(201,168,76,0.04); +} + +.log-item__time { + color: var(--text-muted); + font-variant-numeric: tabular-nums; + white-space: nowrap; + min-width: 50px; +} + +.log-item__type { + font-weight: 700; + text-transform: uppercase; + font-size: 0.65rem; + padding: 0.15rem 0.45rem; + border-radius: 4px; + white-space: nowrap; + align-self: flex-start; + margin-top: 0.1rem; +} + +.log-item__type--heartbeat { + background: rgba(76,175,80,0.15); + color: var(--green); +} + +.log-item__type--execution { + background: rgba(91,155,213,0.15); + color: #5b9bd5; +} + +.log-item__type--error { + background: rgba(217,83,79,0.15); + color: var(--red); +} + +.log-item__type--user { + background: rgba(201,168,76,0.15); + color: var(--gold); +} + +.log-item__msg { + color: var(--text); + line-height: 1.45; +} + +/* --- Queue Panel --- */ +.queue-card { + background: var(--navy-mid); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem; + margin-bottom: 0.75rem; + display: flex; + align-items: center; + gap: 1rem; + transition: transform var(--transition), box-shadow var(--transition); +} + +.queue-card:hover { + transform: translateX(4px); + box-shadow: var(--shadow); +} + +.queue-card__priority { + width: 4px; + align-self: stretch; + border-radius: 2px; + flex-shrink: 0; +} + +.queue-card__priority--high { background: var(--red); } +.queue-card__priority--med { background: var(--amber); } +.queue-card__priority--low { background: var(--green); } + +.queue-card__body { + flex: 1; +} + +.queue-card__title { + font-weight: 600; + color: var(--cream); + font-size: 0.9rem; + margin-bottom: 0.15rem; +} + +.queue-card__meta { + font-size: 0.75rem; + color: var(--text-muted); +} + +.queue-card__status { + text-align: right; +} + +/* --- PR Tab --- */ +.pr-card { + background: var(--navy-mid); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.5rem; + margin-bottom: 1rem; +} + +.pr-card__header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; +} + +.pr-card__company { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--gold); + font-weight: 700; +} + +.pr-card__headline { + font-family: var(--font-serif); + font-size: 1.1rem; + color: var(--cream); + font-weight: 700; + line-height: 1.3; + margin-top: 0.25rem; +} + +.pr-card__detail { + font-size: 0.8rem; + color: var(--text-muted); + margin-top: 0.5rem; +} + +/* --- Responsive --- */ +@media (max-width: 900px) { + .dashboard { + grid-template-columns: 1fr; + } + + .sidebar { + display: none; + position: fixed; + top: 64px; + left: 0; + bottom: 0; + width: 260px; + z-index: 100; + box-shadow: var(--shadow-lg); + } + + .sidebar.open { + display: flex; + } + + .mobile-toggle { + display: flex !important; + } + + .main { + padding: 1.25rem 1rem; + } + + .stats-row { + grid-template-columns: repeat(2, 1fr); + } + + .stats-row--compact { + grid-template-columns: repeat(3, 1fr); + } + + .task-table-wrap { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .task-table { + min-width: 700px; + } + + .page-header__greeting { + font-size: 1.35rem; + } + + .briefing-header { + flex-direction: column; + gap: 0.75rem; + } + + .briefing-header__quote { + max-width: 100%; + } + + .action-items { + grid-template-columns: 1fr; + } +} + +@media (max-width: 500px) { + .stats-row { + grid-template-columns: 1fr; + } + + .stats-row--compact { + grid-template-columns: repeat(2, 1fr); + } + + .topbar { + padding: 0 1rem; + height: 56px; + } + + .topbar__subtitle { + display: none; + } + + .health-inline { + flex-wrap: wrap; + } + + .health-inline__detail { + margin-left: 0; + width: 100%; + padding-left: 1.4rem; + } +} + +/* --- Utility --- */ +.mobile-toggle { + display: none; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: transparent; + color: var(--cream); + cursor: pointer; + font-size: 1.1rem; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + border: 0; +} + +/* Scrollbar styling */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--slate-light); + border-radius: 3px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--gold-dim); +} + +/* Selection */ +::selection { + background: var(--gold); + color: var(--navy); +}