Overview combines deadlines, workflow stages, and upcoming work from every selected project.
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.
Gallery
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.
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:
-
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.
-
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.
-
Refine the plan conversationally. Add details, correct assumptions, and continue until the proposed result and workflow look right.
-
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.yamlfor project scope and refresh behavior;src/curio_dashboard/collector.pyfor CLI collection;src/curio_dashboard/aggregate.pyfor cross-project metrics;src/curio_dashboard/templates/for the pages; andsrc/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
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 & 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" },
]
5f25ef1a668f35c664456cac4a9c375b664d0cd4fb1b7e17c1ed018fd633e472