CLI
  • Curio
  • CLI
  • Forums
  • Support
  • Gallery
  • Docs
  • Release Notes
  • Gallery
  • Docs
  • Release Notes
  • Download
Curio CLI Gallery/Curio Atlas Dashboard

Downloadable recipe

Curio Atlas Dashboard

Turn a Project Center category into a polished local dashboard with deadlines, a read-only Kanban board, tag analysis, and cross-project status.

Status
Official example
Updated
August 14, 2026
By
George Browning
Curio read-only Curio 34.0+ · CLI 1.0.0-beta.9+ · macOS 15.0+ · Python 3.12+
Install uv before downloading

uv is a popular, trusted Python project tool that makes running Python scripts effortless. Curio Atlas uses it to install Python 3.12, create an isolated environment, and run the dashboard with one Terminal command.

Install uv ↗ Learn about uv ↗
Download Dashboard v1.0.4 100 KB View complete source Forum discussion ↗

Gallery

Overview combines deadlines, workflow stages, and upcoming work from every selected project.

Upcoming gathers incomplete tagged figures into exclusive date buckets.

The Board follows the selected Curio tag set and combines cards from multiple projects.

Tag analysis shows usage, workload, activity, and project coverage.

Project health makes current, stale, and cached reads visible.

Settings make refresh behavior and the dashboard's read-only boundary explicit.

A weekend experiment became a complete local app

This project began with a simple question: could an AI coding agent use Curio 34's command-line tools to build a genuinely useful browser dashboard?

The test data was deliberately ordinary. Three Curio projects—Germany, France, and Italy—were grouped in a Project Center category named Europe. Their figures used a mutually exclusive Travel Stage tag set:

  • 💡 Ideas
  • 🔎 Researching
  • ⭐ Shortlisted
  • 📅 Planned
  • ✅ Booked
  • 🎒 Ready
  • 🏁 Done

An agent populated those projects with example tasks, dates, priorities, progress, and tags. It then proposed a local Python application that could discover a Project Center category and summarize all of its projects together.

Note
Curio Atlas is read-only with respect to Curio projects. It reads saved projects directly and never completes, moves, edits, or saves their figures.

What the dashboard demonstrates

Curio Atlas is more than a screenshot mockup. It is a working FastAPI application with server-rendered pages, a responsive interface, and a local SQLite cache for last-known-good results.

It demonstrates how the CLI can power:

  • cross-project deadline and completion statistics;
  • sortable upcoming-item reports;
  • a read-only Kanban board generated from a Curio tag set;
  • tag usage, workload, and project-coverage analysis;
  • project health, stale-data, and partial-failure reporting;
  • light and dark appearance preferences; and
  • manual and scheduled data refreshes.

The server listens only on 127.0.0.1, does not require a public runtime CDN, and does not launch Curio. Project Center discovery can come from Curio's live or persisted state, so Curio itself does not need to remain open.

Before you download: install uv

This project uses uv, a popular and trusted Python project tool, to make running Python scripts effortless. It manages Python and the project's dependencies for you, so you do not need to construct a virtual environment or install FastAPI by hand. If uv is new to you, learn more about uv.

Install uv via direct download using its official standalone installer.

Or, if you use Homebrew:

brew install uv

Confirm the installation:

uv --version

You also need Curio 34 with the Curio CLI installed. In Curio, choose Curio > Install Curio CLI, complete the installer, and then verify it in Terminal:

curio --version

Launch the dashboard

In Terminal, change to the unzipped curio-dashboard folder and run:

uv run --python 3.12 curio-dashboard

uv obtains Python 3.12 if necessary, creates an isolated environment, installs the locked dependencies, and starts the local server. Launching from Terminal also avoids macOS Gatekeeper warnings that can apply to downloaded command scripts.

Keep the Terminal window open while using the dashboard. Closing it stops the local server.

Then click this link to open http://127.0.0.1:8765 in your browser and see your new dashboard.

How an agent built it

The useful part of this experiment was not a technical specification or a single magic prompt. The work followed Curio's plan, refine, then act workflow:

  1. Describe the desired outcome in ordinary language. This project began with:

    With the new curio and curio query skills could we create a slick professional looking dashboard interface? I'm looking for ideas here, don't code it up yet. At the top we could list project categories like "Work" or "School" or "Trips" which contain one or more projects. The user selects a category, and then can see a dashboard status for those projects. For instance it could query to find items due today, tomorrow, within 7 days, within 30 days. It could show a tag set picker and if one is chosen show kanban-style elements for upcoming tasks. Let's plan this out.

    There is no command syntax or specialized programming language in that request. You do not have to be a coder: describe what you want to see and let the agent work out the technical details.

  2. Ask the agent to propose a plan without changing anything yet. The agent can inspect the Curio skills, identify the necessary steps, and surface questions or assumptions.

  3. Refine the plan conversationally. Add details, correct assumptions, and continue until the proposed result and workflow look right.

  4. Say “Do it.” This tells the agent that planning is complete and it may build the approved workflow.

Once something is working, keep refining it in the same conversation. Describe what you like, what feels awkward, and what you want changed until the result fits the way you work.

The result is a useful example of the larger idea behind the CLI: Curio data can participate in tools created for one person's exact workflow, even when that person does not already know the chosen programming language or framework.

Explore and adapt it

The complete packaged source is visible below. Good starting points include:

  • config/dashboard.yaml for project scope and refresh behavior;
  • src/curio_dashboard/collector.py for CLI collection;
  • src/curio_dashboard/aggregate.py for cross-project metrics;
  • src/curio_dashboard/templates/ for the pages; and
  • src/curio_dashboard/static/ for the visual design.

The original experiment and follow-up discussion are available in the Curio forums.

Inspect before downloading

Complete source

30 files

Every text file included in the download is available here. Open a filename to inspect its contents.

README.md4 KB
# Curio Dashboard

A local, read-only dashboard for aggregating projects discovered through
Curio's Project Center.

Curio Atlas is a polished local example with visible loading/stale/failure
states, accessible interactions, generic cross-project analytics, and
last-known-good recovery.

## Start

Requirements:

- macOS with the Curio CLI installed
- Python 3.12 or newer
- [`uv`](https://docs.astral.sh/uv/getting-started/installation/)

uv is a popular, trusted Python project tool that makes running Python scripts
effortless. It installs the required Python version and creates an isolated
environment for Curio Atlas automatically. [Learn more about
uv](https://docs.astral.sh/uv/), or follow its [official installation
instructions](https://docs.astral.sh/uv/getting-started/installation/).

In Terminal, change to the unzipped `curio-dashboard` directory and run:

```bash
uv run --python 3.12 curio-dashboard
```

The server binds to `127.0.0.1:8765`. Keep the Terminal window open; pressing
Control-C or closing the window stops the dashboard.
Set `CURIO_DASHBOARD_CONFIG` to use a different YAML file. Relative cache paths
are resolved from that file's directory.

- `GET /` — dashboard overview
- `GET /api` — endpoint index
- `GET /health` — process health
- `GET /api/status` — latest state, refreshing on first access
- `POST /api/refresh` — force a complete read-only refresh
- `GET /docs` — generated API documentation

Open [http://127.0.0.1:8765](http://127.0.0.1:8765). Curio Atlas initially uses
the built-in **Most Recent** Project Center category. Use the **Category** pop-up
in the dashboard header to switch to any other category containing projects;
the selection is saved to `config/dashboard.yaml` for the next launch.

The first request can take a moment while Curio project files are read; a
loading screen reports progress and provides a retry if discovery fails.
Project Center discovery may be `live` or `persisted`; both are valid. The
server never launches Curio.

## Dashboard

- **Overview** — exclusive deadline cards, Travel Stage pipeline, upcoming
  incomplete items, workflow-board preview, project pulse, and data freshness.
- **Upcoming** — client-side project and date-window filtering with sortable
  item columns.
- **Projects** — validity, freshness, cache state, counts, paths, and last
  successful reads.
- **Settings** — device-local light/dark preference, configured refresh cadence,
  and read-only trust boundaries.
- **Board** — configurable tag-set columns in Curio catalog order, project
  filtering, unassigned actions, conflict warnings, and a figure detail drawer.
- **Tags** — ranked usage, incomplete/overdue/completed workload, tag-set
  distribution, recent activity, and a cross-project matrix.

Category, project, and board tag-set selectors are validated against current
read-only discovery data and saved atomically to `config/dashboard.yaml`.
Invalid arbitrary paths or tag-set names are rejected. Project snapshots
refresh on the configured short cadence; Project Center is rediscovered on its
separate, slower cadence.

## Current boundaries

The application includes:

- validated YAML configuration;
- safe argv-based async CLI execution with concurrency and timeout limits;
- Project Center discovery and trusted selection resolution;
- direct read-only `--project` collection;
- normalized figures with composite project/figure identity;
- progress-based incomplete/completed workflow state independent of checkbox
  visibility;
- exclusive local date buckets and cross-project aggregation;
- normalized catalog-ordered tag sets, usage statistics, and project matrices;
- configurable read-only Kanban with unassigned and inconsistent-tag warnings;
- SQLite last-known-good snapshots;
- isolated project failure and stale-cache behavior;
- server-rendered, escaped HTML with no public runtime CDN;
- responsive navigation, keyboard focus, reduced-motion support, and system or
  explicit theme preferences; and
- manual and scheduled refresh behavior;
- accessible first-load, empty, stale, and failure states; and
- a ready-to-run **Most Recent** default and configuration guide.

See [CONFIGURATION.md](CONFIGURATION.md) for selection, display, refresh, and
runtime settings. The complete application source is included in this package.
config/dashboard.yaml507 bytes
selection:
  mode: category
  category_id: "Most Recent"
  category_title_hint: "Recently Opened"
  project_path: null

dashboard:
  profile: travel
  refresh_seconds: 60
  discovery_refresh_seconds: 300
  include_status_ignored: false
  kanban_tag_set: Travel Stage
  kanban_include_unassigned: true

display:
  theme: system
  density: comfortable

runtime:
  curio_executable: curio
  command_timeout_seconds: 10
  max_concurrency: 3
  cache_path: ../.data/snapshots.sqlite3
  timezone: America/New_York
CONFIGURATION.md4 KB
# Curio Atlas Configuration

Curio Atlas reads one YAML file and never writes to a Curio project. The
download starts with Curio's built-in **Most Recent** Project Center category
in `config/dashboard.yaml`.

## Quick setup

1. In Terminal, change to the unzipped `curio-dashboard` directory and run:

   ```bash
   uv run --python 3.12 curio-dashboard
   ```

2. Open [http://127.0.0.1:8765](http://127.0.0.1:8765).
3. The first view uses **Most Recent**. Choose any populated Project Center
   category from the **Category** pop-up in the dashboard header.
4. Optionally choose a project or the workflow tag set used by the Board.

The dashboard discovers the available category identifiers and project counts
for you. A category selection is validated and saved atomically to
`config/dashboard.yaml`, so it becomes the default for the next launch.

Project Center discovery can come from the running app or Curio's persisted
state. Project files themselves are always read directly in read-only mode.

## Selection

```yaml
selection:
  mode: category
  category_id: "Most Recent"
  category_title_hint: "Recently Opened"
  project_path: null
```

- `mode`: `category` shows every eligible project; `project` narrows the
  dashboard to one trusted path returned by that category.
- `category_id`: stable Project Center identifier. Titles are not used as
  identity.
- `category_title_hint`: display-oriented fallback that makes the file easier
  to understand.
- `project_path`: required only for `mode: project`; it must be absolute and
  must still appear in the latest category response.

The dashboard selectors update this section atomically. Arbitrary project paths
are rejected.

For manual configuration, list stable category identifiers with:

```bash
curio project-center categories
```

## Dashboard behavior

```yaml
dashboard:
  profile: travel
  refresh_seconds: 60
  discovery_refresh_seconds: 300
  include_status_ignored: false
  kanban_tag_set: Travel Stage
  kanban_include_unassigned: true
```

- `profile`: presentation label reserved for category-specific refinements.
  The collection engine remains generic.
- `refresh_seconds`: saved project read cadence; minimum 10 seconds.
- `discovery_refresh_seconds`: Project Center rediscovery cadence; minimum 30
  seconds.
- `include_status_ignored`: includes Project Center projects marked ignored
  when true.
- `kanban_tag_set`: tag-set name used by the Board. If unavailable, the first
  catalog tag set is shown with a warning.
- `kanban_include_unassigned`: shows incomplete items that have tags, but none
  from the selected workflow set.

Incomplete and completed workflow state is based on `progress`, independently
of checkbox visibility:

- Incomplete: tagged figures below 100%.
- Completed: tagged figures at 100%.

## Display

```yaml
display:
  theme: system
  density: comfortable
```

Theme changes made in Settings are stored by the browser on this Mac. The YAML
values are safe defaults for future display preferences.

## Runtime

```yaml
runtime:
  curio_executable: curio
  command_timeout_seconds: 10
  max_concurrency: 3
  cache_path: ../.data/snapshots.sqlite3
  timezone: America/New_York
```

- `curio_executable`: command name or absolute CLI path.
- `command_timeout_seconds`: per-command safety limit, from greater than zero
  through 120 seconds.
- `max_concurrency`: maximum simultaneous CLI reads, from 1 through 8.
- `cache_path`: SQLite last-known-good snapshot location. Relative paths are
  resolved from the YAML file.
- `timezone`: IANA timezone used for exclusive date buckets.

To use another file:

```bash
CURIO_DASHBOARD_CONFIG=/absolute/path/dashboard.yaml \
  uv run --python 3.12 curio-dashboard
```

## Troubleshooting

- **The category is empty:** choose another category in the header or add
  projects to it in Curio's Project Center.
- **A project is stale:** its last successful snapshot remains visible. Inspect
  Projects for the safe error summary, then use Refresh.
- **The first scan fails:** the loading screen reports the issue and offers a
  retry. Confirm that the saved project paths remain valid.
- **The CLI is not found:** install or update the Curio CLI, then confirm that
  `curio --version` works in Terminal.
- **Port 8765 is in use:** close the older Curio Atlas terminal window before
  launching another copy.

Curio does not need to be running. The dashboard never launches, edits, saves,
or navigates Curio.
pyproject.toml476 bytes
[project]
name = "curio-dashboard"
version = "1.0.4"
description = "A local, read-only dashboard for Curio projects"
readme = "README.md"
requires-python = ">=3.12"
dependencies = [
    "fastapi>=0.116,<1",
    "jinja2>=3.1,<4",
    "pydantic>=2.11,<3",
    "python-multipart>=0.0.20,<1",
    "pyyaml>=6.0,<7",
    "uvicorn>=0.35,<1",
]

[project.scripts]
curio-dashboard = "curio_dashboard.main:run"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
src/curio_dashboard/__init__.py60 bytes
"""Curio Dashboard data services."""

__version__ = "1.0.4"
src/curio_dashboard/aggregate.py4 KB
"""Date bucketing and cross-project aggregation."""

from __future__ import annotations

from collections import Counter
from datetime import date, datetime
from zoneinfo import ZoneInfo

from curio_dashboard.models import (
    DashboardAggregate,
    DateBucket,
    ProjectAggregate,
    ProjectSnapshot,
)


class CurioDateError(ValueError):
    pass


def parse_curio_date(value: str, timezone: ZoneInfo) -> date:
    """Parse supported Curio date strings and return their local calendar day."""

    try:
        if "T" not in value:
            return date.fromisoformat(value)
        parsed = datetime.fromisoformat(value)
    except ValueError as error:
        raise CurioDateError(f"invalid Curio date {value!r}") from error

    if parsed.tzinfo is None:
        parsed = parsed.replace(tzinfo=timezone)
    else:
        parsed = parsed.astimezone(timezone)
    return parsed.date()


def classify_due_date(
    value: str | None,
    *,
    today: date,
    timezone: ZoneInfo,
) -> DateBucket:
    if value is None:
        return DateBucket.UNSCHEDULED

    due_day = parse_curio_date(value, timezone)
    delta = (due_day - today).days
    if delta < 0:
        return DateBucket.OVERDUE
    if delta == 0:
        return DateBucket.TODAY
    if delta == 1:
        return DateBucket.TOMORROW
    if delta <= 7:
        return DateBucket.NEXT_7_DAYS
    if delta <= 30:
        return DateBucket.NEXT_30_DAYS
    return DateBucket.LATER


def _project_aggregate(snapshot: ProjectSnapshot) -> ProjectAggregate:
    open_count = len(snapshot.open_actions)
    completed_count = len(snapshot.completed_actions)
    total = open_count + completed_count
    completion = round((completed_count / total) * 100, 1) if total else 0.0
    return ProjectAggregate(
        project_path=snapshot.project_path,
        project_title=snapshot.project_title,
        open_actions=open_count,
        completed_actions=completed_count,
        tagged_figures=len(snapshot.tagged_figures),
        links=len(snapshot.link_figures),
        section_count=len(snapshot.sections),
        completion_percent=completion,
    )


def aggregate_snapshots(
    snapshots: tuple[ProjectSnapshot, ...],
    *,
    today: date,
    timezone: ZoneInfo,
) -> DashboardAggregate:
    buckets: Counter[DateBucket] = Counter({bucket: 0 for bucket in DateBucket})
    priorities: Counter[int] = Counter()
    tags: Counter[str] = Counter()
    invalid: list[str] = []

    for snapshot in snapshots:
        for figure in snapshot.open_actions:
            priorities[figure.priority] += 1
            try:
                buckets[
                    classify_due_date(figure.due_date, today=today, timezone=timezone)
                ] += 1
            except CurioDateError:
                invalid.append(f"{figure.project_path}::{figure.id}")
        for figure in snapshot.tagged_figures:
            tags.update(figure.tags)

    projects = tuple(_project_aggregate(snapshot) for snapshot in snapshots)
    open_count = sum(project.open_actions for project in projects)
    completed_count = sum(project.completed_actions for project in projects)
    total = open_count + completed_count
    completion = round((completed_count / total) * 100, 1) if total else 0.0
    return DashboardAggregate(
        project_count=len(projects),
        open_actions=open_count,
        completed_actions=completed_count,
        tagged_figures=sum(project.tagged_figures for project in projects),
        links=sum(project.links for project in projects),
        completion_percent=completion,
        date_buckets=dict(buckets),
        priority_counts=dict(sorted(priorities.items())),
        tag_counts=dict(tags.most_common()),
        invalid_due_dates=tuple(invalid),
        projects=projects,
    )
src/curio_dashboard/cache.py7 KB
"""SQLite storage for last-known-good project snapshots and refresh state."""

from __future__ import annotations

import asyncio
import sqlite3
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path

from curio_dashboard.models import ProjectSnapshot, RefreshState


@dataclass(frozen=True, slots=True)
class RefreshRecord:
    project_path: str
    project_title: str
    state: RefreshState
    last_attempted_at: datetime
    last_successful_at: datetime | None
    error: str | None


class SnapshotCache:
    def __init__(self, path: str | Path) -> None:
        self.path = Path(path)

    def _connect(self) -> sqlite3.Connection:
        connection = sqlite3.connect(self.path)
        connection.row_factory = sqlite3.Row
        return connection

    def _initialize_sync(self) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with self._connect() as connection:
            connection.execute("PRAGMA journal_mode=WAL")
            connection.executescript(
                """
                CREATE TABLE IF NOT EXISTS snapshots (
                    project_path TEXT PRIMARY KEY,
                    project_title TEXT NOT NULL,
                    collected_at TEXT NOT NULL,
                    payload_json TEXT NOT NULL
                );

                CREATE TABLE IF NOT EXISTS refresh_attempts (
                    project_path TEXT PRIMARY KEY,
                    project_title TEXT NOT NULL,
                    state TEXT NOT NULL,
                    last_attempted_at TEXT NOT NULL,
                    last_successful_at TEXT,
                    error TEXT
                );
                """
            )

    async def initialize(self) -> None:
        await asyncio.to_thread(self._initialize_sync)

    def _save_snapshot_sync(self, snapshot: ProjectSnapshot) -> None:
        attempted_at = datetime.now(UTC)
        with self._connect() as connection:
            connection.execute(
                """
                INSERT INTO snapshots (
                    project_path, project_title, collected_at, payload_json
                ) VALUES (?, ?, ?, ?)
                ON CONFLICT(project_path) DO UPDATE SET
                    project_title = excluded.project_title,
                    collected_at = excluded.collected_at,
                    payload_json = excluded.payload_json
                """,
                (
                    snapshot.project_path,
                    snapshot.project_title,
                    snapshot.collected_at.isoformat(),
                    snapshot.model_dump_json(),
                ),
            )
            connection.execute(
                """
                INSERT INTO refresh_attempts (
                    project_path, project_title, state, last_attempted_at,
                    last_successful_at, error
                ) VALUES (?, ?, ?, ?, ?, NULL)
                ON CONFLICT(project_path) DO UPDATE SET
                    project_title = excluded.project_title,
                    state = excluded.state,
                    last_attempted_at = excluded.last_attempted_at,
                    last_successful_at = excluded.last_successful_at,
                    error = NULL
                """,
                (
                    snapshot.project_path,
                    snapshot.project_title,
                    RefreshState.CURRENT.value,
                    attempted_at.isoformat(),
                    snapshot.collected_at.isoformat(),
                ),
            )

    async def save_snapshot(self, snapshot: ProjectSnapshot) -> None:
        await asyncio.to_thread(self._save_snapshot_sync, snapshot)

    def _load_snapshot_sync(self, project_path: str) -> ProjectSnapshot | None:
        with self._connect() as connection:
            row = connection.execute(
                "SELECT payload_json FROM snapshots WHERE project_path = ?",
                (project_path,),
            ).fetchone()
        if row is None:
            return None
        return ProjectSnapshot.model_validate_json(row["payload_json"])

    async def load_snapshot(self, project_path: str) -> ProjectSnapshot | None:
        return await asyncio.to_thread(self._load_snapshot_sync, project_path)

    def _record_failure_sync(
        self,
        *,
        project_path: str,
        project_title: str,
        attempted_at: datetime,
        error: str,
    ) -> None:
        with self._connect() as connection:
            previous = connection.execute(
                "SELECT last_successful_at FROM refresh_attempts WHERE project_path = ?",
                (project_path,),
            ).fetchone()
            last_success = previous["last_successful_at"] if previous else None
            cached = connection.execute(
                "SELECT 1 FROM snapshots WHERE project_path = ?",
                (project_path,),
            ).fetchone()
            state = RefreshState.STALE if cached else RefreshState.ERROR
            connection.execute(
                """
                INSERT INTO refresh_attempts (
                    project_path, project_title, state, last_attempted_at,
                    last_successful_at, error
                ) VALUES (?, ?, ?, ?, ?, ?)
                ON CONFLICT(project_path) DO UPDATE SET
                    project_title = excluded.project_title,
                    state = excluded.state,
                    last_attempted_at = excluded.last_attempted_at,
                    last_successful_at = excluded.last_successful_at,
                    error = excluded.error
                """,
                (
                    project_path,
                    project_title,
                    state.value,
                    attempted_at.isoformat(),
                    last_success,
                    error,
                ),
            )

    async def record_failure(
        self,
        *,
        project_path: str,
        project_title: str,
        attempted_at: datetime,
        error: str,
    ) -> None:
        await asyncio.to_thread(
            self._record_failure_sync,
            project_path=project_path,
            project_title=project_title,
            attempted_at=attempted_at,
            error=error,
        )

    def _load_refresh_record_sync(self, project_path: str) -> RefreshRecord | None:
        with self._connect() as connection:
            row = connection.execute(
                "SELECT * FROM refresh_attempts WHERE project_path = ?",
                (project_path,),
            ).fetchone()
        if row is None:
            return None
        return RefreshRecord(
            project_path=row["project_path"],
            project_title=row["project_title"],
            state=RefreshState(row["state"]),
            last_attempted_at=datetime.fromisoformat(row["last_attempted_at"]),
            last_successful_at=(
                datetime.fromisoformat(row["last_successful_at"])
                if row["last_successful_at"]
                else None
            ),
            error=row["error"],
        )

    async def load_refresh_record(self, project_path: str) -> RefreshRecord | None:
        return await asyncio.to_thread(self._load_refresh_record_sync, project_path)
src/curio_dashboard/cli.py13 KB
"""Safe async execution and parsing for read-only Curio CLI commands."""

from __future__ import annotations

import asyncio
import json
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any

JSONMapping = Mapping[str, Any]


class CurioResponseError(ValueError):
    """Raised when a Curio CLI response is unsuccessful or malformed."""

    def __init__(self, code: str, message: str) -> None:
        self.code = code
        self.message = message
        super().__init__(f"{code}: {message}")


class CurioCommandError(RuntimeError):
    """Raised when the CLI process cannot produce a usable JSON response."""

    def __init__(
        self,
        code: str,
        message: str,
        *,
        exit_code: int | None = None,
    ) -> None:
        self.code = code
        self.message = message
        self.exit_code = exit_code
        super().__init__(f"{code}: {message}")


class CurioCommandTimeout(CurioCommandError):
    pass


@dataclass(frozen=True, slots=True)
class CommandResult:
    exit_code: int
    stdout: bytes
    stderr: bytes


CommandExecutor = Callable[[tuple[str, ...], float], Awaitable[CommandResult]]


async def execute_subprocess(argv: tuple[str, ...], timeout: float) -> CommandResult:
    """Execute an argv array without a shell and enforce a hard timeout."""

    process = await asyncio.create_subprocess_exec(
        *argv,
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    try:
        stdout, stderr = await asyncio.wait_for(process.communicate(), timeout)
    except TimeoutError as error:
        process.kill()
        await process.communicate()
        raise CurioCommandTimeout(
            "CommandTimeout",
            f"Curio command exceeded {timeout:g} seconds",
        ) from error
    except asyncio.CancelledError:
        process.kill()
        await process.communicate()
        raise
    return CommandResult(process.returncode or 0, stdout, stderr)


@dataclass(frozen=True, slots=True)
class ResponseMetadata:
    api_version: int
    app_version: str
    app_build: str


@dataclass(frozen=True, slots=True)
class Category:
    id: str
    title: str
    kind: str
    project_count: int
    packaged: bool
    sort_by_title: bool


@dataclass(frozen=True, slots=True)
class CategoryDiscovery:
    metadata: ResponseMetadata
    data_source: str
    project_center_loaded: bool
    categories: tuple[Category, ...]


@dataclass(frozen=True, slots=True)
class Project:
    title: str
    path: str
    valid: bool
    status_ignored: bool
    path_is_last_known: bool
    categories: tuple[Category, ...]


@dataclass(frozen=True, slots=True)
class ProjectDiscovery:
    metadata: ResponseMetadata
    data_source: str
    project_center_loaded: bool
    category: Category
    projects: tuple[Project, ...]


@dataclass(frozen=True, slots=True)
class QueryResult:
    metadata: ResponseMetadata
    items: tuple[JSONMapping, ...]
    group_by: str | None
    sort_by: str | None


@dataclass(frozen=True, slots=True)
class TagCatalog:
    metadata: ResponseMetadata
    items: tuple[str, ...]


@dataclass(frozen=True, slots=True)
class SectionCatalog:
    metadata: ResponseMetadata
    items: tuple[JSONMapping, ...]


def _mapping(value: Any, context: str) -> JSONMapping:
    if not isinstance(value, Mapping):
        raise CurioResponseError("InvalidResponse", f"{context} must be an object")
    return value


def _sequence(value: Any, context: str) -> Sequence[Any]:
    if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
        raise CurioResponseError("InvalidResponse", f"{context} must be an array")
    return value


def _required(mapping: JSONMapping, key: str, expected: type, context: str) -> Any:
    if key not in mapping:
        raise CurioResponseError(
            "InvalidResponse", f"{context} is missing required field {key!r}"
        )
    value = mapping[key]
    if not isinstance(value, expected):
        raise CurioResponseError(
            "InvalidResponse", f"{context}.{key} has an invalid type"
        )
    return value


def _envelope(payload: Any) -> tuple[ResponseMetadata, JSONMapping]:
    envelope = _mapping(payload, "response")
    ok = _required(envelope, "response_ok", bool, "response")
    if not ok:
        error = _mapping(envelope.get("error", {}), "response.error")
        code = str(error.get("code", "CurioError"))
        message = str(error.get("message", "Curio CLI request failed"))
        raise CurioResponseError(code, message)

    metadata = ResponseMetadata(
        api_version=_required(envelope, "api_version", int, "response"),
        app_version=str(envelope.get("app_version", "unknown")),
        app_build=str(envelope.get("app_build", "unknown")),
    )
    return metadata, _mapping(envelope.get("result"), "response.result")


def _category(payload: Any, context: str) -> Category:
    item = _mapping(payload, context)
    return Category(
        id=_required(item, "id", str, context),
        title=_required(item, "title", str, context),
        kind=_required(item, "kind", str, context),
        project_count=_required(item, "project_count", int, context),
        packaged=_required(item, "packaged", bool, context),
        sort_by_title=_required(item, "sort_by_title", bool, context),
    )


def _data_source(result: JSONMapping) -> str:
    value = _required(result, "data_source", str, "response.result")
    if value not in {"live", "persisted"}:
        raise CurioResponseError(
            "InvalidResponse", f"unsupported Project Center data source {value!r}"
        )
    return value


def _validate_count(result: JSONMapping, items: Sequence[Any]) -> None:
    count = _required(result, "count", int, "response.result")
    if count != len(items):
        raise CurioResponseError(
            "InvalidResponse",
            f"response.result.count is {count}, but {len(items)} items were returned",
        )


def parse_categories(payload: Any) -> CategoryDiscovery:
    """Parse ``curio project-center categories`` output."""

    metadata, result = _envelope(payload)
    raw_categories = _sequence(result.get("categories"), "response.result.categories")
    _validate_count(result, raw_categories)
    categories = tuple(
        _category(item, f"response.result.categories[{index}]")
        for index, item in enumerate(raw_categories)
    )
    return CategoryDiscovery(
        metadata=metadata,
        data_source=_data_source(result),
        project_center_loaded=_required(
            result, "project_center_loaded", bool, "response.result"
        ),
        categories=categories,
    )


def parse_projects(payload: Any) -> ProjectDiscovery:
    """Parse ``curio project-center projects`` output."""

    metadata, result = _envelope(payload)
    raw_projects = _sequence(result.get("projects"), "response.result.projects")
    _validate_count(result, raw_projects)
    projects: list[Project] = []
    for index, raw_project in enumerate(raw_projects):
        context = f"response.result.projects[{index}]"
        item = _mapping(raw_project, context)
        raw_categories = _sequence(item.get("categories", ()), f"{context}.categories")
        projects.append(
            Project(
                title=_required(item, "title", str, context),
                path=_required(item, "path", str, context),
                valid=_required(item, "valid", bool, context),
                status_ignored=_required(item, "status_ignored", bool, context),
                # Current CLI output omits this field when the condition is false.
                path_is_last_known=bool(item.get("path_is_last_known", False)),
                categories=tuple(
                    _category(category, f"{context}.categories[{category_index}]")
                    for category_index, category in enumerate(raw_categories)
                ),
            )
        )
    return ProjectDiscovery(
        metadata=metadata,
        data_source=_data_source(result),
        project_center_loaded=_required(
            result, "project_center_loaded", bool, "response.result"
        ),
        category=_category(result.get("category"), "response.result.category"),
        projects=tuple(projects),
    )


def parse_query(payload: Any) -> QueryResult:
    """Parse an ungrouped ``curio query`` response."""

    metadata, result = _envelope(payload)
    raw_items = _sequence(result.get("items"), "response.result.items")
    _validate_count(result, raw_items)
    items = tuple(
        _mapping(item, f"response.result.items[{index}]")
        for index, item in enumerate(raw_items)
    )
    group_by = result.get("groupBy")
    sort_by = result.get("sortBy")
    if group_by is not None and not isinstance(group_by, str):
        raise CurioResponseError(
            "InvalidResponse", "result.groupBy has an invalid type"
        )
    if sort_by is not None and not isinstance(sort_by, str):
        raise CurioResponseError("InvalidResponse", "result.sortBy has an invalid type")
    return QueryResult(
        metadata=metadata,
        items=items,
        group_by=group_by,
        sort_by=sort_by,
    )


def parse_tags(payload: Any) -> TagCatalog:
    """Parse ``curio tags`` output while preserving catalog order."""

    metadata, result = _envelope(payload)
    raw_items = _sequence(result.get("items"), "response.result.items")
    _validate_count(result, raw_items)
    if not all(isinstance(item, str) for item in raw_items):
        raise CurioResponseError(
            "InvalidResponse", "response.result.items must contain only tag strings"
        )
    return TagCatalog(metadata=metadata, items=tuple(raw_items))


def parse_sections(payload: Any) -> SectionCatalog:
    """Parse ``curio sections`` output, preserving nested section dictionaries."""

    metadata, result = _envelope(payload)
    raw_items = _sequence(result.get("items"), "response.result.items")
    _validate_count(result, raw_items)
    items = tuple(
        _mapping(item, f"response.result.items[{index}]")
        for index, item in enumerate(raw_items)
    )
    return SectionCatalog(metadata=metadata, items=items)


class CurioRunner:
    """Bounded, read-only Curio CLI adapter.

    Only explicit read methods are exposed. Arguments are passed as an argv tuple,
    so project paths and query strings can never be interpreted by a shell.
    """

    def __init__(
        self,
        *,
        executable: str = "curio",
        timeout: float = 10.0,
        max_concurrency: int = 3,
        executor: CommandExecutor = execute_subprocess,
    ) -> None:
        self.executable = executable
        self.timeout = timeout
        self._semaphore = asyncio.Semaphore(max_concurrency)
        self._executor = executor

    async def _json(self, *arguments: str) -> Any:
        argv = (self.executable, *arguments)
        async with self._semaphore:
            result = await self._executor(argv, self.timeout)

        try:
            payload = json.loads(result.stdout.decode("utf-8"))
        except (UnicodeDecodeError, json.JSONDecodeError) as error:
            stderr = result.stderr.decode("utf-8", errors="replace").strip()
            detail = stderr[:500] if stderr else "Curio CLI returned invalid JSON"
            raise CurioCommandError(
                "InvalidJSON",
                detail,
                exit_code=result.exit_code,
            ) from error
        return payload

    @staticmethod
    def _project_path(path: str) -> str:
        project = Path(path)
        if not project.is_absolute():
            raise ValueError("Curio project path must be absolute")
        if project.suffix.casefold() != ".curio":
            raise ValueError("Curio project path must end in .curio")
        return str(project)

    async def categories(self) -> CategoryDiscovery:
        return parse_categories(await self._json("project-center", "categories"))

    async def projects(self, category_id: str) -> ProjectDiscovery:
        if not category_id:
            raise ValueError("category_id must not be empty")
        return parse_projects(
            await self._json(
                "project-center",
                "projects",
                "--category",
                category_id,
            )
        )

    async def query(
        self,
        *,
        project_path: str,
        query: str,
        fields: tuple[str, ...],
    ) -> QueryResult:
        if not fields:
            raise ValueError("query fields must not be empty")
        return parse_query(
            await self._json(
                "query",
                query,
                "--fields",
                ",".join(fields),
                "--project",
                self._project_path(project_path),
            )
        )

    async def tags(self, *, project_path: str) -> TagCatalog:
        return parse_tags(
            await self._json(
                "tags",
                "--project",
                self._project_path(project_path),
            )
        )

    async def sections(self, *, project_path: str) -> SectionCatalog:
        return parse_sections(
            await self._json(
                "sections",
                "--project",
                self._project_path(project_path),
            )
        )
src/curio_dashboard/collector.py3 KB
"""Concurrent read-only project collection."""

from __future__ import annotations

import asyncio
from datetime import UTC, datetime

from curio_dashboard.cli import CurioRunner, Project
from curio_dashboard.models import ProjectSnapshot
from curio_dashboard.normalize import normalize_query

QUERY_PACK_VERSION = "2026-07-26.3"
OPEN_ITEMS_QUERY = "include:nocheckbox #meta/tagged progress < 100 sort:due"
COMPLETED_ITEMS_QUERY = "include:nocheckbox #meta/tagged progress = 100 sort:-done"

OPEN_ACTION_FIELDS = (
    "id",
    "title",
    "tags",
    "start_date",
    "due_date",
    "done_date",
    "priority",
    "progress",
    "checkmark_visible",
    "kind",
    "section_path",
    "organizer_path",
    "link",
)
TAGGED_FIELDS = (
    "id",
    "title",
    "tags",
    "kind",
    "modified_date",
    "due_date",
    "priority",
    "progress",
    "checkmark_visible",
    "section_path",
    "organizer_path",
    "link",
)
COMPLETED_FIELDS = (
    "id",
    "title",
    "tags",
    "done_date",
    "priority",
    "progress",
    "checkmark_visible",
    "kind",
    "section_path",
    "organizer_path",
    "link",
)
LINK_FIELDS = (
    "id",
    "title",
    "kind",
    "url",
    "link",
    "references",
    "section_path",
    "organizer_path",
)


class ProjectReader:
    def __init__(self, runner: CurioRunner) -> None:
        self.runner = runner

    async def read(self, project: Project) -> ProjectSnapshot:
        if not project.valid:
            raise ValueError(f"project {project.title!r} is unavailable")

        (
            open_actions,
            tagged,
            completed,
            links,
            tags,
            sections,
        ) = await asyncio.gather(
            self.runner.query(
                project_path=project.path,
                query=OPEN_ITEMS_QUERY,
                fields=OPEN_ACTION_FIELDS,
            ),
            self.runner.query(
                project_path=project.path,
                query="#meta/tagged",
                fields=TAGGED_FIELDS,
            ),
            self.runner.query(
                project_path=project.path,
                query=COMPLETED_ITEMS_QUERY,
                fields=COMPLETED_FIELDS,
            ),
            self.runner.query(
                project_path=project.path,
                query="kind=url",
                fields=LINK_FIELDS,
            ),
            self.runner.tags(project_path=project.path),
            self.runner.sections(project_path=project.path),
        )

        return ProjectSnapshot(
            query_pack_version=QUERY_PACK_VERSION,
            project_path=project.path,
            project_title=project.title,
            collected_at=datetime.now(UTC),
            cli_version=open_actions.metadata.app_version,
            cli_build=open_actions.metadata.app_build,
            open_actions=normalize_query(open_actions, project),
            tagged_figures=normalize_query(tagged, project),
            completed_actions=normalize_query(completed, project),
            link_figures=normalize_query(links, project),
            tag_catalog=tags.items,
            sections=tuple(dict(item) for item in sections.items),
        )
src/curio_dashboard/config.py5 KB
"""Validated YAML configuration for the local dashboard."""

from __future__ import annotations

import os
import tempfile
from enum import StrEnum
from pathlib import Path
from typing import Any

import yaml
from pydantic import BaseModel, ConfigDict, Field, model_validator


class SelectionMode(StrEnum):
    CATEGORY = "category"
    PROJECT = "project"


class SelectionConfig(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)

    mode: SelectionMode = SelectionMode.CATEGORY
    category_id: str = Field(min_length=1)
    category_title_hint: str | None = None
    project_path: Path | None = None

    @model_validator(mode="after")
    def validate_project_selection(self) -> SelectionConfig:
        if self.mode is SelectionMode.PROJECT and self.project_path is None:
            raise ValueError("project_path is required when selection.mode is project")
        if self.project_path is not None and not self.project_path.is_absolute():
            raise ValueError("project_path must be absolute")
        return self


class DashboardConfig(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)

    profile: str = "travel"
    refresh_seconds: int = Field(default=60, ge=10)
    discovery_refresh_seconds: int = Field(default=300, ge=30)
    include_status_ignored: bool = False
    kanban_tag_set: str = Field(default="Travel Stage", min_length=1)
    kanban_include_unassigned: bool = True


class DisplayConfig(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)

    theme: str = "system"
    density: str = "comfortable"


class RuntimeConfig(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)

    curio_executable: str = Field(default="curio", min_length=1)
    command_timeout_seconds: float = Field(default=10.0, gt=0, le=120)
    max_concurrency: int = Field(default=3, ge=1, le=8)
    cache_path: Path = Path(".data/snapshots.sqlite3")
    timezone: str = Field(default="America/New_York", min_length=1)


class AppConfig(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)

    selection: SelectionConfig
    dashboard: DashboardConfig = DashboardConfig()
    display: DisplayConfig = DisplayConfig()
    runtime: RuntimeConfig = RuntimeConfig()


def load_config(path: str | Path) -> AppConfig:
    """Load YAML and resolve runtime paths relative to the config file."""

    config_path = Path(path).resolve()
    try:
        raw: Any = yaml.safe_load(config_path.read_text(encoding="utf-8"))
    except OSError as error:
        raise ValueError(
            f"unable to read dashboard config {config_path}: {error}"
        ) from error
    except yaml.YAMLError as error:
        raise ValueError(
            f"invalid YAML in dashboard config {config_path}: {error}"
        ) from error

    if not isinstance(raw, dict):
        raise TypeError("dashboard config must contain a YAML object")
    config = AppConfig.model_validate(raw)
    cache_path = config.runtime.cache_path
    if not cache_path.is_absolute():
        cache_path = (config_path.parent / cache_path).resolve()
        config = config.model_copy(
            update={
                "runtime": config.runtime.model_copy(update={"cache_path": cache_path})
            }
        )
    return config


def save_config(config: AppConfig, path: str | Path) -> None:
    """Atomically persist dashboard preferences without touching Curio data."""

    config_path = Path(path).resolve()
    serialized = config.model_dump(mode="json")
    cache_path = Path(serialized["runtime"]["cache_path"])
    if cache_path.is_absolute():
        serialized["runtime"]["cache_path"] = os.path.relpath(
            cache_path,
            start=config_path.parent,
        )

    config_path.parent.mkdir(parents=True, exist_ok=True)
    temporary_path: str | None = None
    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            dir=config_path.parent,
            prefix=f".{config_path.name}.",
            suffix=".tmp",
            delete=False,
        ) as temporary:
            temporary_path = temporary.name
            yaml.safe_dump(
                serialized,
                temporary,
                allow_unicode=True,
                sort_keys=False,
            )
            temporary.flush()
            os.fsync(temporary.fileno())
        os.replace(temporary_path, config_path)
    except OSError as error:
        if temporary_path:
            Path(temporary_path).unlink(missing_ok=True)
        raise ValueError(
            f"unable to save dashboard config {config_path}: {error}"
        ) from error
src/curio_dashboard/discovery.py3 KB
"""Project Center discovery and trusted selection resolution."""

from __future__ import annotations

from dataclasses import dataclass
from datetime import UTC, datetime

from curio_dashboard.cli import (
    CategoryDiscovery,
    CurioRunner,
    Project,
    ProjectDiscovery,
)
from curio_dashboard.config import AppConfig, SelectionMode


@dataclass(frozen=True, slots=True)
class DiscoverySnapshot:
    discovered_at: datetime
    categories: CategoryDiscovery
    projects: ProjectDiscovery


@dataclass(frozen=True, slots=True)
class ResolvedSelection:
    scope: tuple[Project, ...]
    included: tuple[Project, ...]
    warning: str | None


class DiscoveryService:
    def __init__(self, runner: CurioRunner) -> None:
        self.runner = runner

    async def discover(self, category_id: str) -> DiscoverySnapshot:
        categories = await self.runner.categories()
        projects = await self.runner.projects(category_id)
        return DiscoverySnapshot(
            discovered_at=datetime.now(UTC),
            categories=categories,
            projects=projects,
        )


def resolve_selection(
    discovery: ProjectDiscovery,
    config: AppConfig,
) -> ResolvedSelection:
    """Resolve only paths returned by the latest Project Center response."""

    visible = tuple(
        project
        for project in discovery.projects
        if config.dashboard.include_status_ignored or not project.status_ignored
    )
    eligible = tuple(project for project in visible if project.valid)
    if config.selection.mode is SelectionMode.CATEGORY:
        return ResolvedSelection(scope=visible, included=eligible, warning=None)

    selected_path = str(config.selection.project_path)
    selected = next(
        (project for project in discovery.projects if project.path == selected_path),
        None,
    )
    if selected is None:
        return ResolvedSelection(
            scope=visible,
            included=eligible,
            warning=(
                "The selected project is no longer present in the latest category "
                "response; using the category-wide view."
            ),
        )
    if not selected.valid:
        return ResolvedSelection(
            scope=visible,
            included=eligible,
            warning=(
                "The selected project is currently unavailable; using the "
                "category-wide view."
            ),
        )
    if selected.status_ignored and not config.dashboard.include_status_ignored:
        return ResolvedSelection(
            scope=visible,
            included=eligible,
            warning=(
                "The selected project is ignored by status; using the "
                "category-wide view."
            ),
        )
    return ResolvedSelection(scope=(selected,), included=(selected,), warning=None)
src/curio_dashboard/main.py4 KB
"""Local-only FastAPI application for the Curio dashboard."""

from __future__ import annotations

import asyncio
import logging
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager, suppress
from pathlib import Path
from time import monotonic

import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates

from curio_dashboard.config import load_config
from curio_dashboard.models import DashboardState
from curio_dashboard.service import DashboardService
from curio_dashboard.web import register_web_routes

DEFAULT_CONFIG_PATH = Path(__file__).resolve().parents[2] / "config" / "dashboard.yaml"
PACKAGE_PATH = Path(__file__).resolve().parent
LOGGER = logging.getLogger(__name__)


def create_app(
    *,
    service: DashboardService | None = None,
    config_path: str | Path | None = None,
    enable_scheduler: bool = True,
) -> FastAPI:
    if service is None:
        selected_config = Path(
            config_path or os.environ.get("CURIO_DASHBOARD_CONFIG", DEFAULT_CONFIG_PATH)
        )
        service = DashboardService(
            load_config(selected_config),
            config_path=str(selected_config),
        )

    @asynccontextmanager
    async def lifespan(app: FastAPI) -> AsyncIterator[None]:
        task: asyncio.Task[None] | None = None
        if enable_scheduler and hasattr(service, "config"):
            task = asyncio.create_task(_scheduled_refresh(service))
        try:
            yield
        finally:
            if task is not None:
                task.cancel()
                with suppress(asyncio.CancelledError):
                    await task

    app = FastAPI(
        title="Curio Dashboard",
        version="1.0.4",
        lifespan=lifespan,
        docs_url="/docs",
        redoc_url=None,
    )
    app.state.dashboard_service = service
    app.mount(
        "/static",
        StaticFiles(directory=PACKAGE_PATH / "static"),
        name="static",
    )
    templates = Jinja2Templates(directory=PACKAGE_PATH / "templates")
    register_web_routes(app, service=service, templates=templates)

    @app.get("/api")
    async def api_index() -> dict[str, object]:
        return {
            "name": "Curio Dashboard",
            "milestone": 5,
            "mode": "read-only local release",
            "endpoints": {
                "health": "/health",
                "status": "/api/status",
                "refresh": "POST /api/refresh",
                "openapi": "/docs",
            },
        }

    @app.get("/health")
    async def health() -> dict[str, str]:
        return {"status": "ok"}

    async def state_or_503(*, force_refresh: bool) -> DashboardState:
        try:
            if force_refresh:
                return await service.refresh()
            state = await service.get_state(refresh_if_empty=True)
            if state is None:
                raise RuntimeError("dashboard state is not initialized")
            return state
        except Exception as error:
            raise HTTPException(
                status_code=503,
                detail={
                    "code": type(error).__name__,
                    "message": str(error)[:500],
                },
            ) from error

    @app.get("/api/status", response_model=DashboardState)
    async def status() -> DashboardState:
        return await state_or_503(force_refresh=False)

    @app.post("/api/refresh", response_model=DashboardState)
    async def refresh() -> DashboardState:
        return await state_or_503(force_refresh=True)

    return app


async def _scheduled_refresh(service: DashboardService) -> None:
    last_discovery = monotonic()
    while True:
        await asyncio.sleep(service.config.dashboard.refresh_seconds)
        discover = (
            monotonic() - last_discovery
            >= service.config.dashboard.discovery_refresh_seconds
        )
        try:
            await service.refresh(discover=discover)
            if discover:
                last_discovery = monotonic()
        except Exception as error:  # noqa: BLE001
            # The last successful state remains visible until a later refresh.
            LOGGER.warning(
                "scheduled refresh failed: %s",
                type(error).__name__,
            )


app = create_app()


def run() -> None:
    uvicorn.run(
        "curio_dashboard.main:app",
        host="127.0.0.1",
        port=8765,
        reload=False,
    )
src/curio_dashboard/models.py5 KB
"""Normalized and diagnostic models shared by the data core."""

from __future__ import annotations

from datetime import datetime
from enum import StrEnum
from typing import Any

from pydantic import BaseModel, ConfigDict, Field


class FrozenModel(BaseModel):
    model_config = ConfigDict(extra="forbid", frozen=True)


class RefreshState(StrEnum):
    CURRENT = "current"
    REFRESHING = "refreshing"
    STALE = "stale"
    UNAVAILABLE = "unavailable"
    IGNORED = "ignored"
    NOT_SELECTED = "not_selected"
    ERROR = "error"


class DateBucket(StrEnum):
    OVERDUE = "overdue"
    TODAY = "today"
    TOMORROW = "tomorrow"
    NEXT_7_DAYS = "next_7_days"
    NEXT_30_DAYS = "next_30_days"
    LATER = "later"
    UNSCHEDULED = "unscheduled"


class NormalizedFigure(FrozenModel):
    project_path: str
    project_title: str
    id: str
    title: str = ""
    kind: str = "unknown"
    tags: tuple[str, ...] = ()
    start_date: str | None = None
    due_date: str | None = None
    done_date: str | None = None
    modified_date: str | None = None
    priority: int = Field(default=0, ge=0, le=5)
    progress: int = Field(default=0, ge=0, le=100)
    checkmark_visible: bool = False
    section_path: str | None = None
    organizer_path: str | None = None
    url: str | None = None
    link: str | None = None
    references: dict[str, tuple[str, ...]] = Field(default_factory=dict)

    @property
    def composite_id(self) -> tuple[str, str]:
        return self.project_path, self.id


class ProjectSnapshot(FrozenModel):
    query_pack_version: str
    project_path: str
    project_title: str
    collected_at: datetime
    cli_version: str
    cli_build: str
    # Field names are retained for snapshot compatibility. These collections
    # are progress-based tagged items, independent of checkbox visibility.
    open_actions: tuple[NormalizedFigure, ...] = ()
    tagged_figures: tuple[NormalizedFigure, ...] = ()
    completed_actions: tuple[NormalizedFigure, ...] = ()
    link_figures: tuple[NormalizedFigure, ...] = ()
    tag_catalog: tuple[str, ...] = ()
    sections: tuple[dict[str, Any], ...] = ()
    warnings: tuple[str, ...] = ()


class ProjectAggregate(FrozenModel):
    project_path: str
    project_title: str
    open_actions: int
    completed_actions: int
    tagged_figures: int
    links: int
    section_count: int
    completion_percent: float


class DashboardAggregate(FrozenModel):
    project_count: int
    open_actions: int
    completed_actions: int
    tagged_figures: int
    links: int
    completion_percent: float
    date_buckets: dict[DateBucket, int]
    priority_counts: dict[int, int]
    tag_counts: dict[str, int]
    invalid_due_dates: tuple[str, ...]
    projects: tuple[ProjectAggregate, ...]


class DiagnosticProject(FrozenModel):
    title: str
    path: str
    valid: bool
    status_ignored: bool
    path_is_last_known: bool
    refresh_state: RefreshState
    last_attempted_refresh: datetime | None = None
    last_successful_refresh: datetime | None = None
    using_cached_snapshot: bool = False
    error: str | None = None
    snapshot: ProjectSnapshot | None = None


class CategoryOption(FrozenModel):
    id: str
    title: str
    kind: str
    project_count: int
    selectable: bool


class TagSetDefinition(FrozenModel):
    name: str
    tags: tuple[str, ...]


class TagStatistic(FrozenModel):
    tag: str
    total_figures: int
    open_actions: int
    overdue_actions: int
    completed_actions: int
    last_modified: str | None = None


class ProjectTagRow(FrozenModel):
    project_path: str
    project_title: str
    counts: dict[str, int]


class TagAnalytics(FrozenModel):
    total_tagged_figures: int
    distinct_used_tags: int
    tagged_open_actions: int
    tagged_overdue_actions: int
    tagged_completed_actions: int
    tag_sets: tuple[TagSetDefinition, ...]
    statistics: tuple[TagStatistic, ...]
    project_matrix: tuple[ProjectTagRow, ...]
    tag_set_distribution: dict[str, int]


class BoardCard(FrozenModel):
    figure: NormalizedFigure
    is_open_action: bool
    conflicting: bool


class BoardColumn(FrozenModel):
    tag: str
    label: str
    cards: tuple[BoardCard, ...]


class KanbanBoard(FrozenModel):
    tag_set: str | None
    available_tag_sets: tuple[str, ...]
    columns: tuple[BoardColumn, ...]
    unassigned: tuple[BoardCard, ...]
    conflicting_cards: tuple[str, ...]
    warnings: tuple[str, ...]


class DashboardState(FrozenModel):
    generated_at: datetime
    last_refresh_attempt: datetime | None = None
    last_refresh_error: str | None = None
    discovery_timestamp: datetime
    discovery_data_source: str
    category_id: str
    category_title: str
    available_categories: tuple[CategoryOption, ...] = ()
    selected_project_path: str | None = None
    selection_warning: str | None = None
    projects: tuple[DiagnosticProject, ...]
    aggregate: DashboardAggregate
    tag_analytics: TagAnalytics = Field(
        default_factory=lambda: TagAnalytics(
            total_tagged_figures=0,
            distinct_used_tags=0,
            tagged_open_actions=0,
            tagged_overdue_actions=0,
            tagged_completed_actions=0,
            tag_sets=(),
            statistics=(),
            project_matrix=(),
            tag_set_distribution={},
        )
    )
    board: KanbanBoard = Field(
        default_factory=lambda: KanbanBoard(
            tag_set=None,
            available_tag_sets=(),
            columns=(),
            unassigned=(),
            conflicting_cards=(),
            warnings=(),
        )
    )
src/curio_dashboard/normalize.py3 KB
"""Normalize loose Curio query dictionaries into stable dashboard models."""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import Any

from curio_dashboard.cli import Project, QueryResult
from curio_dashboard.models import NormalizedFigure


def _optional_string(value: Any) -> str | None:
    return value if isinstance(value, str) and value else None


def _string_tuple(value: Any) -> tuple[str, ...]:
    if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
        return ()
    return tuple(item for item in value if isinstance(item, str))


def _references(value: Any) -> dict[str, tuple[str, ...]]:
    if not isinstance(value, Mapping):
        return {}
    return {
        str(name): _string_tuple(links)
        for name, links in value.items()
        if isinstance(name, str)
    }


def normalize_figure(raw: Mapping[str, Any], project: Project) -> NormalizedFigure:
    """Normalize one query item while retaining project attribution."""

    figure_id = raw.get("id")
    if not isinstance(figure_id, str) or not figure_id:
        raise ValueError("query figure is missing a non-empty id")

    priority = raw.get("priority", 0)
    progress = raw.get("progress", 0)
    return NormalizedFigure(
        project_path=project.path,
        project_title=project.title,
        id=figure_id,
        title=raw.get("title") if isinstance(raw.get("title"), str) else "",
        kind=raw.get("kind") if isinstance(raw.get("kind"), str) else "unknown",
        tags=_string_tuple(raw.get("tags")),
        start_date=_optional_string(raw.get("start_date")),
        due_date=_optional_string(raw.get("due_date")),
        done_date=_optional_string(raw.get("done_date")),
        modified_date=_optional_string(raw.get("modified_date")),
        priority=priority if isinstance(priority, int) else 0,
        progress=progress if isinstance(progress, int) else 0,
        checkmark_visible=(
            raw.get("checkmark_visible")
            if isinstance(raw.get("checkmark_visible"), bool)
            else False
        ),
        section_path=_optional_string(raw.get("section_path")),
        organizer_path=_optional_string(raw.get("organizer_path")),
        url=_optional_string(raw.get("url")),
        link=_optional_string(raw.get("link")),
        references=_references(raw.get("references")),
    )


def normalize_query(
    result: QueryResult, project: Project
) -> tuple[NormalizedFigure, ...]:
    return tuple(normalize_figure(item, project) for item in result.items)
src/curio_dashboard/presentation.py8 KB
"""View-model helpers for the server-rendered dashboard."""

from __future__ import annotations

from datetime import datetime
from typing import Any

from curio_dashboard.aggregate import CurioDateError, classify_due_date
from curio_dashboard.models import DashboardState, DateBucket, RefreshState

BUCKET_LABELS = {
    DateBucket.OVERDUE: "Overdue",
    DateBucket.TODAY: "Today",
    DateBucket.TOMORROW: "Tomorrow",
    DateBucket.NEXT_7_DAYS: "Next 7 days",
    DateBucket.NEXT_30_DAYS: "Next 30 days",
    DateBucket.LATER: "Later",
    DateBucket.UNSCHEDULED: "Unscheduled",
}
BUCKET_ORDER = {bucket: index for index, bucket in enumerate(DateBucket)}
PROJECT_ACCENTS = ("ochre", "blue", "coral", "violet", "green", "rose")


def _freshness(value: datetime, now: datetime) -> str:
    seconds = max(0, int((now - value).total_seconds()))
    if seconds < 60:
        return "Updated just now"
    minutes = seconds // 60
    if minutes < 60:
        return f"Updated {minutes}m ago"
    hours = minutes // 60
    if hours < 24:
        return f"Updated {hours}h ago"
    return f"Updated {hours // 24}d ago"


def _project_initials(title: str) -> str:
    words = [word for word in title.split() if word]
    if not words:
        return "—"
    if len(words) == 1:
        return words[0][:2].upper()
    return "".join(word[0] for word in words[:2]).upper()


def build_page_context(
    state: DashboardState,
    *,
    page: str,
    now: datetime,
    timezone: Any,
) -> dict[str, Any]:
    aggregate_by_path = {
        project.project_path: project for project in state.aggregate.projects
    }
    projects: list[dict[str, Any]] = []
    for index, diagnostic in enumerate(state.projects):
        metrics = aggregate_by_path.get(diagnostic.path)
        projects.append(
            {
                "diagnostic": diagnostic,
                "metrics": metrics,
                "initials": _project_initials(diagnostic.title),
                "accent": PROJECT_ACCENTS[index % len(PROJECT_ACCENTS)],
                "selectable": diagnostic.valid
                and not (
                    diagnostic.status_ignored
                    and diagnostic.refresh_state is RefreshState.IGNORED
                ),
            }
        )

    upcoming: list[dict[str, Any]] = []
    aggregate_paths = set(aggregate_by_path)
    local_today = now.astimezone(timezone).date()
    for project in state.projects:
        if project.path not in aggregate_paths or project.snapshot is None:
            continue
        for figure in project.snapshot.open_actions:
            try:
                bucket = classify_due_date(
                    figure.due_date,
                    today=local_today,
                    timezone=timezone,
                )
            except CurioDateError:
                bucket = None
            upcoming.append(
                {
                    "figure": figure,
                    "bucket": bucket,
                    "bucket_label": BUCKET_LABELS.get(bucket, "Invalid date"),
                    "sort_key": (
                        BUCKET_ORDER.get(bucket, len(BUCKET_ORDER)),
                        figure.due_date or "9999",
                        figure.title.casefold(),
                    ),
                }
            )
    upcoming.sort(key=lambda item: item["sort_key"])

    stages = []
    stage_counts = {
        name: count
        for name, count in state.aggregate.tag_counts.items()
        if name.casefold().startswith("travel stage/")
    }
    stage_total = sum(stage_counts.values())
    for name, count in stage_counts.items():
        stages.append(
            {
                "full_name": name,
                "name": name.split("/", 1)[-1],
                "count": count,
                "percent": round((count / stage_total) * 100) if stage_total else 0,
            }
        )

    accent_by_path = {
        project["diagnostic"].path: project["accent"] for project in projects
    }
    board_columns = []
    for column in state.board.columns:
        board_columns.append(
            {
                "column": column,
                "cards": tuple(
                    {
                        "card": card,
                        "accent": accent_by_path.get(
                            card.figure.project_path,
                            "ochre",
                        ),
                    }
                    for card in column.cards
                ),
            }
        )
    board_unassigned = tuple(
        {
            "card": card,
            "accent": accent_by_path.get(card.figure.project_path, "ochre"),
        }
        for card in state.board.unassigned
    )
    board_card_ids = {
        card["card"].figure.composite_id
        for column in board_columns
        for card in column["cards"]
    } | {card["card"].figure.composite_id for card in board_unassigned}

    max_tag_count = max(
        (item.total_figures for item in state.tag_analytics.statistics),
        default=0,
    )
    tag_statistics = tuple(
        {
            "statistic": statistic,
            "label": statistic.tag.split("/", 1)[-1],
            "set_name": (
                statistic.tag.split("/", 1)[0] if "/" in statistic.tag else "Standalone"
            ),
            "percent": round(statistic.total_figures / max_tag_count * 100)
            if max_tag_count
            else 0,
        }
        for statistic in state.tag_analytics.statistics
    )
    distribution_total = sum(state.tag_analytics.tag_set_distribution.values())
    tag_set_distribution = tuple(
        {
            "name": name,
            "count": count,
            "percent": round(count / distribution_total * 100)
            if distribution_total
            else 0,
        }
        for name, count in state.tag_analytics.tag_set_distribution.items()
    )
    matrix_tags = tuple(column.tag for column in state.board.columns)
    matrix_rows = tuple(
        {
            "project": row,
            "accent": accent_by_path.get(row.project_path, "ochre"),
            "cells": tuple(row.counts.get(tag, 0) for tag in matrix_tags),
            "total": sum(row.counts.get(tag, 0) for tag in matrix_tags),
        }
        for row in state.tag_analytics.project_matrix
    )
    recent_tags = tuple(
        sorted(
            (
                item
                for item in state.tag_analytics.statistics
                if item.last_modified is not None
            ),
            key=lambda item: item.last_modified or "",
            reverse=True,
        )
    )

    source_label = (
        "Live Project Center"
        if state.discovery_data_source == "live"
        else "Last saved Project Center state"
    )
    selected_project_title = next(
        (
            project["diagnostic"].title
            for project in projects
            if project["diagnostic"].path == state.selected_project_path
        ),
        None,
    )
    return {
        "page": page,
        "state": state,
        "projects": projects,
        "upcoming": upcoming,
        "stages": stages,
        "stage_total": stage_total,
        "board_columns": tuple(board_columns),
        "board_unassigned": board_unassigned,
        "board_card_count": len(board_card_ids),
        "tag_statistics": tag_statistics,
        "tag_set_distribution": tag_set_distribution,
        "matrix_tags": matrix_tags,
        "matrix_rows": matrix_rows,
        "recent_tags": recent_tags,
        "tagged_open_actions": state.tag_analytics.tagged_open_actions,
        "tagged_overdue_actions": state.tag_analytics.tagged_overdue_actions,
        "tagged_completed_actions": state.tag_analytics.tagged_completed_actions,
        "source_label": source_label,
        "freshness_label": _freshness(state.generated_at, now),
        "selected_project_title": selected_project_title,
        "bucket_labels": BUCKET_LABELS,
    }
src/curio_dashboard/service.py15 KB
"""Milestone 2 orchestration with isolated project failures."""

from __future__ import annotations

import asyncio
from collections.abc import Callable
from datetime import UTC, date, datetime
from pathlib import Path
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

from curio_dashboard.aggregate import aggregate_snapshots
from curio_dashboard.cache import SnapshotCache
from curio_dashboard.cli import (
    CurioCommandError,
    CurioResponseError,
    CurioRunner,
    Project,
)
from curio_dashboard.collector import ProjectReader
from curio_dashboard.config import (
    AppConfig,
    SelectionConfig,
    SelectionMode,
    save_config,
)
from curio_dashboard.discovery import (
    DiscoveryService,
    DiscoverySnapshot,
    resolve_selection,
)
from curio_dashboard.models import (
    CategoryOption,
    DashboardState,
    DiagnosticProject,
    ProjectSnapshot,
    RefreshState,
)
from curio_dashboard.tags import build_kanban_board, build_tag_analytics


def _safe_error(error: Exception) -> str:
    if isinstance(error, (CurioCommandError, CurioResponseError)):
        return f"{error.code}: {error.message}"[:500]
    return f"{type(error).__name__}: {error}"[:500]


class DashboardService:
    def __init__(
        self,
        config: AppConfig,
        *,
        runner: CurioRunner | None = None,
        cache: SnapshotCache | None = None,
        reader: ProjectReader | None = None,
        now: Callable[[], datetime] | None = None,
        config_path: str | Path | None = None,
    ) -> None:
        self.config = config
        self.runner = runner or CurioRunner(
            executable=config.runtime.curio_executable,
            timeout=config.runtime.command_timeout_seconds,
            max_concurrency=config.runtime.max_concurrency,
        )
        self.cache = cache or SnapshotCache(config.runtime.cache_path)
        self.reader = reader or ProjectReader(self.runner)
        self.discovery = DiscoveryService(self.runner)
        try:
            self.timezone = ZoneInfo(config.runtime.timezone)
        except ZoneInfoNotFoundError as error:
            raise ValueError(
                f"unknown runtime timezone {config.runtime.timezone!r}"
            ) from error
        self._now = now or (lambda: datetime.now(UTC))
        self._state: DashboardState | None = None
        self._last_discovery: DiscoverySnapshot | None = None
        self._config_path = Path(config_path).resolve() if config_path else None
        self._initialized = False
        self._refresh_lock = asyncio.Lock()

    async def initialize(self) -> None:
        if not self._initialized:
            await self.cache.initialize()
            self._initialized = True

    async def get_state(
        self, *, refresh_if_empty: bool = True
    ) -> DashboardState | None:
        if self._state is None and refresh_if_empty:
            return await self.refresh()
        return self._state

    def now(self) -> datetime:
        return self._now()

    def _set_config(self, config: AppConfig) -> None:
        if self._config_path is not None:
            save_config(config, self._config_path)
        self.config = config
        self._state = None

    async def select_category(self, category_id: str) -> DashboardState:
        categories = await self.runner.categories()
        category = next(
            (item for item in categories.categories if item.id == category_id),
            None,
        )
        if category is None:
            raise ValueError(
                "category is not present in the latest Project Center state"
            )
        if category.project_count == 0:
            raise ValueError("empty categories cannot be selected")

        selection = SelectionConfig(
            mode=SelectionMode.CATEGORY,
            category_id=category.id,
            category_title_hint=category.title,
            project_path=None,
        )
        self._set_config(self.config.model_copy(update={"selection": selection}))
        self._last_discovery = None
        return await self.refresh(discover=True)

    async def select_project(self, project_path: str | None) -> DashboardState:
        state = await self.get_state(refresh_if_empty=True)
        if state is None:
            raise RuntimeError("dashboard state is not initialized")

        if project_path:
            project = next(
                (item for item in state.projects if item.path == project_path),
                None,
            )
            if project is None:
                raise ValueError(
                    "project is not present in the latest category response"
                )
            if not project.valid:
                raise ValueError("unavailable projects cannot be selected")
            if (
                project.status_ignored
                and not self.config.dashboard.include_status_ignored
            ):
                raise ValueError("status-ignored projects cannot be selected")
            selection = SelectionConfig(
                mode=SelectionMode.PROJECT,
                category_id=state.category_id,
                category_title_hint=state.category_title,
                project_path=project.path,
            )
        else:
            selection = SelectionConfig(
                mode=SelectionMode.CATEGORY,
                category_id=state.category_id,
                category_title_hint=state.category_title,
                project_path=None,
            )

        self._set_config(self.config.model_copy(update={"selection": selection}))
        return await self.refresh(discover=False)

    async def select_tag_set(self, tag_set: str) -> DashboardState:
        state = await self.get_state(refresh_if_empty=True)
        if state is None:
            raise RuntimeError("dashboard state is not initialized")
        available = {name.casefold(): name for name in state.board.available_tag_sets}
        canonical = available.get(tag_set.casefold())
        if canonical is None:
            raise ValueError("tag set is not available in the current project scope")
        dashboard = self.config.dashboard.model_copy(
            update={"kanban_tag_set": canonical}
        )
        self._set_config(self.config.model_copy(update={"dashboard": dashboard}))
        return await self.refresh(discover=False)

    async def _refresh_project(self, project: Project) -> DiagnosticProject:
        attempted_at = self._now()
        try:
            snapshot = await self.reader.read(project)
            await self.cache.save_snapshot(snapshot)
            return DiagnosticProject(
                title=project.title,
                path=project.path,
                valid=project.valid,
                status_ignored=project.status_ignored,
                path_is_last_known=project.path_is_last_known,
                refresh_state=RefreshState.CURRENT,
                last_attempted_refresh=attempted_at,
                last_successful_refresh=snapshot.collected_at,
                snapshot=snapshot,
            )
        # Every per-project failure is isolated so sibling reads still complete.
        except Exception as error:  # noqa: BLE001
            message = _safe_error(error)
            await self.cache.record_failure(
                project_path=project.path,
                project_title=project.title,
                attempted_at=attempted_at,
                error=message,
            )
            cached = await self.cache.load_snapshot(project.path)
            return DiagnosticProject(
                title=project.title,
                path=project.path,
                valid=project.valid,
                status_ignored=project.status_ignored,
                path_is_last_known=project.path_is_last_known,
                refresh_state=RefreshState.STALE if cached else RefreshState.ERROR,
                last_attempted_refresh=attempted_at,
                last_successful_refresh=cached.collected_at if cached else None,
                using_cached_snapshot=cached is not None,
                error=message,
                snapshot=cached,
            )

    async def _inactive_project(
        self,
        project: Project,
        *,
        state: RefreshState,
        include_cached: bool,
    ) -> DiagnosticProject:
        cached = (
            await self.cache.load_snapshot(project.path) if include_cached else None
        )
        record = await self.cache.load_refresh_record(project.path)
        return DiagnosticProject(
            title=project.title,
            path=project.path,
            valid=project.valid,
            status_ignored=project.status_ignored,
            path_is_last_known=project.path_is_last_known,
            refresh_state=state,
            last_attempted_refresh=record.last_attempted_at if record else None,
            last_successful_refresh=(
                cached.collected_at
                if cached
                else record.last_successful_at
                if record
                else None
            ),
            using_cached_snapshot=cached is not None,
            error=record.error
            if record and state is RefreshState.UNAVAILABLE
            else None,
            snapshot=cached,
        )

    async def refresh(self, *, discover: bool = True) -> DashboardState:
        try:
            return await self._refresh_once(discover=discover)
        except Exception as error:
            if self._state is not None:
                self._state = self._state.model_copy(
                    update={
                        "last_refresh_attempt": self._now(),
                        "last_refresh_error": _safe_error(error),
                    }
                )
            raise

    async def _refresh_once(self, *, discover: bool) -> DashboardState:
        async with self._refresh_lock:
            await self.initialize()
            if discover or self._last_discovery is None:
                discovery = await self.discovery.discover(
                    self.config.selection.category_id
                )
                self._last_discovery = discovery
            else:
                discovery = self._last_discovery
            selection = resolve_selection(discovery.projects, self.config)
            if (
                selection.warning
                and self.config.selection.mode is SelectionMode.PROJECT
            ):
                fallback = SelectionConfig(
                    mode=SelectionMode.CATEGORY,
                    category_id=discovery.projects.category.id,
                    category_title_hint=discovery.projects.category.title,
                    project_path=None,
                )
                self._set_config(self.config.model_copy(update={"selection": fallback}))
            readable_paths = {project.path for project in selection.included}
            scope_paths = {project.path for project in selection.scope}

            refresh_tasks = {
                project.path: asyncio.create_task(self._refresh_project(project))
                for project in selection.included
            }
            diagnostics: list[DiagnosticProject] = []
            for project in discovery.projects.projects:
                if project.path in readable_paths:
                    diagnostics.append(await refresh_tasks[project.path])
                elif (
                    project.status_ignored
                    and not self.config.dashboard.include_status_ignored
                ):
                    diagnostics.append(
                        await self._inactive_project(
                            project,
                            state=RefreshState.IGNORED,
                            include_cached=False,
                        )
                    )
                elif project.path in scope_paths and not project.valid:
                    diagnostics.append(
                        await self._inactive_project(
                            project,
                            state=RefreshState.UNAVAILABLE,
                            include_cached=True,
                        )
                    )
                else:
                    diagnostics.append(
                        await self._inactive_project(
                            project,
                            state=RefreshState.NOT_SELECTED,
                            include_cached=False,
                        )
                    )

            aggregate_snapshots_list: list[ProjectSnapshot] = [
                project.snapshot
                for project in diagnostics
                if project.path in scope_paths
                and not project.status_ignored
                and project.snapshot is not None
            ]
            generated_at = self._now()
            local_today: date = generated_at.astimezone(self.timezone).date()
            aggregate = aggregate_snapshots(
                tuple(aggregate_snapshots_list),
                today=local_today,
                timezone=self.timezone,
            )
            snapshots_tuple = tuple(aggregate_snapshots_list)
            tag_analytics = build_tag_analytics(
                snapshots_tuple,
                today=local_today,
                timezone=self.timezone,
            )
            board = build_kanban_board(
                snapshots_tuple,
                tag_analytics,
                requested_tag_set=self.config.dashboard.kanban_tag_set,
                include_unassigned=self.config.dashboard.kanban_include_unassigned,
            )
            self._state = DashboardState(
                generated_at=generated_at,
                last_refresh_attempt=generated_at,
                last_refresh_error=None,
                discovery_timestamp=discovery.discovered_at,
                discovery_data_source=discovery.projects.data_source,
                category_id=discovery.projects.category.id,
                category_title=discovery.projects.category.title,
                available_categories=tuple(
                    CategoryOption(
                        id=category.id,
                        title=category.title,
                        kind=category.kind,
                        project_count=category.project_count,
                        selectable=category.project_count > 0,
                    )
                    for category in discovery.categories.categories
                ),
                selected_project_path=(
                    str(self.config.selection.project_path)
                    if self.config.selection.mode is SelectionMode.PROJECT
                    else None
                ),
                selection_warning=selection.warning,
                projects=tuple(diagnostics),
                aggregate=aggregate,
                tag_analytics=tag_analytics,
                board=board,
            )
            return self._state
src/curio_dashboard/static/app.css48 KB
:root {
  color-scheme: light;
  --canvas: #f2efe7;
  --canvas-soft: #eae6db;
  --surface: #fffdf8;
  --surface-raised: #ffffff;
  --surface-muted: #f7f4ec;
  --ink: #202522;
  --ink-soft: #555b55;
  --ink-faint: #606660;
  --line: #dedbd1;
  --line-strong: #c9c5ba;
  --forest: #1f2a26;
  --forest-soft: #2c3934;
  --forest-ink: #f7f3e9;
  --accent: #bd6940;
  --accent-soft: #f1ded2;
  --warning: #a84e35;
  --success: #42725e;
  --shadow: 0 16px 38px rgb(35 39 35 / 7%);
  --sidebar-width: 248px;
  --radius-lg: 20px;
  --radius-md: 13px;
  --radius-sm: 9px;
  --serif: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
  --sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI",
    sans-serif;
  --mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
  --text-2xs: 0.6rem;
  --text-xs: 0.65rem;
  --text-sm: 0.7rem;
  --text-base: 0.8rem;
  --text-md: 0.9rem;
  --text-lg: 1rem;
  --text-xl: 1.2rem;
  --text-2xl: 1.5rem;
  --text-3xl: 1.8rem;
  --text-4xl: 2.6rem;
  --text-hero: 3.1rem;
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --canvas: #161b19;
  --canvas-soft: #1c2320;
  --surface: #202724;
  --surface-raised: #252d29;
  --surface-muted: #1b221f;
  --ink: #f2eee5;
  --ink-soft: #b7bcb6;
  --ink-faint: #969e98;
  --line: #38423d;
  --line-strong: #4a5650;
  --forest: #101513;
  --forest-soft: #202a26;
  --forest-ink: #f7f3e9;
  --accent: #dc8b61;
  --accent-soft: #4a3026;
  --warning: #ef967a;
  --success: #78ae95;
  --shadow: 0 18px 42px rgb(0 0 0 / 20%);
}

* {
  box-sizing: border-box;
}

html {
  min-width: 320px;
  background: var(--canvas);
  color: var(--ink);
  font: 125%/1.5 var(--sans);
  scroll-behavior: smooth;
}

body {
  margin: 0;
  min-height: 100vh;
  background: var(--canvas);
  font-size: var(--text-base);
}

button,
select,
input {
  font: inherit;
}

button,
a,
select {
  -webkit-tap-highlight-color: transparent;
}

a {
  color: inherit;
}

button {
  color: inherit;
}

:focus-visible {
  outline: 3px solid color-mix(in srgb, var(--accent) 64%, white);
  outline-offset: 3px;
}

.skip-link {
  position: fixed;
  z-index: 100;
  top: 12px;
  left: 12px;
  padding: 10px 14px;
  border-radius: 8px;
  background: var(--surface-raised);
  color: var(--ink);
  transform: translateY(-150%);
}

.skip-link:focus {
  transform: translateY(0);
}

.sidebar {
  position: fixed;
  inset: 0 auto 0 0;
  z-index: 30;
  display: flex;
  width: var(--sidebar-width);
  flex-direction: column;
  padding: 28px 18px 22px;
  overflow-y: auto;
  background: var(--forest);
  color: var(--forest-ink);
}

.brand {
  display: flex;
  align-items: center;
  gap: 13px;
  padding: 0 8px 28px;
  text-decoration: none;
}

.brand > span:last-child,
.sidebar-foot > span:last-child {
  display: grid;
  gap: 1px;
}

.brand strong {
  font-family: var(--serif);
  font-size: var(--text-xl);
  font-weight: 600;
  letter-spacing: -0.02em;
}

.brand small,
.sidebar-foot small {
  color: rgb(247 243 233 / 54%);
  font-size: var(--text-sm);
  letter-spacing: 0.04em;
}

.brand-mark {
  display: block;
  width: 2rem;
  height: 2rem;
  flex: 0 0 auto;
  object-fit: contain;
}

.nav-label {
  margin: 8px 12px 9px;
  color: rgb(247 243 233 / 54%);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.primary-nav {
  display: grid;
  gap: 3px;
}

.primary-nav a {
  position: relative;
  display: grid;
  grid-template-columns: 25px 1fr auto;
  align-items: center;
  min-height: 43px;
  padding: 0 13px;
  border-radius: 10px;
  color: rgb(247 243 233 / 66%);
  font-size: var(--text-md);
  font-weight: 550;
  text-decoration: none;
  transition:
    color 160ms ease,
    background 160ms ease;
}

.primary-nav a:hover {
  background: rgb(255 255 255 / 5%);
  color: var(--forest-ink);
}

.primary-nav a[aria-current="page"] {
  background: rgb(255 255 255 / 9%);
  color: var(--forest-ink);
}

.primary-nav a[aria-current="page"]::before {
  position: absolute;
  left: -18px;
  width: 3px;
  height: 20px;
  border-radius: 0 3px 3px 0;
  background: #d98a5e;
  content: "";
}

.nav-icon {
  font-size: var(--text-lg);
  text-align: center;
}

.nav-soon {
  padding: 2px 5px;
  border: 1px solid rgb(247 243 233 / 14%);
  border-radius: 5px;
  color: rgb(247 243 233 / 38%);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.overview-board-preview {
  margin-top: 17px;
}

.overview-board-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 10px;
  margin-top: 22px;
}

.overview-board-column {
  min-width: 0;
  padding: 11px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--surface-muted);
}

.overview-board-column > header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  padding: 1px 2px 9px;
  border-bottom: 1px solid var(--line);
  font-size: var(--text-xs);
}

.overview-board-column > header span {
  display: grid;
  width: 20px;
  height: 20px;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--surface);
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.overview-board-column ol {
  display: grid;
  gap: 6px;
  margin: 8px 0 0;
  padding: 0;
  list-style: none;
}

.overview-board-column li {
  --project-accent: #bd6940;
  position: relative;
  display: flex;
  min-width: 0;
  align-items: center;
  gap: 8px;
  padding: 9px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface-raised);
}

.overview-board-column li > i {
  width: 3px;
  height: 28px;
  flex: 0 0 auto;
  border-radius: 999px;
  background: var(--project-accent);
}

.overview-board-column li > span {
  display: grid;
  min-width: 0;
  gap: 2px;
}

.overview-board-column li strong,
.overview-board-column li small {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.overview-board-column li strong {
  font-size: var(--text-2xs);
}

.overview-board-column li small,
.overview-board-column > p {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.overview-board-column li b {
  margin-left: auto;
  color: var(--warning);
  font-size: var(--text-xs);
}

.overview-board-column > p {
  margin: 22px 0;
  text-align: center;
}

.preview-footnote {
  margin: 12px 2px 0;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.board-toolbar {
  display: grid;
  grid-template-columns: minmax(220px, 0.8fr) minmax(180px, 0.6fr) auto;
  align-items: end;
  gap: 18px;
  margin-bottom: 16px;
  padding: 16px 18px;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface);
}

.tag-set-form,
.matrix-tag-set-form {
  display: grid;
  gap: 4px;
  margin-top: 6px;
}

.tag-set-form label,
.matrix-tag-set-form label {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
}

.tag-set-form select,
.matrix-tag-set-form select,
.board-project-filter select {
  height: 36px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface-muted);
  color: var(--ink);
  font-size: var(--text-base);
  font-weight: 650;
}

.tag-set-form select {
  width: min(100%, 310px);
}

.board-project-filter {
  display: grid;
  gap: 5px;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.board-summary {
  display: flex;
  justify-content: flex-end;
  gap: 16px;
  padding-bottom: 8px;
  color: var(--ink-soft);
  font-size: var(--text-xs);
}

.board-summary strong {
  margin-right: 2px;
  color: var(--ink);
  font-family: var(--serif);
  font-size: var(--text-md);
}

.board-notice {
  margin: 0 0 12px;
}

.kanban-shell {
  width: 100%;
  overflow-x: auto;
  overscroll-behavior-inline: contain;
  padding-bottom: 12px;
}

.kanban-board {
  display: flex;
  min-width: max-content;
  align-items: flex-start;
  gap: 11px;
}

.kanban-column {
  width: 264px;
  min-height: 410px;
  padding: 11px;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface-muted);
}

.kanban-column > header {
  display: flex;
  min-height: 38px;
  align-items: center;
  justify-content: space-between;
  gap: 9px;
  padding: 0 4px 9px;
  border-bottom: 1px solid var(--line);
}

.kanban-column > header span {
  overflow: hidden;
  font-size: var(--text-sm);
  font-weight: 750;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.kanban-column > header strong {
  display: grid;
  width: 22px;
  height: 22px;
  flex: 0 0 auto;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--surface);
  color: var(--ink-soft);
  font-size: var(--text-2xs);
}

.kanban-unassigned {
  border-style: dashed;
}

.kanban-stack {
  display: grid;
  gap: 8px;
  padding-top: 9px;
}

.kanban-card {
  --project-accent: #bd6940;
  position: relative;
  display: grid;
  width: 100%;
  gap: 8px;
  padding: 13px 13px 12px 16px;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--surface-raised);
  box-shadow: 0 7px 16px rgb(35 39 35 / 5%);
  color: var(--ink);
  cursor: pointer;
  text-align: left;
}

.kanban-card::before {
  position: absolute;
  inset: 0 auto 0 0;
  width: 3px;
  background: var(--project-accent);
  content: "";
}

.kanban-card:hover {
  border-color: var(--line-strong);
  transform: translateY(-1px);
}

.kanban-card.has-conflict {
  border-color: color-mix(in srgb, var(--warning) 42%, var(--line));
}

.kanban-card-topline {
  display: flex;
  align-items: center;
  gap: 6px;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.card-project-mark {
  display: grid;
  width: 20px;
  height: 20px;
  place-items: center;
  border-radius: 50%;
  background: color-mix(in srgb, var(--project-accent) 13%, var(--surface));
  color: var(--project-accent);
  font-family: var(--serif);
  font-size: var(--text-2xs);
  font-weight: 700;
}

.conflict-mark {
  display: grid;
  width: 17px;
  height: 17px;
  margin-left: auto;
  place-items: center;
  border-radius: 50%;
  background: color-mix(in srgb, var(--warning) 12%, var(--surface));
  color: var(--warning);
  font-size: var(--text-2xs);
  font-weight: 800;
}

.kanban-card > strong {
  font-family: var(--serif);
  font-size: var(--text-md);
  font-weight: 600;
  line-height: 1.25;
}

.kanban-card-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
}

.kanban-card-meta span {
  padding: 3px 6px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-muted);
  color: var(--ink-soft);
  font-size: var(--text-2xs);
}

.kanban-card > small {
  overflow: hidden;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.empty-column {
  margin: 30px 8px;
  color: var(--ink-faint);
  font-size: var(--text-xs);
  text-align: center;
}

.board-footnote {
  margin: 1px 2px 0;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.figure-drawer {
  width: min(440px, calc(100vw - 24px));
  max-height: calc(100vh - 32px);
  margin: auto 16px auto auto;
  padding: 29px;
  overflow-y: auto;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-lg);
  background: var(--surface);
  box-shadow: 0 30px 80px rgb(0 0 0 / 25%);
  color: var(--ink);
}

.figure-drawer::backdrop {
  background: rgb(13 18 16 / 48%);
  backdrop-filter: blur(3px);
}

.drawer-close {
  position: absolute;
  top: 15px;
  right: 15px;
  display: grid;
  width: 31px;
  height: 31px;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--surface-muted);
  color: var(--ink-soft);
  cursor: pointer;
  font-size: var(--text-lg);
}

.drawer-project {
  display: flex;
  align-items: center;
  gap: 7px;
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 700;
}

.drawer-project [data-drawer-project-mark] {
  display: grid;
  width: 24px;
  height: 24px;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--accent);
  font-family: var(--serif);
  font-size: var(--text-2xs);
}

.figure-drawer h2 {
  margin: 20px 35px 8px 0;
  font-family: var(--serif);
  font-size: var(--text-2xl);
  font-weight: 500;
  letter-spacing: -0.03em;
}

.drawer-status-line {
  display: flex;
  gap: 7px;
}

.drawer-status-line span {
  padding: 4px 7px;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--ink-soft);
  font-size: var(--text-2xs);
  text-transform: capitalize;
}

.drawer-details {
  display: grid;
  grid-template-columns: 1fr 1fr;
  margin: 24px 0;
  border-block: 1px solid var(--line);
}

.drawer-details > div {
  padding: 13px 0;
}

.drawer-details > div:nth-child(odd) {
  border-right: 1px solid var(--line);
}

.drawer-details > div:nth-child(even) {
  padding-left: 17px;
}

.drawer-details > div:nth-child(n + 3) {
  border-top: 1px solid var(--line);
}

.drawer-details dt,
.drawer-tags > span {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.drawer-details dd {
  margin: 4px 0 0;
  overflow-wrap: anywhere;
  font-size: var(--text-sm);
}

.drawer-tags p {
  margin: 7px 0 0;
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

.drawer-warning {
  margin-top: 17px;
  padding: 10px;
  border: 1px solid color-mix(in srgb, var(--warning) 32%, var(--line));
  border-radius: 8px;
  color: var(--warning);
  font-size: var(--text-xs);
}

.drawer-future-action {
  width: 100%;
  min-height: 39px;
  margin-top: 24px;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--surface-muted);
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

.tag-metric-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 12px;
  margin-bottom: 17px;
}

.tag-metric-grid article {
  display: grid;
  gap: 3px;
  padding: 17px 18px;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface);
}

.tag-metric-grid span {
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.tag-metric-grid strong {
  margin-top: 9px;
  font-family: var(--serif);
  font-size: var(--text-2xl);
  font-weight: 500;
}

.tag-metric-grid small {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.tags-layout {
  display: grid;
  grid-template-columns: minmax(0, 1.4fr) minmax(300px, 0.7fr);
  gap: 17px;
}

.tag-ranking-list {
  display: grid;
  gap: 17px;
  margin-top: 25px;
}

.tag-ranking-label {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 15px;
  margin-bottom: 7px;
}

.tag-ranking-label > span:first-child {
  display: grid;
}

.tag-ranking-label small {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.tag-ranking-label strong {
  font-size: var(--text-sm);
}

.tag-counts {
  display: flex;
  align-items: baseline;
  gap: 7px;
}

.tag-counts b {
  font-family: var(--serif);
  font-size: var(--text-lg);
}

.tag-ranking-track,
.distribution-bar {
  display: block;
  height: 5px;
  overflow: hidden;
  border-radius: 999px;
  background: var(--canvas-soft);
}

.tag-ranking-track span {
  display: block;
  width: max(var(--tag-width), 3px);
  height: 100%;
  border-radius: inherit;
  background: var(--accent);
}

.tag-ranking-row:nth-child(2n) .tag-ranking-track span {
  background: #6d8892;
}

.tag-ranking-row:nth-child(3n) .tag-ranking-track span {
  background: #70836c;
}

.distribution-list {
  display: grid;
  gap: 12px;
  margin-top: 25px;
}

.distribution-list > div {
  display: grid;
  grid-template-columns: minmax(80px, 0.8fr) minmax(80px, 1.2fr) 25px;
  align-items: center;
  gap: 10px;
}

.distribution-name {
  overflow: hidden;
  font-size: var(--text-xs);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.distribution-bar i {
  display: block;
  width: max(var(--distribution-width), 3px);
  height: 100%;
  border-radius: inherit;
  background: #74868e;
}

.distribution-list strong {
  font-family: var(--serif);
  font-size: var(--text-md);
  text-align: right;
}

.recent-tags {
  margin-top: 28px;
  padding-top: 20px;
  border-top: 1px solid var(--line);
}

.recent-tags h3 {
  margin: 0 0 10px;
  font-family: var(--serif);
  font-size: var(--text-md);
  font-weight: 600;
}

.recent-tags ol {
  display: grid;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}

.recent-tags li {
  display: grid;
  gap: 1px;
}

.recent-tags li span {
  overflow: hidden;
  font-size: var(--text-xs);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.recent-tags time,
.recent-tags p {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.matrix-panel {
  margin-top: 17px;
}

.matrix-heading {
  align-items: flex-end;
}

.matrix-tag-set-form select {
  min-width: 190px;
}

.matrix-scroll {
  margin-top: 23px;
  overflow-x: auto;
}

.tag-matrix {
  width: 100%;
  min-width: 720px;
  border-collapse: separate;
  border-spacing: 0;
}

.tag-matrix th,
.tag-matrix td {
  padding: 10px 9px;
  border-bottom: 1px solid var(--line);
  font-size: var(--text-2xs);
  text-align: center;
}

.tag-matrix thead th {
  max-width: 120px;
  color: var(--ink-faint);
  font-weight: 700;
}

.tag-matrix th:first-child {
  position: sticky;
  left: 0;
  z-index: 2;
  min-width: 150px;
  background: var(--surface);
  text-align: left;
}

.matrix-project {
  --project-accent: #bd6940;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  color: var(--ink);
  font-size: var(--text-xs);
}

.matrix-project i {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--project-accent);
}

.matrix-value {
  display: inline-grid;
  width: 26px;
  height: 26px;
  place-items: center;
  border-radius: 6px;
  background: var(--surface-muted);
  color: var(--ink-faint);
}

.matrix-value.level-1 {
  background: color-mix(in srgb, var(--accent) 13%, var(--surface));
  color: var(--ink);
}

.matrix-value.level-2 {
  background: color-mix(in srgb, var(--accent) 23%, var(--surface));
  color: var(--ink);
}

.matrix-value.level-3 {
  background: color-mix(in srgb, var(--accent) 36%, var(--surface));
  color: var(--ink);
  font-weight: 800;
}

.matrix-note {
  margin: 12px 0 0;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.sidebar-foot {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: auto;
  padding: 16px 10px 0;
  border-top: 1px solid rgb(247 243 233 / 9%);
}

.sidebar-foot strong {
  font-size: var(--text-base);
  font-weight: 600;
}

.local-dot {
  width: 8px;
  height: 8px;
  border: 2px solid #80ae92;
  border-radius: 50%;
  box-shadow: 0 0 0 3px rgb(128 174 146 / 12%);
}

.app-frame {
  min-height: 100vh;
  margin-left: var(--sidebar-width);
}

.topbar {
  position: sticky;
  z-index: 20;
  top: 0;
  display: flex;
  min-height: 68px;
  align-items: center;
  gap: 20px;
  padding: 10px clamp(24px, 4vw, 58px);
  border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
  background: color-mix(in srgb, var(--canvas) 93%, transparent);
  backdrop-filter: blur(16px);
}

.selectors {
  display: flex;
  min-width: 0;
  align-items: center;
  gap: 10px;
}

.select-form label {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.select-wrap {
  position: relative;
}

.select-wrap::after {
  position: absolute;
  top: 50%;
  right: 8px;
  color: var(--ink-faint);
  content: "⌄";
  font-size: var(--text-base);
  pointer-events: none;
  transform: translateY(-56%);
}

.select-wrap select {
  max-width: 220px;
  height: 36px;
  padding: 0 26px 0 9px;
  border: 1px solid transparent;
  border-radius: 8px;
  appearance: none;
  background: transparent;
  color: var(--ink);
  font-size: var(--text-base);
  font-weight: 650;
  text-overflow: ellipsis;
}

.select-wrap select:hover {
  border-color: var(--line);
  background: var(--surface-muted);
}

.select-wrap select:disabled {
  opacity: 0.6;
}

.selector-divider {
  color: var(--line-strong);
}

.topbar-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-left: auto;
}

.icon-button,
.refresh-button {
  display: inline-flex;
  height: 36px;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--line);
  border-radius: 9px;
  background: var(--surface);
  cursor: pointer;
}

.icon-button {
  width: 36px;
  color: var(--ink-soft);
}

.refresh-button {
  gap: 7px;
  padding: 0 13px;
  color: var(--ink);
  font-size: var(--text-base);
  font-weight: 700;
}

.refresh-button:hover,
.icon-button:hover {
  border-color: var(--line-strong);
  background: var(--surface-raised);
}

.refresh-button.is-loading span:first-child {
  animation: spin 800ms linear infinite;
}

.mobile-menu {
  display: none;
}

.main-content {
  width: min(100%, 1540px);
  margin: 0 auto;
  padding: 42px clamp(24px, 4vw, 58px) 30px;
}

.page-heading {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 30px;
  margin-bottom: 29px;
}

.page-heading h1 {
  margin: 5px 0 4px;
  font-family: var(--serif);
  font-size: clamp(var(--text-3xl), 4vw, var(--text-4xl));
  font-weight: 500;
  letter-spacing: -0.045em;
  line-height: 1;
}

.page-heading > div > p:last-child {
  margin: 9px 0 0;
  color: var(--ink-soft);
  font-size: var(--text-md);
}

.eyebrow,
.section-kicker {
  margin: 0;
  color: var(--accent);
  font-size: var(--text-xs);
  font-weight: 800;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

.eyebrow span {
  margin-left: 8px;
  color: var(--ink-faint);
}

.source-cluster {
  display: flex;
  align-items: center;
  gap: 11px;
  padding-bottom: 4px;
}

.source-badge {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 6px 9px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface);
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.03em;
}

.source-badge > span,
.status-dot,
.state-pill > span {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--success);
}

.source-persisted > span {
  background: #b87943;
}

.freshness {
  color: var(--ink-faint);
  font-size: var(--text-sm);
}

.notice {
  display: flex;
  align-items: center;
  gap: 12px;
  margin: -8px 0 22px;
  padding: 11px 14px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--surface-muted);
  font-size: var(--text-base);
}

.notice strong {
  flex: 0 0 auto;
}

.notice span {
  color: var(--ink-soft);
}

.notice-warning {
  border-color: color-mix(in srgb, #b87943 35%, var(--line));
}

.notice-data {
  border-color: color-mix(in srgb, var(--warning) 32%, var(--line));
}

.notice-error {
  border-color: color-mix(in srgb, var(--warning) 48%, var(--line));
  background: color-mix(in srgb, var(--warning) 7%, var(--surface));
}

.notice code {
  margin-left: 5px;
  overflow-wrap: anywhere;
  color: var(--ink-faint);
  font-family: var(--mono);
  font-size: var(--text-2xs);
}

.metric-grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 12px;
  margin-bottom: 17px;
}

.metric-card {
  min-width: 0;
  padding: 18px 19px 16px;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface);
  box-shadow: 0 3px 14px rgb(35 39 35 / 2%);
}

.metric-topline {
  display: flex;
  align-items: center;
  justify-content: space-between;
  color: var(--ink-soft);
  font-size: var(--text-sm);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.metric-symbol {
  display: grid;
  width: 21px;
  height: 21px;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

.metric-card > strong {
  display: block;
  margin: 17px 0 6px;
  font-family: var(--serif);
  font-size: var(--text-3xl);
  font-weight: 500;
  letter-spacing: -0.04em;
  line-height: 0.9;
}

.metric-card > strong small {
  margin-left: 2px;
  color: var(--ink-soft);
  font-family: var(--sans);
  font-size: var(--text-md);
  font-weight: 600;
}

.metric-card > p {
  margin: 0;
  color: var(--ink-faint);
  font-size: var(--text-sm);
}

.metric-alert .metric-symbol {
  border-color: color-mix(in srgb, var(--warning) 30%, var(--line));
  color: var(--warning);
}

.metric-completion {
  border-color: color-mix(in srgb, var(--success) 24%, var(--line));
}

.dashboard-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.45fr) minmax(320px, 0.8fr);
  gap: 17px;
}

.panel {
  min-width: 0;
  padding: 24px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface);
  box-shadow: var(--shadow);
}

.panel-heading,
.section-heading {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
}

.panel-heading h2,
.section-heading h2,
.deferred-copy h2 {
  margin: 5px 0 0;
  font-family: var(--serif);
  font-size: var(--text-xl);
  font-weight: 500;
  letter-spacing: -0.025em;
}

.panel-count {
  padding: 6px 8px;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--ink-soft);
  font-size: var(--text-xs);
  font-weight: 700;
}

.text-link {
  color: var(--accent);
  font-size: var(--text-sm);
  font-weight: 700;
  text-underline-offset: 3px;
}

.stage-list {
  display: grid;
  gap: 16px;
  margin-top: 29px;
}

.stage-label {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 7px;
  font-size: var(--text-base);
}

.stage-label strong {
  font-family: var(--serif);
  font-size: var(--text-lg);
  font-weight: 600;
}

.stage-track {
  display: block;
  height: 6px;
  overflow: hidden;
  border-radius: 999px;
  background: var(--canvas-soft);
}

.stage-track span {
  display: block;
  width: max(var(--stage-width), 3px);
  height: 100%;
  border-radius: inherit;
  background: var(--accent);
}

.stage-row:nth-child(2n) .stage-track span {
  background: #718993;
}

.stage-row:nth-child(3n) .stage-track span {
  background: #8b8064;
}

.stage-row:nth-child(4n) .stage-track span {
  background: #667d68;
}

.panel-footnote {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 25px;
  padding-top: 15px;
  border-top: 1px solid var(--line);
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

.footnote-mark {
  display: grid;
  width: 16px;
  height: 16px;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  font-family: var(--serif);
  font-size: var(--text-sm);
}

.empty-state {
  display: flex;
  min-height: 240px;
  align-items: center;
  justify-content: center;
  gap: 15px;
  text-align: left;
}

.compact-empty {
  min-height: 170px;
}

.empty-mark {
  display: grid;
  width: 43px;
  height: 43px;
  flex: 0 0 auto;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--success);
  font-family: var(--serif);
  font-size: var(--text-lg);
}

.empty-state h3 {
  margin: 0 0 4px;
  font-family: var(--serif);
  font-size: var(--text-md);
  font-weight: 600;
}

.empty-state p {
  max-width: 330px;
  margin: 0;
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

.upcoming-list {
  display: grid;
  gap: 0;
  margin: 18px 0 0;
  padding: 0;
  list-style: none;
}

.upcoming-list li {
  display: flex;
  align-items: center;
  gap: 13px;
  padding: 12px 0;
  border-bottom: 1px solid var(--line);
}

.date-tile {
  display: grid;
  width: 72px;
  flex: 0 0 auto;
}

.date-tile small,
.upcoming-copy small {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.date-tile strong,
.upcoming-copy strong {
  font-size: var(--text-base);
}

.upcoming-copy {
  display: grid;
}

.section-block {
  margin-top: 32px;
}

.section-heading {
  align-items: flex-end;
  margin-bottom: 14px;
}

.project-strip {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 13px;
}

.project-pulse,
.project-card {
  --project-accent: #bd6940;
  position: relative;
  overflow: hidden;
  border: 1px solid var(--line);
  background: var(--surface);
}

.project-pulse {
  padding: 18px;
  border-radius: var(--radius-md);
}

.project-pulse::before,
.project-card::before {
  position: absolute;
  inset: 0 auto 0 0;
  width: 3px;
  background: var(--project-accent);
  content: "";
}

.accent-blue {
  --project-accent: #668494;
}

.accent-coral {
  --project-accent: #c26d58;
}

.accent-violet {
  --project-accent: #81728e;
}

.accent-green {
  --project-accent: #5c806c;
}

.accent-rose {
  --project-accent: #a86375;
}

.project-identity {
  display: flex;
  align-items: center;
  gap: 11px;
}

.project-monogram {
  display: grid;
  width: 37px;
  height: 37px;
  flex: 0 0 auto;
  place-items: center;
  border: 1px solid color-mix(in srgb, var(--project-accent) 34%, var(--line));
  border-radius: 50%;
  background: color-mix(in srgb, var(--project-accent) 9%, var(--surface));
  color: var(--project-accent);
  font-family: var(--serif);
  font-size: var(--text-base);
  font-weight: 700;
}

.project-monogram.large {
  width: 48px;
  height: 48px;
  font-size: var(--text-md);
}

.project-identity h3 {
  margin: 0;
  font-family: var(--serif);
  font-size: var(--text-lg);
  font-weight: 600;
}

.project-identity p {
  display: flex;
  align-items: center;
  gap: 6px;
  margin: 2px 0 0;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
  text-transform: capitalize;
}

.status-dot {
  width: 5px;
  height: 5px;
}

.status-stale,
.status-unavailable,
.status-error,
.state-stale > span,
.state-unavailable > span,
.state-error > span {
  background: var(--warning);
}

.status-ignored,
.status-not_selected,
.state-ignored > span,
.state-not_selected > span {
  background: var(--ink-faint);
}

.project-mini-metrics {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  margin: 18px 0 0;
  padding-top: 15px;
  border-top: 1px solid var(--line);
}

.project-mini-metrics div {
  display: grid;
  gap: 4px;
}

.project-mini-metrics dt {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.project-mini-metrics dd {
  margin: 0;
  font-family: var(--serif);
  font-size: var(--text-lg);
  font-weight: 600;
}

.project-progress {
  display: grid;
  gap: 7px;
  margin-top: 14px;
}

.project-progress > span:first-child {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.project-progress small {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.project-progress strong {
  font-family: var(--serif);
  font-size: var(--text-base);
}

.project-progress-track {
  display: block;
  height: 4px;
  overflow: hidden;
  border-radius: 999px;
  background: var(--canvas-soft);
}

.project-progress-track i {
  display: block;
  width: var(--project-progress);
  height: 100%;
  border-radius: inherit;
  background: var(--project-accent);
}

.muted-copy {
  color: var(--ink-faint);
  font-size: var(--text-sm);
}

.status-ribbon {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  margin-top: 17px;
  padding: 15px 18px;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background: var(--surface-muted);
}

.status-ribbon > div:first-child {
  display: flex;
  align-items: center;
  gap: 10px;
}

.status-ribbon strong {
  font-size: var(--text-sm);
}

.status-ribbon small {
  display: block;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.ribbon-icon {
  display: grid;
  width: 31px;
  height: 31px;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--accent);
}

.ribbon-facts {
  display: flex;
  gap: 21px;
  color: var(--ink-soft);
  font-size: var(--text-xs);
}

.ribbon-facts strong {
  margin-right: 2px;
  color: var(--ink);
  font-family: var(--serif);
  font-size: var(--text-base);
}

.content-footer {
  display: flex;
  justify-content: space-between;
  margin-top: 36px;
  padding-top: 18px;
  border-top: 1px solid var(--line);
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

.content-footer a {
  text-underline-offset: 3px;
}

.table-panel {
  padding: 0;
  overflow: hidden;
}

.table-heading {
  padding: 24px 25px 19px;
}

.table-tools {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 20px;
  padding: 13px 24px;
  border-block: 1px solid var(--line);
  background: var(--surface-muted);
}

.filter-group {
  display: flex;
  flex-wrap: wrap;
  gap: 5px;
}

.filter-chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 29px;
  padding: 0 9px;
  border: 1px solid transparent;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-soft);
  cursor: pointer;
  font-size: var(--text-xs);
}

.filter-chip span {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.filter-chip:hover,
.filter-chip.is-active {
  border-color: var(--line);
  background: var(--surface);
  color: var(--ink);
}

.project-filter {
  display: grid;
  gap: 4px;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
  font-weight: 700;
  text-transform: uppercase;
}

.project-filter select {
  min-width: 150px;
  height: 31px;
  border: 1px solid var(--line);
  border-radius: 7px;
  background: var(--surface);
  color: var(--ink);
  font-size: var(--text-sm);
}

.table-scroll {
  overflow-x: auto;
}

.action-table {
  width: 100%;
  border-collapse: collapse;
  text-align: left;
}

.action-table th {
  padding: 11px 15px;
  border-bottom: 1px solid var(--line);
  color: var(--ink-faint);
  font-size: var(--text-2xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.action-table th:first-child,
.action-table td:first-child {
  padding-left: 24px;
}

.action-table th button {
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
  cursor: pointer;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
}

.action-table td {
  padding: 14px 15px;
  border-bottom: 1px solid var(--line);
  color: var(--ink-soft);
  font-size: var(--text-sm);
  vertical-align: middle;
}

.action-table td strong {
  display: block;
  color: var(--ink);
  font-size: var(--text-base);
}

.action-table td small {
  display: block;
  margin-top: 3px;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.tag-line {
  display: flex;
  gap: 4px;
  margin-top: 5px;
}

.tag-line span,
.priority,
.due-badge {
  display: inline-flex;
  padding: 3px 6px;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--surface-muted);
  font-size: var(--text-2xs);
}

.due-badge {
  margin-right: 4px;
}

.due-overdue {
  border-color: color-mix(in srgb, var(--warning) 32%, var(--line));
  color: var(--warning);
}

.priority-4,
.priority-5 {
  color: var(--warning);
}

.empty-table {
  min-height: 360px;
}

.no-filter-results {
  padding: 30px;
  color: var(--ink-soft);
  text-align: center;
}

.project-card-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 15px;
}

.project-card {
  padding: 23px;
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow);
}

.project-card-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
}

.state-pill {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 5px 7px;
  border: 1px solid var(--line);
  border-radius: 999px;
  color: var(--ink-soft);
  font-size: var(--text-2xs);
}

.state-pill > span {
  width: 5px;
  height: 5px;
}

.project-card-title {
  margin: 22px 0 17px;
}

.project-card-title p {
  margin: 0;
  color: var(--ink-faint);
  font-size: var(--text-2xs);
  font-weight: 700;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}

.project-card-title h2 {
  margin: 3px 0 0;
  font-family: var(--serif);
  font-size: var(--text-2xl);
  font-weight: 500;
}

.project-stat-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  margin: 0;
  border-block: 1px solid var(--line);
}

.project-stat-grid > div {
  padding: 13px 0;
}

.project-stat-grid > div:nth-child(odd) {
  border-right: 1px solid var(--line);
}

.project-stat-grid > div:nth-child(even) {
  padding-left: 18px;
}

.project-stat-grid > div:nth-child(n + 3) {
  border-top: 1px solid var(--line);
}

.project-stat-grid dt {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.project-stat-grid dd {
  margin: 3px 0 0;
  font-family: var(--serif);
  font-size: var(--text-xl);
  font-weight: 600;
}

.project-paths {
  display: grid;
  gap: 12px;
  margin-top: 17px;
}

.project-paths > div {
  display: grid;
  gap: 3px;
}

.project-paths span {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
  font-weight: 700;
  text-transform: uppercase;
}

.project-paths code,
.project-paths time {
  display: block;
  max-width: 100%;
  overflow: hidden;
  color: var(--ink-soft);
  font-family: var(--mono);
  font-size: var(--text-2xs);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.empty-project-data {
  min-height: 122px;
  padding: 20px 0;
  border-block: 1px solid var(--line);
}

.empty-project-data p {
  color: var(--ink-soft);
  font-size: var(--text-xs);
}

.card-warning {
  display: grid;
  gap: 2px;
  margin-top: 15px;
  padding: 10px;
  border-radius: 8px;
  background: color-mix(in srgb, var(--warning) 8%, var(--surface));
  font-size: var(--text-xs);
}

.card-warning span {
  color: var(--ink-soft);
}

.project-summary {
  margin-top: 17px;
}

.coverage-row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  margin-top: 22px;
}

.coverage-row > div {
  display: grid;
  gap: 3px;
  padding: 0 20px;
  border-right: 1px solid var(--line);
}

.coverage-row > div:first-child {
  padding-left: 0;
}

.coverage-row > div:last-child {
  border: 0;
}

.coverage-row strong {
  font-family: var(--serif);
  font-size: var(--text-xl);
}

.coverage-row span {
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

.deferred-panel {
  position: relative;
  display: grid;
  min-height: 500px;
  grid-template-columns: minmax(0, 1fr) minmax(280px, 0.65fr);
  align-items: center;
  gap: 40px;
  padding: clamp(35px, 7vw, 90px);
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface);
  box-shadow: var(--shadow);
}

.deferred-index {
  position: absolute;
  top: -40px;
  right: 20px;
  color: color-mix(in srgb, var(--ink) 5%, transparent);
  font-family: var(--serif);
  font-size: clamp(7.5rem, 25vw, 16rem);
  line-height: 1;
  pointer-events: none;
}

.deferred-copy {
  position: relative;
  z-index: 1;
}

.deferred-copy h2 {
  max-width: 560px;
  margin-top: 10px;
  font-size: clamp(var(--text-3xl), 5vw, var(--text-hero));
  line-height: 1.02;
}

.deferred-copy > p:not(.section-kicker) {
  max-width: 590px;
  margin: 20px 0 25px;
  color: var(--ink-soft);
  font-size: var(--text-md);
}

.primary-link {
  display: inline-flex;
  min-height: 39px;
  align-items: center;
  padding: 0 14px;
  border-radius: 8px;
  background: var(--forest);
  color: var(--forest-ink);
  font-size: var(--text-sm);
  font-weight: 700;
  text-decoration: none;
}

.deferred-preview {
  position: relative;
  z-index: 1;
  display: grid;
  gap: 8px;
}

.deferred-preview div {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px;
  border: 1px solid var(--line);
  border-radius: 9px;
  background: var(--surface-raised);
  font-size: var(--text-sm);
}

.deferred-preview strong {
  font-family: var(--serif);
  font-size: var(--text-md);
}

.settings-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 17px;
}

.settings-wide {
  grid-column: 1 / -1;
}

.settings-description {
  max-width: 600px;
  color: var(--ink-soft);
  font-size: var(--text-base);
}

.theme-options {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 8px;
  margin-top: 20px;
}

.theme-options button {
  display: grid;
  gap: 2px;
  padding: 11px;
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--surface-muted);
  color: var(--ink);
  cursor: pointer;
  text-align: left;
}

.theme-options button.is-selected {
  border-color: var(--accent);
}

.theme-options strong {
  font-size: var(--text-sm);
}

.theme-options small {
  color: var(--ink-faint);
  font-size: var(--text-2xs);
}

.theme-swatch {
  display: block;
  height: 28px;
  margin-bottom: 7px;
  border: 1px solid var(--line);
  border-radius: 6px;
}

.swatch-system {
  background: linear-gradient(90deg, #f5f1e7 50%, #202724 50%);
}

.swatch-light {
  background: #f5f1e7;
}

.swatch-dark {
  background: #202724;
}

.settings-list {
  display: grid;
  gap: 0;
  margin: 18px 0 0;
}

.settings-list div {
  display: flex;
  justify-content: space-between;
  gap: 20px;
  padding: 11px 0;
  border-bottom: 1px solid var(--line);
}

.settings-list dt {
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

.settings-list dd {
  margin: 0;
  font-size: var(--text-sm);
  font-weight: 700;
}

.settings-note {
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

.boundary-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 25px;
  margin-top: 22px;
}

.boundary-grid > div {
  padding-right: 20px;
  border-right: 1px solid var(--line);
}

.boundary-grid > div:last-child {
  border: 0;
}

.boundary-mark {
  display: grid;
  width: 25px;
  height: 25px;
  place-items: center;
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--accent);
  font-family: var(--serif);
  font-size: var(--text-sm);
}

.boundary-grid h3 {
  margin: 11px 0 4px;
  font-family: var(--serif);
  font-size: var(--text-md);
  font-weight: 600;
}

.boundary-grid p {
  margin: 0;
  color: var(--ink-soft);
  font-size: var(--text-xs);
}

.toast {
  position: fixed;
  z-index: 50;
  right: 22px;
  bottom: 22px;
  padding: 11px 14px;
  border: 1px solid var(--line-strong);
  border-radius: 9px;
  background: var(--forest);
  box-shadow: var(--shadow);
  color: var(--forest-ink);
  font-size: var(--text-sm);
}

.toast a {
  margin-left: 4px;
  color: #edac82;
}

.nav-scrim {
  position: fixed;
  z-index: 25;
  inset: 0;
  padding: 0;
  border: 0;
  background: rgb(0 0 0 / 35%);
  cursor: pointer;
}

.loading-page {
  min-height: 100vh;
  padding: clamp(24px, 7vw, 80px);
}

.loading-shell {
  width: min(100%, 960px);
  margin: 0 auto;
}

.loading-brand {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: clamp(42px, 9vh, 90px);
}

.loading-brand > span:last-child {
  display: grid;
}

.loading-brand strong {
  font-family: var(--serif);
  font-size: var(--text-lg);
}

.loading-brand small,
.loading-boundary {
  color: var(--ink-faint);
  font-size: var(--text-xs);
}

.loading-card {
  position: relative;
  max-width: 590px;
  padding-left: 54px;
}

.loading-card h1 {
  margin: 8px 0 11px;
  font-family: var(--serif);
  font-size: clamp(var(--text-3xl), 6vw, 2.9rem);
  font-weight: 500;
  letter-spacing: -0.04em;
  line-height: 1;
}

.loading-card > p:not(.section-kicker) {
  max-width: 490px;
  color: var(--ink-soft);
  font-size: var(--text-base);
}

.loading-spinner {
  position: absolute;
  top: 7px;
  left: 2px;
  width: 31px;
  height: 31px;
  border: 2px solid var(--line);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin 900ms linear infinite;
}

.has-loading-error .loading-spinner {
  border-color: color-mix(in srgb, var(--warning) 30%, var(--line));
  animation: none;
}

.has-loading-error .loading-spinner::after {
  display: grid;
  height: 100%;
  place-items: center;
  color: var(--warning);
  content: "!";
  font-size: var(--text-base);
  font-weight: 800;
}

.primary-button {
  min-height: 39px;
  margin-top: 12px;
  padding: 0 15px;
  border: 0;
  border-radius: 8px;
  background: var(--forest);
  color: var(--forest-ink);
  cursor: pointer;
  font-size: var(--text-sm);
  font-weight: 700;
}

.loading-skeleton {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 11px;
  margin-top: clamp(55px, 11vh, 110px);
}

.loading-skeleton span {
  display: block;
  height: 115px;
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  background:
    linear-gradient(
      100deg,
      transparent 30%,
      color-mix(in srgb, var(--surface) 75%, transparent) 50%,
      transparent 70%
    ),
    var(--surface-muted);
  background-size: 220% 100%;
  animation: loading-sheen 1.7s ease-in-out infinite;
}

.loading-boundary {
  margin-top: 15px;
}

@keyframes loading-sheen {
  to {
    background-position-x: -220%;
  }
}

.error-page {
  display: grid;
  min-height: 100vh;
  place-items: center;
  padding: 24px;
}

.error-card {
  width: min(100%, 520px);
  padding: 42px;
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  background: var(--surface);
  box-shadow: var(--shadow);
}

.error-card .brand-mark {
  margin-bottom: 22px;
}

.error-card h1 {
  margin: 8px 0;
  font-family: var(--serif);
  font-size: var(--text-3xl);
  font-weight: 500;
}

.error-card > p {
  color: var(--ink-soft);
}

.error-card details {
  margin: 20px 0;
  color: var(--ink-soft);
  font-size: var(--text-sm);
}

.error-card code {
  display: block;
  margin-top: 8px;
  overflow-wrap: anywhere;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

@media (max-width: 1180px) {
  .metric-grid {
    grid-template-columns: repeat(3, 1fr);
  }

  .dashboard-grid {
    grid-template-columns: 1fr;
  }

  .project-card-grid {
    grid-template-columns: repeat(2, 1fr);
  }

  .overview-board-grid,
  .tag-metric-grid {
    grid-template-columns: repeat(2, 1fr);
  }

  .tags-layout {
    grid-template-columns: 1fr;
  }
}

@media (max-width: 860px) {
  :root {
    --sidebar-width: 230px;
  }

  .sidebar {
    transform: translateX(-100%);
    transition: transform 180ms ease;
  }

  .sidebar.is-open {
    transform: translateX(0);
  }

  .app-frame {
    margin-left: 0;
  }

  .mobile-menu {
    display: inline-flex;
  }

  .topbar {
    padding-inline: 18px;
  }

  .main-content {
    padding-inline: 20px;
  }

  .project-strip,
  .project-card-grid {
    grid-template-columns: 1fr;
  }

  .settings-grid {
    grid-template-columns: 1fr;
  }

  .settings-wide {
    grid-column: auto;
  }

  .board-toolbar {
    grid-template-columns: 1fr 1fr;
  }

  .board-summary {
    grid-column: 1 / -1;
    justify-content: flex-start;
  }
}

@media (max-width: 640px) {
  .topbar {
    flex-wrap: wrap;
    gap: 7px;
    padding-block: 8px;
  }

  .selectors {
    order: 3;
    width: 100%;
  }

  .select-form {
    min-width: 0;
    flex: 1;
  }

  .select-wrap select {
    width: 100%;
    max-width: none;
  }

  .topbar-actions {
    margin-left: auto;
  }

  .refresh-button span:last-child {
    display: none;
  }

  .refresh-button {
    width: 36px;
    padding: 0;
  }

  .main-content {
    padding-top: 30px;
  }

  .page-heading {
    display: grid;
    align-items: start;
  }

  .source-cluster {
    flex-wrap: wrap;
  }

  .metric-grid {
    grid-template-columns: repeat(2, 1fr);
  }

  .metric-completion {
    grid-column: 1 / -1;
  }

  .panel {
    padding: 19px;
  }

  .project-strip {
    grid-template-columns: 1fr;
  }

  .status-ribbon,
  .ribbon-facts,
  .table-tools {
    align-items: flex-start;
    flex-direction: column;
  }

  .ribbon-facts {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    width: 100%;
    gap: 8px;
  }

  .filter-group {
    flex-wrap: nowrap;
    width: 100%;
    padding-bottom: 4px;
    overflow-x: auto;
  }

  .filter-chip {
    flex: 0 0 auto;
  }

  .project-filter,
  .project-filter select {
    width: 100%;
  }

  .board-toolbar,
  .overview-board-grid,
  .tag-metric-grid {
    grid-template-columns: 1fr;
  }

  .board-project-filter,
  .board-project-filter select,
  .tag-set-form select {
    width: 100%;
  }

  .board-summary {
    flex-wrap: wrap;
    padding-bottom: 0;
  }

  .figure-drawer {
    margin: auto;
  }

  .loading-page {
    padding: 22px;
  }

  .loading-card {
    padding-left: 0;
  }

  .loading-spinner {
    position: static;
    margin-bottom: 20px;
  }

  .loading-skeleton {
    grid-template-columns: repeat(2, 1fr);
  }

  .drawer-details {
    grid-template-columns: 1fr;
  }

  .drawer-details > div:nth-child(odd) {
    border-right: 0;
  }

  .drawer-details > div:nth-child(even) {
    padding-left: 0;
  }

  .drawer-details > div:nth-child(n + 2) {
    border-top: 1px solid var(--line);
  }

  .coverage-row,
  .boundary-grid,
  .theme-options {
    grid-template-columns: 1fr;
  }

  .coverage-row > div,
  .boundary-grid > div {
    padding: 12px 0;
    border-right: 0;
    border-bottom: 1px solid var(--line);
  }

  .deferred-panel {
    min-height: 540px;
    grid-template-columns: 1fr;
    padding: 34px 24px;
  }

  .content-footer {
    display: grid;
    gap: 6px;
  }
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    scroll-behavior: auto !important;
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}
src/curio_dashboard/static/app.js9 KB
(() => {
  const root = document.documentElement;
  const themeKey = "curio-atlas-theme";
  const systemDark = window.matchMedia("(prefers-color-scheme: dark)");

  const storedTheme = () => localStorage.getItem(themeKey) || "system";
  const resolvedTheme = (choice) =>
    choice === "system" ? (systemDark.matches ? "dark" : "light") : choice;

  const applyTheme = (choice) => {
    root.dataset.theme = resolvedTheme(choice);
    root.dataset.themeChoice = choice;
    document.querySelectorAll("[data-theme-choice]").forEach((button) => {
      const selected = button.dataset.themeChoice === choice;
      button.classList.toggle("is-selected", selected);
      button.setAttribute("aria-pressed", String(selected));
    });
    const icon = document.querySelector("[data-theme-icon]");
    if (icon) icon.textContent = choice === "dark" ? "☼" : "◐";
  };

  applyTheme(storedTheme());
  systemDark.addEventListener("change", () => {
    if (storedTheme() === "system") applyTheme("system");
  });

  const loadingPage = document.querySelector("[data-initial-loading]");
  if (loadingPage) {
    const status = document.querySelector("#loading-status");
    const message = document.querySelector("[data-loading-message]");
    const retry = document.querySelector("[data-loading-retry]");
    let loading = false;
    const loadInitialState = async () => {
      if (loading) return;
      loading = true;
      loadingPage.classList.remove("has-loading-error");
      status?.setAttribute("aria-busy", "true");
      if (message) {
        message.textContent =
          "Reading saved Curio projects and restoring the latest dashboard.";
      }
      if (retry) retry.hidden = true;
      try {
        const response = await fetch("/api/status", {
          headers: { Accept: "application/json" },
          cache: "no-store",
        });
        if (response.ok) {
          window.location.reload();
          return;
        }
        const payload = await response.json().catch(() => ({}));
        const detail = payload.detail?.message;
        throw new Error(detail || "The project scan could not be completed.");
      } catch (error) {
        loadingPage.classList.add("has-loading-error");
        status?.setAttribute("aria-busy", "false");
        if (message) {
          message.textContent =
            error instanceof Error
              ? error.message
              : "The project scan could not be completed.";
        }
        if (retry) retry.hidden = false;
        status?.focus();
      } finally {
        loading = false;
      }
    };
    retry?.addEventListener("click", loadInitialState);
    window.setTimeout(loadInitialState, 100);
    return;
  }

  document.querySelector("[data-theme-toggle]")?.addEventListener("click", () => {
    const next = resolvedTheme(storedTheme()) === "dark" ? "light" : "dark";
    localStorage.setItem(themeKey, next);
    applyTheme(next);
  });

  document.querySelectorAll("[data-theme-choice]").forEach((button) => {
    button.addEventListener("click", () => {
      const choice = button.dataset.themeChoice;
      localStorage.setItem(themeKey, choice);
      applyTheme(choice);
    });
  });

  document.querySelectorAll("[data-auto-submit]").forEach((select) => {
    select.addEventListener("change", () => {
      const form = select.closest("form");
      form?.setAttribute("aria-busy", "true");
      form?.requestSubmit();
    });
  });

  document.querySelector("[data-refresh-form]")?.addEventListener("submit", (event) => {
    const button = event.currentTarget.querySelector("button");
    button?.classList.add("is-loading");
    if (button) button.disabled = true;
  });

  const navToggle = document.querySelector("[data-nav-toggle]");
  const sidebar = document.querySelector("#sidebar");
  const scrim = document.querySelector("[data-nav-scrim]");
  const setNav = (open) => {
    sidebar?.classList.toggle("is-open", open);
    navToggle?.setAttribute("aria-expanded", String(open));
    navToggle?.setAttribute(
      "aria-label",
      open ? "Close navigation" : "Open navigation",
    );
    if (scrim) scrim.hidden = !open;
  };
  navToggle?.addEventListener("click", () => setNav(!sidebar?.classList.contains("is-open")));
  scrim?.addEventListener("click", () => setNav(false));
  document.addEventListener("keydown", (event) => {
    if (event.key === "Escape" && sidebar?.classList.contains("is-open")) {
      setNav(false);
      navToggle?.focus();
    }
  });

  const bucketButtons = [...document.querySelectorAll("[data-bucket-filter]")];
  const projectFilter = document.querySelector("[data-project-filter]");
  const actionRows = [...document.querySelectorAll("[data-action-table] tbody tr")];
  let activeBucket = "all";

  const filterRows = () => {
    const project = projectFilter?.value || "all";
    let visible = 0;
    actionRows.forEach((row) => {
      const matchesBucket = activeBucket === "all" || row.dataset.bucket === activeBucket;
      const matchesProject = project === "all" || row.dataset.project === project;
      const show = matchesBucket && matchesProject;
      row.hidden = !show;
      if (show) visible += 1;
    });
    const empty = document.querySelector("[data-no-filter-results]");
    if (empty) empty.hidden = visible > 0;
  };

  bucketButtons.forEach((button) => {
    button.addEventListener("click", () => {
      activeBucket = button.dataset.bucketFilter;
      bucketButtons.forEach((candidate) => {
        const active = candidate === button;
        candidate.classList.toggle("is-active", active);
        candidate.setAttribute("aria-pressed", String(active));
      });
      filterRows();
    });
  });
  projectFilter?.addEventListener("change", filterRows);

  const boardProjectFilter = document.querySelector("[data-board-project-filter]");
  const boardCards = [...document.querySelectorAll("[data-figure-card]")];
  const filterBoard = () => {
    const project = boardProjectFilter?.value || "all";
    document.querySelectorAll("[data-board-column]").forEach((column) => {
      let visible = 0;
      column.querySelectorAll("[data-figure-card]").forEach((card) => {
        const show = project === "all" || card.dataset.projectPath === project;
        card.hidden = !show;
        if (show) visible += 1;
      });
      const count = column.querySelector("[data-column-count]");
      if (count) count.textContent = String(visible);
      const empty = column.querySelector(".empty-column");
      if (empty) empty.hidden = visible > 0;
    });
  };
  boardProjectFilter?.addEventListener("change", filterBoard);

  const drawer = document.querySelector("[data-figure-drawer]");
  const drawerField = (name, value) => {
    const field = drawer?.querySelector(`[data-drawer-${name}]`);
    if (field) field.textContent = value || "—";
  };
  boardCards.forEach((card) => {
    card.addEventListener("click", () => {
      drawerField("title", card.dataset.title);
      drawerField("project", card.dataset.project);
      drawerField("project-mark", (card.dataset.project || "—").slice(0, 2).toUpperCase());
      drawerField("kind", card.dataset.kind);
      drawerField("action", card.dataset.action);
      drawerField("due", card.dataset.due);
      drawerField("priority", card.dataset.priority);
      drawerField("progress", card.dataset.progress);
      drawerField("checkmark", card.dataset.checkmark);
      drawerField("section", card.dataset.section);
      drawerField("organizer", card.dataset.organizer);
      drawerField("tags", card.dataset.tags || "None");
      const warning = drawer?.querySelector("[data-drawer-warning]");
      if (warning) warning.hidden = card.dataset.conflicting !== "true";
      drawer?.showModal();
    });
  });
  drawer?.addEventListener("click", (event) => {
    if (event.target === drawer) drawer.close();
  });

  document.querySelectorAll("[data-sort-table]").forEach((button) => {
    button.addEventListener("click", () => {
      const key = button.dataset.sortTable;
      const body = document.querySelector("[data-action-table] tbody");
      const direction = button.dataset.direction === "asc" ? "desc" : "asc";
      button.dataset.direction = direction;
      document.querySelectorAll("[data-sort-heading]").forEach((heading) => {
        heading.setAttribute(
          "aria-sort",
          heading.contains(button) ? `${direction}ending` : "none",
        );
      });
      const multiplier = direction === "asc" ? 1 : -1;
      actionRows
        .sort((a, b) => (a.dataset[key] || "").localeCompare(b.dataset[key] || "") * multiplier)
        .forEach((row) => body?.appendChild(row));
    });
  });

  const toast = document.querySelector("[data-toast]");
  const initialGeneration = document.body.dataset.generatedAt;
  const checkForNewSnapshot = async () => {
    if (document.hidden || !initialGeneration) return;
    try {
      const response = await fetch("/api/status", {
        headers: { Accept: "application/json" },
        cache: "no-store",
      });
      if (!response.ok) return;
      const state = await response.json();
      if (state.generated_at !== initialGeneration && toast) {
        toast.hidden = false;
        toast.innerHTML = 'Fresh data is ready. <a href="">Reload dashboard</a>';
      }
    } catch {
      // The visible last-known state is intentionally retained.
    }
  };
  window.setInterval(checkForNewSnapshot, 30000);
})();
src/curio_dashboard/tags.py9 KB
"""Generic tag analytics and read-only Kanban construction."""

from __future__ import annotations

from collections import Counter, OrderedDict
from datetime import date
from zoneinfo import ZoneInfo

from curio_dashboard.aggregate import CurioDateError, classify_due_date
from curio_dashboard.models import (
    BoardCard,
    BoardColumn,
    DateBucket,
    KanbanBoard,
    NormalizedFigure,
    ProjectSnapshot,
    ProjectTagRow,
    TagAnalytics,
    TagSetDefinition,
    TagStatistic,
)


def split_tag(tag: str) -> tuple[str | None, str]:
    if "/" not in tag:
        return None, tag
    set_name, value = tag.split("/", 1)
    if not set_name or not value:
        return None, tag
    return set_name, value


def normalize_tag_sets(
    snapshots: tuple[ProjectSnapshot, ...],
) -> tuple[TagSetDefinition, ...]:
    """Preserve catalog order, then append uncatalogued used tags."""

    ordered: OrderedDict[str, list[str]] = OrderedDict()
    seen: dict[str, set[str]] = {}
    canonical_names: dict[str, str] = {}

    def add(tag: str) -> None:
        set_name, _ = split_tag(tag)
        if set_name is None:
            return
        key = set_name.casefold()
        if key not in seen:
            seen[key] = set()
            ordered[set_name] = []
            canonical_names[key] = set_name
        canonical_name = canonical_names[key]
        if tag.casefold() not in seen[key]:
            seen[key].add(tag.casefold())
            ordered[canonical_name].append(tag)

    for snapshot in snapshots:
        for tag in snapshot.tag_catalog:
            add(tag)
    for snapshot in snapshots:
        for figure in (
            *snapshot.tagged_figures,
            *snapshot.open_actions,
            *snapshot.completed_actions,
        ):
            for tag in figure.tags:
                add(tag)

    return tuple(
        TagSetDefinition(name=name, tags=tuple(tags)) for name, tags in ordered.items()
    )


def _unique_figures(
    snapshots: tuple[ProjectSnapshot, ...],
    attribute: str,
) -> dict[tuple[str, str], NormalizedFigure]:
    figures: dict[tuple[str, str], NormalizedFigure] = {}
    for snapshot in snapshots:
        for figure in getattr(snapshot, attribute):
            figures[figure.composite_id] = figure
    return figures


def build_tag_analytics(
    snapshots: tuple[ProjectSnapshot, ...],
    *,
    today: date,
    timezone: ZoneInfo,
) -> TagAnalytics:
    tagged = _unique_figures(snapshots, "tagged_figures")
    open_actions = _unique_figures(snapshots, "open_actions")
    completed = _unique_figures(snapshots, "completed_actions")
    tag_sets = normalize_tag_sets(snapshots)

    total_counts: Counter[str] = Counter()
    open_counts: Counter[str] = Counter()
    overdue_counts: Counter[str] = Counter()
    completed_counts: Counter[str] = Counter()
    last_modified: dict[str, str] = {}
    distribution: Counter[str] = Counter()
    tagged_open_actions = 0
    tagged_overdue_actions = 0
    tagged_completed_actions = sum(bool(figure.tags) for figure in completed.values())
    project_counts: dict[tuple[str, str], Counter[str]] = {
        (snapshot.project_path, snapshot.project_title): Counter()
        for snapshot in snapshots
    }

    for figure in tagged.values():
        project_key = (figure.project_path, figure.project_title)
        project_counts.setdefault(project_key, Counter())
        for tag in set(figure.tags):
            total_counts[tag] += 1
            project_counts[project_key][tag] += 1
            set_name, _ = split_tag(tag)
            distribution[set_name or "Standalone"] += 1
            if figure.modified_date:
                last_modified[tag] = max(
                    last_modified.get(tag, figure.modified_date),
                    figure.modified_date,
                )

    for figure in open_actions.values():
        if figure.tags:
            tagged_open_actions += 1
        is_overdue = False
        try:
            is_overdue = (
                classify_due_date(
                    figure.due_date,
                    today=today,
                    timezone=timezone,
                )
                is DateBucket.OVERDUE
            )
        except CurioDateError:
            pass
        if figure.tags and is_overdue:
            tagged_overdue_actions += 1
        for tag in set(figure.tags):
            open_counts[tag] += 1
            if is_overdue:
                overdue_counts[tag] += 1

    for figure in completed.values():
        for tag in set(figure.tags):
            completed_counts[tag] += 1

    used_tags = set(total_counts) | set(open_counts) | set(completed_counts)
    statistics = tuple(
        sorted(
            (
                TagStatistic(
                    tag=tag,
                    total_figures=total_counts[tag],
                    open_actions=open_counts[tag],
                    overdue_actions=overdue_counts[tag],
                    completed_actions=completed_counts[tag],
                    last_modified=last_modified.get(tag),
                )
                for tag in used_tags
            ),
            key=lambda item: (-item.total_figures, item.tag.casefold()),
        )
    )
    matrix = tuple(
        ProjectTagRow(
            project_path=path,
            project_title=title,
            counts=dict(counts),
        )
        for (path, title), counts in project_counts.items()
    )
    return TagAnalytics(
        total_tagged_figures=len(tagged),
        distinct_used_tags=len(used_tags),
        tagged_open_actions=tagged_open_actions,
        tagged_overdue_actions=tagged_overdue_actions,
        tagged_completed_actions=tagged_completed_actions,
        tag_sets=tag_sets,
        statistics=statistics,
        project_matrix=matrix,
        tag_set_distribution=dict(distribution.most_common()),
    )


def _card_sort_key(card: BoardCard) -> tuple[int, str, str]:
    return (
        -card.figure.priority,
        card.figure.due_date or "9999",
        (card.figure.title or card.figure.id).casefold(),
    )


def build_kanban_board(
    snapshots: tuple[ProjectSnapshot, ...],
    analytics: TagAnalytics,
    *,
    requested_tag_set: str,
    include_unassigned: bool,
) -> KanbanBoard:
    definitions = {item.name.casefold(): item for item in analytics.tag_sets}
    selected = definitions.get(requested_tag_set.casefold())
    warnings: list[str] = []
    if selected is None and analytics.tag_sets:
        selected = analytics.tag_sets[0]
        warnings.append(
            f"Configured tag set {requested_tag_set!r} was unavailable; "
            f"showing {selected.name!r}."
        )
    if selected is None:
        return KanbanBoard(
            tag_set=None,
            available_tag_sets=(),
            columns=(),
            unassigned=(),
            conflicting_cards=(),
            warnings=("No tag sets are available in the selected projects.",),
        )

    tagged = _unique_figures(snapshots, "tagged_figures")
    open_actions = _unique_figures(snapshots, "open_actions")
    candidates = dict(tagged)
    candidates.update(open_actions)
    open_ids = set(open_actions)
    tag_lookup = {tag.casefold(): tag for tag in selected.tags}
    cards_by_tag: dict[str, list[BoardCard]] = {tag: [] for tag in selected.tags}
    unassigned: list[BoardCard] = []
    conflicting_ids: list[str] = []

    for identity, figure in candidates.items():
        matches = [
            tag_lookup[tag.casefold()]
            for tag in figure.tags
            if tag.casefold() in tag_lookup
        ]
        matches = list(dict.fromkeys(matches))
        conflicting = len(matches) > 1
        card = BoardCard(
            figure=figure,
            is_open_action=identity in open_ids,
            conflicting=conflicting,
        )
        if conflicting:
            conflicting_ids.append(f"{figure.project_path}::{figure.id}")
        for tag in matches:
            cards_by_tag[tag].append(card)
        if not matches and identity in open_ids and include_unassigned:
            unassigned.append(card)

    if conflicting_ids:
        warnings.append(
            f"{len(conflicting_ids)} card"
            f"{'s' if len(conflicting_ids) != 1 else ''} use multiple "
            f"{selected.name} tags."
        )
    columns = tuple(
        BoardColumn(
            tag=tag,
            label=split_tag(tag)[1],
            cards=tuple(sorted(cards_by_tag[tag], key=_card_sort_key)),
        )
        for tag in selected.tags
    )
    return KanbanBoard(
        tag_set=selected.name,
        available_tag_sets=tuple(item.name for item in analytics.tag_sets),
        columns=columns,
        unassigned=tuple(sorted(unassigned, key=_card_sort_key)),
        conflicting_cards=tuple(conflicting_ids),
        warnings=tuple(warnings),
    )
src/curio_dashboard/templates/base.html11 KB
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <meta
      name="description"
      content="A local, read-only command center for Curio projects."
    >
    <title>{{ title }} · Curio Atlas</title>
    <link rel="stylesheet" href="{{ url_for('static', path='/app.css') }}">
    <script src="{{ url_for('static', path='/app.js') }}" defer></script>
  </head>
  <body data-generated-at="{{ state.generated_at.isoformat() if state else '' }}">
    <a class="skip-link" href="#main-content">Skip to content</a>

    <aside class="sidebar" id="sidebar" aria-label="Primary navigation">
      <a class="brand" href="/" aria-label="Curio Atlas overview">
        <img
          class="brand-mark"
          src="{{ url_for('static', path='/curio128x128.png') }}"
          width="128"
          height="128"
          alt=""
        >
        <span>
          <strong>Curio Atlas</strong>
          <small>Local project intelligence</small>
        </span>
      </a>

      <nav class="primary-nav">
        <p class="nav-label">Workspace</p>
        <a href="/" {% if page == "overview" %}aria-current="page"{% endif %}>
          <span class="nav-icon" aria-hidden="true">⌂</span>
          <span>Overview</span>
        </a>
        <a
          href="/upcoming"
          {% if page == "upcoming" %}aria-current="page"{% endif %}
        >
          <span class="nav-icon" aria-hidden="true">◷</span>
          <span>Upcoming</span>
        </a>
        <a href="/board" {% if page == "board" %}aria-current="page"{% endif %}>
          <span class="nav-icon" aria-hidden="true">▥</span>
          <span>Board</span>
        </a>
        <a href="/tags" {% if page == "tags" %}aria-current="page"{% endif %}>
          <span class="nav-icon" aria-hidden="true">◇</span>
          <span>Tags</span>
        </a>
        <a
          href="/projects"
          {% if page == "projects" %}aria-current="page"{% endif %}
        >
          <span class="nav-icon" aria-hidden="true">▦</span>
          <span>Projects</span>
        </a>
        <a
          href="/settings"
          {% if page == "settings" %}aria-current="page"{% endif %}
        >
          <span class="nav-icon" aria-hidden="true">⚙</span>
          <span>Settings</span>
        </a>
      </nav>

      <div class="sidebar-foot">
        <span class="local-dot" aria-hidden="true"></span>
        <span>
          <strong>Local &amp; read-only</strong>
          <small>Curio projects stay untouched</small>
        </span>
      </div>
    </aside>

    <div class="app-frame">
      <header class="topbar">
        <button
          class="icon-button mobile-menu"
          type="button"
          data-nav-toggle
          aria-controls="sidebar"
          aria-expanded="false"
          aria-label="Open navigation"
        >
          <span aria-hidden="true">☰</span>
        </button>

        <div class="selectors" aria-label="Dashboard scope">
          <form action="/selection/category" method="post" class="select-form">
            <input type="hidden" name="return_to" value="{{ request.url.path }}">
            <label for="category-select">Category</label>
            <div class="select-wrap">
              <select
                id="category-select"
                name="category_id"
                data-auto-submit
                aria-label="Project Center category"
              >
                {% for category in state.available_categories %}
                  <option
                    value="{{ category.id }}"
                    {% if category.id == state.category_id %}selected{% endif %}
                    {% if not category.selectable %}disabled{% endif %}
                  >
                    {{ category.title }} · {{ category.project_count }}
                  </option>
                {% endfor %}
              </select>
            </div>
          </form>

          <span class="selector-divider" aria-hidden="true">/</span>

          <form action="/selection/project" method="post" class="select-form">
            <input type="hidden" name="return_to" value="{{ request.url.path }}">
            <label for="project-select">Project</label>
            <div class="select-wrap">
              <select
                id="project-select"
                name="project_path"
                data-auto-submit
                aria-label="Project scope"
              >
                <option value="" {% if not state.selected_project_path %}selected{% endif %}>
                  All {{ state.category_title }} projects
                </option>
                {% for project in projects %}
                  <option
                    value="{{ project.diagnostic.path }}"
                    {% if project.diagnostic.path == state.selected_project_path %}selected{% endif %}
                    {% if not project.selectable %}disabled{% endif %}
                  >
                    {{ project.diagnostic.title }}
                    {% if project.diagnostic.refresh_state.value != "current" %}
                      · {{ project.diagnostic.refresh_state.value | replace("_", " ") }}
                    {% endif %}
                  </option>
                {% endfor %}
              </select>
            </div>
          </form>
        </div>

        <div class="topbar-actions">
          <button
            class="icon-button"
            type="button"
            data-theme-toggle
            aria-label="Switch color theme"
          >
            <span data-theme-icon aria-hidden="true">◐</span>
          </button>
          <form action="/refresh" method="post" data-refresh-form>
            <input type="hidden" name="return_to" value="{{ request.url.path }}">
            <button
              class="refresh-button"
              type="submit"
              aria-label="Refresh dashboard data"
            >
              <span aria-hidden="true">↻</span>
              <span>Refresh</span>
            </button>
          </form>
        </div>
      </header>

      <main id="main-content" class="main-content" tabindex="-1">
        <section class="page-heading" aria-labelledby="page-title">
          <div>
            <p class="eyebrow">
              {{ state.category_title }}
              {% if selected_project_title %} · {{ selected_project_title }}{% endif %}
              <span>Travel workspace</span>
            </p>
            <h1 id="page-title">{{ title }}</h1>
            <p>{{ subtitle }}</p>
          </div>
          <div class="source-cluster">
            <span
              class="source-badge source-{{ state.discovery_data_source }}"
              title="Project Center discovery source"
            >
              <span aria-hidden="true"></span>
              {{ source_label }}
            </span>
            <time
              class="freshness"
              datetime="{{ state.generated_at.isoformat() }}"
              data-freshness
            >
              {{ freshness_label }}
            </time>
          </div>
        </section>

        {% if state.selection_warning %}
          <div class="notice notice-warning" role="status">
            <strong>Selection adjusted</strong>
            <span>{{ state.selection_warning }}</span>
          </div>
        {% endif %}

        {% if state.last_refresh_error %}
          <div class="notice notice-error" role="alert">
            <strong>Latest refresh failed</strong>
            <span>
              The last successful dashboard remains visible.
              <code>{{ state.last_refresh_error }}</code>
            </span>
          </div>
        {% elif snapshot_is_stale %}
          <div class="notice notice-data" role="status">
            <strong>Dashboard data is older than expected</strong>
            <span>
              The last successful snapshot is still available. Use Refresh to
              try the read-only project scan again.
            </span>
          </div>
        {% endif %}

        {% set problem_projects = state.projects | selectattr("refresh_state.value", "in", ["stale", "unavailable", "error"]) | list %}
        {% if problem_projects %}
          <div class="notice notice-data" role="status">
            <strong>Some project data needs attention</strong>
            <span>
              {% for project in problem_projects %}
                {{ project.title }}{% if not loop.last %}, {% endif %}
              {% endfor %}
              — last known information remains visible where available.
            </span>
          </div>
        {% endif %}

        {% if not state.projects %}
          <div class="notice notice-data" role="status">
            <strong>No projects in this category</strong>
            <span>
              Project Center returned an empty category. Choose another
              category to populate the dashboard.
            </span>
          </div>
        {% endif %}

        {% block content %}{% endblock %}

        <footer class="content-footer">
          <span>Curio Atlas · Read-only local dashboard</span>
          <a href="/api/status">View diagnostic JSON</a>
        </footer>
      </main>
    </div>

    <div class="toast" role="status" aria-live="polite" data-toast hidden></div>
    <button
      class="nav-scrim"
      type="button"
      data-nav-scrim
      aria-label="Close navigation"
      hidden
    ></button>

    <dialog class="figure-drawer" data-figure-drawer aria-labelledby="drawer-title">
      <form method="dialog">
        <button
          class="drawer-close"
          value="close"
          aria-label="Close figure details"
        >
          ×
        </button>
      </form>
      <div class="drawer-project">
        <span data-drawer-project-mark></span>
        <span data-drawer-project></span>
      </div>
      <h2 id="drawer-title" data-drawer-title>Figure details</h2>
      <div class="drawer-status-line">
        <span data-drawer-kind></span>
        <span data-drawer-action></span>
      </div>
      <dl class="drawer-details">
        <div>
          <dt>Due date</dt>
          <dd data-drawer-due>—</dd>
        </div>
        <div>
          <dt>Priority</dt>
          <dd data-drawer-priority>None</dd>
        </div>
        <div>
          <dt>Progress</dt>
          <dd data-drawer-progress>0%</dd>
        </div>
        <div>
          <dt>Checkbox</dt>
          <dd data-drawer-checkmark>Hidden</dd>
        </div>
        <div>
          <dt>Section</dt>
          <dd data-drawer-section>—</dd>
        </div>
        <div>
          <dt>Organizer</dt>
          <dd data-drawer-organizer>—</dd>
        </div>
      </dl>
      <div class="drawer-tags">
        <span>Tags</span>
        <p data-drawer-tags>None</p>
      </div>
      <div class="drawer-warning" data-drawer-warning hidden>
        This figure has multiple tags in a mutually exclusive workflow set.
      </div>
      <button class="drawer-future-action" type="button" disabled>
        Open in Curio · Future live action
      </button>
    </dialog>
  </body>
</html>
src/curio_dashboard/templates/board.html5 KB
{% extends "base.html" %}

{% macro figure_card(item) %}
  {% set card = item.card %}
  <button
    type="button"
    class="kanban-card accent-{{ item.accent }}{% if card.conflicting %} has-conflict{% endif %}"
    data-figure-card
    data-project-path="{{ card.figure.project_path }}"
    data-title="{{ card.figure.title or 'Untitled figure' }}"
    data-project="{{ card.figure.project_title }}"
    data-kind="{{ card.figure.kind }}"
    data-action="{{ 'Incomplete item' if card.is_open_action else 'Tagged figure' }}"
    data-due="{{ card.figure.due_date or 'No due date' }}"
    data-priority="{{ ('P' ~ card.figure.priority) if card.figure.priority else 'None' }}"
    data-progress="{{ card.figure.progress }}%"
    data-checkmark="{{ 'Visible' if card.figure.checkmark_visible else 'Hidden' }}"
    data-section="{{ card.figure.section_path or '—' }}"
    data-organizer="{{ card.figure.organizer_path or '—' }}"
    data-tags="{{ card.figure.tags | join(' · ') }}"
    data-conflicting="{{ 'true' if card.conflicting else 'false' }}"
  >
    <span class="kanban-card-topline">
      <span class="card-project-mark">{{ card.figure.project_title[:2] | upper }}</span>
      <span>{{ card.figure.project_title }}</span>
      {% if card.conflicting %}
        <span class="conflict-mark" title="Multiple workflow tags">!</span>
      {% endif %}
    </span>
    <strong>{{ card.figure.title or "Untitled figure" }}</strong>
    <span class="kanban-card-meta">
      {% if card.figure.due_date %}
        <span>{{ card.figure.due_date }}</span>
      {% endif %}
      {% if card.figure.priority %}
        <span>P{{ card.figure.priority }}</span>
      {% endif %}
      {% if not card.figure.due_date and not card.figure.priority %}
        <span>{{ card.figure.kind | title }}</span>
      {% endif %}
    </span>
    {% if card.figure.organizer_path %}
      <small>{{ card.figure.organizer_path }}</small>
    {% endif %}
  </button>
{% endmacro %}

{% block content %}
  <section class="board-toolbar" aria-label="Board controls">
    <div>
      <p class="section-kicker">Workflow tag set</p>
      <form action="/selection/tag-set" method="post" class="tag-set-form">
        <input type="hidden" name="return_to" value="/board">
        <label for="tag-set-select">Board tag set</label>
        <select id="tag-set-select" name="tag_set" data-auto-submit>
          {% for tag_set in state.board.available_tag_sets %}
            <option
              value="{{ tag_set }}"
              {% if tag_set == state.board.tag_set %}selected{% endif %}
            >
              {{ tag_set }}
            </option>
          {% endfor %}
        </select>
      </form>
    </div>
    <label class="board-project-filter">
      <span>Show cards from</span>
      <select data-board-project-filter>
        <option value="all">All projects</option>
        {% for project in projects %}
          <option value="{{ project.diagnostic.path }}">
            {{ project.diagnostic.title }}
          </option>
        {% endfor %}
      </select>
    </label>
    <div class="board-summary">
      <span><strong>{{ board_card_count }}</strong> cards</span>
      <span><strong>{{ state.board.columns | length }}</strong> stages</span>
      <span>
        <strong>{{ state.board.conflicting_cards | length }}</strong> conflicts
      </span>
    </div>
  </section>

  {% for warning in state.board.warnings %}
    <div class="notice notice-warning board-notice" role="status">
      <strong>Board data note</strong>
      <span>{{ warning }}</span>
    </div>
  {% endfor %}

  {% if state.board.tag_set %}
    <section class="kanban-shell" aria-label="{{ state.board.tag_set }} board">
      <div class="kanban-board">
        {% for item in board_columns %}
          <section class="kanban-column" data-board-column>
            <header>
              <span>{{ item.column.label }}</span>
              <strong data-column-count>{{ item.cards | length }}</strong>
            </header>
            <div class="kanban-stack">
              {% for card in item.cards %}
                {{ figure_card(card) }}
              {% endfor %}
              <p class="empty-column" {% if item.cards %}hidden{% endif %}>
                No cards in this stage
              </p>
            </div>
          </section>
        {% endfor %}

        {% if service_config.dashboard.kanban_include_unassigned %}
          <section class="kanban-column kanban-unassigned" data-board-column>
            <header>
              <span>Unassigned</span>
              <strong data-column-count>{{ board_unassigned | length }}</strong>
            </header>
            <div class="kanban-stack">
              {% for card in board_unassigned %}
                {{ figure_card(card) }}
              {% endfor %}
              <p class="empty-column" {% if board_unassigned %}hidden{% endif %}>
                Every incomplete item has a {{ state.board.tag_set }} tag
              </p>
            </div>
          </section>
        {% endif %}
      </div>
    </section>
    <p class="board-footnote">
      Read-only board · Column order follows the Curio tag catalog · Select a
      card for traceable figure details
    </p>
  {% else %}
    <div class="panel empty-state empty-table">
      <span class="empty-mark" aria-hidden="true">▥</span>
      <div>
        <h3>No workflow tag set available</h3>
        <p>Add a Curio tag set to build a cross-project read-only board.</p>
      </div>
    </div>
  {% endif %}
{% endblock %}
src/curio_dashboard/templates/error.html901 bytes
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Dashboard unavailable · Curio Atlas</title>
    <link rel="stylesheet" href="{{ url_for('static', path='/app.css') }}">
  </head>
  <body class="error-page">
    <main class="error-card">
      <img
        class="brand-mark"
        src="{{ url_for('static', path='/curio128x128.png') }}"
        width="128"
        height="128"
        alt=""
      >
      <p class="section-kicker">Curio Atlas</p>
      <h1>Dashboard unavailable</h1>
      <p>
        The last refresh could not produce a dashboard state. Curio was not
        modified.
      </p>
      <details>
        <summary>Technical detail</summary>
        <code>{{ error }}</code>
      </details>
      <a class="primary-link" href="/">Try again</a>
    </main>
  </body>
</html>
src/curio_dashboard/templates/loading.html2 KB
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="color-scheme" content="light dark">
    <meta
      name="description"
      content="Curio Atlas is preparing a local, read-only dashboard."
    >
    <title>Preparing {{ title }} · Curio Atlas</title>
    <link rel="stylesheet" href="{{ url_for('static', path='/app.css') }}">
    <script src="{{ url_for('static', path='/app.js') }}" defer></script>
  </head>
  <body class="loading-page" data-initial-loading>
    <a class="skip-link" href="#loading-status">Skip to loading status</a>
    <main class="loading-shell">
      <div class="loading-brand">
        <img
          class="brand-mark"
          src="{{ url_for('static', path='/curio128x128.png') }}"
          width="128"
          height="128"
          alt=""
        >
        <span>
          <strong>Curio Atlas</strong>
          <small>Local project intelligence</small>
        </span>
      </div>
      <section
        id="loading-status"
        class="loading-card"
        role="status"
        aria-live="polite"
        aria-busy="true"
        tabindex="-1"
      >
        <span class="loading-spinner" aria-hidden="true"></span>
        <p class="section-kicker">Read-only project scan</p>
        <h1>Preparing {{ title | lower }}</h1>
        <p data-loading-message>
          Reading saved Curio projects and restoring the latest dashboard.
        </p>
        <button class="primary-button" type="button" data-loading-retry hidden>
          Try again
        </button>
        <noscript>
          <p>
            JavaScript is required for automatic startup. Open
            <a href="/api/status">diagnostic status</a>, wait for the scan to
            finish, then reload this page.
          </p>
        </noscript>
      </section>
      <div class="loading-skeleton" aria-hidden="true">
        {% for _ in range(4) %}
          <span></span>
        {% endfor %}
      </div>
      <p class="loading-boundary">
        Curio projects remain unchanged. This page refreshes automatically.
      </p>
    </main>
  </body>
</html>
src/curio_dashboard/templates/overview.html10 KB
{% extends "base.html" %}

{% block content %}
  <section class="metric-grid" aria-label="Planning summary">
    <article class="metric-card metric-alert">
      <div class="metric-topline">
        <span>Overdue</span>
        <span class="metric-symbol" aria-hidden="true">!</span>
      </div>
      <strong>{{ state.aggregate.date_buckets.get("overdue", 0) }}</strong>
      <p>Past their due date</p>
    </article>
    <article class="metric-card">
      <div class="metric-topline">
        <span>Today</span>
        <span class="metric-symbol" aria-hidden="true">•</span>
      </div>
      <strong>{{ state.aggregate.date_buckets.get("today", 0) }}</strong>
      <p>Needs attention now</p>
    </article>
    <article class="metric-card">
      <div class="metric-topline">
        <span>Tomorrow</span>
        <span class="metric-symbol" aria-hidden="true">→</span>
      </div>
      <strong>{{ state.aggregate.date_buckets.get("tomorrow", 0) }}</strong>
      <p>Coming up next</p>
    </article>
    <article class="metric-card">
      <div class="metric-topline">
        <span>Next 7 days</span>
        <span class="metric-symbol" aria-hidden="true">7</span>
      </div>
      <strong>{{ state.aggregate.date_buckets.get("next_7_days", 0) }}</strong>
      <p>Near-term plans</p>
    </article>
    <article class="metric-card metric-completion">
      <div class="metric-topline">
        <span>Completion</span>
        <span class="metric-symbol" aria-hidden="true">✓</span>
      </div>
      <strong>{{ state.aggregate.completion_percent | round | int }}<small>%</small></strong>
      <p>{{ state.aggregate.completed_actions }} completed tagged items</p>
    </article>
  </section>

  <div class="dashboard-grid">
    <section class="panel panel-wide" aria-labelledby="pipeline-title">
      <div class="panel-heading">
        <div>
          <p class="section-kicker">Travel profile</p>
          <h2 id="pipeline-title">Planning pipeline</h2>
        </div>
        <span class="panel-count">{{ stage_total }} tagged items</span>
      </div>

      {% if stages %}
        <div class="stage-list">
          {% for stage in stages %}
            <div class="stage-row">
              <div class="stage-label">
                <span>{{ stage.name }}</span>
                <strong>{{ stage.count }}</strong>
              </div>
              <div
                class="stage-track"
                role="img"
                aria-label="{{ stage.name }}: {{ stage.count }} tagged items"
              >
                <span style="--stage-width: {{ stage.percent }}%"></span>
              </div>
            </div>
          {% endfor %}
        </div>
        <div class="panel-footnote">
          <span class="footnote-mark" aria-hidden="true">i</span>
          Uses the Travel Stage tag set returned by Curio.
        </div>
      {% else %}
        <div class="empty-state compact-empty">
          <span class="empty-mark" aria-hidden="true">◇</span>
          <div>
            <h3>No Travel Stage tags yet</h3>
            <p>Tagged planning items will form this project-wide pipeline.</p>
          </div>
        </div>
      {% endif %}
    </section>

    <section class="panel" aria-labelledby="upcoming-title">
      <div class="panel-heading">
        <div>
          <p class="section-kicker">Next moves</p>
          <h2 id="upcoming-title">Upcoming itinerary</h2>
        </div>
        <a class="text-link" href="/upcoming">View all</a>
      </div>

      {% if upcoming %}
        <ol class="upcoming-list">
          {% for item in upcoming[:5] %}
            <li>
              <span class="date-tile">
                <small>{{ item.bucket_label }}</small>
                <strong>{{ item.figure.due_date or "—" }}</strong>
              </span>
              <span class="upcoming-copy">
                <strong>{{ item.figure.title or "Untitled action" }}</strong>
                <small>
                  {{ item.figure.project_title }}
                  {% if item.figure.organizer_path %}
                    · {{ item.figure.organizer_path }}
                  {% endif %}
                </small>
              </span>
            </li>
          {% endfor %}
        </ol>
      {% else %}
        <div class="empty-state">
          <span class="empty-mark" aria-hidden="true">✓</span>
          <div>
            <h3>No incomplete travel items</h3>
            <p>
              Your selected Curio projects have no tagged figures below 100%
              progress. Planning tags are still summarized alongside.
            </p>
          </div>
        </div>
      {% endif %}
    </section>
  </div>

  <section class="panel overview-board-preview" aria-labelledby="board-preview-title">
    <div class="panel-heading">
      <div>
        <p class="section-kicker">Cross-project workflow</p>
        <h2 id="board-preview-title">
          {{ state.board.tag_set or "Workflow" }} board
        </h2>
      </div>
      <a class="text-link" href="/board">Open full board</a>
    </div>

    {% if board_columns %}
      <div class="overview-board-grid">
        {% for item in board_columns[:4] %}
          <article class="overview-board-column">
            <header>
              <strong>{{ item.column.label }}</strong>
              <span>{{ item.cards | length }}</span>
            </header>
            {% if item.cards %}
              <ol>
                {% for item_card in item.cards[:3] %}
                  <li class="accent-{{ item_card.accent }}">
                    <i aria-hidden="true"></i>
                    <span>
                      <strong>
                        {{ item_card.card.figure.title or "Untitled figure" }}
                      </strong>
                      <small>{{ item_card.card.figure.project_title }}</small>
                    </span>
                    {% if item_card.card.conflicting %}
                      <b title="Multiple workflow tags">!</b>
                    {% endif %}
                  </li>
                {% endfor %}
              </ol>
            {% else %}
              <p>No cards</p>
            {% endif %}
          </article>
        {% endfor %}
      </div>
      {% if state.board.columns | length > 4 %}
        <p class="preview-footnote">
          {{ state.board.columns | length - 4 }} more stages are available on
          the full board.
        </p>
      {% endif %}
    {% else %}
      <div class="empty-state compact-empty">
        <span class="empty-mark" aria-hidden="true">▥</span>
        <div>
          <h3>No workflow preview available</h3>
          <p>A populated Curio tag set will appear here after refresh.</p>
        </div>
      </div>
    {% endif %}
  </section>

  <section class="section-block" aria-labelledby="pulse-title">
    <div class="section-heading">
      <div>
        <p class="section-kicker">Across the map</p>
        <h2 id="pulse-title">Project pulse</h2>
      </div>
      <a class="text-link" href="/projects">Project details</a>
    </div>

    {% if projects %}
      <div class="project-strip">
        {% for project in projects %}
        <article class="project-pulse accent-{{ project.accent }}">
          <div class="project-identity">
            <span class="project-monogram">{{ project.initials }}</span>
            <div>
              <h3>{{ project.diagnostic.title }}</h3>
              <p>
                <span class="status-dot status-{{ project.diagnostic.refresh_state.value }}"></span>
                {{ project.diagnostic.refresh_state.value | replace("_", " ") | title }}
              </p>
            </div>
          </div>
          {% if project.metrics %}
            <dl class="project-mini-metrics">
              <div>
                <dt>Planning tags</dt>
                <dd>{{ project.metrics.tagged_figures }}</dd>
              </div>
              <div>
                <dt>Incomplete</dt>
                <dd>{{ project.metrics.open_actions }}</dd>
              </div>
              <div>
                <dt>Sections</dt>
                <dd>{{ project.metrics.section_count }}</dd>
              </div>
            </dl>
            <div class="project-progress">
              <span>
                <small>Workflow completion</small>
                <strong>{{ project.metrics.completion_percent | round | int }}%</strong>
              </span>
              <span
                class="project-progress-track"
                role="img"
                aria-label="{{ project.diagnostic.title }} workflow completion: {{ project.metrics.completion_percent | round | int }} percent"
              >
                <i style="--project-progress: {{ project.metrics.completion_percent }}%"></i>
              </span>
            </div>
          {% else %}
            <p class="muted-copy">No current snapshot in this view.</p>
          {% endif %}
        </article>
        {% endfor %}
      </div>
    {% else %}
      <div class="panel empty-state compact-empty">
        <span class="empty-mark" aria-hidden="true">▦</span>
        <div>
          <h3>No project pulse yet</h3>
          <p>Select a category containing at least one Curio project.</p>
        </div>
      </div>
    {% endif %}
  </section>

  <section class="status-ribbon" aria-label="Data status summary">
    <div>
      <span class="ribbon-icon" aria-hidden="true">◎</span>
      <span>
        <strong>{{ state.aggregate.project_count }} projects in view</strong>
        <small>{{ source_label }} · {{ freshness_label }}</small>
      </span>
    </div>
    <div class="ribbon-facts">
      <span><strong>{{ state.aggregate.tagged_figures }}</strong> tagged figures</span>
      <span><strong>{{ state.aggregate.links }}</strong> links</span>
      <span><strong>{{ state.aggregate.invalid_due_dates | length }}</strong> date warnings</span>
    </div>
  </section>
{% endblock %}
src/curio_dashboard/templates/projects.html4 KB
{% extends "base.html" %}

{% block content %}
  {% if projects %}
    <section class="project-card-grid" aria-label="Project status">
      {% for project in projects %}
      <article class="project-card accent-{{ project.accent }}">
        <div class="project-card-head">
          <span class="project-monogram large">{{ project.initials }}</span>
          <span class="state-pill state-{{ project.diagnostic.refresh_state.value }}">
            <span aria-hidden="true"></span>
            {{ project.diagnostic.refresh_state.value | replace("_", " ") | title }}
          </span>
        </div>
        <div class="project-card-title">
          <p>Curio project</p>
          <h2>{{ project.diagnostic.title }}</h2>
        </div>

        {% if project.metrics %}
          <dl class="project-stat-grid">
            <div>
              <dt>Incomplete</dt>
              <dd>{{ project.metrics.open_actions }}</dd>
            </div>
            <div>
              <dt>Planning tags</dt>
              <dd>{{ project.metrics.tagged_figures }}</dd>
            </div>
            <div>
              <dt>Completed</dt>
              <dd>{{ project.metrics.completed_actions }}</dd>
            </div>
            <div>
              <dt>Sections</dt>
              <dd>{{ project.metrics.section_count }}</dd>
            </div>
          </dl>
        {% else %}
          <div class="empty-project-data">
            <strong>No active snapshot</strong>
            <p>
              This project is represented in Project Center but is not part of
              the current aggregate.
            </p>
          </div>
        {% endif %}

        <div class="project-paths">
          <div>
            <span>Project path</span>
            <code title="{{ project.diagnostic.path }}">
              {{ project.diagnostic.path }}
            </code>
          </div>
          {% if project.diagnostic.last_successful_refresh %}
            <div>
              <span>Last successful read</span>
              <time datetime="{{ project.diagnostic.last_successful_refresh.isoformat() }}">
                {{ project.diagnostic.last_successful_refresh.strftime("%b %d, %Y · %H:%M") }}
              </time>
            </div>
          {% endif %}
        </div>

        {% if project.diagnostic.path_is_last_known %}
          <div class="card-warning">
            <strong>Last-known location</strong>
            <span>The path is valid, but Curio marked it as last known.</span>
          </div>
        {% endif %}
        {% if project.diagnostic.error %}
          <div class="card-warning">
            <strong>Latest read issue</strong>
            <span>{{ project.diagnostic.error }}</span>
          </div>
        {% endif %}
      </article>
      {% endfor %}
    </section>
  {% else %}
    <section class="panel empty-state empty-table" aria-labelledby="no-projects-title">
      <span class="empty-mark" aria-hidden="true">▦</span>
      <div>
        <h2 id="no-projects-title">No projects returned</h2>
        <p>
          This Project Center category is empty. Choose another category from
          the selector above.
        </p>
      </div>
    </section>
  {% endif %}

  <section class="panel project-summary" aria-labelledby="coverage-title">
    <div class="panel-heading">
      <div>
        <p class="section-kicker">Collection coverage</p>
        <h2 id="coverage-title">Current category status</h2>
      </div>
    </div>
    <div class="coverage-row">
      <div>
        <strong>{{ state.projects | length }}</strong>
        <span>Returned by Project Center</span>
      </div>
      <div>
        <strong>{{ state.aggregate.project_count }}</strong>
        <span>Included in this view</span>
      </div>
      <div>
        <strong>{{ state.projects | selectattr("valid") | list | length }}</strong>
        <span>Valid project paths</span>
      </div>
      <div>
        <strong>{{ state.projects | selectattr("using_cached_snapshot") | list | length }}</strong>
        <span>Using cached data</span>
      </div>
    </div>
  </section>
{% endblock %}
src/curio_dashboard/templates/settings.html3 KB
{% extends "base.html" %}

{% block content %}
  <div class="settings-grid">
    <section class="panel settings-panel" aria-labelledby="appearance-title">
      <div class="panel-heading">
        <div>
          <p class="section-kicker">Display</p>
          <h2 id="appearance-title">Appearance</h2>
        </div>
      </div>
      <p class="settings-description">
        Follow your Mac automatically, or keep an explicit preference on this
        device. This visual choice never changes Curio.
      </p>
      <div class="theme-options" role="group" aria-label="Color theme">
        <button type="button" data-theme-choice="system">
          <span class="theme-swatch swatch-system" aria-hidden="true"></span>
          <strong>System</strong>
          <small>Match macOS</small>
        </button>
        <button type="button" data-theme-choice="light">
          <span class="theme-swatch swatch-light" aria-hidden="true"></span>
          <strong>Light</strong>
          <small>Warm paper</small>
        </button>
        <button type="button" data-theme-choice="dark">
          <span class="theme-swatch swatch-dark" aria-hidden="true"></span>
          <strong>Dark</strong>
          <small>Quiet evening</small>
        </button>
      </div>
    </section>

    <section class="panel settings-panel" aria-labelledby="refresh-title">
      <div class="panel-heading">
        <div>
          <p class="section-kicker">Freshness</p>
          <h2 id="refresh-title">Refresh cadence</h2>
        </div>
      </div>
      <dl class="settings-list">
        <div>
          <dt>Project data</dt>
          <dd>Every {{ service_config.dashboard.refresh_seconds if service_config else 60 }} seconds</dd>
        </div>
        <div>
          <dt>Project Center</dt>
          <dd>Every {{ service_config.dashboard.discovery_refresh_seconds if service_config else 300 }} seconds</dd>
        </div>
        <div>
          <dt>Workflow board</dt>
          <dd>{{ state.board.tag_set or "No tag set" }}</dd>
        </div>
      </dl>
      <p class="settings-note">
        Existing snapshots stay visible while a refresh runs or if one project
        cannot be read.
      </p>
    </section>

    <section class="panel settings-panel settings-wide" aria-labelledby="boundary-title">
      <div class="panel-heading">
        <div>
          <p class="section-kicker">Trust boundary</p>
          <h2 id="boundary-title">Local and read-only by design</h2>
        </div>
      </div>
      <div class="boundary-grid">
        <div>
          <span class="boundary-mark" aria-hidden="true">1</span>
          <h3>Project Center decides scope</h3>
          <p>Only paths returned by the latest category response are accepted.</p>
        </div>
        <div>
          <span class="boundary-mark" aria-hidden="true">2</span>
          <h3>Direct project reads</h3>
          <p>Saved files are queried with Curio’s read-only project transport.</p>
        </div>
        <div>
          <span class="boundary-mark" aria-hidden="true">3</span>
          <h3>No Curio mutations</h3>
          <p>The dashboard cannot complete, move, tag, or save Curio figures.</p>
        </div>
      </div>
    </section>
  </div>
{% endblock %}
src/curio_dashboard/templates/tags.html6 KB
{% extends "base.html" %}

{% block content %}
  <section class="tag-metric-grid" aria-label="Tag summary">
    <article>
      <span>Total tagged figures</span>
      <strong>{{ state.tag_analytics.total_tagged_figures }}</strong>
      <small>Across {{ state.aggregate.project_count }} projects</small>
    </article>
    <article>
      <span>Distinct tags used</span>
      <strong>{{ state.tag_analytics.distinct_used_tags }}</strong>
      <small>{{ state.tag_analytics.tag_sets | length }} catalog tag sets</small>
    </article>
    <article>
      <span>Tagged incomplete</span>
      <strong>{{ tagged_open_actions }}</strong>
      <small>{{ tagged_overdue_actions }} overdue items</small>
    </article>
    <article>
      <span>Completed tagged</span>
      <strong>{{ tagged_completed_actions }}</strong>
      <small>Figures at 100% progress</small>
    </article>
  </section>

  <div class="tags-layout">
    <section class="panel tag-ranking" aria-labelledby="ranking-title">
      <div class="panel-heading">
        <div>
          <p class="section-kicker">Actual figure usage</p>
          <h2 id="ranking-title">Most-used tags</h2>
        </div>
        <span class="panel-count">Top {{ [tag_statistics | length, 12] | min }}</span>
      </div>
      {% if tag_statistics %}
        <div class="tag-ranking-list">
          {% for item in tag_statistics[:12] %}
            <div class="tag-ranking-row">
              <div class="tag-ranking-label">
                <span>
                  <small>{{ item.set_name }}</small>
                  <strong>{{ item.label }}</strong>
                </span>
                <span class="tag-counts">
                  <b>{{ item.statistic.total_figures }}</b>
                  <small>
                    {{ item.statistic.open_actions }} incomplete ·
                    {{ item.statistic.overdue_actions }} overdue
                  </small>
                </span>
              </div>
              <div
                class="tag-ranking-track"
                role="img"
                aria-label="{{ item.statistic.tag }}: {{ item.statistic.total_figures }} figures"
              >
                <span style="--tag-width: {{ item.percent }}%"></span>
              </div>
            </div>
          {% endfor %}
        </div>
      {% else %}
        <div class="empty-state compact-empty">
          <span class="empty-mark" aria-hidden="true">◇</span>
          <div>
            <h3>No used tags in this scope</h3>
            <p>The catalog may exist, but no figures currently use its tags.</p>
          </div>
        </div>
      {% endif %}
    </section>

    <section class="panel tag-distribution" aria-labelledby="distribution-title">
      <div class="panel-heading">
        <div>
          <p class="section-kicker">Assignment mix</p>
          <h2 id="distribution-title">Tag-set distribution</h2>
        </div>
      </div>
      {% if tag_set_distribution %}
        <div class="distribution-list">
          {% for item in tag_set_distribution %}
            <div>
              <span class="distribution-name">{{ item.name }}</span>
              <span class="distribution-bar">
                <i style="--distribution-width: {{ item.percent }}%"></i>
              </span>
              <strong>{{ item.count }}</strong>
            </div>
          {% endfor %}
        </div>
      {% else %}
        <p class="muted-copy">No tag assignments are available.</p>
      {% endif %}

      <div class="recent-tags">
        <h3>Recently active</h3>
        {% if recent_tags %}
          <ol>
            {% for item in recent_tags[:5] %}
              <li>
                <span>{{ item.tag }}</span>
                <time datetime="{{ item.last_modified }}">
                  {{ item.last_modified | replace("T", " · ") }}
                </time>
              </li>
            {% endfor %}
          </ol>
        {% else %}
          <p>Modification dates are not available.</p>
        {% endif %}
      </div>
    </section>
  </div>

  <section class="panel matrix-panel" aria-labelledby="matrix-title">
    <div class="panel-heading matrix-heading">
      <div>
        <p class="section-kicker">Cross-project coverage</p>
        <h2 id="matrix-title">{{ state.board.tag_set or "Selected tag set" }} matrix</h2>
      </div>
      <form action="/selection/tag-set" method="post" class="matrix-tag-set-form">
        <input type="hidden" name="return_to" value="/tags">
        <label for="matrix-tag-set">Tag set</label>
        <select id="matrix-tag-set" name="tag_set" data-auto-submit>
          {% for tag_set in state.board.available_tag_sets %}
            <option
              value="{{ tag_set }}"
              {% if tag_set == state.board.tag_set %}selected{% endif %}
            >
              {{ tag_set }}
            </option>
          {% endfor %}
        </select>
      </form>
    </div>

    {% if matrix_tags %}
      <div class="matrix-scroll">
        <table class="tag-matrix">
          <thead>
            <tr>
              <th scope="col">Project</th>
              {% for tag in matrix_tags %}
                <th scope="col">{{ tag.split("/", 1)[-1] }}</th>
              {% endfor %}
              <th scope="col">Total</th>
            </tr>
          </thead>
          <tbody>
            {% for row in matrix_rows %}
              <tr>
                <th scope="row">
                  <span class="matrix-project accent-{{ row.accent }}">
                    <i></i>{{ row.project.project_title }}
                  </span>
                </th>
                {% for value in row.cells %}
                  <td>
                    <span class="matrix-value level-{{ [value, 3] | min }}">
                      {{ value }}
                    </span>
                  </td>
                {% endfor %}
                <td><strong>{{ row.total }}</strong></td>
              </tr>
            {% endfor %}
          </tbody>
        </table>
      </div>
      <p class="matrix-note">
        Counts represent tagged figures, not catalog availability. Zero-value
        projects remain visible for honest coverage.
      </p>
    {% else %}
      <div class="empty-state compact-empty">
        <span class="empty-mark" aria-hidden="true">▦</span>
        <div>
          <h3>No matrix columns available</h3>
          <p>Select a populated tag set to compare projects.</p>
        </div>
      </div>
    {% endif %}
  </section>
{% endblock %}
src/curio_dashboard/templates/upcoming.html4 KB
{% extends "base.html" %}

{% block content %}
  <section class="panel table-panel" aria-labelledby="actions-title">
    <div class="panel-heading table-heading">
      <div>
        <p class="section-kicker">Exclusive date buckets</p>
        <h2 id="actions-title">Incomplete items</h2>
      </div>
      <span class="panel-count">{{ state.aggregate.open_actions }} total</span>
    </div>

    <div class="table-tools">
      <div class="filter-group" role="group" aria-label="Filter by due window">
        <button
          type="button"
          class="filter-chip is-active"
          data-bucket-filter="all"
          aria-pressed="true"
        >
          All
        </button>
        {% for bucket, label in bucket_labels.items() %}
          <button
            type="button"
            class="filter-chip"
            data-bucket-filter="{{ bucket.value }}"
            aria-pressed="false"
          >
            {{ label }}
            <span>{{ state.aggregate.date_buckets.get(bucket, 0) }}</span>
          </button>
        {% endfor %}
      </div>
      <label class="project-filter">
        <span>Project</span>
        <select data-project-filter>
          <option value="all">All projects</option>
          {% for project in projects %}
            <option value="{{ project.diagnostic.path }}">
              {{ project.diagnostic.title }}
            </option>
          {% endfor %}
        </select>
      </label>
    </div>

    {% if upcoming %}
      <div class="table-scroll">
        <table class="action-table" data-action-table>
          <thead>
            <tr>
              <th scope="col" data-sort-heading aria-sort="none">
                <button type="button" data-sort-table="title">Item</button>
              </th>
              <th scope="col">Project</th>
              <th scope="col" data-sort-heading aria-sort="none">
                <button type="button" data-sort-table="due">Due</button>
              </th>
              <th scope="col">Priority</th>
              <th scope="col">Location</th>
            </tr>
          </thead>
          <tbody>
            {% for item in upcoming %}
              <tr
                data-project="{{ item.figure.project_path }}"
                data-bucket="{{ item.bucket.value if item.bucket else 'invalid' }}"
                data-title="{{ item.figure.title | lower }}"
                data-due="{{ item.figure.due_date or '9999' }}"
              >
                <td>
                  <strong>{{ item.figure.title or "Untitled item" }}</strong>
                  {% if item.figure.tags %}
                    <span class="tag-line">
                      {% for tag in item.figure.tags[:2] %}
                        <span>{{ tag }}</span>
                      {% endfor %}
                    </span>
                  {% endif %}
                </td>
                <td>{{ item.figure.project_title }}</td>
                <td>
                  <span class="due-badge due-{{ item.bucket.value if item.bucket else 'invalid' }}">
                    {{ item.bucket_label }}
                  </span>
                  <small>{{ item.figure.due_date or "No date" }}</small>
                </td>
                <td>
                  {% if item.figure.priority %}
                    <span class="priority priority-{{ item.figure.priority }}">
                      P{{ item.figure.priority }}
                    </span>
                  {% else %}
                    <span class="muted-copy">None</span>
                  {% endif %}
                </td>
                <td>
                  <span>{{ item.figure.section_path or "—" }}</span>
                  <small>{{ item.figure.organizer_path or "" }}</small>
                </td>
              </tr>
            {% endfor %}
          </tbody>
        </table>
      </div>
      <p
        class="no-filter-results"
        data-no-filter-results
        role="status"
        aria-live="polite"
        hidden
      >
        No actions match these filters.
      </p>
    {% else %}
      <div class="empty-state empty-table">
        <span class="empty-mark" aria-hidden="true">◷</span>
        <div>
          <h3>No incomplete items</h3>
          <p>
            There are no tagged Curio figures below 100% progress in this
            scope. Dated and unscheduled items each appear in one window.
          </p>
        </div>
      </div>
    {% endif %}
  </section>
{% endblock %}
src/curio_dashboard/web.py6 KB
"""HTML routes for the local Curio dashboard."""

from __future__ import annotations

import logging
from typing import Any

from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates

from curio_dashboard.presentation import build_page_context
from curio_dashboard.service import DashboardService

LOGGER = logging.getLogger(__name__)

PAGES = {
    "overview": ("Overview", "Your travel planning command center."),
    "upcoming": ("Upcoming", "Every incomplete tagged item, in one timeline."),
    "projects": ("Projects", "Project health across your selected category."),
    "board": ("Board", "A read-only workflow across every selected project."),
    "tags": ("Tags", "Usage, workload, and coverage across your project scope."),
    "settings": ("Settings", "Local dashboard preferences and data boundaries."),
}
SAFE_RETURN_PATHS = {
    "/",
    "/upcoming",
    "/projects",
    "/board",
    "/tags",
    "/settings",
}


def _safe_return_path(value: str | None) -> str:
    return value if value in SAFE_RETURN_PATHS else "/"


def register_web_routes(
    app: FastAPI,
    *,
    service: DashboardService,
    templates: Jinja2Templates,
) -> None:
    async def render(
        request: Request,
        page: str,
        template_name: str,
    ) -> HTMLResponse:
        try:
            state = await service.get_state(refresh_if_empty=False)
            if state is None:
                title, _ = PAGES[page]
                return templates.TemplateResponse(
                    request=request,
                    name="loading.html",
                    context={"title": title, "request": request},
                    status_code=202,
                )
            now = service.now()
            context: dict[str, Any] = build_page_context(
                state,
                page=page,
                now=now,
                timezone=service.timezone,
            )
            title, subtitle = PAGES[page]
            snapshot_age = max(0, int((now - state.generated_at).total_seconds()))
            context.update(
                {
                    "title": title,
                    "subtitle": subtitle,
                    "request": request,
                    "service_config": service.config,
                    "snapshot_age_seconds": snapshot_age,
                    "snapshot_is_stale": snapshot_age
                    > max(120, service.config.dashboard.refresh_seconds * 2),
                }
            )
            return templates.TemplateResponse(
                request=request,
                name=template_name,
                context=context,
            )
        except Exception as error:  # noqa: BLE001
            return templates.TemplateResponse(
                request=request,
                name="error.html",
                context={
                    "title": "Dashboard unavailable",
                    "request": request,
                    "error": str(error)[:500],
                },
                status_code=503,
            )

    @app.get("/", response_class=HTMLResponse)
    async def overview(request: Request) -> HTMLResponse:
        return await render(request, "overview", "overview.html")

    @app.get("/upcoming", response_class=HTMLResponse)
    async def upcoming(request: Request) -> HTMLResponse:
        return await render(request, "upcoming", "upcoming.html")

    @app.get("/projects", response_class=HTMLResponse)
    async def projects(request: Request) -> HTMLResponse:
        return await render(request, "projects", "projects.html")

    @app.get("/board", response_class=HTMLResponse)
    async def board(request: Request) -> HTMLResponse:
        return await render(request, "board", "board.html")

    @app.get("/tags", response_class=HTMLResponse)
    async def tags(request: Request) -> HTMLResponse:
        return await render(request, "tags", "tags.html")

    @app.get("/settings", response_class=HTMLResponse)
    async def settings(request: Request) -> HTMLResponse:
        return await render(request, "settings", "settings.html")

    @app.post("/refresh")
    async def refresh_dashboard(
        return_to: str = Form(default="/"),
    ) -> RedirectResponse:
        try:
            await service.refresh(discover=True)
        except Exception as error:  # noqa: BLE001
            # The service attaches a safe error to any last-known-good state.
            LOGGER.warning("manual dashboard refresh failed: %s", type(error).__name__)
        return RedirectResponse(_safe_return_path(return_to), status_code=303)

    @app.post("/selection/category")
    async def select_category(
        category_id: str = Form(),
        return_to: str = Form(default="/"),
    ) -> RedirectResponse:
        try:
            await service.select_category(category_id)
        except ValueError as error:
            raise HTTPException(status_code=400, detail=str(error)) from error
        except Exception as error:  # noqa: BLE001
            LOGGER.warning(
                "category selection refresh failed: %s", type(error).__name__
            )
        return RedirectResponse(_safe_return_path(return_to), status_code=303)

    @app.post("/selection/project")
    async def select_project(
        project_path: str = Form(default=""),
        return_to: str = Form(default="/"),
    ) -> RedirectResponse:
        try:
            await service.select_project(project_path or None)
        except ValueError as error:
            raise HTTPException(status_code=400, detail=str(error)) from error
        except Exception as error:  # noqa: BLE001
            LOGGER.warning("project selection refresh failed: %s", type(error).__name__)
        return RedirectResponse(_safe_return_path(return_to), status_code=303)

    @app.post("/selection/tag-set")
    async def select_tag_set(
        tag_set: str = Form(),
        return_to: str = Form(default="/board"),
    ) -> RedirectResponse:
        try:
            await service.select_tag_set(tag_set)
        except ValueError as error:
            raise HTTPException(status_code=400, detail=str(error)) from error
        except Exception as error:  # noqa: BLE001
            LOGGER.warning("tag-set selection refresh failed: %s", type(error).__name__)
        return RedirectResponse(_safe_return_path(return_to), status_code=303)
uv.lock63 KB
version = 1
revision = 3
requires-python = ">=3.12"

[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]

[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]

[[package]]
name = "anyio"
version = "4.14.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "idna" },
    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
]

[[package]]
name = "click"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
]

[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]

[[package]]
name = "curio-dashboard"
version = "1.0.4"
source = { editable = "." }
dependencies = [
    { name = "fastapi" },
    { name = "jinja2" },
    { name = "pydantic" },
    { name = "python-multipart" },
    { name = "pyyaml" },
    { name = "uvicorn" },
]

[package.metadata]
requires-dist = [
    { name = "fastapi", specifier = ">=0.116,<1" },
    { name = "jinja2", specifier = ">=3.1,<4" },
    { name = "pydantic", specifier = ">=2.11,<3" },
    { name = "python-multipart", specifier = ">=0.0.20,<1" },
    { name = "pyyaml", specifier = ">=6.0,<7" },
    { name = "uvicorn", specifier = ">=0.35,<1" },
]

[[package]]
name = "fastapi"
version = "0.140.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "annotated-doc" },
    { name = "pydantic" },
    { name = "starlette" },
    { name = "typing-extensions" },
    { name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/fb/fd7671137d9fa3df1d93a2f5111eb982709201724b29f211e4beb2d58688/fastapi-0.140.0.tar.gz", hash = "sha256:f338951b82fd74ca8f843163aec43ea1a1ce84d515415a50fa98fa25572a5544", size = 420968, upload-time = "2026-07-24T21:16:41.187Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/eb/76/6d9e25ad88da9d3ff744bcdbec4736e38c2288611d43f673a5d9bfa27c07/fastapi-0.140.0-py3-none-any.whl", hash = "sha256:e951c0a0d9540bf5d9a2a9e078fd415da2ab7e312d435139e7d9e2e7fe9f0b23", size = 130863, upload-time = "2026-07-24T21:16:42.89Z" },
]

[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]

[[package]]
name = "idna"
version = "3.18"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
]

[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]

[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
    { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
    { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
    { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
    { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
    { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
    { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
    { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
    { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
    { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
    { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
    { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
    { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
    { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
    { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
    { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
    { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
    { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
    { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
    { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
    { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
    { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
    { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
    { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
    { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
    { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
    { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
    { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
    { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
    { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
    { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
    { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
    { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
    { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
    { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
    { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
    { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
    { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
    { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
    { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
    { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
    { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
    { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
    { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
    { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
    { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
    { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
    { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
    { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
    { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
    { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
    { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
    { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
    { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
    { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]

[[package]]
name = "pydantic"
version = "2.13.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "annotated-types" },
    { name = "pydantic-core" },
    { name = "typing-extensions" },
    { name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
]

[[package]]
name = "pydantic-core"
version = "2.46.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
    { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
    { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
    { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
    { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
    { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
    { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
    { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
    { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
    { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
    { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
    { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
    { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
    { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
    { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
    { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
    { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
    { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
    { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
    { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
    { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
    { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
    { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
    { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
    { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
    { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
    { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
    { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
    { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
    { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
    { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
    { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
    { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
    { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
    { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
    { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
    { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
    { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
    { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
    { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
    { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
    { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
    { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
    { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
    { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
    { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
    { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
    { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
    { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
    { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
    { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
    { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
    { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
    { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
    { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
    { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
    { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
    { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
    { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
    { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
    { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
    { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
    { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
    { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
]

[[package]]
name = "python-multipart"
version = "0.0.32"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" },
]

[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
    { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
    { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
    { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
    { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
    { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
    { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
    { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
    { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
    { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
    { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
    { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
    { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
    { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
    { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
    { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
    { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
    { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
    { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
    { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
    { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
    { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
    { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
    { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
    { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
    { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
    { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
    { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
    { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
    { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
    { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
    { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
    { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
    { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
    { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
    { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
    { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
    { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]

[[package]]
name = "starlette"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "anyio" },
    { name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" },
]

[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]

[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]

[[package]]
name = "uvicorn"
version = "0.51.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
    { name = "click" },
    { name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/65/b7c6c443ccc58678c91e1e973bbe2a878591538655d6e1d47f24ba1c51f3/uvicorn-0.51.0.tar.gz", hash = "sha256:f6f4b69b657c312f516dd2d268ab9ae6f254b11e4bac504f37b2ab58b24dd0b0", size = 94412, upload-time = "2026-07-08T10:59:05.962Z" }
wheels = [
    { url = "https://files.pythonhosted.org/packages/45/ec/dbb7e5a6b91f86bfb9eb7d2988a2730907b6a729875b949c7f022e8b88fa/uvicorn-0.51.0-py3-none-any.whl", hash = "sha256:5d38af6cd620f2ae3849fb44fd4879e0890aa1febe8d47eb355fb45d93fe6a5b", size = 73219, upload-time = "2026-07-08T10:59:04.44Z" },
]
SHA-2565f25ef1a668f35c664456cac4a9c375b664d0cd4fb1b7e17c1ed018fd633e472

  • Copyright © Zengobi, Inc.
  • Contact Us
  • Terms of Use
  • Privacy Policy