diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b73f5a2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +.git +.venv +__pycache__ +.pytest_cache +.ruff_cache +web/node_modules +web/dist +data/raw +data/interim +data/processed +data/reports +data/cache +*.log diff --git a/.env.example b/.env.example index e545b9f..d4f2259 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,6 @@ SMK_EN_FILE=./data/raw/smk_all_en.json # They are NOT the primary bulk-ingest path for v1. SMK_API_BASE=https://api.smk.dk/api/v1 SMK_ENRICHMENT_BASE=https://enrichment.api.smk.dk/api/enrichment + +# Docker Compose publishes the single application service here. +HOST_PORT=8135 diff --git a/.gitignore b/.gitignore index 0210521..1f394cd 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ venv/ dist/ build/ *.egg-info/ +node_modules/ +*.tsbuildinfo # Environment / secrets .env @@ -41,6 +43,8 @@ data/reports/* *.duckdb.wal *.db .cache/ +data/cache/* +!data/cache/.gitkeep # Generated columnar/export formats *.parquet diff --git a/AGENTS.md b/AGENTS.md index dd7f7e9..a5438ce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,11 @@ Milestone 6 keeps enrichment as a separate append-only source/cache. Weighted pa must never overwrite bulk uniform v1. Corpus membership remains unchanged when enrichment is missing. Palette transport uses exact OKLab min-cost flow, and weighted similarity is a new profile. +Milestone 7 serves the frozen analytical artifacts through one FastAPI + React application. +Processed data is read-only at runtime. Browser audio is user-triggered and uses versioned, +deterministic sonification mappings. Weighted similarity is an explicit deep action and may write +only profile-hash-keyed results under `data/cache/`. + Never overwrite source data. Never silently invent missing values. ## Primary bulk source @@ -397,7 +402,8 @@ Use fast feature-vector candidate retrieval, then optionally rerank top candidat Sonification is a separate mapping layer, never an objective artwork feature. -Initial profile: `art-tone-v1`. +Initial profile set: `weighted-color-tone-v1`, `physical-artwork-tone-v1`, and +`palette-arpeggio-v1`. Tentative mapping: diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c81fcf0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM node:22-alpine AS frontend +WORKDIR /web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +FROM python:3.12-slim +WORKDIR /app +COPY pyproject.toml README.md ./ +COPY src/ ./src/ +RUN pip install --no-cache-dir . +COPY --from=frontend /web/dist ./web/dist + +ENV SMK_ROOT=/app \ + PYTHONUNBUFFERED=1 +EXPOSE 8000 +CMD ["uvicorn", "smk_playground.api.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index a4ad430..b9650a5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ A small, reproducible analysis pipeline for visual and colour features in artworks from **Statens Museum for Kunst (SMK)**. -The first goal is deliberately not a web app. The goal is to turn the locally downloaded SMK dataset into a trustworthy, versioned analytical foundation that can be reused for colour search, similarity, clustering, timelines, mosaics, visual experiments and data sonification. +The project first established a trustworthy, versioned analytical foundation from the locally +downloaded SMK dataset. It now includes a small exploratory web application for colour search, +similarity, weighted palettes, and deterministic data sonification. ## Local source files @@ -121,14 +123,16 @@ See: ## Current status -Milestones 0–6 complete: source structure and audits are manifested, the language snapshots have +Milestones 0–7 complete: source structure and audits are manifested, the language snapshots have bounded-memory source Parquets, DA/EN rows are paired into stable SMK entities, and the exact `smk-public-visual-v1` membership rule is frozen. Deterministic raw visual features and analytical palettes now exist. Versioned corpus statistics, normalized scalar features and three unweighted candidate feature families are materialized. Three deterministic, explainable exact-distance profiles now support color, visual-character and specialist geometry queries; ANN is not used. Separately cached SMK enrichment responses add weighted palettes, v2 weighted features and an -exact palette-transport similarity profile without mutating the bulk-derived v1 artifacts. +exact palette-transport similarity profile without mutating the bulk-derived v1 artifacts. A +single-service FastAPI + React application now exposes those frozen artifacts, explicit deep +weighted similarity, and deterministic browser sonification. Audit results are documented in: @@ -148,6 +152,8 @@ Audit results are documented in: - [`docs/ENRICHMENT_V1.md`](docs/ENRICHMENT_V1.md) - [`docs/WEIGHTED_PALETTES_V1.md`](docs/WEIGHTED_PALETTES_V1.md) - [`docs/WEIGHTED_SIMILARITY_V1.md`](docs/WEIGHTED_SIMILARITY_V1.md) +- [`docs/SONIFICATION_V1.md`](docs/SONIFICATION_V1.md) +- [`docs/WEB_UI_V1.md`](docs/WEB_UI_V1.md) Run the audited ingest with: @@ -160,4 +166,24 @@ smk milestone5 smk similar KMS8892 --profile color --limit 10 smk enrichment status smk similar KMS8892 --profile weighted-color --limit 10 +smk web-catalog ``` + +## Run the application + +The existing Milestones 1–6 processed artifacts must be present locally. Build the compact app +catalog once after those artifacts change: + +```bash +smk web-catalog +``` + +Then start the single application service: + +```bash +cp .env.example .env +docker compose up --build +``` + +Open [http://localhost:8135](http://localhost:8135). The processed directory is mounted read-only; +only `data/cache/` is writable. Set `HOST_PORT` in `.env` to publish another host port. diff --git a/data/cache/.gitkeep b/data/cache/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/cache/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..28e535d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +services: + app: + build: . + ports: + - "${HOST_PORT:-8135}:8000" + environment: + SMK_ROOT: /app + volumes: + - ./data/processed:/app/data/processed:ro + - ./data/cache:/app/data/cache + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')"] + interval: 10s + timeout: 3s + retries: 5 + start_period: 10s diff --git a/docs/IMPLEMENTATION_PLAN.md b/docs/IMPLEMENTATION_PLAN.md index 8ba0592..5abadd0 100644 --- a/docs/IMPLEMENTATION_PLAN.md +++ b/docs/IMPLEMENTATION_PLAN.md @@ -81,13 +81,11 @@ resumable append-only source cache, weighted palettes/features v2, bulk/enrichme an exact palette-transport similarity profile. See `ENRICHMENT_V1.md`, `WEIGHTED_PALETTES_V1.md` and `WEIGHTED_SIMILARITY_V1.md`. -## Milestone 7 — Full dataset build +## Milestone 7 — First usable application + sonification -Run the complete local source snapshots. - -Definition of done: - -Every canonical artwork has valid applicable features or explicit nulls/flags explaining missing/invalid prerequisites. Every normalization result references a frozen profile and exact source manifest. +Status: complete. A compact read-only web catalog feeds one FastAPI + React service with search, +artwork detail, existing exact similarity profiles, explicit cached weighted similarity, and three +versioned deterministic browser-audio mappings. See `SONIFICATION_V1.md` and `WEB_UI_V1.md`. ## Milestone 8 — Similarity diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 7170688..e92fa42 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -26,6 +26,8 @@ select/freeze normalization corpus normalized features ↓ similarity / sonification experiments + ↓ +compact web catalog + read-only application ``` ## Recommended CLI stages @@ -99,6 +101,14 @@ separate source manifest, parse repeated palette tokens into explicit weights, e palette/features v2, and add exact OKLab palette transport under a new similarity profile. Existing bulk v1 corpus, features, normalization and profiles remain byte-independent inputs. +## Stage 7 — exploratory application + +Join only the fields needed for interactive browsing into `web-catalog-v1.parquet`, freeze the +sonification profile manifest, and serve the built React client and FastAPI routes from one +container. Runtime startup validates artifacts but never triggers ingestion, enrichment, or +normalization. Processed inputs remain read-only; deep weighted-similarity responses use the +separate invalidatable cache. + ## Ingest performance rule The source files are large. Use bounded-memory streaming where practical. A one-time conversion to Parquet/Arrow as an interim representation is encouraged if it simplifies all later stages. diff --git a/docs/SONIFICATION_V1.md b/docs/SONIFICATION_V1.md new file mode 100644 index 0000000..31851f0 --- /dev/null +++ b/docs/SONIFICATION_V1.md @@ -0,0 +1,38 @@ +# Sonification v1 + +Sonification is an interpretive presentation layer, not an objective artwork feature. The API +returns deterministic synthesis parameters; sound is produced only after a user action by the +browser Web Audio API. The frozen profile set is `1.0.0` and is materialized as +`data/processed/sonification-profiles-v1.json`. + +## Weighted color tone + +`weighted-color-tone-v1` maps the enrichment-weighted OKLab centroid to one sustained tone: + +- hue uses twelve equal 30-degree bins, `floor(hue / 30)`, mapped C through B; +- undefined achromatic hue maps explicitly to A and reports `achromatic_neutral`; +- centroid OKLab lightness sets a target register from MIDI 36 through 84, rounded to the nearest + MIDI note in the selected pitch class; +- centroid chroma controls velocity, palette spread controls harmonic richness, normalized + palette entropy controls a small noise component, hue concentration controls tonal purity, and + dominant weight controls attack and release. + +All controls are clamped to documented safe ranges in `sonification/profiles.py`. + +## Physical artwork tone + +`physical-artwork-tone-v1` uses the same hue and timbre mapping, but inverse log-area percentile +sets register: larger physical works sound lower. Signed log aspect ratio produces subtle stereo +pan via `0.6 * tanh(log_aspect)`. The profile is explicitly unavailable unless both a weighted +palette and reliable Netto width/height-derived normalized geometry exist. Pixel dimensions never +substitute for physical measurements. + +## Palette arpeggio + +`palette-arpeggio-v1` plays weighted palette entries in descending weight, with palette rank as a +stable tie-break. Each color maps hue to pitch class and lightness to octave; its weight controls +duration and velocity. The API includes the color, weight, note, MIDI number, frequency, duration, +and pitch-source explanation for every step. + +These mappings are deliberately simple, explainable, and deterministic. They do not claim that an +artwork has an inherent sound or that musical distance is visual similarity. diff --git a/docs/WEB_UI_V1.md b/docs/WEB_UI_V1.md new file mode 100644 index 0000000..f3b4703 --- /dev/null +++ b/docs/WEB_UI_V1.md @@ -0,0 +1,36 @@ +# Web UI v1 + +Milestone 7 is one FastAPI application serving both a built React/TypeScript frontend and a JSON +API. It reads existing processed artifacts without mutating them. Only weighted-similarity query +results are written, under `data/cache/weighted-similarity/`. + +## User flows + +- `/` searches title, artist, classification, and object number; it also offers a deterministic + seeded random entry point and corpus coverage figures. +- `/artwork/:id` presents the image and bilingual metadata, a proportional weighted-palette strip, + weighted feature values, user-triggered sonification, and similarity tabs. +- `/about` explains provenance, interpretation, missingness, and the distinction between source, + derived features, and artistic mappings. + +Color similarity is the default. Visual-character and geometry profiles remain separate. The +weighted-color profile is labelled as a slower, explicit deep comparison; it is never launched by +page load or tab hover. Its exact result is cached by canonical entity ID and the SHA-256 of +`similarity-profiles-v2.json`, so profile changes invalidate old cache entries. + +Audio never autoplays. Play and stop controls are keyboard accessible, palette values have visible +text labels, focus indicators are explicit, and reduced-motion preferences disable decorative +motion. Missing physical geometry is shown as unavailable rather than inferred from pixels. + +## API + +- `GET /api/health` and `/api/stats` +- `GET /api/artworks?q=&limit=&offset=&classification=&has_physical=&chromatic=` +- `GET /api/artworks/random?seed=` +- `GET /api/resolve/{object_number}` (retains ambiguous object-number groups) +- `GET /api/artworks/{smk_entity_id}` +- `GET /api/artworks/{smk_entity_id}/similar?profile=color|visual|geometry|weighted-color` + +The compact web catalog is reproducibly built with `smk web-catalog`. Application startup fails +with an explicit artifact list when required processed files are absent; it never rebuilds or +downloads analytical data automatically. diff --git a/pyproject.toml b/pyproject.toml index b0848ae..785d589 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,16 +10,19 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "duckdb>=1", + "fastapi>=0.115", "ijson>=3.3", "numpy>=2", "polars>=1", "pyarrow>=18", "typer>=0.12", + "uvicorn>=0.34", ] [project.optional-dependencies] dev = [ "pytest>=8", + "httpx>=0.28", "ruff>=0.8", ] diff --git a/src/smk_playground/api/__init__.py b/src/smk_playground/api/__init__.py new file mode 100644 index 0000000..e796ae9 --- /dev/null +++ b/src/smk_playground/api/__init__.py @@ -0,0 +1 @@ +"""HTTP application package.""" diff --git a/src/smk_playground/api/app.py b/src/smk_playground/api/app.py new file mode 100644 index 0000000..2b7aa4a --- /dev/null +++ b/src/smk_playground/api/app.py @@ -0,0 +1,97 @@ +"""FastAPI application serving the API and built React frontend.""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any + +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from smk_playground.api.catalog import Catalog + + +def repository_root() -> Path: + configured = os.environ.get("SMK_ROOT") + return Path(configured).resolve() if configured else Path.cwd().resolve() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.catalog = Catalog(repository_root()) + yield + + +app = FastAPI(title="SMK Playground API", version="1.0.0", lifespan=lifespan) + + +def catalog() -> Catalog: + return app.state.catalog + + +@app.get("/api/health") +def health() -> dict[str, Any]: + return {"status": "ok", "catalog_rows": len(catalog().rows)} + + +@app.get("/api/stats") +def stats() -> dict[str, Any]: + return catalog().stats() + + +@app.get("/api/artworks") +def artworks( + q: str = "", limit: int = Query(24, ge=1, le=100), offset: int = Query(0, ge=0), + classification: str | None = None, has_physical: bool | None = None, + chromatic: bool | None = None, +) -> dict[str, Any]: + return catalog().search(q, limit, offset, classification, has_physical, chromatic) + + +@app.get("/api/artworks/random") +def random_artwork(seed: int | None = None) -> dict[str, Any]: + return catalog().random(seed) + + +@app.get("/api/resolve/{object_number}") +def resolve_object_number(object_number: str) -> dict[str, Any]: + matches = catalog().resolve(object_number) + if not matches: + raise HTTPException(404, "Object number not found in corpus") + return {"object_number": object_number, "ambiguous": len(matches) > 1, "items": matches} + + +@app.get("/api/artworks/{entity_id}/similar") +def similar_artworks(entity_id: str, profile: str = "color", + limit: int = Query(10, ge=1, le=50)) -> dict[str, Any]: + if profile not in {"color", "visual", "geometry", "weighted-color"}: + raise HTTPException(400, "Unknown similarity profile") + try: + return catalog().similar(entity_id, profile, limit) + except KeyError: + raise HTTPException(404, "Artwork not found") from None + except ValueError as error: + raise HTTPException(422, str(error)) from error + + +@app.get("/api/artworks/{entity_id}") +def artwork(entity_id: str) -> dict[str, Any]: + try: + return catalog().artwork(entity_id) + except KeyError: + raise HTTPException(404, "Artwork not found") from None + + +frontend = repository_root() / "web" / "dist" +if frontend.exists(): + app.mount("/assets", StaticFiles(directory=frontend / "assets"), name="assets") + + @app.get("/{full_path:path}", include_in_schema=False) + def frontend_route(full_path: str) -> FileResponse: + candidate = frontend / full_path + if full_path and candidate.is_file(): + return FileResponse(candidate) + return FileResponse(frontend / "index.html") diff --git a/src/smk_playground/api/catalog.py b/src/smk_playground/api/catalog.py new file mode 100644 index 0000000..0fb16c1 --- /dev/null +++ b/src/smk_playground/api/catalog.py @@ -0,0 +1,173 @@ +"""Read-only in-memory catalog over frozen compact Parquet artifacts.""" + +from __future__ import annotations + +import json +import random +from pathlib import Path +from typing import Any + +import pyarrow.parquet as pq + +from smk_playground.enrichment.milestone6 import WeightedSimilarityEngine +from smk_playground.normalize.milestone4 import sha256_file +from smk_playground.similarity.engine import SimilarityEngine +from smk_playground.sonification.profiles import ( + palette_arpeggio, + physical_artwork_tone, + weighted_color_tone, +) + + +class Catalog: + def __init__(self, root: Path): + self.root = root + required = [ + root / "data" / "processed" / "web-catalog-v1.parquet", + root / "data" / "processed" / "artwork-weighted-palettes-v1.parquet", + root / "data" / "processed" / "similarity-profiles-v1.json", + root / "data" / "processed" / "similarity-profiles-v2.json", + root / "data" / "processed" / "sonification-profiles-v1.json", + ] + missing = [str(path) for path in required if not path.exists()] + if missing: + raise RuntimeError("Missing required processed artifacts: " + ", ".join(missing)) + self.rows = pq.read_table(required[0]).to_pylist() + self.by_id = {row["smk_entity_id"]: row for row in self.rows} + self.by_number: dict[str, list[dict[str, Any]]] = {} + for row in self.rows: + self.by_number.setdefault(row["object_number"], []).append(row) + palette_rows = pq.read_table(required[1]).to_pylist() + self.palettes: dict[str, list[dict[str, Any]]] = {} + for row in palette_rows: + self.palettes.setdefault(row["smk_entity_id"], []).append(row) + for values in self.palettes.values(): + values.sort(key=lambda row: row["palette_rank"]) + self.fast_similarity = SimilarityEngine(root) + self._weighted_similarity: WeightedSimilarityEngine | None = None + self.weighted_profile_hash = sha256_file(required[3]) + self.cache_dir = root / "data" / "cache" / "weighted-similarity" + self.cache_dir.mkdir(parents=True, exist_ok=True) + + @property + def weighted_similarity(self) -> WeightedSimilarityEngine: + if self._weighted_similarity is None: + self._weighted_similarity = WeightedSimilarityEngine(self.root) + return self._weighted_similarity + + def public_card(self, row: dict[str, Any]) -> dict[str, Any]: + return {key: row.get(key) for key in ( + "smk_entity_id", "object_number", "display_title", "artist_display", "date_text", + "classification", "image_thumbnail", "centroid_chroma", "dominant_color_hex", + )} + + def search(self, query: str, limit: int, offset: int, classification: str | None, + has_physical: bool | None, chromatic: bool | None) -> dict[str, Any]: + needle = query.casefold().strip() + matches = [] + for row in self.rows: + if needle and needle not in row["search_text"]: + continue + if classification and classification.casefold() not in (row.get("classification") or "").casefold(): + continue + if has_physical is not None: + present = row.get("net_width_cm") is not None and row.get("net_height_cm") is not None + if present != has_physical: + continue + if chromatic is not None: + present = row.get("centroid_hue_deg") is not None + if present != chromatic: + continue + matches.append(row) + return {"total": len(matches), "limit": limit, "offset": offset, + "items": [self.public_card(row) for row in matches[offset:offset + limit]]} + + def artwork(self, entity_id: str) -> dict[str, Any]: + if entity_id not in self.by_id: + raise KeyError(entity_id) + row = self.by_id[entity_id] + palette = self.palettes.get(entity_id, []) + feature = row + normalized = row + return { + **{key: row.get(key) for key in ( + "smk_entity_id", "object_number", "title_da", "title_en", "display_title", + "artist_display", "date_text", "classification", "image_iiif_id", + "image_thumbnail", "image_native", "image_width", "image_height", + "rights_semantic", "net_width_cm", "net_height_cm", "quality_flags", + )}, + "weighted_palette": [{key: item.get(key) for key in ( + "palette_rank", "hex", "token_count", "weight", "oklab_l", "oklab_a", + "oklab_b", "oklch_c", "oklch_h_deg")} + for item in palette], + "weighted_features": {key: row.get(key) for key in ( + "centroid_l", "centroid_a", "centroid_b", "centroid_chroma", + "centroid_hue_deg", "palette_entropy", "palette_entropy_01", + "effective_palette_size", "palette_spread_oklab", "chroma_stddev", + "hue_concentration_01", "dominant_color_hex", "dominant_color_weight", + "weighted_palette_status")}, + "sonification": { + "weighted_color": weighted_color_tone(feature), + "physical": physical_artwork_tone(feature, normalized), + "palette_arpeggio": palette_arpeggio(palette), + }, + "provenance": { + "metadata": "SMK bulk open data", + "palette_weights": "SMK enrichment colorextractor token frequencies", + "features": "SMK Playground derived weighted features v2", + "sonification": "SMK Playground deterministic artistic mappings v1", + }, + } + + def resolve(self, object_number: str) -> list[dict[str, Any]]: + return [self.public_card(row) for row in self.by_number.get(object_number, [])] + + def random(self, seed: int | None) -> dict[str, Any]: + rng = random.Random(seed) if seed is not None else random.SystemRandom() + return self.public_card(self.rows[rng.randrange(len(self.rows))]) + + def similar(self, entity_id: str, profile: str, limit: int) -> dict[str, Any]: + if entity_id not in self.by_id: + raise KeyError(entity_id) + if profile == "weighted-color": + return self._weighted_cached(entity_id, limit) + query = self.fast_similarity.resolve(smk_entity_id=entity_id) + results = self.fast_similarity.search_row(query, profile, limit) + return {"profile": profile, "cached": False, + "items": [self._similar_item(item) for item in results]} + + def _similar_item(self, result: dict[str, Any]) -> dict[str, Any]: + catalog = self.by_id[result["smk_entity_id"]] + return {**self.public_card(catalog), "rank": result["rank"], + "distance": result["total_distance"], + "active_components": result["active_components"], + "components": result["components"]} + + def _weighted_cached(self, entity_id: str, limit: int) -> dict[str, Any]: + cache_limit = max(50, limit) + cache_path = self.cache_dir / f"{entity_id}.json" + if cache_path.exists(): + payload = json.loads(cache_path.read_text(encoding="utf-8")) + if (payload.get("profile_hash") == self.weighted_profile_hash + and payload.get("max_results", 0) >= limit): + return {"profile": "weighted-color", "cached": True, + "items": payload["items"][:limit]} + query = self.weighted_similarity.resolve(smk_entity_id=entity_id) + results = self.weighted_similarity.search_row(query, cache_limit) + items = [self._similar_item(item) for item in results] + payload = {"profile": "weighted-color", "profile_hash": self.weighted_profile_hash, + "max_results": cache_limit, "items": items} + temporary = cache_path.with_suffix(".tmp") + temporary.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")), encoding="utf-8") + temporary.replace(cache_path) + return {"profile": "weighted-color", "cached": False, "items": items[:limit]} + + def stats(self) -> dict[str, Any]: + return { + "artwork_count": len(self.rows), + "weighted_palette_count": sum(bool(row["has_weighted_palette_v1"]) for row in self.rows), + "physical_geometry_count": sum( + row.get("net_width_cm") is not None and row.get("net_height_cm") is not None + for row in self.rows + ), + } diff --git a/src/smk_playground/cli.py b/src/smk_playground/cli.py index 23ebe5e..40f54f2 100644 --- a/src/smk_playground/cli.py +++ b/src/smk_playground/cli.py @@ -76,6 +76,15 @@ def milestone6(root: Path | None = None) -> None: typer.echo(f"Milestone 6 complete: {summary['weighted_palette_entity_count']} weighted entities") +@app.command("web-catalog") +def web_catalog(root: Path | None = None) -> None: + """Build the compact read-only catalog and sonification manifest for the app.""" + from smk_playground.web_catalog.build import run + + summary = run((root or Path.cwd()).resolve()) + typer.echo(f"Web catalog complete: {summary['rows']} rows") + + @enrichment_app.command("status") def enrichment_status(root: Path | None = None) -> None: """Report resumable enrichment-cache status.""" diff --git a/src/smk_playground/sonification/profiles.py b/src/smk_playground/sonification/profiles.py new file mode 100644 index 0000000..d7f40c1 --- /dev/null +++ b/src/smk_playground/sonification/profiles.py @@ -0,0 +1,122 @@ +"""Deterministic artistic mappings from artwork features to browser-audio parameters.""" + +from __future__ import annotations + +import math +from typing import Any + +PROFILE_VERSION = "1.0.0" +PITCH_CLASSES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") + + +def clamp(value: float, low: float = 0.0, high: float = 1.0) -> float: + return min(high, max(low, value)) + + +def hue_pitch_class(hue_degrees: float | None) -> tuple[int, str, str]: + if hue_degrees is None: + return 9, "A", "achromatic_neutral" + index = int((hue_degrees % 360.0) // 30.0) + return index, PITCH_CLASSES[index], "weighted_centroid_hue" + + +def nearest_midi(target: float, pitch_class: int) -> int: + candidates = [midi for midi in range(36, 85) if midi % 12 == pitch_class] + return min(candidates, key=lambda midi: (abs(midi - target), midi)) + + +def midi_frequency(midi: int) -> float: + return 440.0 * (2.0 ** ((midi - 69) / 12.0)) + + +def _shared_timbre(feature: dict[str, Any]) -> dict[str, float]: + return { + "velocity": clamp(0.28 + 4.2 * float(feature.get("centroid_chroma") or 0.0), 0.25, 0.92), + "attack": 0.08 + 0.18 * (1.0 - float(feature.get("dominant_color_weight") or 0.0)), + "release": 0.65 + 1.35 * float(feature.get("dominant_color_weight") or 0.0), + "harmonic_richness": clamp(float(feature.get("palette_spread_oklab") or 0.0) / 0.35), + "noise_amount": 0.10 * clamp(float(feature.get("palette_entropy_01") or 0.0)), + "tonal_purity": clamp(float(feature.get("hue_concentration_01") or 0.0)), + } + + +def weighted_color_tone(feature: dict[str, Any]) -> dict[str, Any]: + available = bool(feature.get("has_weighted_palette_v1")) + if not available: + return {"profile": "weighted-color-tone-v1", "version": PROFILE_VERSION, + "available": False, "reason": "weighted palette unavailable"} + pitch_class, note, pitch_source = hue_pitch_class(feature.get("centroid_hue_deg")) + lightness = clamp(float(feature["centroid_l"])) + midi = nearest_midi(36.0 + 48.0 * lightness, pitch_class) + return { + "profile": "weighted-color-tone-v1", "version": PROFILE_VERSION, "available": True, + "note": note, "octave": midi // 12 - 1, "midi": midi, + "frequency_hz": midi_frequency(midi), "pitch_source": pitch_source, + **_shared_timbre(feature), + "stereo_pan": 0.0, + "explanation": { + "pitch": "weighted centroid hue; A is the explicit achromatic neutral", + "register": "weighted centroid OKLab lightness mapped to MIDI 36–84", + "timbre": "weighted chroma, spread, entropy, hue concentration, and dominant weight", + }, + } + + +def physical_artwork_tone(feature: dict[str, Any], normalized: dict[str, Any]) -> dict[str, Any]: + area_percentile = normalized.get("log_net_area_cm2_percentile_01") + log_aspect = normalized.get("log_net_aspect_ratio") + if not feature.get("has_weighted_palette_v1") or area_percentile is None or log_aspect is None: + return {"profile": "physical-artwork-tone-v1", "version": PROFILE_VERSION, + "available": False, "reason": "reliable physical Netto geometry unavailable"} + pitch_class, note, pitch_source = hue_pitch_class(feature.get("centroid_hue_deg")) + midi = nearest_midi(84.0 - 48.0 * clamp(float(area_percentile)), pitch_class) + return { + "profile": "physical-artwork-tone-v1", "version": PROFILE_VERSION, "available": True, + "note": note, "octave": midi // 12 - 1, "midi": midi, + "frequency_hz": midi_frequency(midi), "pitch_source": pitch_source, + **_shared_timbre(feature), + "stereo_pan": 0.6 * math.tanh(float(log_aspect)), + "explanation": { + "pitch": "weighted centroid hue; A is the explicit achromatic neutral", + "register": "inverse physical log-area percentile: larger works sound lower", + "stereo_pan": "subtle signed physical log-aspect mapping", + "timbre": "weighted chroma, spread, entropy, hue concentration, and dominant weight", + }, + } + + +def palette_arpeggio(palette: list[dict[str, Any]]) -> dict[str, Any]: + notes = [] + for row in sorted(palette, key=lambda item: (-item["weight"], item["palette_rank"])): + pitch_class, note, pitch_source = hue_pitch_class(row.get("oklch_h_deg")) + midi = nearest_midi(48.0 + 24.0 * clamp(float(row["oklab_l"])), pitch_class) + notes.append({ + "hex": row["hex"], "weight": row["weight"], "note": note, + "octave": midi // 12 - 1, "midi": midi, "frequency_hz": midi_frequency(midi), + "velocity": clamp(0.30 + 0.70 * row["weight"], 0.3, 0.9), + "duration_seconds": 0.18 + 1.15 * row["weight"], "pitch_source": pitch_source, + }) + return {"profile": "palette-arpeggio-v1", "version": PROFILE_VERSION, + "available": bool(notes), "notes": notes, + "explanation": "descending palette weight; hue→pitch, lightness→octave, weight→duration/intensity"} + + +def profile_manifest(input_hash: str) -> dict[str, Any]: + return { + "profile_set_version": PROFILE_VERSION, "input_feature_sha256": input_hash, + "profiles": { + "weighted-color-tone-v1": { + "version": PROFILE_VERSION, "pitch": "floor(hue_deg/30) over C..B; achromatic=A", + "register": "nearest matching pitch class to 36 + 48*centroid_l", + }, + "physical-artwork-tone-v1": { + "version": PROFILE_VERSION, "required": ["weighted palette", "physical Netto geometry"], + "register": "nearest matching pitch class to 84 - 48*log-area percentile", + "aspect": "stereo_pan = 0.6*tanh(log_net_aspect_ratio)", + }, + "palette-arpeggio-v1": { + "version": PROFILE_VERSION, "order": "weight descending, palette rank ascending", + "mapping": "hue→pitch; lightness→octave; weight→duration and velocity", + }, + }, + } diff --git a/src/smk_playground/web_catalog/__init__.py b/src/smk_playground/web_catalog/__init__.py new file mode 100644 index 0000000..94af064 --- /dev/null +++ b/src/smk_playground/web_catalog/__init__.py @@ -0,0 +1 @@ +"""Compact web catalog generation.""" diff --git a/src/smk_playground/web_catalog/build.py b/src/smk_playground/web_catalog/build.py new file mode 100644 index 0000000..e2fd9cf --- /dev/null +++ b/src/smk_playground/web_catalog/build.py @@ -0,0 +1,108 @@ +"""Build deterministic compact catalog and sonification profile manifest.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pyarrow as pa +import pyarrow.parquet as pq + +from smk_playground.normalize.milestone4 import sha256_file +from smk_playground.sonification.profiles import profile_manifest + + +def _json(value: str | None) -> Any: + if not value: + return None + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + +def _first_text(value: str | None, *keys: str) -> str | None: + parsed = _json(value) + if isinstance(parsed, list): + for item in parsed: + if isinstance(item, str) and item.strip(): + return item.strip() + if isinstance(item, dict): + for key in keys: + candidate = item.get(key) + if candidate: + return str(candidate).strip() + return None + + +def run(root: Path) -> dict[str, Any]: + artworks_path = root / "data" / "processed" / "artworks.parquet" + features_path = root / "data" / "processed" / "artwork-visual-features-v2.parquet" + normalized_path = root / "data" / "processed" / "artwork-normalized-features-v1.parquet" + artwork_columns = [ + "smk_entity_id", "object_number", "titles_da_json", "titles_en_json", + "artists_da_json", "artists_en_json", "production_dates_da_json", + "object_names_da_json", "object_names_en_json", "classifications_da_json", + "classifications_en_json", "image_iiif_id", "image_thumbnail", "image_native", + "image_width", "image_height", "rights_semantic", "net_width_cm", "net_height_cm", + "quality_flags", "corpus_smk_public_visual_v1", + ] + canonical = { + row["smk_entity_id"]: row for row in pq.read_table( + artworks_path, columns=artwork_columns, + filters=[("corpus_smk_public_visual_v1", "=", True)], + ).to_pylist() + } + feature_columns = [ + "smk_entity_id", "centroid_l", "centroid_a", "centroid_b", "centroid_chroma", + "centroid_hue_deg", "palette_entropy", "palette_entropy_01", "effective_palette_size", + "palette_spread_oklab", "chroma_stddev", "hue_concentration_01", + "dominant_color_hex", "dominant_color_weight", "has_weighted_palette_v1", + "weighted_palette_status", + ] + features = {row["smk_entity_id"]: row for row in pq.read_table( + features_path, columns=feature_columns + ).to_pylist()} + normalized = {row["smk_entity_id"]: row for row in pq.read_table( + normalized_path, columns=["smk_entity_id", "log_net_area_cm2_percentile_01", + "log_net_aspect_ratio"] + ).to_pylist()} + rows = [] + for entity_id in sorted(canonical): + source, feature, norm = canonical[entity_id], features[entity_id], normalized[entity_id] + title_da = _first_text(source["titles_da_json"], "title") + title_en = _first_text(source["titles_en_json"], "title") + artist = _first_text(source["artists_da_json"]) or _first_text(source["artists_en_json"]) + date_text = _first_text(source["production_dates_da_json"], "period", "start") + classification = ( + _first_text(source["classifications_da_json"], "name", "classification") + or _first_text(source["object_names_da_json"], "name") + or _first_text(source["classifications_en_json"], "name", "classification") + or _first_text(source["object_names_en_json"], "name") + ) + rows.append({ + "smk_entity_id": entity_id, "object_number": source["object_number"], + "title_da": title_da, "title_en": title_en, "display_title": title_da or title_en or source["object_number"], + "artist_display": artist, "date_text": date_text, "classification": classification, + "search_text": " ".join(filter(None, (source["object_number"], title_da, title_en, artist, classification))).casefold(), + "image_iiif_id": source["image_iiif_id"], "image_thumbnail": source["image_thumbnail"], + "image_native": source["image_native"], "image_width": source["image_width"], + "image_height": source["image_height"], "rights_semantic": source["rights_semantic"], + "net_width_cm": source["net_width_cm"], "net_height_cm": source["net_height_cm"], + "quality_flags": source["quality_flags"], **feature, + "log_net_area_cm2_percentile_01": norm["log_net_area_cm2_percentile_01"], + "log_net_aspect_ratio": norm["log_net_aspect_ratio"], + }) + metadata = { + b"smk.web_catalog_version": b"1.0.0", + b"smk.artworks_sha256": sha256_file(artworks_path).encode(), + b"smk.weighted_features_sha256": sha256_file(features_path).encode(), + } + output = root / "data" / "processed" / "web-catalog-v1.parquet" + table = pa.Table.from_pylist(rows).replace_schema_metadata(metadata) + pq.write_table(table, output, compression="zstd", version="2.6", write_statistics=True) + profile_path = root / "data" / "processed" / "sonification-profiles-v1.json" + profile_path.write_text(json.dumps(profile_manifest(sha256_file(features_path)), indent=2) + "\n", encoding="utf-8") + return {"rows": len(rows), "catalog_sha256": sha256_file(output), + "sonification_profiles_sha256": sha256_file(profile_path)} diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..07d7ae5 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,31 @@ +"""Focused API acceptance tests over the frozen processed artifacts.""" + +import pytest +from fastapi.testclient import TestClient + +from smk_playground.api.app import app + + +def test_health_search_and_kms8892_detail(): + with TestClient(app) as client: + health = client.get("/api/health") + assert health.status_code == 200 + assert health.json()["catalog_rows"] > 0 + + search = client.get("/api/artworks", params={"q": "KMS8892", "limit": 5}).json() + assert search["total"] >= 1 + match = next(item for item in search["items"] if item["object_number"] == "KMS8892") + + detail = client.get(f"/api/artworks/{match['smk_entity_id']}") + assert detail.status_code == 200 + payload = detail.json() + assert payload["weighted_palette"] + assert sum(item["weight"] for item in payload["weighted_palette"]) == pytest.approx(1.0) + assert payload["sonification"]["weighted_color"]["available"] + + +def test_not_found_and_profile_validation(): + with TestClient(app) as client: + assert client.get("/api/artworks/not-an-id").status_code == 404 + response = client.get("/api/artworks/not-an-id/similar", params={"profile": "invented"}) + assert response.status_code == 400 diff --git a/tests/test_sonification.py b/tests/test_sonification.py new file mode 100644 index 0000000..0fbd4e4 --- /dev/null +++ b/tests/test_sonification.py @@ -0,0 +1,62 @@ +"""Focused invariants for deterministic sonification profiles.""" + +import pytest + +from smk_playground.sonification.profiles import ( + hue_pitch_class, + palette_arpeggio, + physical_artwork_tone, + weighted_color_tone, +) + + +def feature(**updates): + values = { + "has_weighted_palette_v1": True, + "centroid_hue_deg": 30.0, + "centroid_l": 0.5, + "centroid_chroma": 0.1, + "dominant_color_weight": 0.4, + "palette_spread_oklab": 0.15, + "palette_entropy_01": 0.6, + "hue_concentration_01": 0.7, + } + values.update(updates) + return values + + +def test_hue_bins_and_explicit_achromatic_neutral(): + assert hue_pitch_class(0)[:2] == (0, "C") + assert hue_pitch_class(30)[:2] == (1, "C#") + assert hue_pitch_class(359)[:2] == (11, "B") + assert hue_pitch_class(None) == (9, "A", "achromatic_neutral") + + +def test_weighted_tone_is_deterministic_and_bounded(): + first = weighted_color_tone(feature()) + assert first == weighted_color_tone(feature()) + assert first["note"] == "C#" + assert 36 <= first["midi"] <= 84 + assert 0 <= first["velocity"] <= 1 + + +def test_physical_profile_requires_geometry_and_large_works_sound_lower(): + assert not physical_artwork_tone(feature(), {})["available"] + small = physical_artwork_tone( + feature(), {"log_net_area_cm2_percentile_01": 0.1, "log_net_aspect_ratio": -0.5} + ) + large = physical_artwork_tone( + feature(), {"log_net_area_cm2_percentile_01": 0.9, "log_net_aspect_ratio": 0.5} + ) + assert large["midi"] < small["midi"] + assert small["stereo_pan"] == pytest.approx(-large["stereo_pan"]) + + +def test_palette_arpeggio_orders_by_weight_then_rank(): + palette = [ + {"palette_rank": 2, "hex": "#aaaaaa", "weight": 0.3, "oklch_h_deg": None, "oklab_l": 0.5}, + {"palette_rank": 1, "hex": "#ff0000", "weight": 0.7, "oklch_h_deg": 0.0, "oklab_l": 0.7}, + ] + notes = palette_arpeggio(palette)["notes"] + assert [note["hex"] for note in notes] == ["#ff0000", "#aaaaaa"] + assert notes[1]["pitch_source"] == "achromatic_neutral" diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ff24568 --- /dev/null +++ b/web/index.html @@ -0,0 +1,10 @@ + + + + + + + SMK Playground — Colour into sound + +
+ diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..82b3e3c --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2159 @@ +{ + "name": "smk-playground-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "smk-playground-web", + "version": "1.0.0", + "dependencies": { + "@tailwindcss/vite": "latest", + "@vitejs/plugin-react": "latest", + "lucide-react": "latest", + "react": "latest", + "react-dom": "latest", + "react-router-dom": "latest", + "tailwindcss": "latest", + "typescript": "latest", + "vite": "latest" + }, + "devDependencies": { + "@types/react": "latest", + "@types/react-dom": "latest", + "vitest": "latest" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.53.0.tgz", + "integrity": "sha512-OxJ7elNku+iqVzOolk5caxq2LM8Mck6lhA6uZx3g2PlZ53+FvBnT6PlJxGCukCa6oF5wQwgSatn9cH1b4olWmQ==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz", + "integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/vite": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c5ec233 --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "smk-playground-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "test": "vitest run" + }, + "dependencies": { + "@vitejs/plugin-react": "latest", + "vite": "latest", + "typescript": "latest", + "react": "latest", + "react-dom": "latest", + "react-router-dom": "latest", + "lucide-react": "latest", + "tailwindcss": "latest", + "@tailwindcss/vite": "latest" + }, + "devDependencies": { + "@types/react": "latest", + "@types/react-dom": "latest", + "vitest": "latest" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..76e6c79 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,50 @@ +import { FormEvent, useEffect, useState } from "react"; +import { Link, NavLink, Route, Routes, useNavigate, useParams } from "react-router-dom"; +import { ChevronLeft, Dices, Headphones, Pause, Play, Search, Sparkles } from "lucide-react"; +import { api } from "./api"; +import { playArpeggio, playTone, stopAudio } from "./audio"; +import { ArtworkTile, Loading, PaletteBar } from "./components"; +import type { Artwork, ArtworkCard, SimilarItem } from "./types"; + +function Shell({ children }: { children: React.ReactNode }) { + return <>
SMK Playground
{children}; +} + +function Home() { + const [query, setQuery] = useState(""); const [items, setItems] = useState([]); + const [stats, setStats] = useState<{ artwork_count: number; weighted_palette_count: number; physical_geometry_count: number }>(); + const [loading, setLoading] = useState(true); const navigate = useNavigate(); + const load = (q = "") => { setLoading(true); api.search(q).then((r) => setItems(r.items)).finally(() => setLoading(false)); }; + useEffect(() => { load(); api.stats().then(setStats); }, []); + const submit = (event: FormEvent) => { event.preventDefault(); load(query); }; + const surprise = () => api.random().then((art) => navigate(`/artwork/${art.smk_entity_id}`)); + return

An experimental colour archive

See art.
Hear colour.

Explore 27,254 open artworks from SMK through weighted palettes, perceptual colour and deterministic sound.

setQuery(e.target.value)} placeholder="Search title, artist, object number…" aria-label="Search artworks" />
{stats &&
{stats.artwork_count.toLocaleString()}open works
{stats.weighted_palette_count.toLocaleString()}weighted palettes
{stats.physical_geometry_count.toLocaleString()}measured works
}

The collection

{query ? `Results for “${query}”` : "A field of colour"}

{loading ? :
{items.map((item) => )}
}
; +} + +const profileNames: Record = { color: "Color", visual: "Visual character", geometry: "Physical geometry", "weighted-color": "Deep weighted color" }; +function SimilarWorks({ id }: { id: string }) { + const [profile, setProfile] = useState("color"); const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState>({}); + const load = (next: string) => { setProfile(next); if (next === "weighted-color" && !loaded[next]) return; setLoading(true); api.similar(id, next).then((r) => { setItems(r.items); setLoaded((x) => ({ ...x, [next]: true })); }).finally(() => setLoading(false)); }; + useEffect(() => load("color"), [id]); + return

Connections

Works nearby

{Object.entries(profileNames).map(([key, label]) => )}
{profile === "weighted-color" && !loaded[profile] ?
Deep palette comparison

Exact transport across weighted colours takes around 12 seconds the first time, then it is cached.

: loading ? :
{items.map((item) =>

#{item.rank} · distance {item.distance.toFixed(4)}

)}
}
; +} + +function ToneCard({ art }: { art: Artwork }) { + const [selected, setSelected] = useState<"weighted_color" | "physical">("weighted_color"); + const tone = art.sonification[selected]; + return

Sonification

The artwork as tone

{tone.available &&
{tone.note}{tone.octave}{tone.frequency_hz?.toFixed(2)} Hz
}
{tone.available ? <>
Why this sound?

{tone.explanation?.pitch}. {tone.explanation?.register}.

:

{tone.reason}

}
; +} + +function ArtworkPage() { + const { id = "" } = useParams(); const [art, setArt] = useState(); const [error, setError] = useState(""); + useEffect(() => { stopAudio(); setArt(undefined); api.artwork(id).then(setArt).catch((e) => setError(String(e))); return stopAudio; }, [id]); + if (error) return
{error}
; + if (!art) return ; + const f = art.weighted_features; + return
Back to collection
{`${art.display_title}

{art.classification || "Artwork"} · {art.object_number}

{art.display_title}

{art.artist_display || "Unknown artist"}

{art.date_text}{art.net_width_cm ? ` · ${art.net_width_cm} × ${art.net_height_cm} cm` : ""}

L {Number(f.centroid_l).toFixed(3)}C {Number(f.centroid_chroma).toFixed(3)}spread {Number(f.palette_spread_oklab).toFixed(3)}

What comes from where

{Object.entries(art.provenance).map(([key, value]) =>

{key.replaceAll("_", " ")}{value}

)}
; +} + +function About() { return

About the experiment

Colour is data.
Sound is interpretation.

SMK Playground uses open collection metadata and images from Statens Museum for Kunst. The corpus contains public-domain works with usable image and palette data.

Palette weights come from SMK’s enrichment data. Perceptual colour features, similarity distances and sonification parameters are derived here and versioned separately.

Hue-to-pitch and size-to-register are deterministic artistic conventions—not natural laws or claims about how paintings “really” sound.

} + +export default function App() { return } />} />} />; } diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..9e2c9fa --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,16 @@ +import type { Artwork, ArtworkCard, SimilarItem } from "./types"; + +async function get(url: string): Promise { + const response = await fetch(url); + if (!response.ok) throw new Error((await response.text()) || `Request failed: ${response.status}`); + return response.json() as Promise; +} + +export const api = { + stats: () => get<{ artwork_count: number; weighted_palette_count: number; physical_geometry_count: number }>("/api/stats"), + search: (q = "", offset = 0) => get<{ total: number; items: ArtworkCard[] }>(`/api/artworks?q=${encodeURIComponent(q)}&limit=24&offset=${offset}`), + random: () => get("/api/artworks/random"), + artwork: (id: string) => get(`/api/artworks/${encodeURIComponent(id)}`), + similar: (id: string, profile: string, limit = 10) => + get<{ profile: string; cached: boolean; items: SimilarItem[] }>(`/api/artworks/${encodeURIComponent(id)}/similar?profile=${profile}&limit=${limit}`), +}; diff --git a/web/src/audio.ts b/web/src/audio.ts new file mode 100644 index 0000000..5a321fc --- /dev/null +++ b/web/src/audio.ts @@ -0,0 +1,41 @@ +import type { PaletteNote, Tone } from "./types"; + +let context: AudioContext | null = null; +let active: AudioNode[] = []; + +function ctx() { context ??= new AudioContext(); return context; } +export function stopAudio() { active.forEach((node) => { try { (node as OscillatorNode).stop?.(); } catch {} }); active = []; } + +export function playTone(tone: Tone) { + if (!tone.available || !tone.frequency_hz) return; + stopAudio(); + const audio = ctx(); + const now = audio.currentTime; + const master = audio.createGain(); + const pan = audio.createStereoPanner(); pan.pan.value = tone.stereo_pan ?? 0; + master.gain.setValueAtTime(0.0001, now); + master.gain.exponentialRampToValueAtTime(Math.max(0.02, (tone.velocity ?? .5) * .28), now + (tone.attack ?? .1)); + master.gain.exponentialRampToValueAtTime(0.0001, now + (tone.release ?? 1.2) + (tone.attack ?? .1)); + master.connect(pan).connect(audio.destination); + const harmonics = 1 + Math.round((tone.harmonic_richness ?? 0) * 3); + for (let harmonic = 1; harmonic <= harmonics; harmonic++) { + const oscillator = audio.createOscillator(); + const gain = audio.createGain(); gain.gain.value = 1 / (harmonic * harmonics); + oscillator.type = harmonic === 1 ? "sine" : "triangle"; + oscillator.frequency.value = tone.frequency_hz * harmonic; + oscillator.connect(gain).connect(master); oscillator.start(now); oscillator.stop(now + (tone.release ?? 1.2) + .4); + active.push(oscillator); + } +} + +export function playArpeggio(notes: PaletteNote[]) { + stopAudio(); const audio = ctx(); let cursor = audio.currentTime; + for (const note of notes) { + const oscillator = audio.createOscillator(); const gain = audio.createGain(); + oscillator.type = "sine"; oscillator.frequency.value = note.frequency_hz; + gain.gain.setValueAtTime(.0001, cursor); gain.gain.exponentialRampToValueAtTime(note.velocity * .22, cursor + .04); + gain.gain.exponentialRampToValueAtTime(.0001, cursor + note.duration_seconds); + oscillator.connect(gain).connect(audio.destination); oscillator.start(cursor); oscillator.stop(cursor + note.duration_seconds + .05); + active.push(oscillator); cursor += note.duration_seconds; + } +} diff --git a/web/src/components.tsx b/web/src/components.tsx new file mode 100644 index 0000000..3428824 --- /dev/null +++ b/web/src/components.tsx @@ -0,0 +1,28 @@ +import { Link } from "react-router-dom"; +import { ArrowUpRight, Music2 } from "lucide-react"; +import type { ArtworkCard } from "./types"; + +export function ArtworkTile({ artwork }: { artwork: ArtworkCard }) { + return +
+ {artwork.image_thumbnail ? {artwork.display_title} :
} + +
+
+

{artwork.object_number}

+

{artwork.display_title}

+

{artwork.artist_display || "Unknown artist"}{artwork.date_text ? ` · ${artwork.date_text}` : ""}

+
+ ; +} + +export function PaletteBar({ palette }: { palette: Array<{ hex: string; weight: number }> }) { + return
+
{palette.map((color) =>
)}
+
{palette.map((color) =>
{color.hex}{(color.weight * 100).toFixed(1)}%
)}
+
; +} + +export function Loading({ label = "Loading collection" }: { label?: string }) { + return

{label}

; +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..47f667c --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,7 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render(); diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..b245ea1 --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,39 @@ +@import "tailwindcss"; + +:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #201f19; background: #f3f0e8; font-synthesis: none; --ink:#201f19; --paper:#f3f0e8; --acid:#dbe85b; --line:rgba(32,31,25,.17); } +* { box-sizing: border-box; } +body { margin: 0; min-width: 320px; } +a { color: inherit; text-decoration: none; } +button,input { font: inherit; } +button { cursor: pointer; } +button:focus-visible,a:focus-visible,input:focus-visible { outline: 3px solid #756dff; outline-offset: 3px; } +.site-header { height: 74px; padding: 0 clamp(22px,4vw,70px); display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); position:relative; z-index:4; } +.brand { font-weight:750; letter-spacing:-.02em; display:flex; align-items:center; gap:10px; } +.brand-mark { width:17px;height:17px;border-radius:50%;background:conic-gradient(#dbe85b,#f49b67,#7987ef,#dbe85b); } +nav { display:flex; gap:28px; font-size:14px; } nav a { opacity:.55; } nav a.active,nav a:hover { opacity:1; } +main { min-height:calc(100vh - 150px); } +.hero { min-height:660px; padding:90px clamp(22px,7vw,120px) 70px; display:grid; grid-template-columns:minmax(0,1.15fr) minmax(300px,.85fr); align-items:center; overflow:hidden; } +.kicker,.eyebrow { text-transform:uppercase; letter-spacing:.12em; font-weight:700; font-size:11px; display:flex; align-items:center; gap:7px; } +.hero h1,.about h1 { font-family:Georgia,serif; font-size:clamp(64px,8.5vw,132px); line-height:.82; letter-spacing:-.065em; margin:22px 0 34px; font-weight:400; } +.hero h1 em,.about h1 em { font-weight:400;color:#5967db; } +.hero-copy { max-width:590px;font-size:19px;line-height:1.55;color:#545248; } +.search-box { margin-top:38px;max-width:650px;background:#fff;border:1px solid var(--line);border-radius:2px;padding:8px 8px 8px 18px;display:flex;align-items:center;gap:12px;box-shadow:0 12px 45px rgba(42,39,25,.08); } +.search-box input { flex:1;border:0;outline:0;background:transparent;padding:13px 0;min-width:0; } +.search-box button,.deep-callout button { border:0;background:var(--ink);color:#fff;padding:13px 22px;border-radius:2px;font-weight:650; } +.text-button { border:0;background:none;padding:18px 0;display:flex;align-items:center;gap:8px;font-weight:650; } +.hero-orbit { position:relative; width:min(38vw,500px); aspect-ratio:1; margin:auto; border:1px solid var(--line); border-radius:50%; animation:float 10s ease-in-out infinite; } +.hero-orbit:before,.hero-orbit:after { content:"";position:absolute;border:1px solid var(--line);border-radius:50%;inset:14%; }.hero-orbit:after{inset:31%;} +.hero-orbit>span { position:absolute;width:68px;height:68px;border-radius:50%;filter:saturate(.8);box-shadow:0 8px 28px #0002; }.hero-orbit>span:nth-child(1){background:#908d41;left:8%;top:18%}.hero-orbit>span:nth-child(2){background:#aed7ea;right:4%;top:44%;width:92px;height:92px}.hero-orbit>span:nth-child(3){background:#795025;left:20%;bottom:2%;width:48px;height:48px}.tone-dot{position:absolute;inset:42%;border-radius:50%;display:grid;place-items:center;background:var(--acid)} +@keyframes float { 50% { transform:translateY(-12px) rotate(4deg); } } +.stat-strip { border-block:1px solid var(--line);display:grid;grid-template-columns:repeat(3,1fr);padding:28px clamp(22px,7vw,120px); }.stat-strip div{display:flex;gap:12px;align-items:baseline;border-right:1px solid var(--line);padding-left:30px}.stat-strip div:first-child{padding-left:0}.stat-strip div:last-child{border:0}.stat-strip strong{font:42px Georgia,serif}.stat-strip span{font-size:12px;text-transform:uppercase;letter-spacing:.1em;color:#6c695d} +.collection,.similar-section { padding:90px clamp(22px,5vw,80px); }.section-heading h2,.tone-card h2 { font:48px Georgia,serif;margin:8px 0 35px;letter-spacing:-.04em; } +.art-grid { display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:42px 22px; }.tile-image-wrap { aspect-ratio:4/5;background:#e4e0d5;position:relative;overflow:hidden; }.tile-image { width:100%;height:100%;object-fit:cover;transition:transform .5s cubic-bezier(.2,.7,.2,1); }.group:hover .tile-image{transform:scale(1.025)}.tile-arrow{position:absolute;right:10px;top:10px;width:34px;height:34px;background:#f7f4eddd;display:grid;place-items:center;border-radius:50%;opacity:0;transform:translateY(5px);transition:.25s}.group:hover .tile-arrow{opacity:1;transform:none}.tile-title{font:21px Georgia,serif;margin:5px 0 5px;line-height:1.18}.muted{color:#747064;font-size:14px}.image-fallback{height:100%;display:grid;place-items:center;color:#999} +.loading { min-height:280px;display:grid;place-items:center;align-content:center;gap:16px;color:#777 }.loading span{width:30px;height:30px;border:2px solid #bbb;border-top-color:#333;border-radius:50%;animation:spin .8s linear infinite}@keyframes spin{to{transform:rotate(360deg)}} +.artwork-page { padding:42px clamp(20px,5vw,80px) 100px; }.back{display:inline-flex;align-items:center;gap:3px;font-size:13px;margin-bottom:35px}.artwork-hero{display:grid;grid-template-columns:minmax(0,1.15fr) minmax(350px,.85fr);gap:clamp(45px,7vw,120px);align-items:center}.artwork-image-stage{background:#ddd8cc;min-height:600px;display:grid;place-items:center;padding:40px}.artwork-image-stage img{max-width:100%;max-height:75vh;object-fit:contain;box-shadow:0 18px 60px #342d1f2b}.artwork-info h1{font:clamp(48px,5.2vw,84px)/.95 Georgia,serif;letter-spacing:-.055em;margin:20px 0}.artwork-byline{font-size:20px}.feature-pills{display:flex;flex-wrap:wrap;gap:7px;margin:25px 0}.feature-pills span{font:12px ui-monospace,monospace;border:1px solid var(--line);border-radius:20px;padding:7px 10px} +.palette-wrap{margin-top:35px}.palette-bar{height:74px;display:flex;box-shadow:0 8px 25px #0001}.palette-bar div{min-width:3px}.palette-labels{display:grid;grid-template-columns:repeat(2,1fr);gap:8px 18px;margin-top:18px}.palette-label{display:grid;grid-template-columns:13px 1fr auto;gap:8px;align-items:center;font:11px ui-monospace,monospace}.swatch{width:11px;height:11px;border-radius:50%;border:1px solid #0002} +.tone-card{margin:100px auto 40px;max-width:1180px;background:#171812;color:#f4f1e8;padding:clamp(28px,5vw,70px);border-radius:3px}.tone-top{display:flex;justify-content:space-between;align-items:start}.tone-card .eyebrow{color:var(--acid)}.note-display{text-align:right}.note-display strong{display:block;font:76px Georgia,serif;color:var(--acid)}.note-display span{font:12px ui-monospace,monospace;color:#aaa}.tone-tabs,.tabs{display:flex;gap:7px;flex-wrap:wrap}.tone-tabs button,.tabs button{border:1px solid currentColor;background:transparent;color:inherit;padding:9px 13px;border-radius:30px;font-size:12px;opacity:.55}.tone-tabs button.active,.tabs button.active{opacity:1;background:var(--acid);color:#15150f;border-color:var(--acid)}.tone-tabs button:disabled{opacity:.2;cursor:not-allowed}.audio-actions{display:flex;align-items:center;gap:10px;margin:35px 0}.play-button,.outline-button,.icon-button{border:0;padding:14px 20px;display:flex;align-items:center;gap:8px;border-radius:2px}.play-button{background:var(--acid);color:#111;font-weight:750}.outline-button,.icon-button{background:transparent;color:#fff;border:1px solid #ffffff55}.icon-button{padding:13px}.why{border-top:1px solid #ffffff2b;padding-top:24px}.why p{color:#bcbdb3}.unavailable{color:#aaa} +.similar-section{padding-inline:0}.tabs{margin-bottom:30px}.tabs button{color:var(--ink);border-color:var(--line)}.tabs button.active{background:var(--ink);color:#fff;border-color:var(--ink)}.similar-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:18px}.distance{font:11px ui-monospace,monospace;color:#777}.deep-callout{padding:30px;border:1px solid var(--line);display:flex;justify-content:space-between;align-items:center}.deep-callout p{color:#777;margin-bottom:0}.provenance{margin-top:80px;border-top:1px solid var(--line);padding-top:30px}.provenance>div{display:grid;grid-template-columns:repeat(4,1fr);gap:25px}.provenance p{font-size:13px;color:#777}.provenance strong{display:block;color:#222;text-transform:capitalize;margin-bottom:6px} +.about{padding:110px clamp(22px,8vw,140px)}.about h1{font-size:clamp(62px,8vw,120px)}.about-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:45px;max-width:1100px;font-size:18px;line-height:1.7}.message{padding:100px}footer{border-top:1px solid var(--line);display:flex;justify-content:space-between;padding:30px clamp(22px,4vw,70px);font-size:11px;text-transform:uppercase;letter-spacing:.09em;color:#777} +@media(max-width:1000px){.art-grid{grid-template-columns:repeat(3,1fr)}.similar-grid{grid-template-columns:repeat(3,1fr)}.artwork-hero{grid-template-columns:1fr}.artwork-image-stage{min-height:450px}.hero{grid-template-columns:1fr}.hero-orbit{display:none}.provenance>div{grid-template-columns:repeat(2,1fr)}} +@media(max-width:650px){.site-header{height:62px}.hero{padding-top:60px;min-height:600px}.hero h1{font-size:60px}.art-grid{grid-template-columns:repeat(2,1fr);gap:30px 12px}.stat-strip{grid-template-columns:1fr;padding-block:18px}.stat-strip div{border-right:0;border-bottom:1px solid var(--line);padding:12px 0}.similar-grid{grid-template-columns:repeat(2,1fr)}.palette-labels{grid-template-columns:1fr}.tone-top{display:block}.note-display{text-align:left}.provenance>div,.about-grid{grid-template-columns:1fr}.deep-callout{align-items:flex-start;gap:18px;flex-direction:column}footer{flex-direction:column;gap:8px}.artwork-image-stage{padding:20px;min-height:360px}.collection{padding-top:60px}} +@media(prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..cf3e7ed --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,29 @@ +export type ArtworkCard = { + smk_entity_id: string; object_number: string; display_title: string; + artist_display?: string; date_text?: string; classification?: string; + image_thumbnail?: string; centroid_chroma?: number; dominant_color_hex?: string; +}; + +export type Tone = { + profile: string; version: string; available: boolean; reason?: string; note?: string; + octave?: number; midi?: number; frequency_hz?: number; velocity?: number; attack?: number; + release?: number; harmonic_richness?: number; noise_amount?: number; tonal_purity?: number; + stereo_pan?: number; explanation?: Record; +}; + +export type PaletteNote = { hex: string; weight: number; note: string; octave: number; + frequency_hz: number; velocity: number; duration_seconds: number }; + +export type Artwork = ArtworkCard & { + title_da?: string; title_en?: string; image_native?: string; image_width?: number; + image_height?: number; rights_semantic?: string; net_width_cm?: number; net_height_cm?: number; + quality_flags: string[]; + weighted_palette: Array<{ palette_rank: number; hex: string; token_count: number; weight: number; + oklab_l: number; oklch_c: number; oklch_h_deg?: number }>; + weighted_features: Record; + sonification: { weighted_color: Tone; physical: Tone; + palette_arpeggio: { available: boolean; notes: PaletteNote[]; explanation: string } }; + provenance: Record; +}; + +export type SimilarItem = ArtworkCard & { rank: number; distance: number; active_components: string[] }; diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..e935337 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..82a8007 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..513dead --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { proxy: { "/api": "http://localhost:8000" } }, +});