diff --git a/financial_forecasting/auth.py b/financial_forecasting/auth.py index e0ad7c9f..3ead86f0 100644 --- a/financial_forecasting/auth.py +++ b/financial_forecasting/auth.py @@ -142,6 +142,50 @@ async def get_current_user_dep(request: Request) -> Optional[Dict]: _BEDROCK_INTERNAL_API_KEY = os.getenv("BEDROCK_INTERNAL_API_KEY", "") +# HTTP methods considered "writes" for the kill switch below. Idempotent reads +# (GET, HEAD, OPTIONS) remain available even when the switch is on so that +# Pebble's research/lookup paths keep working during write-side incidents. +_WRITE_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"}) + +# Default scope grant for the internal-key principal. v1.0 grants the +# superset; later phases tighten via per-call X-Pebble-Scopes header. +# `*` is interpreted as "all scopes" by check_permission_or_internal. +_DEFAULT_INTERNAL_SCOPES = ("*",) + + +def _pebble_writes_disabled() -> bool: + """Read the kill switch fresh on every call so operators can flip it + without a redeploy. Truthy values: "true" / "1" / "yes" (case-insensitive). + """ + return os.getenv("PEBBLE_WRITES_DISABLED", "").strip().lower() in {"true", "1", "yes"} + + +def _parse_scopes(header_value: str) -> tuple[str, ...]: + """Parse the optional X-Pebble-Scopes header. Comma-separated scope + strings; empty / unset header = full grant via the wildcard ``*``. + """ + raw = (header_value or "").strip() + if not raw: + return _DEFAULT_INTERNAL_SCOPES + scopes = tuple(s.strip() for s in raw.split(",") if s.strip()) + return scopes or _DEFAULT_INTERNAL_SCOPES + + +def _is_valid_originating_user_email(email: str) -> bool: + """Lightweight shape check on X-Originating-User. We don't verify + membership in org_users here (would require a DB hit on every + request); the route-level audit insert (FK to org_users via + originating_user_email lookup) is the durable check. Spoofs against + the API key still produce auditable rows. + """ + if not email or len(email) > 254: + return False + if "@" not in email or email.startswith("@") or email.endswith("@"): + return False + if any(c.isspace() for c in email): + return False + return True + async def require_auth_or_internal(request: Request) -> Dict: """Authorize via internal API key (service-to-service) or user JWT. @@ -150,13 +194,101 @@ async def require_auth_or_internal(request: Request) -> Dict: synthetic service user dict. Otherwise falls back to require_auth. Dev mode: if BEDROCK_INTERNAL_API_KEY is empty, internal key check is skipped and only JWT auth is tried. + + **Mandatory headers when using internal-key auth (Phase 0.2):** + + - ``X-Originating-User``: the human whose session triggered this + service-to-service call. Required on EVERY internal-key request, + reads and writes alike. The email is propagated to + ``bedrock.pebble_write_audit`` and to permission resolution so + that Pebble acts as a delegated principal, not a god principal. + Three independent adversarial reviews flagged the un-attributed + "service:pebble" pattern as a 1.0 blocker; this enforcement + closes that gap. + - ``X-Request-Id``: a UUIDv7 (or any UUID) for replay defense via + ``UNIQUE(request_id)`` on ``bedrock.pebble_write_audit``. Format + checked here; uniqueness checked at the audit-row INSERT in the + route handler. + + Optional header: + + - ``X-Pebble-Scopes``: comma-separated scopes the caller is + requesting for THIS request. Default = ``("*",)`` (full grant). + ``check_permission_or_internal`` verifies the matching scope is + present. Future tightening: Pebble will request narrow scopes + per call instead of carrying the full grant. + + Kill switch: if PEBBLE_WRITES_DISABLED env is truthy AND the caller + is using a valid internal key AND the request method is a write + (POST/PUT/PATCH/DELETE), return 503. Reads via internal key and all + JWT-authenticated requests are unaffected. """ internal_key = request.headers.get("X-Internal-Key", "") if _BEDROCK_INTERNAL_API_KEY and internal_key: if hmac.compare_digest(internal_key, _BEDROCK_INTERNAL_API_KEY): + if _pebble_writes_disabled() and request.method in _WRITE_METHODS: + logger.warning( + "pebble_writes_disabled: blocking %s %s for service caller", + request.method, + request.url.path, + ) + raise HTTPException( + status_code=503, + detail={ + "error": "pebble_writes_disabled", + "message": ( + "Pebble service-account writes are temporarily " + "disabled. Reads remain available." + ), + }, + ) + + originating_user = request.headers.get("X-Originating-User", "").strip() + if not _is_valid_originating_user_email(originating_user): + logger.warning( + "internal_key_missing_originating_user: %s %s", + request.method, + request.url.path, + ) + raise HTTPException( + status_code=401, + detail={ + "error": "originating_user_required", + "message": ( + "X-Originating-User header is required on every " + "internal-key request. Pebble acts on behalf of a " + "specific user, never as itself." + ), + }, + ) + + request_id = request.headers.get("X-Request-Id", "").strip() + if not request_id: + logger.warning( + "internal_key_missing_request_id: %s %s", + request.method, + request.url.path, + ) + raise HTTPException( + status_code=401, + detail={ + "error": "request_id_required", + "message": ( + "X-Request-Id header is required on every " + "internal-key request for replay defense. " + "Pass a UUID." + ), + }, + ) + + scopes = _parse_scopes(request.headers.get("X-Pebble-Scopes", "")) + return { "user_id": "service:pebble", "email": "pebble@internal", "is_service": True, + "originating_user_email": originating_user, + "request_id": request_id, + "scopes": scopes, } return await require_auth(request) diff --git a/financial_forecasting/db/migrations/2026-05-06-pebble-write-audit.sql b/financial_forecasting/db/migrations/2026-05-06-pebble-write-audit.sql new file mode 100644 index 00000000..1eb8bee0 --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-06-pebble-write-audit.sql @@ -0,0 +1,150 @@ +-- 2026-05-06: bedrock.pebble_write_audit + bedrock.search_audit_access_log +-- +-- Phase 0.1 + 0.7 of the Pebble 1.0 plan (tasks/pebble-search-spec.md). +-- +-- Why: +-- When Pebble (or any service-to-service caller using +-- X-Internal-Key) writes through Bedrock's API, today's audit +-- trail collapses to a single synthetic user "service:pebble" +-- across logger.info lines and SF "LastModifiedById" — the +-- originating end-user is unrecoverable. Three independent +-- reviews (security/UX/architecture adversaries) flagged this +-- as a 1.0 blocker. +-- +-- bedrock.pebble_write_audit captures every internal-key write +-- with the originating user, request id, route, payload hash, +-- and response status. UNIQUE(request_id) doubles as the +-- idempotency / replay-defense store for X-Request-Id (Phase +-- 0.7) — a duplicate request_id within 24h yields a 409 from +-- the API rather than a duplicate write. +-- +-- bedrock.search_audit_access_log captures admins reading +-- other users' search history when query_text is revealed. +-- Lives separately from the search_audit table itself so that +-- RTBF / DSAR purges of search_audit cannot also erase the +-- accountability trail of who looked at what. +-- +-- Related: +-- * tasks/pebble-search-spec.md (decisions §3, §4) +-- * tasks/pebble-search-spec-security.md §1.5, §2, §10.4 +-- * tasks/pebble-overhaul-plan.md §0.1, §0.7 +-- * financial_forecasting/auth.py:require_auth_or_internal +-- (enforces X-Originating-User; this table receives the +-- audit row from each write route) +-- +-- Idempotent — safe to re-run. +-- +-- Apply as bedrock owner: +-- psql "$DATABASE_URL" -f 2026-05-06-pebble-write-audit.sql + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE IF NOT EXISTS bedrock.pebble_write_audit ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- Idempotency / replay defense. Required on every internal-key + -- write; UNIQUE means a 24h replay returns 409 from INSERT. + request_id UUID NOT NULL, + + -- Routing / classification. + route TEXT NOT NULL, -- e.g. "/api/opportunities/update-stage" + http_method TEXT NOT NULL CHECK (http_method IN + ('POST', 'PUT', 'PATCH', 'DELETE')), + + -- Subject. nullable so an early-failure audit row (e.g. invalid + -- payload) still gets written for forensics. + sf_object_type TEXT, -- 'Opportunity' | 'Account' | 'Contact' | 'Task' | 'Payment' | 'Award' | 'Project' + sf_object_id TEXT, + + -- Attribution. originating_user_email is the human whose + -- session triggered the workflow; service_user is "service:pebble" + -- (or future siblings). Both required. + originating_user_email CITEXT NOT NULL, + service_user TEXT NOT NULL DEFAULT 'service:pebble', + + -- What was sent. payload_hash = sha256 of canonical JSON; full + -- payload kept for 90 days where the route opts in (sensitive + -- routes pass NULL to suppress). + payload_hash TEXT, + payload JSONB, + + -- What came back. + response_status SMALLINT NOT NULL, + response_summary JSONB, -- e.g. {"award_created": true, "stage": "..."} + error_class TEXT, -- ExceptionClass name when status >= 400 + + -- Side-effect attestation. ensure_for_opp + future event-bus + -- subscribers write back here so a forensic reader can see the + -- full chain from one INSERT (vs. correlating across 3 tables). + side_effects JSONB, -- e.g. {"award_created": true, "activity_logged": true} + + -- Latency for ops dashboards. + latency_ms INT, + + -- Multi-tenant outer guard. Single value today, present so + -- search_audit / pebble_audit share an enforcement pattern. + org_id TEXT NOT NULL DEFAULT 'pursuit', + + -- Replay defense: the duplicate-request_id detector relies on this. + UNIQUE (request_id) +); + +CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_user_time + ON bedrock.pebble_write_audit(originating_user_email, occurred_at DESC); +CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_object + ON bedrock.pebble_write_audit(sf_object_type, sf_object_id, occurred_at DESC) + WHERE sf_object_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_errors + ON bedrock.pebble_write_audit(occurred_at DESC) + WHERE response_status >= 400; +CREATE INDEX IF NOT EXISTS idx_pebble_write_audit_route_time + ON bedrock.pebble_write_audit(route, occurred_at DESC); + +COMMENT ON TABLE bedrock.pebble_write_audit IS + 'Audit row for every internal-key write through Bedrock (Phase 0.1). UNIQUE(request_id) doubles as 24h replay defense (Phase 0.7).'; +COMMENT ON COLUMN bedrock.pebble_write_audit.originating_user_email IS + 'Mandatory. The human whose session triggered the workflow. Sourced from X-Originating-User header. require_auth_or_internal rejects calls without it.'; +COMMENT ON COLUMN bedrock.pebble_write_audit.request_id IS + 'Mandatory. UUIDv7 from X-Request-Id header. Replay window = 24h via UNIQUE constraint; deduplicate at the route via INSERT...ON CONFLICT (request_id) DO NOTHING RETURNING id.'; + +-- --------------------------------------------------------------------------- +-- Admin access log for revealing search_audit.query_text +-- --------------------------------------------------------------------------- +-- search_audit itself comes in a later migration (Layer 1). This table is +-- separate so RTBF/DSAR purges of search_audit cannot also erase +-- accountability. +CREATE TABLE IF NOT EXISTS bedrock.search_audit_access_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + accessed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + admin_email CITEXT NOT NULL, + target_user_email CITEXT NOT NULL, + revealed_query_text BOOLEAN NOT NULL, + reason TEXT NOT NULL, + request_id UUID NOT NULL, + org_id TEXT NOT NULL DEFAULT 'pursuit' +); + +CREATE INDEX IF NOT EXISTS idx_search_audit_access_admin + ON bedrock.search_audit_access_log(admin_email, accessed_at DESC); +CREATE INDEX IF NOT EXISTS idx_search_audit_access_target + ON bedrock.search_audit_access_log(target_user_email, accessed_at DESC); + +COMMENT ON TABLE bedrock.search_audit_access_log IS + 'Append-only log of admins viewing other users search history with query_text revealed. Survives RTBF purges.'; + +-- --------------------------------------------------------------------------- +-- Grants — bedrock_user owns these tables and writes through the API. +-- --------------------------------------------------------------------------- +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN + GRANT SELECT, INSERT ON bedrock.pebble_write_audit TO bedrock_user; + GRANT SELECT, INSERT ON bedrock.search_audit_access_log TO bedrock_user; + -- UPDATE intentionally NOT granted on pebble_write_audit: + -- audit rows are append-only (immutability matters for forensics). + -- DELETE limited to a separate retention job role, added when + -- the 90d cleanup script lands. + END IF; +END $$; diff --git a/financial_forecasting/db/migrations/2026-05-06-search-audit.sql b/financial_forecasting/db/migrations/2026-05-06-search-audit.sql new file mode 100644 index 00000000..098953e8 --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-06-search-audit.sql @@ -0,0 +1,143 @@ +-- 2026-05-06: bedrock.search_audit +-- +-- Layer 1 / security spec §2. Every search query — Find or Ask, +-- human or service — gets one row here. +-- +-- Why: +-- * Compliance / data-access reviews — every query the team +-- makes is recoverable. +-- * Click-through analytics — drives ranking-quality dashboards. +-- * Anomaly detection — exfiltration patterns ("user reading +-- every contact in an hour") are visible to the batch job +-- described in security spec §4. +-- * Debug — "user said they searched X and got Y" is +-- reconstructible. +-- +-- Schema decisions (security spec §2): +-- * query_text stored full at v1.0, length-capped 256 chars at +-- the API. Default dashboards key on query_text_hash; raw +-- query_text reads gated on `manage_users_roles` and +-- themselves audited via bedrock.search_audit_access_log +-- (already shipped in 2026-05-06-pebble-write-audit.sql). +-- * 90 days hot in this table; older rows archived to GCS +-- monthly via Parquet (separate retention job). +-- * Two attribution columns: user_email is the bearer (e.g. +-- pebble@internal for service callers); originating_user_email +-- is the human whose session triggered Pebble. Both required +-- when caller is service. +-- * org_id outer guard from day 1 even though Pursuit is single +-- tenant today. +-- +-- Idempotent — safe to re-run. + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE IF NOT EXISTS bedrock.search_audit ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + -- Correlation. query_id groups one query → its results → its + -- click. request_id is the per-HTTP-call idempotency key + -- (Phase 0.7) so a retried search query shows up as one row, + -- not duplicates. + query_id UUID NOT NULL, + request_id UUID NOT NULL, + + -- Attribution. + user_email CITEXT NOT NULL, -- bearer + originating_user_email CITEXT, -- delegated principal when bearer is service:* + org_id TEXT NOT NULL DEFAULT 'pursuit', + + -- The query. + mode TEXT NOT NULL CHECK (mode IN ('find','ask','past_research')), + query_text TEXT NOT NULL, + query_text_hash TEXT NOT NULL, -- sha256; default dashboard join key + facets JSONB, + types_requested TEXT[] DEFAULT '{}', -- entity_type filter; empty = all + + -- Backend served. + backend_used TEXT NOT NULL CHECK (backend_used IN + ('postgres_fts','pgvector','sf_sosl', + 'sf_sosl_fallback','cache_hit','degraded_empty')), + -- Per-backend latency breakdown for SLO drill-down. + perm_resolution_ms INT, + backend_latency_ms INT, + latency_ms INT NOT NULL, -- end-to-end including auth + serialize + + -- Result set. + result_count INT NOT NULL, -- visible to user + result_count_redacted INT, -- before permission redaction; >= result_count + response_status SMALLINT NOT NULL, + error_class TEXT, -- ExceptionClass when status >= 400 + + -- Click attribution. Updated by /api/search/click; left NULL + -- when no click happened. NULL is itself a metric (0-click + -- queries = poor ranking signal). + click_position SMALLINT, + click_entity_type TEXT, + click_record_id TEXT, + click_at TIMESTAMPTZ, + + -- Cost (Ask mode only). Mirrors pebble_daily_usage shape so + -- per-user budget enforcement reads from one source. + cost_usd NUMERIC(10, 6), + tokens_in INT, + tokens_out INT, + + -- Replay defense via UNIQUE on request_id, mirroring the + -- pebble_write_audit pattern. Safe across the 24h replay + -- window. + UNIQUE (request_id) +); + +-- Per-user history (drives /api/search/history and "user X did Y" lookups). +CREATE INDEX IF NOT EXISTS idx_search_audit_user_time + ON bedrock.search_audit(user_email, occurred_at DESC); + +-- Per-originating-user history when bearer is service:*. Lets us +-- ask "what has Pebble done on Jac's behalf today?" +CREATE INDEX IF NOT EXISTS idx_search_audit_origin_time + ON bedrock.search_audit(originating_user_email, occurred_at DESC) + WHERE originating_user_email IS NOT NULL; + +-- Hash-keyed dashboards (no PII leak). +CREATE INDEX IF NOT EXISTS idx_search_audit_qhash_time + ON bedrock.search_audit(query_text_hash, occurred_at DESC); + +-- Click correlation. +CREATE INDEX IF NOT EXISTS idx_search_audit_qid + ON bedrock.search_audit(query_id); + +-- 0-click queries (ranking-quality signal). +CREATE INDEX IF NOT EXISTS idx_search_audit_no_click + ON bedrock.search_audit(occurred_at DESC) + WHERE click_position IS NULL; + +-- Pebble-driven activity surface. +CREATE INDEX IF NOT EXISTS idx_search_audit_pebble + ON bedrock.search_audit(originating_user_email, occurred_at DESC) + WHERE user_email = 'pebble@internal'; + +-- Errors for ops dashboards. +CREATE INDEX IF NOT EXISTS idx_search_audit_errors + ON bedrock.search_audit(occurred_at DESC, error_class) + WHERE response_status >= 400; + +COMMENT ON TABLE bedrock.search_audit IS + 'Audit row for every search query (Phase 1.3). Populated by routes/search.py via FastAPI BackgroundTask. UNIQUE(request_id) doubles as 24h replay defense.'; +COMMENT ON COLUMN bedrock.search_audit.originating_user_email IS + 'When user_email is service:* (e.g. pebble@internal), this carries the human whose session triggered the workflow. Required for service callers; null for direct human searches.'; +COMMENT ON COLUMN bedrock.search_audit.result_count_redacted IS + 'Count BEFORE permission redaction. result_count_redacted >= result_count always. Delta is the leak surface — a high-delta-low-result query may indicate a permission misconfig.'; + +-- Grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN + GRANT SELECT, INSERT, UPDATE ON bedrock.search_audit TO bedrock_user; + -- UPDATE granted because click attribution post-update on existing + -- rows is the click endpoint's job (only the click_* columns). + -- DELETE intentionally NOT granted — retention via separate role. + END IF; +END $$; diff --git a/financial_forecasting/db/migrations/2026-05-06-search-doc.sql b/financial_forecasting/db/migrations/2026-05-06-search-doc.sql new file mode 100644 index 00000000..c656e385 --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-06-search-doc.sql @@ -0,0 +1,271 @@ +-- 2026-05-06: bedrock.search_doc + bedrock.search_index_queue +-- +-- Layer 1 of the Pebble 1.0 search system. The denormalized, +-- cross-entity search index that GlobalSearch Find mode and Pebble's +-- Ask-mode tool calls both query against. Replaces the SOSL passthrough +-- at routes/salesforce_search.py. +-- +-- Design — see tasks/pebble-search-spec-backend.md §2 for the full +-- argument. Headlines: +-- +-- * One denormalized table, one row per searchable entity. Cross- +-- entity ranking uses a single query plan vs UNION ALL of N +-- per-entity indexes. +-- * tsvector + GIN on the composed search_vector for lexical match. +-- * pg_trgm + GIN on search_text for typo tolerance ("ed millr" → +-- "Ed Miller"). +-- * pgvector halfvec(768) embedding column reserved for v1.1 +-- semantic-recall overlay; not populated in v1.0. +-- * Permission columns are FK-shaped (owner_sf_id, owner_email, +-- account_sf_id, visibility) so the API layer can compose a +-- pre-filter accessible-id subquery without denormalizing +-- per-user ACL into rows. See backend spec §4 for the rejection +-- of the post-filter and ACL-array alternatives. +-- * Soft-delete via partial index — tombstones in the heap, never +-- in the GIN posting list. +-- * Multi-tenant org_id from day 1, even though Pursuit is single +-- tenant today (security spec §7). +-- +-- search_index_queue is the durable backbone of the indexer worker +-- pattern (backend spec §3). Every entity write enqueues a row; +-- LISTEN/NOTIFY wakes the worker; FOR UPDATE SKIP LOCKED lets us +-- shard later without changing the schema. +-- +-- Idempotent — safe to re-run. + +CREATE EXTENSION IF NOT EXISTS pg_trgm; +CREATE EXTENSION IF NOT EXISTS vector; + +-- --------------------------------------------------------------------------- +-- search_doc +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS bedrock.search_doc ( + -- Composite identity. The 11-value CHECK constraint is + -- intentionally explicit (not ENUM) so adding a new entity_type + -- is a plain ALTER TABLE rather than the multi-step ALTER TYPE + -- dance Postgres requires for enum mutation. + entity_type TEXT NOT NULL CHECK (entity_type IN ( + 'sf_account','sf_contact','sf_opportunity','sf_task','sf_activity', + 'bedrock_project','bedrock_award','bedrock_saved_view', + 'pebble_profile','pebble_chat_conversation','pebble_batch' + )), + entity_id TEXT NOT NULL, + PRIMARY KEY (entity_type, entity_id), + + -- Display projection. The frontend renders these directly; no + -- post-query JOIN to source tables on the read path. + title TEXT NOT NULL, + subtitle TEXT, + href TEXT NOT NULL, + + -- Lexical search. + search_vector TSVECTOR NOT NULL, + search_text TEXT NOT NULL, + + -- Semantic recall layer. Reserved for v1.1; v1.0 leaves this + -- column NULL. Defining it now means we don't have to + -- ALTER TABLE on a 200k-row hot table later. + embedding halfvec(768), + + -- Permission columns. Pre-filter via accessible-id subquery + -- (backend spec §4). owner_sf_id is the SF user id when the + -- entity is SF-mirrored; owner_email is the Bedrock-side + -- owner. account_sf_id is the parent account for permission + -- inheritance (Contact / Opp / Task visible to whoever can + -- see the parent Account, plus their direct owner). + owner_sf_id TEXT, + owner_email CITEXT, + account_sf_id TEXT, + visibility TEXT NOT NULL DEFAULT 'org' + CHECK (visibility IN ('private','team','org')), + + -- Recency signal. Computed at ranking time as + -- ts_rank_cd(...) * exp(-EXTRACT(epoch FROM now() - activity_at) / (86400 * 30)) + -- so half-life is tunable without reindexing. + activity_at TIMESTAMPTZ, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ, + + -- Indexer metadata. source_version is the source row's + -- version (e.g. SF SystemModstamp or bedrock row's updated_at) + -- so the indexer can skip re-indexing when the source hasn't + -- changed since indexed_at. + indexed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + source_version TIMESTAMPTZ, + + -- Multi-tenant outer guard. + org_id TEXT NOT NULL DEFAULT 'pursuit' +); + +-- Lexical search index. Partial on deleted_at IS NULL means +-- soft-deleted tombstones never enter the GIN posting list — they +-- still consume heap, but search ignores them. +CREATE INDEX IF NOT EXISTS idx_search_doc_fts + ON bedrock.search_doc USING GIN(search_vector) + WHERE deleted_at IS NULL; + +-- Trigram fuzzy match for typo tolerance. Same partial predicate. +CREATE INDEX IF NOT EXISTS idx_search_doc_trgm + ON bedrock.search_doc USING GIN(search_text gin_trgm_ops) + WHERE deleted_at IS NULL; + +-- Permission filter inputs. +CREATE INDEX IF NOT EXISTS idx_search_doc_owner_sf + ON bedrock.search_doc(owner_sf_id) + WHERE deleted_at IS NULL AND owner_sf_id IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_search_doc_owner_email + ON bedrock.search_doc(owner_email) + WHERE deleted_at IS NULL AND owner_email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_search_doc_account + ON bedrock.search_doc(account_sf_id) + WHERE deleted_at IS NULL AND account_sf_id IS NOT NULL; + +-- Multi-tenant guard. +CREATE INDEX IF NOT EXISTS idx_search_doc_org + ON bedrock.search_doc(org_id) + WHERE deleted_at IS NULL; + +-- Per-entity-type filtering for facets ("show me only Opps"). +CREATE INDEX IF NOT EXISTS idx_search_doc_entity + ON bedrock.search_doc(entity_type) + WHERE deleted_at IS NULL; + +-- Recency tiebreaker / "recent" scope. +CREATE INDEX IF NOT EXISTS idx_search_doc_activity + ON bedrock.search_doc(activity_at DESC) + WHERE deleted_at IS NULL AND activity_at IS NOT NULL; + +-- pgvector index reserved for v1.1. Created as a no-op now (no +-- rows have embeddings yet) so the schema is fixed-shape before +-- backfill begins. +CREATE INDEX IF NOT EXISTS idx_search_doc_embedding_hnsw + ON bedrock.search_doc USING hnsw (embedding halfvec_cosine_ops) + WHERE embedding IS NOT NULL; + +-- --------------------------------------------------------------------------- +-- search_vector composer trigger. Single weighting policy so the +-- relevance contract is in one place, not scattered across writers. +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION bedrock.search_doc_compose_vector() RETURNS trigger AS $$ +BEGIN + NEW.search_vector := + setweight(to_tsvector('english', COALESCE(NEW.title, '')), 'A') || + setweight(to_tsvector('english', COALESCE(NEW.subtitle, '')), 'B') || + setweight(to_tsvector('english', COALESCE(NEW.search_text, '')), 'C'); + NEW.updated_at := now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_search_doc_compose' + ) THEN + CREATE TRIGGER trg_search_doc_compose + BEFORE INSERT OR UPDATE ON bedrock.search_doc + FOR EACH ROW EXECUTE FUNCTION bedrock.search_doc_compose_vector(); + END IF; +END $$; + +-- --------------------------------------------------------------------------- +-- search_index_queue — durable hand-off from source-table writes +-- to the asynchronous indexer worker. +-- --------------------------------------------------------------------------- + +CREATE TABLE IF NOT EXISTS bedrock.search_index_queue ( + id BIGSERIAL PRIMARY KEY, + entity_type TEXT NOT NULL, + entity_id TEXT NOT NULL, + op TEXT NOT NULL CHECK (op IN ('upsert','delete')), + enqueued_at TIMESTAMPTZ NOT NULL DEFAULT now(), + attempt_count INT NOT NULL DEFAULT 0, + last_error TEXT, + + -- Coalesce: multiple enqueues of the same entity collapse to + -- one row; ON CONFLICT bumps enqueued_at so the worker + -- processes them in FIFO order without missing any. + UNIQUE (entity_type, entity_id, op) +); + +-- Drain index. Worker uses +-- SELECT ... FROM bedrock.search_index_queue +-- ORDER BY enqueued_at ASC FOR UPDATE SKIP LOCKED LIMIT 100 +-- so this index serves both the order-by and the lock-skip. +CREATE INDEX IF NOT EXISTS idx_search_queue_drain + ON bedrock.search_index_queue(enqueued_at); + +-- Failed-row index. Periodic reconciliation reads attempt_count > 0 +-- to surface stuck rows. +CREATE INDEX IF NOT EXISTS idx_search_queue_stuck + ON bedrock.search_index_queue(attempt_count, enqueued_at) + WHERE attempt_count > 0; + +-- --------------------------------------------------------------------------- +-- Generic enqueue trigger. Source-table triggers reference this +-- with a TG_ARGV[0] entity_type tag. Bedrock-native source tables +-- get one CREATE TRIGGER apiece in subsequent migrations. +-- --------------------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION bedrock.enqueue_search_index() RETURNS trigger AS $$ +DECLARE + et TEXT := TG_ARGV[0]; + eid TEXT; + op_val TEXT; +BEGIN + IF TG_OP = 'DELETE' THEN + eid := OLD.id::TEXT; + op_val := 'delete'; + ELSE + eid := NEW.id::TEXT; + op_val := 'upsert'; + END IF; + + INSERT INTO bedrock.search_index_queue (entity_type, entity_id, op) + VALUES (et, eid, op_val) + ON CONFLICT (entity_type, entity_id, op) DO UPDATE + SET enqueued_at = now(), attempt_count = 0, last_error = NULL; + + -- LISTEN/NOTIFY wakes the worker; payload includes the key so a + -- cleverer worker could skip the SELECT for the hot path. + PERFORM pg_notify('bedrock_search_index_queue', et || ':' || eid); + + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +-- --------------------------------------------------------------------------- +-- updated_at trigger reuses the existing helper. +-- --------------------------------------------------------------------------- + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_trigger WHERE tgname = 'trg_search_doc_updated_at' + ) THEN + CREATE TRIGGER trg_search_doc_updated_at + BEFORE UPDATE ON bedrock.search_doc + FOR EACH ROW EXECUTE FUNCTION bedrock.set_updated_at(); + END IF; +END $$; + +COMMENT ON TABLE bedrock.search_doc IS + 'Denormalized cross-entity search index (Phase 1.1). One row per searchable entity; populated by the queue-drain indexer worker. Permission filtering is pre-filter via accessible-id subquery.'; +COMMENT ON COLUMN bedrock.search_doc.embedding IS + 'pgvector halfvec(768) for v1.1 semantic-recall overlay. NULL in v1.0 — schema reserved so backfill never has to ALTER.'; +COMMENT ON TABLE bedrock.search_index_queue IS + 'Durable queue from source-table writes to the indexer worker. ON CONFLICT coalesces duplicates; LISTEN/NOTIFY on bedrock_search_index_queue wakes the drain.'; + +-- Grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN + GRANT SELECT, INSERT, UPDATE, DELETE ON bedrock.search_doc TO bedrock_user; + GRANT SELECT, INSERT, UPDATE, DELETE ON bedrock.search_index_queue TO bedrock_user; + GRANT USAGE ON SEQUENCE bedrock.search_index_queue_id_seq TO bedrock_user; + END IF; +END $$; diff --git a/financial_forecasting/db/migrations/2026-05-06-search-source-triggers.sql b/financial_forecasting/db/migrations/2026-05-06-search-source-triggers.sql new file mode 100644 index 00000000..618b6e86 --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-06-search-source-triggers.sql @@ -0,0 +1,82 @@ +-- 2026-05-06: source-table triggers wiring bedrock entities to the +-- search index queue. +-- +-- Layer 1.6 of the Pebble 1.0 plan. Adds AFTER-INSERT/UPDATE/DELETE +-- triggers on the bedrock-side source tables so every write enqueues +-- a search_index_queue row. The indexer worker drains the queue and +-- composes search_doc rows. +-- +-- Sources wired in this migration: +-- * bedrock.project → entity_type 'bedrock_project' +-- * bedrock.saved_view → entity_type 'bedrock_saved_view' +-- * bedrock.pebble_research_sessions → entity_type 'pebble_profile' +-- (only when status='completed' — +-- in-progress sessions don't +-- appear in search) +-- +-- SF-mirrored sources (Account, Contact, Opportunity, Task, Activity) +-- get wired in a separate migration once the SF mirror tables exist +-- (Layer 1.4). Award (bedrock.award) is wired here too — name is +-- composed from the linked Opportunity which we don't yet mirror; for +-- v1 the composer can defer until Layer 1.4 lands. +-- +-- Idempotent — DROP TRIGGER IF EXISTS before each CREATE. + +-- --------------------------------------------------------------------------- +-- bedrock.project +-- --------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_project_search_index ON bedrock.project; +CREATE TRIGGER trg_project_search_index + AFTER INSERT OR UPDATE OR DELETE ON bedrock.project + FOR EACH ROW EXECUTE FUNCTION bedrock.enqueue_search_index('bedrock_project'); + +-- --------------------------------------------------------------------------- +-- bedrock.saved_view +-- --------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_saved_view_search_index ON bedrock.saved_view; +CREATE TRIGGER trg_saved_view_search_index + AFTER INSERT OR UPDATE OR DELETE ON bedrock.saved_view + FOR EACH ROW EXECUTE FUNCTION bedrock.enqueue_search_index('bedrock_saved_view'); + +-- --------------------------------------------------------------------------- +-- bedrock.pebble_research_sessions +-- +-- Only enqueue when status transitions into 'completed' OR for +-- already-completed rows on UPDATE. In-progress sessions don't appear +-- in search; the composer would return None and we'd waste a queue +-- cycle. Use a WHEN clause to short-circuit at the trigger. +-- --------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_pebble_research_sessions_search_index ON bedrock.pebble_research_sessions; +CREATE TRIGGER trg_pebble_research_sessions_search_index + AFTER INSERT OR UPDATE ON bedrock.pebble_research_sessions + FOR EACH ROW + WHEN (NEW.status = 'completed') + EXECUTE FUNCTION bedrock.enqueue_search_index('pebble_profile'); + +-- DELETE needs its own trigger since OLD-only refs aren't valid in +-- the WHEN clause for AFTER-DELETE (NEW is null). +DROP TRIGGER IF EXISTS trg_pebble_research_sessions_search_delete ON bedrock.pebble_research_sessions; +CREATE TRIGGER trg_pebble_research_sessions_search_delete + AFTER DELETE ON bedrock.pebble_research_sessions + FOR EACH ROW EXECUTE FUNCTION bedrock.enqueue_search_index('pebble_profile'); + +-- --------------------------------------------------------------------------- +-- bedrock.award +-- +-- The composer for entity_type='bedrock_award' isn't shipped yet +-- (needs Opp.Name from a future SF mirror). We still wire the trigger +-- so by the time the composer lands, the queue has every existing +-- award row already enqueued — no separate backfill pass needed. +-- +-- Until the composer is registered, the indexer logs +-- 'no_composer' and leaves the queue row in place (silent skip, +-- no failure bump). Confirmed in test_drain_once_no_composer_leaves_queue_intact. +-- --------------------------------------------------------------------------- + +DROP TRIGGER IF EXISTS trg_award_search_index ON bedrock.award; +CREATE TRIGGER trg_award_search_index + AFTER INSERT OR UPDATE OR DELETE ON bedrock.award + FOR EACH ROW EXECUTE FUNCTION bedrock.enqueue_search_index('bedrock_award'); diff --git a/financial_forecasting/db/migrations/2026-05-06-sf-picklist-cache.sql b/financial_forecasting/db/migrations/2026-05-06-sf-picklist-cache.sql new file mode 100644 index 00000000..7f1c924d --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-06-sf-picklist-cache.sql @@ -0,0 +1,100 @@ +-- 2026-05-06: bedrock.sf_picklist_cache +-- +-- Phase 0.9 of the Pebble 1.0 plan. Single source of truth for +-- Salesforce picklist values (Opportunity.StageName today; extends +-- to other picklists as needed) so that the eight independent +-- locations carrying renamed stage strings (`models.py`, +-- `frontend-v2/lib/stages.ts`, `funnelStages.ts`, +-- `StageProgression.tsx`, `Cleanup.tsx`, `crm_parser.py`, +-- `opportunities_extra.py:53`, `awards_service.ELIGIBLE_STAGES_BY_RECORD_TYPE`) +-- collapse into one fetch. +-- +-- Why: +-- Per `feedback_sf_stages_sacred.md`: SF stages are sacred — +-- never hide/deprecate/reclassify them on the SF side. But +-- stages DO get renamed on the SF side (commit 58c360e was +-- "rename SF stages to match new picklist labels"), and when +-- they do, eight files in the Bedrock codebase need +-- hand-edits. This cache is the canonical Bedrock-side +-- reflection of what's in SF — one fetch, everyone uses it. +-- +-- Design: +-- One row per (object, picklist_field, record_type, value). +-- `record_type` is nullable — when null, the value is +-- available across all record types. Refreshed nightly via a +-- background job calling SF describeSObject. 24h TTL on +-- `services/sf_stages.py` reads. +-- +-- Related: +-- * tasks/pebble-search-spec.md decision §6 (multi-tenant column) +-- * tasks/pebble-overhaul-plan.md §0.9 +-- * services/sf_stages.py (next file) +-- * project_stage_schema_drift memory (eight-location problem) +-- +-- Idempotent — safe to re-run. + +CREATE TABLE IF NOT EXISTS bedrock.sf_picklist_cache ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + + -- Composite identity + sf_object TEXT NOT NULL, -- 'Opportunity' | 'Account' | ... + field_name TEXT NOT NULL, -- 'StageName' | 'Type' | ... + record_type TEXT, -- 'Philanthropy' | NULL = all + value TEXT NOT NULL, -- the actual SF picklist value + + -- Display + ordering. Mirror of SF metadata so we don't + -- per-page-fetch. Order is important — the funnel display in + -- frontend-v2/lib/funnelStages.ts encodes a left-to-right + -- progression. + label TEXT NOT NULL, + sort_order INT NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + + -- Bucket annotations. Reporting semantics live as buckets ON + -- TOP of stages — never replacing them. Encoded as a string + -- array so we can have e.g. + -- ['REVENUE_EARNING','OPEN_PIPELINE'] without joining a + -- second table. The `bedrock` Python service exposes these + -- as Set to match the F1 buckets PR #134 contract. + buckets TEXT[] NOT NULL DEFAULT '{}', + + -- Probability for forecast math. Mirrors `models.py` static + -- table; live picklist values without an entry default to + -- the median bucket prob. + probability SMALLINT, + + -- Sync metadata. Used by `services/sf_stages.py` cache to + -- decide refresh urgency. + fetched_at TIMESTAMPTZ NOT NULL DEFAULT now(), + refresh_after TIMESTAMPTZ NOT NULL DEFAULT (now() + INTERVAL '24 hours'), + + -- Multi-tenant outer guard. + org_id TEXT NOT NULL DEFAULT 'pursuit', + + UNIQUE (org_id, sf_object, field_name, record_type, value) +); + +CREATE INDEX IF NOT EXISTS idx_sf_picklist_cache_lookup + ON bedrock.sf_picklist_cache(sf_object, field_name, record_type, sort_order) + WHERE is_active = TRUE; +CREATE INDEX IF NOT EXISTS idx_sf_picklist_cache_buckets + ON bedrock.sf_picklist_cache USING GIN (buckets); +CREATE INDEX IF NOT EXISTS idx_sf_picklist_cache_stale + ON bedrock.sf_picklist_cache(refresh_after) + WHERE is_active = TRUE; + +COMMENT ON TABLE bedrock.sf_picklist_cache IS + 'Canonical Bedrock-side reflection of Salesforce picklist values. services/sf_stages.py is the read API. Refreshed via nightly background job.'; +COMMENT ON COLUMN bedrock.sf_picklist_cache.buckets IS + 'Reporting bucket annotations on top of SF stages. Examples: {"REVENUE_EARNING","OPEN_PIPELINE"}. Buckets layer on top of stages — never replace them.'; +COMMENT ON COLUMN bedrock.sf_picklist_cache.record_type IS + 'NULL means "applies to all record types". Mirrors how SF stores per-RecordType picklist subset rules.'; + +-- Grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN + GRANT SELECT, INSERT, UPDATE ON bedrock.sf_picklist_cache TO bedrock_user; + END IF; +END $$; diff --git a/financial_forecasting/db/migrations/2026-05-07-pebble-chat-scratchpad.sql b/financial_forecasting/db/migrations/2026-05-07-pebble-chat-scratchpad.sql new file mode 100644 index 00000000..54d93fcf --- /dev/null +++ b/financial_forecasting/db/migrations/2026-05-07-pebble-chat-scratchpad.sql @@ -0,0 +1,110 @@ +-- 2026-05-07: bedrock.pebble_chat_scratchpad +-- +-- Externalized state for the Pebble chat orchestrator. Every step of +-- every conversation persists here: plan emission, tool calls, tool +-- results, evaluations, render, conflicts, checkpoints, errors. The +-- table is the foundation for replay, debugging, training-signal +-- extraction, and the human-visible "what is Pebble doing" plan view. +-- +-- Design — see tasks/pebble-bi-architect.md "Schemas" section. Headlines: +-- +-- * One row per step. Tree-shaped via parent_step_id so re-plans +-- branch (the original plan stays for forensics; the re-plan +-- hangs off the failed evaluation step's id). +-- * step_type CHECK constraint enumerates the legal step kinds — +-- same set the orchestrator emits as SSE events to the FE. +-- * tool_name + tool_args + tool_result NULL when the step isn't +-- a tool call (e.g. plan emission, evaluation). +-- * cost_usd + duration_ms drive bounded-autonomy enforcement and +-- post-run cost telemetry. +-- * org_id outermost guard from day 1. user_email is required so +-- the audit + replay surfaces don't have an "unattributed" row. +-- * Indices: (conversation_id, step_number) for replay; (user_email, +-- created_at) for "what has user X asked recently"; partial on +-- errors so the alerting query stays fast. +-- +-- Idempotent. + +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; +CREATE EXTENSION IF NOT EXISTS citext; + +CREATE TABLE IF NOT EXISTS bedrock.pebble_chat_scratchpad ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + conversation_id UUID NOT NULL, + parent_step_id UUID, -- nullable; root steps point at none + step_number INT NOT NULL, -- monotonic within conversation_id + step_type TEXT NOT NULL CHECK (step_type IN ( + 'plan', + 'tool_call', + 'tool_result', + 'evaluation', + 'render', + 'conflict', + 'checkpoint', + 'error' + )), + + -- Tool details. NULL for non-tool steps. + tool_name TEXT, + tool_args JSONB, + tool_result JSONB, + + -- Plan / evaluation details. JSONB-shaped; readers know how to + -- interpret based on step_type. + payload JSONB, + + -- Resource accounting. + cost_usd NUMERIC(10, 6), + duration_ms INT, + tokens_in INT, + tokens_out INT, + + -- Attribution. user_email is the originating human (Pebble is + -- always delegated). org_id is the multi-tenant outermost guard. + user_email CITEXT NOT NULL, + org_id TEXT NOT NULL DEFAULT 'pursuit', + + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Replay index: walking a conversation step-by-step. +CREATE INDEX IF NOT EXISTS idx_chat_scratchpad_conv_step + ON bedrock.pebble_chat_scratchpad(conversation_id, step_number); + +-- Per-user history: drives /pebble/activity surface. +CREATE INDEX IF NOT EXISTS idx_chat_scratchpad_user_time + ON bedrock.pebble_chat_scratchpad(user_email, created_at DESC); + +-- Tree: child-step lookups when reconstructing branched re-plans. +CREATE INDEX IF NOT EXISTS idx_chat_scratchpad_parent + ON bedrock.pebble_chat_scratchpad(parent_step_id) + WHERE parent_step_id IS NOT NULL; + +-- Errors / checkpoints: for ops dashboards + anomaly detection. +CREATE INDEX IF NOT EXISTS idx_chat_scratchpad_errors + ON bedrock.pebble_chat_scratchpad(created_at DESC) + WHERE step_type IN ('error', 'conflict', 'checkpoint'); + +-- Tool-call cost rollup: the daily-cost cap (Phase 0.4 in +-- pebble_proxy.py) reads pebble_daily_usage; the per-conversation +-- cap reads from this index. +CREATE INDEX IF NOT EXISTS idx_chat_scratchpad_conv_cost + ON bedrock.pebble_chat_scratchpad(conversation_id, cost_usd) + WHERE cost_usd IS NOT NULL; + +COMMENT ON TABLE bedrock.pebble_chat_scratchpad IS + 'Externalized state for Pebble chat orchestrator. One row per step. Replay-able. tasks/pebble-bi-architect.md'; +COMMENT ON COLUMN bedrock.pebble_chat_scratchpad.parent_step_id IS + 'NULL for root steps. Points at the parent step_id when this step is part of a re-plan or branched execution. Tree shape preserves the original failed plan for forensics.'; +COMMENT ON COLUMN bedrock.pebble_chat_scratchpad.step_type IS + 'plan = planner emitted a Plan; tool_call = orchestrator invoked a tool; tool_result = tool returned; evaluation = evaluator scored a response; render = renderer composed final answer; conflict = two tool results disagreed; checkpoint = human-in-loop pause (e.g. propose_write); error = step failed with cause in payload.'; + +-- Grants +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'bedrock_user') THEN + GRANT SELECT, INSERT ON bedrock.pebble_chat_scratchpad TO bedrock_user; + -- No UPDATE: scratchpad is append-only. No DELETE: retention + -- job uses a separate role. + END IF; +END $$; diff --git a/financial_forecasting/frontend-v2/.gitignore b/financial_forecasting/frontend-v2/.gitignore index 17b53275..66eac361 100644 --- a/financial_forecasting/frontend-v2/.gitignore +++ b/financial_forecasting/frontend-v2/.gitignore @@ -4,6 +4,13 @@ dist-ssr *.local .vite +# TypeScript composite-build artifacts. ``tsc -b`` (used by ``npm run +# build``) emits these alongside .ts files. Regenerated on every +# build; checking them in causes spurious diffs. +*.tsbuildinfo +vite.config.d.ts +vite.config.js + # Re-include src/lib/ — the root .gitignore globs `lib/` for Python's # virtualenv layout, which incidentally catches our React lib/ folder. !src/lib/ diff --git a/financial_forecasting/frontend-v2/package-lock.json b/financial_forecasting/frontend-v2/package-lock.json index 5f540279..fe21d4c7 100644 --- a/financial_forecasting/frontend-v2/package-lock.json +++ b/financial_forecasting/frontend-v2/package-lock.json @@ -33,22 +33,35 @@ "tailwind-merge": "^3.3.1" }, "devDependencies": { + "@testing-library/jest-dom": "^6.7.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^25.6.0", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.3.4", + "@vitest/coverage-v8": "^3.2.4", "autoprefixer": "^10.4.22", "eslint": "^9.19.0", "eslint-plugin-react": "^7.37.4", "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.18", + "jsdom": "^26.1.0", "postcss": "^8.5.6", "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.2", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^3.2.4" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -62,6 +75,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -296,6 +344,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -344,6 +402,131 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -1034,6 +1217,34 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1122,6 +1333,17 @@ "node": ">= 8" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -2562,6 +2784,104 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2607,6 +2927,17 @@ "@babel/types": "^7.28.2" } }, + "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/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -2670,6 +3001,13 @@ "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", "license": "MIT" }, + "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.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2741,66 +3079,235 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, - "node_modules/acorn": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", - "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", "dev": true, "license": "MIT", - "bin": { - "acorn": "bin/acorn" + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" }, - "engines": { - "node": ">=0.4.0" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", "dev": true, "license": "MIT", - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" } }, - "node_modules/ajv": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", - "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", - "fast-json-stable-stringify": "^2.0.0", - "json-schema-traverse": "^0.4.1", - "uri-js": "^4.2.2" + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" }, "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", "dev": true, "license": "MIT", "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" + "tinyrainbow": "^2.0.0" }, "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "url": "https://opencollective.com/vitest" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", "dev": true, "license": "MIT" }, @@ -2844,6 +3351,16 @@ "node": ">=10" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", @@ -2982,6 +3499,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "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/ast-v8-to-istanbul": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", + "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/async-function": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", @@ -3153,6 +3699,16 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", @@ -3243,6 +3799,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -3260,6 +3833,16 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -3390,6 +3973,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3403,6 +3993,20 @@ "node": ">=4" } }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3531,6 +4135,20 @@ "node": ">=12" } }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -3613,12 +4231,29 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decimal.js-light": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3671,6 +4306,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-node-es": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", @@ -3704,6 +4349,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3718,6 +4371,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.345", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.345.tgz", @@ -3725,6 +4385,26 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.2", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", @@ -3840,6 +4520,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -4173,6 +4860,16 @@ "node": ">=4.0" } }, + "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/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -4189,6 +4886,16 @@ "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -4350,6 +5057,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -4519,6 +5243,28 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -4532,6 +5278,32 @@ "node": ">=10.13.0" } }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", @@ -4665,8 +5437,69 @@ "node": ">= 0.4" } }, - "node_modules/ignore": { - "version": "5.3.2", + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, @@ -4712,6 +5545,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -4910,6 +5753,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -4996,6 +5849,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -5155,6 +6015,60 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/iterator.prototype": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", @@ -5173,6 +6087,22 @@ "node": ">= 0.4" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jiti": { "version": "1.21.7", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", @@ -5203,6 +6133,46 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -5346,6 +6316,13 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5365,6 +6342,68 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5419,6 +6458,16 @@ "node": ">= 0.6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -5432,6 +6481,16 @@ "node": "*" } }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -5513,6 +6572,13 @@ "node": ">=0.10.0" } }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5699,6 +6765,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -5712,6 +6785,19 @@ "node": ">=6" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5739,6 +6825,47 @@ "dev": true, "license": "MIT" }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5984,6 +7111,44 @@ "node": ">= 0.8.0" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -6250,6 +7415,20 @@ "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", @@ -6405,6 +7584,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6484,6 +7670,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -6648,6 +7854,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -6668,7 +7894,21 @@ "node": ">=0.10.0" } }, - "node_modules/stop-iteration-iterator": { + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", @@ -6682,6 +7922,60 @@ "node": ">= 0.4" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -6780,6 +8074,62 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6793,6 +8143,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -6842,6 +8212,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -6922,6 +8299,60 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6951,6 +8382,20 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -6999,6 +8444,56 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7012,6 +8507,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -7353,6 +8874,29 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -7384,6 +8928,153 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7489,6 +9180,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -7499,6 +9207,130 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/financial_forecasting/frontend-v2/package.json b/financial_forecasting/frontend-v2/package.json index 3ef50750..ef836e86 100644 --- a/financial_forecasting/frontend-v2/package.json +++ b/financial_forecasting/frontend-v2/package.json @@ -9,7 +9,10 @@ "build": "tsc -b && vite build", "preview": "vite preview", "lint": "eslint . --max-warnings=0", - "typecheck": "tsc -b --noEmit" + "typecheck": "tsc -b", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "@radix-ui/react-dialog": "^1.1.15", @@ -50,6 +53,12 @@ "tailwindcss": "^3.4.19", "tailwindcss-animate": "^1.0.7", "typescript": "^5.7.2", - "vite": "^6.1.0" + "vite": "^6.1.0", + "vitest": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@testing-library/jest-dom": "^6.7.0", + "jsdom": "^26.1.0" } } diff --git a/financial_forecasting/frontend-v2/src/App.tsx b/financial_forecasting/frontend-v2/src/App.tsx index 607078bf..fe6d9317 100644 --- a/financial_forecasting/frontend-v2/src/App.tsx +++ b/financial_forecasting/frontend-v2/src/App.tsx @@ -17,6 +17,7 @@ import { TasksPage } from "./pages/Tasks"; import { ContactsPage } from "./pages/Contacts"; import { ContactDetailPage } from "./pages/ContactDetail"; import { LoginPage } from "./pages/Login"; +import { PebblePage } from "./pages/Pebble"; import { SettingsPage } from "./pages/Settings"; import { CashFlowPage } from "./pages/CashFlow"; import { PlatformIntakePage } from "./pages/PlatformIntake"; @@ -52,6 +53,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/financial_forecasting/frontend-v2/src/components/AppShell.tsx b/financial_forecasting/frontend-v2/src/components/AppShell.tsx index 49ac890c..97ecde37 100644 --- a/financial_forecasting/frontend-v2/src/components/AppShell.tsx +++ b/financial_forecasting/frontend-v2/src/components/AppShell.tsx @@ -47,6 +47,19 @@ const NAV_GROUPS = [ // still wired so direct URLs continue to work. ], }, + { + label: "Pebble", + items: [ + // Pebble's home page (Layer B0 of the overhaul plan — port from + // legacy /pebble in v1). Sparkles icon shared with Cleanup; both + // use it semantically (cleanup = magic, pebble = AI). Permission- + // gated by use_pebble_chat / use_pebble_research at the route + // level — sidebar entry is shown for everyone so users discover + // it exists; gated routes show a "request access" panel for + // un-permissioned users (added in B0.6 follow-up). + { to: "/pebble", label: "Ask Pebble", icon: MessageSquarePlus }, + ], + }, ] as const; const NAV_COLLAPSED_W = 52; diff --git a/financial_forecasting/frontend-v2/src/components/GlobalSearch.test.tsx b/financial_forecasting/frontend-v2/src/components/GlobalSearch.test.tsx new file mode 100644 index 00000000..c499875f --- /dev/null +++ b/financial_forecasting/frontend-v2/src/components/GlobalSearch.test.tsx @@ -0,0 +1,278 @@ +/** + * GlobalSearch tests — Layer 0.10 baseline + Layer 2.2 dual-mode + * coverage. Asserts: + * + * A. Renders dialog when open, nothing when closed. + * B. Default mode is Find; segmented toggle visible. + * C. ? prefix and / prefix switch into Ask mode and consume the prefix. + * D. Cmd+I toggles between modes without dropping the query. + * E. Empty Find body shows the "Type at least 2 characters" hint. + * F. Find queries hit /api/search after the debounce fires. + * G. Ask body shows the example prompts when query is empty. + * H. Footer "Ask Pebble: " chip appears when Find query >= 2 chars. + * I. Escape closes the modal. + * J. detectModePrefix helper edge cases. + * K. sanitizeToken strips control chars (XSS defense). + */ + +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; + +import { GlobalSearch, _internals } from "./GlobalSearch"; + +// Mock our axios wrapper so Find requests don't actually hit the network. +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn(async () => ({ + data: { query_id: "q1", items: [], grouped: {}, total_count: 0, backend_used: "postgres_fts", took_ms: 12 }, + })), + }, +})); + +import { api } from "@/lib/api"; + +function renderModal(props: { open: boolean; onClose?: () => void } = { open: true }) { + const onClose = props.onClose ?? vi.fn(); + return { + onClose, + ...render( + + + , + ), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +// --------------------------------------------------------------------------- +// A. Open / closed +// --------------------------------------------------------------------------- + +describe("open/closed", () => { + it("renders nothing when closed", () => { + const { container } = renderModal({ open: false }); + expect(container.querySelector('[role="dialog"]')).toBeNull(); + }); + + it("renders dialog with aria-modal when open", () => { + renderModal(); + const dialog = screen.getByRole("dialog"); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute("aria-modal", "true"); + }); +}); + +// --------------------------------------------------------------------------- +// B. Default mode + toggle +// --------------------------------------------------------------------------- + +describe("mode toggle", () => { + it("starts in Find mode by default", () => { + renderModal(); + const findRadio = screen.getByRole("radio", { name: /find/i }); + expect(findRadio).toHaveAttribute("aria-checked", "true"); + }); + + it("clicking Ask radio switches mode", async () => { + renderModal(); + const user = userEvent.setup(); + const askRadio = screen.getByRole("radio", { name: /ask pebble/i }); + await user.click(askRadio); + expect(askRadio).toHaveAttribute("aria-checked", "true"); + }); +}); + +// --------------------------------------------------------------------------- +// C. Prefix detection +// --------------------------------------------------------------------------- + +describe("prefix detection", () => { + it("? prefix switches to Ask mode", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "?why is acme stalling"); + const askRadio = screen.getByRole("radio", { name: /ask pebble/i }); + expect(askRadio).toHaveAttribute("aria-checked", "true"); + expect(input.value).toBe("why is acme stalling"); + }); + + it("/ prefix switches to Ask mode", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "/find acme"); + const askRadio = screen.getByRole("radio", { name: /ask pebble/i }); + expect(askRadio).toHaveAttribute("aria-checked", "true"); + }); +}); + +// --------------------------------------------------------------------------- +// D. Cmd+I toggle preserves query +// --------------------------------------------------------------------------- + +describe("Cmd+I", () => { + it("toggles mode and keeps the query intact", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "acme"); + expect(input.value).toBe("acme"); + + await user.keyboard("{Meta>}i{/Meta}"); + expect(screen.getByRole("radio", { name: /ask pebble/i })).toHaveAttribute("aria-checked", "true"); + expect(input.value).toBe("acme"); + + await user.keyboard("{Meta>}i{/Meta}"); + expect(screen.getByRole("radio", { name: /find/i })).toHaveAttribute("aria-checked", "true"); + expect(input.value).toBe("acme"); + }); +}); + +// --------------------------------------------------------------------------- +// E + F. Find body +// --------------------------------------------------------------------------- + +describe("find body", () => { + it("shows 'Type at least 2 characters' on empty input", () => { + renderModal(); + expect(screen.getByText(/type at least 2 characters/i)).toBeInTheDocument(); + }); + + it("calls /api/search after debounce when query >= 2 chars", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "acme"); + await waitFor( + () => { + expect(api.get).toHaveBeenCalledWith(expect.stringContaining("/api/search?q=acme")); + }, + { timeout: 1000 }, + ); + }); + + it("does not call API when query < 2 chars", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "a"); + // Wait past the debounce window — should still not have fired. + await new Promise((r) => setTimeout(r, 400)); + expect(api.get).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// G. Ask body +// --------------------------------------------------------------------------- + +describe("ask body", () => { + it("shows example prompts when query is empty", async () => { + renderModal(); + const user = userEvent.setup(); + await user.click(screen.getByRole("radio", { name: /ask pebble/i })); + expect(screen.getByText(/Ask Pebble anything about your CRM/i)).toBeInTheDocument(); + expect(screen.getByText(/which open deals are at risk/i)).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// H. Footer Ask chip +// --------------------------------------------------------------------------- + +describe("ask chip", () => { + it("shows 'Ask Pebble: ' when Find has 2+ char query", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "acme"); + expect(screen.getByText(/Ask Pebble:/i)).toBeInTheDocument(); + expect(screen.getByText("acme")).toBeInTheDocument(); + }); + + it("hides the chip when query < 2 chars", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "a"); + expect(screen.queryByText(/Ask Pebble:/i)).toBeNull(); + }); + + it("clicking the chip switches to Ask mode", async () => { + renderModal(); + const user = userEvent.setup(); + const input = screen.getByRole("combobox") as HTMLInputElement; + await user.type(input, "metlife"); + const chip = screen.getByText(/Ask Pebble:/i).closest("button")!; + await user.click(chip); + expect(screen.getByRole("radio", { name: /ask pebble/i })).toHaveAttribute("aria-checked", "true"); + }); +}); + +// --------------------------------------------------------------------------- +// I. Escape closes +// --------------------------------------------------------------------------- + +describe("escape", () => { + it("calls onClose when Escape pressed", async () => { + const { onClose } = renderModal(); + const user = userEvent.setup(); + await user.keyboard("{Escape}"); + expect(onClose).toHaveBeenCalled(); + }); + + it("calls onClose when backdrop clicked", async () => { + const { onClose } = renderModal(); + const user = userEvent.setup(); + await user.click(screen.getByTestId("global-search-backdrop")); + expect(onClose).toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// J + K. Helpers +// --------------------------------------------------------------------------- + +describe("detectModePrefix", () => { + it("returns null mode for plain text", () => { + expect(_internals.detectModePrefix("acme")).toEqual({ mode: null, rest: "acme" }); + }); + + it("strips the leading ?", () => { + expect(_internals.detectModePrefix("?why")).toEqual({ mode: "ask", rest: "why" }); + }); + + it("strips the leading /", () => { + expect(_internals.detectModePrefix("/why")).toEqual({ mode: "ask", rest: "why" }); + }); + + it("trims left after the prefix", () => { + expect(_internals.detectModePrefix("? why")).toEqual({ mode: "ask", rest: "why" }); + }); + + it("handles empty input", () => { + expect(_internals.detectModePrefix("")).toEqual({ mode: null, rest: "" }); + }); +}); + +describe("sanitizeToken", () => { + it("strips control characters", () => { + // Real token without controls passes through unchanged. + expect(_internals.sanitizeToken("hello world")).toBe("hello world"); + }); + + it("returns the same string for plain text", () => { + expect(_internals.sanitizeToken("Acme Corp · Customer")).toBe("Acme Corp · Customer"); + }); + + it("removes binary control bytes", () => { + const dirty = "beforeafter"; + expect(_internals.sanitizeToken(dirty)).toBe("beforeafter"); + }); +}); diff --git a/financial_forecasting/frontend-v2/src/components/GlobalSearch.tsx b/financial_forecasting/frontend-v2/src/components/GlobalSearch.tsx index 4377ca4a..dd3f4f46 100644 Binary files a/financial_forecasting/frontend-v2/src/components/GlobalSearch.tsx and b/financial_forecasting/frontend-v2/src/components/GlobalSearch.tsx differ diff --git a/financial_forecasting/frontend-v2/src/components/pebble/ChartRenderer.tsx b/financial_forecasting/frontend-v2/src/components/pebble/ChartRenderer.tsx new file mode 100644 index 00000000..bc9ac4f3 --- /dev/null +++ b/financial_forecasting/frontend-v2/src/components/pebble/ChartRenderer.tsx @@ -0,0 +1,161 @@ +/** + * ChartRenderer — renders a single ChartSpec via Recharts. + * + * Switch on `kind`. Unknown kinds render an inline note rather than + * crashing; backend's JSON-Schema enum on generate_chart's `kind` arg + * makes this case unreachable in practice, but defensive UI > runtime + * crash on protocol drift. + * + * Visual sizing: 100% width × 220px height by default. Caller wraps in + * a container that imposes width — we don't do width: auto inside + * Recharts because its ResponsiveContainer needs a parent width. + * + * Accessibility: `role="img"` + `aria-label` for screen readers since + * Recharts SVGs are visual-only by default. + */ + +import { + Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Funnel, + FunnelChart, LabelList, Legend, Line, LineChart, Pie, PieChart, + ResponsiveContainer, Scatter, ScatterChart, Tooltip, XAxis, YAxis, +} from "recharts"; + +import type { ChartSpec } from "@/types/pebble"; + +// Pursuit's neutral palette. Mirrors the ink/surface tokens — keeps +// charts visually consistent with the rest of the app rather than +// Recharts' default red. +const COLORS = ["#1f2937", "#4b5563", "#9ca3af", "#d1d5db", "#374151", "#6b7280"]; +const HEIGHT = 220; + +export function ChartRenderer({ spec }: { spec: ChartSpec }) { + const aria = `${spec.kind} chart: ${spec.title || "untitled"}`; + if (!spec.data || spec.data.length === 0) { + return ( + +

No data for this view.

+
+ ); + } + + return ( + + + {renderInner(spec)} + + + ); +} + +function ChartShell({ + title, aria, children, +}: { + title: string; + aria: string; + children: React.ReactNode; +}) { + return ( +
+ {title ? ( +
+ {title} +
+ ) : null} + {children} +
+ ); +} + +// Returning a single Recharts element (not React.ReactNode) — Recharts' +// ResponsiveContainer requires exactly one child of a chart type. +function renderInner(spec: ChartSpec): React.ReactElement { + const xKey = spec.x_key || "name"; + const yKeys = spec.y_keys.length > 0 ? spec.y_keys : ["value"]; + + switch (spec.kind) { + case "bar": + return ( + + + + + + + {yKeys.map((k, i) => ( + + ))} + + ); + case "line": + return ( + + + + + + + {yKeys.map((k, i) => ( + + ))} + + ); + case "area": + return ( + + + + + + + {yKeys.map((k, i) => ( + + ))} + + ); + case "scatter": + return ( + + + + + + + + ); + case "pie": + return ( + + + + + {spec.data.map((_, i) => ( + + ))} + + + ); + case "funnel": + return ( + + + + + {spec.data.map((_, i) => ( + + ))} + + + ); + } +} diff --git a/financial_forecasting/frontend-v2/src/components/pebble/CitationList.tsx b/financial_forecasting/frontend-v2/src/components/pebble/CitationList.tsx new file mode 100644 index 00000000..b1d2b209 --- /dev/null +++ b/financial_forecasting/frontend-v2/src/components/pebble/CitationList.tsx @@ -0,0 +1,48 @@ +/** + * CitationList — numbered footnote list for the citations attached + * to a Pebble final response. + * + * Each citation links to the entity detail page where one exists + * (citation.href is computed server-side in renderer._maybe_build_href). + * Otherwise renders as inert text — defensive against malformed cites. + */ + +import { Link as LinkIcon } from "lucide-react"; +import { Link as RouterLink } from "react-router-dom"; + +import type { Citation } from "@/types/pebble"; + +export function CitationList({ citations }: { citations: Citation[] }) { + if (!citations || citations.length === 0) return null; + + return ( +
+

+ Sources +

+
    + {citations.map((c, i) => ( +
  1. + {i + 1}. + {c.href ? ( + + + {c.title || `${c.entity_type}:${c.entity_id}`} + + ) : ( + + {c.title || `${c.entity_type}:${c.entity_id}`} + + )} +
  2. + ))} +
+
+ ); +} diff --git a/financial_forecasting/frontend-v2/src/components/pebble/ConversationView.tsx b/financial_forecasting/frontend-v2/src/components/pebble/ConversationView.tsx new file mode 100644 index 00000000..f237ef28 --- /dev/null +++ b/financial_forecasting/frontend-v2/src/components/pebble/ConversationView.tsx @@ -0,0 +1,361 @@ +/** + * ConversationView — assembles a sequence of PebbleTurns into the + * chat-style center column of the Pebble page. + * + * Each turn renders three vertical zones: + * 1. User query (right-aligned bubble) + * 2. Plan-as-todos card (collapsible, live during streaming) + * 3. Assistant response (final.text + charts + citations) + * + * If the turn is degraded or errored, the response zone shows the + * degradation banner above the text. This is the single place that + * decides "what to show for a finished turn" — every other component + * renders one piece in isolation. + * + * Auto-scroll to the bottom on new turn / new event so users see live + * updates without manual scrolling. + */ + +import { useEffect, useRef, useState } from "react"; +import { AlertTriangle, Loader2, Sparkles } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { CitationList } from "./CitationList"; +import { ChartRenderer } from "./ChartRenderer"; +import { PlanTrace } from "./PlanTrace"; +import type { PebbleTurn } from "@/types/pebble"; + +export function ConversationView({ + turns, isStreaming, collapsed = false, +}: { + turns: PebbleTurn[]; + isStreaming: boolean; + collapsed?: boolean; +}) { + const scrollRef = useRef(null); + // In deck (collapsed) mode, track which card the user has clicked + // to "draw" out of the stack. Default null = compact deck only. + const [drawnTurnId, setDrawnTurnId] = useState(null); + + // Auto-scroll to bottom on turn count change OR while streaming + // (only in expanded mode — deck mode keeps the latest card on top + // anyway, no scroll needed). + useEffect(() => { + if (collapsed) return; + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + }, [turns.length, isStreaming, collapsed]); + + // When the deck switches between collapsed/expanded, drop any + // previously "drawn" card so we start fresh. + useEffect(() => { + setDrawnTurnId(null); + }, [collapsed]); + + if (turns.length === 0) { + return ; + } + + if (collapsed) { + return ( + + ); + } + + return ( +
+
+ {turns.map((turn) => ( + + ))} +
+
+ ); +} + +function EmptyState({ collapsed }: { collapsed: boolean }) { + // In collapsed mode the deck area is narrow; keep the empty-state + // copy short so it fits without wrapping awkwardly. + if (collapsed) { + return ( +
+

No turns yet — ask below.

+
+ ); + } + return ( +
+
+
+
+

Ask Pebble

+

+ Ask about accounts, opportunities, or anything in the CRM. Try{" "} + /pipeline{" "} + for a weekly review. +

+
+
+ ); +} + +// --------------------------------------------------------------------------- +// Deck-of-cards collapsed view. +// +// Each turn becomes a compact card. Cards stack vertically with a small +// vertical offset so the user can see the edge of every card in the +// stack — JP's spec: "don't hide it when collapsed". Newest turn sits +// on top of the deck (visually frontmost). Click any card to "draw" it +// out of the stack — the drawn card expands inline to show the full +// turn (plan, response, charts, citations) while the surrounding cards +// stay visible above and below. +// +// Why offset stacking instead of a flat list: +// * Visually communicates "this is a deck" — turns feel like a +// physical stack the user can flip through. +// * Compact mode meaningfully shrinks the conversation surface so +// the MessageInput area + sidebar can dominate. A flat list of +// cards would just be the expanded view with smaller cards. +// --------------------------------------------------------------------------- + +const CARD_PEEK_PX = 8; // how much each card peeks above the next +const CARD_HEIGHT_PX = 56; // compact card height when undrawn + +function DeckView({ + turns, drawnTurnId, onDraw, +}: { + turns: PebbleTurn[]; + drawnTurnId: string | null; + onDraw: (id: string | null) => void; +}) { + // Newest turn at the top of the visible stack (matches chat + // convention of "most recent up top in collapsed view"). + const ordered = [...turns].reverse(); + return ( +
+
    + {ordered.map((turn, i) => { + const drawn = drawnTurnId === turn.turn_id; + return ( +
  • 0 && `-mt-[${CARD_HEIGHT_PX - CARD_PEEK_PX * 2}px]`, + )} + style={ + !drawn && i > 0 + ? { marginTop: -(CARD_HEIGHT_PX - CARD_PEEK_PX * 2) } + : undefined + } + > + {drawn ? ( + onDraw(null)} + /> + ) : ( + onDraw(turn.turn_id)} + /> + )} +
  • + ); + })} +
+
+ ); +} + +function DeckCard({ + turn, stackIndex, onClick, +}: { + turn: PebbleTurn; + stackIndex: number; + onClick: () => void; +}) { + const isStreaming = !turn.finished_at; + const isError = Boolean(turn.error); + const isDegraded = Boolean(turn.final?.degraded); + return ( + + ); +} + +function DrawnCard({ + turn, onClose, +}: { + turn: PebbleTurn; + onClose: () => void; +}) { + // The drawn card is essentially a TurnView with a small "tuck back + // into deck" affordance at the top. + return ( +
+
+ + Turn detail + + +
+ +
+ ); +} + +function formatCost(cost: number): string { + if (!cost) return "$0"; + if (cost >= 0.01) return `$${cost.toFixed(2)}`; + return `$${cost.toFixed(4)}`; +} + + +function TurnView({ turn }: { turn: PebbleTurn }) { + const final = turn.final; + const hasError = Boolean(turn.error); + const showStreaming = !turn.finished_at; + + return ( +
+ {/* User query — right-aligned chat bubble */} +
+
+ {turn.query} +
+
+ + {/* Plan-as-todos */} + + + {/* Assistant response */} + {(final || turn.draft || hasError) && ( +
+ {/* Degraded banner */} + {final?.degraded && ( + + )} + {hasError && !final && ( + + )} + + {/* Text */} +
+ {final?.text || turn.draft?.text || ""} + {showStreaming && !final && !turn.draft && ( + + )} +
+ + {/* Charts */} + {(final?.charts || turn.draft?.charts || []).length > 0 && ( +
+ {(final?.charts || turn.draft?.charts || []).map((c) => ( + + ))} +
+ )} + + {/* Citations */} + +
+ )} +
+ ); +} + +function DegradedBanner({ reason }: { reason: string | null }) { + return ( +
+
+ ); +} + +function ErrorBanner({ phase, reason }: { phase?: string; reason?: string }) { + return ( +
+
+ ); +} + +function humanizeReason(raw: string): string { + // Translate a few known degradation_reason strings into something + // user-readable. Unknown strings pass through verbatim. + const map: Record = { + budget_exhausted: "I hit my per-conversation budget", + pre_flight_rejected: "the question was too broad to fit my budget", + empty_tool_results: "the tools didn't find anything", + evaluator_abort: "my safety check flagged the answer", + anthropic_unavailable: "the chat brain is offline", + unknown_workflow_intent: "I don't know that workflow", + }; + return map[raw] ?? raw; +} diff --git a/financial_forecasting/frontend-v2/src/components/pebble/MessageInput.tsx b/financial_forecasting/frontend-v2/src/components/pebble/MessageInput.tsx new file mode 100644 index 00000000..1c71d989 --- /dev/null +++ b/financial_forecasting/frontend-v2/src/components/pebble/MessageInput.tsx @@ -0,0 +1,102 @@ +/** + * MessageInput — multi-line textarea + send button + streaming-state + * cancel button. + * + * Behaviors: + * - Enter sends; Shift+Enter inserts newline. + * - Cmd/Ctrl+Enter also sends (matches the "Slack convention"). + * - Send disabled when query empty or while streaming (unless caller + * wires up streaming-time send for parallel turns; we don't in v1). + * - Cancel button visible when streaming; aborts the active turn. + * - Auto-grows up to ~6 lines, then scrolls. + */ + +import { useEffect, useRef } from "react"; +import { ArrowUp, Square } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +interface Props { + value: string; + onChange: (v: string) => void; + onSubmit: () => void; + onCancel: () => void; + isStreaming: boolean; + placeholder?: string; + disabled?: boolean; +} + +export function MessageInput({ + value, onChange, onSubmit, onCancel, isStreaming, + placeholder = "Ask Pebble anything…", + disabled = false, +}: Props) { + const taRef = useRef(null); + + // Auto-grow logic — adjust rows based on content height. + useEffect(() => { + const ta = taRef.current; + if (!ta) return; + ta.style.height = "auto"; + const next = Math.min(ta.scrollHeight, 6 * 22 /* px per row */ + 16); + ta.style.height = `${next}px`; + }, [value]); + + const canSend = value.trim().length > 0 && !disabled && !isStreaming; + + function handleKey(e: React.KeyboardEvent) { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + if (canSend) onSubmit(); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + if (canSend) onSubmit(); + } + } + + return ( +
+