From c8cc9f20b08c97bdb5af185fd8887981acd4412e Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Sun, 19 Apr 2026 22:53:22 -0400 Subject: [PATCH 01/12] feat(server): add explicit GitHub webhook strategies Move GitHub webhook intake onto the main API router, add explicit server_url and tailscale_funnel strategies, and validate strategy requirements at config resolution. This also updates the API contract, generated client, and operator docs to match the new webhook model. --- docs/administration/server-configuration.mdx | 15 +- docs/api-reference/fabro-api.yaml | 22 + docs/changelog/2026-04-18.mdx | 8 + docs/integrations/github.mdx | 30 +- lib/crates/fabro-config/src/resolve/server.rs | 56 ++- .../fabro-config/tests/resolve_server.rs | 50 +++ .../fabro-server/src/github_webhooks.rs | 379 +++--------------- lib/crates/fabro-server/src/serve.rs | 208 ++++++++-- lib/crates/fabro-server/src/server.rs | 217 +++++++++- lib/crates/fabro-server/tests/it/api/docs.rs | 19 + .../fabro-server/tests/it/api/routing.rs | 10 +- lib/crates/fabro-types/src/settings/server.rs | 1 + .../src/.openapi-generator/FILES | 1 + lib/packages/fabro-api-client/src/api.ts | 1 + .../src/api/integrations-api.ts | 122 ++++++ 15 files changed, 746 insertions(+), 393 deletions(-) create mode 100644 lib/packages/fabro-api-client/src/api/integrations-api.ts diff --git a/docs/administration/server-configuration.mdx b/docs/administration/server-configuration.mdx index 0ce3714c6..bd444ed98 100644 --- a/docs/administration/server-configuration.mdx +++ b/docs/administration/server-configuration.mdx @@ -211,7 +211,7 @@ Customize the git author identity used for checkpoint commits. When not set, def ### `[server.integrations.github]` section -Configure GitHub integration auth. `strategy = "token"` is the default and uses a stored `GITHUB_TOKEN` from the vault (with `GH_TOKEN` as a fallback). `strategy = "app"` enables the GitHub App flow, browser OAuth, and webhooks. +Configure GitHub integration auth. `strategy = "token"` is the default and uses a stored `GITHUB_TOKEN` from the vault (with `GH_TOKEN` as a fallback). `strategy = "app"` enables the GitHub App flow and browser OAuth; webhook delivery is configured separately under `[server.integrations.github.webhooks]`. ```toml title="settings.toml" [server.integrations.github] @@ -227,11 +227,20 @@ app_id = "123456" client_id = "Iv1.abc123" slug = "fabro-app" +[server.api] +url = "https://fabro-api.example.com" + [server.integrations.github.webhooks] -strategy = "tailscale_funnel" +strategy = "server_url" ``` -When `webhooks.strategy = "tailscale_funnel"` is configured, `fabro server start` binds a local HTTP listener, exposes it through `tailscale funnel`, and updates the GitHub App's webhook URL on startup. Incoming webhooks are verified with HMAC-SHA256. Requires the `GITHUB_APP_WEBHOOK_SECRET` environment variable. +Fabro always serves the GitHub webhook handler at `POST /api/v1/webhooks/github` when `GITHUB_APP_WEBHOOK_SECRET` is configured. The `strategy` field controls how Fabro exposes that route and whether it mutates the GitHub App webhook URL on startup: + +- `strategy = "server_url"`: recommended for production or any deployment with a stable public API URL. Fabro sets the GitHub App webhook URL to `/api/v1/webhooks/github` on startup. Requires `server.api.url` and `GITHUB_APP_WEBHOOK_SECRET`. +- `strategy = "tailscale_funnel"`: opt-in for Tailscale-hosted machines without a stable public URL. Fabro runs `tailscale funnel `, exposes the main server on that Funnel URL, and best-effort updates the GitHub App webhook URL to `/api/v1/webhooks/github`. Requires a TCP listener and `GITHUB_APP_WEBHOOK_SECRET`. +- `strategy` unset: Fabro still accepts signed webhook deliveries on `/api/v1/webhooks/github` when the secret is present, but it does not run `tailscale funnel` and does not update the GitHub App webhook URL. + +Incoming webhooks are authenticated only by GitHub's `X-Hub-Signature-256` HMAC signature, not by Fabro's bearer/session auth. ### `[run.checkpoint]` section diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 1b9cb3a1f..e3edd6bea 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -9,6 +9,8 @@ tags: description: API discovery and health - name: Install description: First-run browser install workflow + - name: Integrations + description: External provider callbacks and integration endpoints - name: Runs description: Run management operations - name: Human-in-the-Loop @@ -374,6 +376,26 @@ paths: schema: type: object + /api/v1/webhooks/github: + post: + operationId: receiveGithubWebhook + tags: [Integrations] + summary: Receive GitHub Webhook + description: Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: string + format: binary + responses: + "200": + description: Webhook accepted + "401": + description: Missing or invalid webhook signature + /api/v1/user: get: operationId: getUser diff --git a/docs/changelog/2026-04-18.mdx b/docs/changelog/2026-04-18.mdx index caf21e39a..f9acfedf2 100644 --- a/docs/changelog/2026-04-18.mdx +++ b/docs/changelog/2026-04-18.mdx @@ -19,6 +19,14 @@ The published Docker image switched to [Docker Hardened Images](https://www.dock A new `docker-compose.prod.yaml` stands up a Caddy 2 sidecar that handles auto-HTTPS on ports 80/443 and proxies to the Fabro service. Set `FABRO_DOMAIN` to your domain and Caddy provisions and renews the certificate; certs persist in a named volume. The base `docker-compose.yaml` has moved to the repo root. +## GitHub webhook exposure is now explicit + +GitHub App webhook exposure no longer auto-enables just because `[server.integrations.github.webhooks]` exists. The webhook handler now lives on the main API router at `POST /api/v1/webhooks/github`, and operators must choose an explicit strategy if they want Fabro to mutate any external state on startup. + +- Breaking: if you previously relied on Fabro silently starting `tailscale funnel` and rewriting the GitHub App webhook URL, add `strategy = "tailscale_funnel"` under `[server.integrations.github.webhooks]` to restore that behavior. +- New: `strategy = "server_url"` tells Fabro to use `server.api.url` and set the GitHub App webhook URL to `/api/v1/webhooks/github` on startup. This is the recommended choice for stable production deployments behind HTTPS. +- Unset `strategy`: Fabro still verifies and serves signed GitHub webhooks when `GITHUB_APP_WEBHOOK_SECRET` is present, but it will not run `tailscale funnel` and it will not rewrite the GitHub App webhook URL. + ## More diff --git a/docs/integrations/github.mdx b/docs/integrations/github.mdx index 201a29d72..cb9297732 100644 --- a/docs/integrations/github.mdx +++ b/docs/integrations/github.mdx @@ -6,7 +6,7 @@ description: "Integrate Fabro with GitHub for repository access and OAuth login" Fabro supports two GitHub integration strategies: - `token` — the default for local and individual use. Fabro captures `gh auth token` during `fabro install`, stores it as `GITHUB_TOKEN`, and uses that token directly for repo access, pull requests, and sandbox `GITHUB_TOKEN` injection. -- `app` — the team-oriented option. Fabro registers a [GitHub App](https://docs.github.com/en/apps/overview), uses installation tokens for repo access, and enables browser OAuth and webhooks. +- `app` — the team-oriented option. Fabro registers a [GitHub App](https://docs.github.com/en/apps/overview), uses installation tokens for repo access, enables browser OAuth, and supports webhook delivery when you configure a [webhook strategy](#webhook-delivery-strategies). `token` changes GitHub integration auth only. It does not provide browser sign-in, so the embedded web UI is disabled when `strategy = "token"`. @@ -19,11 +19,11 @@ Fabro supports two GitHub integration strategies: | Sandbox `GITHUB_TOKEN` | Direct token | Scoped installation token | | Browser sign-in | No | Yes | | Web UI routes | Disabled | Enabled | -| Webhooks | No | Yes | +| Webhooks | No | Strategy-dependent | ## GitHub App mode -The rest of this page describes the `app` strategy, which is required for browser auth and webhooks. +The rest of this page describes the `app` strategy, which is required for browser auth and for any webhook delivery strategy. | Feature | How it's used | |---|---| @@ -125,6 +125,30 @@ Fabro stores the GitHub App secrets in `/server.env` under these keys: The private key is stored as base64-encoded PEM. Fabro also accepts raw PEM format (starting with `-----BEGIN`). +### Webhook delivery strategies + +Fabro receives GitHub webhooks on `POST /api/v1/webhooks/github` whenever `GITHUB_APP_WEBHOOK_SECRET` is configured. The webhook `strategy` controls how that route becomes reachable from GitHub: + +```toml title="settings.toml" +[server.integrations.github] +strategy = "app" +app_id = "123456" +client_id = "Iv1.abc123def" +slug = "fabro-a3f2" + +[server.api] +url = "https://fabro-api.example.com" + +[server.integrations.github.webhooks] +strategy = "server_url" +``` + +- `server_url`: recommended for production. Fabro assumes `server.api.url` is already publicly reachable and best-effort updates the GitHub App webhook URL to `/api/v1/webhooks/github` each time `fabro server start` runs. +- `tailscale_funnel`: opt-in for machines reachable through Tailscale but not through a stable public URL. Fabro runs `tailscale funnel` against the main server port and best-effort updates the GitHub App webhook URL to the resulting Funnel origin. +- Unset `strategy`: Fabro still serves the webhook route if the secret is present, but it does not expose the route for you and does not mutate the GitHub App webhook URL. + +`tailscale_funnel` has host-wide side effects: it changes Tailscale Funnel state on the server and rewrites the GitHub App webhook URL on startup. Use `server_url` when you already have HTTPS and a stable hostname. + ### Reconfigure GitHub after install To switch GitHub strategies or re-register the app without re-running the full install wizard, use `fabro install github`: diff --git a/lib/crates/fabro-config/src/resolve/server.rs b/lib/crates/fabro-config/src/resolve/server.rs index befb71ea7..4084f8f9a 100644 --- a/lib/crates/fabro-config/src/resolve/server.rs +++ b/lib/crates/fabro-config/src/resolve/server.rs @@ -1,15 +1,16 @@ use fabro_types::settings::InterpString; use fabro_types::settings::server::{ - DiscordIntegrationSettings, GithubIntegrationSettings, IntegrationWebhooksLayer, - IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreLocalLayer, ObjectStoreProvider, - ObjectStoreS3Layer, ObjectStoreSettings, ServerApiLayer, ServerApiSettings, - ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthLayer, - ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer, ServerIntegrationsSettings, - ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, ServerIpAllowlistOverrideSettings, - ServerIpAllowlistSettings, ServerLayer, ServerListenLayer, ServerListenSettings, - ServerLoggingSettings, ServerSchedulerSettings, ServerSettings, ServerSlateDbLayer, - ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings, ServerWebLayer, - ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, + DiscordIntegrationSettings, GithubIntegrationSettings, GithubIntegrationStrategy, + IntegrationWebhooksLayer, IntegrationWebhooksSettings, IpAllowEntry, ObjectStoreLocalLayer, + ObjectStoreProvider, ObjectStoreS3Layer, ObjectStoreSettings, ServerApiLayer, + ServerApiSettings, ServerArtifactsLayer, ServerArtifactsSettings, ServerAuthGithubSettings, + ServerAuthLayer, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsLayer, + ServerIntegrationsSettings, ServerIpAllowlistLayer, ServerIpAllowlistOverrideLayer, + ServerIpAllowlistOverrideSettings, ServerIpAllowlistSettings, ServerLayer, ServerListenLayer, + ServerListenSettings, ServerLoggingSettings, ServerSchedulerSettings, ServerSettings, + ServerSlateDbLayer, ServerSlateDbSettings, ServerStorageLayer, ServerStorageSettings, + ServerWebLayer, ServerWebSettings, SlackIntegrationSettings, TeamsIntegrationSettings, + WebhookStrategy, }; use fabro_util::Home; @@ -25,6 +26,7 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> Se let integrations = resolve_integrations(layer.integrations.as_ref(), errors); validate_ip_allowlist_for_listen(&listen, &ip_allowlist, errors); validate_github_webhook_ip_allowlist_for_listen(&listen, &ip_allowlist, &integrations, errors); + validate_github_webhook_strategy(&integrations, layer.api.as_ref(), errors); ServerSettings { listen, @@ -294,6 +296,40 @@ fn validate_github_webhook_ip_allowlist_for_listen( } } +fn validate_github_webhook_strategy( + integrations: &ServerIntegrationsSettings, + api_layer: Option<&ServerApiLayer>, + errors: &mut Vec, +) { + let github = &integrations.github; + let strategy = github + .webhooks + .as_ref() + .and_then(|webhooks| webhooks.strategy); + + if strategy.is_some() + && github.strategy == GithubIntegrationStrategy::App + && github.app_id.is_none() + { + errors.push(ResolveError::Invalid { + path: "server.integrations.github.app_id".to_string(), + reason: "must be set when server.integrations.github.webhooks.strategy is configured" + .to_string(), + }); + } + + if matches!(strategy, Some(WebhookStrategy::ServerUrl)) + && api_layer.and_then(|api| api.url.as_ref()).is_none() + { + errors.push(ResolveError::Invalid { + path: "server.api.url".to_string(), + reason: + "must be set when server.integrations.github.webhooks.strategy = \"server_url\"" + .to_string(), + }); + } +} + fn resolve_artifacts( layer: Option<&ServerArtifactsLayer>, storage_root: &InterpString, diff --git a/lib/crates/fabro-config/tests/resolve_server.rs b/lib/crates/fabro-config/tests/resolve_server.rs index b5d55abac..72a670574 100644 --- a/lib/crates/fabro-config/tests/resolve_server.rs +++ b/lib/crates/fabro-config/tests/resolve_server.rs @@ -298,6 +298,56 @@ trusted_proxy_count = 3 assert_eq!(webhook_allowlist.trusted_proxy_count, Some(3)); } +#[test] +fn rejects_server_url_webhook_strategy_without_server_api_url() { + let file = parse( + r#" +_version = 1 + +[server.integrations.github] +strategy = "app" + +[server.integrations.github.webhooks] +strategy = "server_url" +"#, + ); + + let errors = fabro_config::resolve_server_from_file(&file) + .expect_err("server_url webhook strategy should require server.api.url"); + let rendered = errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + assert!(rendered.contains("server.api.url")); +} + +#[test] +fn rejects_configured_webhook_strategy_without_github_app_id() { + let file = parse( + r#" +_version = 1 + +[server.integrations.github] +strategy = "app" + +[server.integrations.github.webhooks] +strategy = "tailscale_funnel" +"#, + ); + + let errors = fabro_config::resolve_server_from_file(&file) + .expect_err("configured webhook strategy should require server.integrations.github.app_id"); + let rendered = errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + + assert!(rendered.contains("server.integrations.github.app_id")); +} + #[test] fn rejects_invalid_ip_allowlist_entry() { let file = parse( diff --git a/lib/crates/fabro-server/src/github_webhooks.rs b/lib/crates/fabro-server/src/github_webhooks.rs index 97c7fb471..640944b02 100644 --- a/lib/crates/fabro-server/src/github_webhooks.rs +++ b/lib/crates/fabro-server/src/github_webhooks.rs @@ -1,19 +1,7 @@ -use std::net::SocketAddr; -use std::sync::Arc; - -use axum::body::Bytes; -use axum::extract::State; -use axum::http::{HeaderMap, StatusCode}; -use axum::routing::post; -use axum::{Router, middleware}; use hmac::{Hmac, Mac}; use sha2::Sha256; -use tokio::net::TcpListener; use tokio::process::Command; -use tokio::sync::oneshot; -use tracing::{debug, error, info, warn}; - -use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware}; +use tracing::{info, warn}; type HmacSha256 = Hmac; @@ -21,7 +9,7 @@ type HmacSha256 = Hmac; /// /// `signature_header` is the value of the `X-Hub-Signature-256` header, /// expected in the form `sha256=`. -pub fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> bool { +pub(crate) fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> bool { let Some(hex_digest) = signature_header.strip_prefix("sha256=") else { return false; }; @@ -37,60 +25,7 @@ pub fn verify_signature(secret: &[u8], body: &[u8], signature_header: &str) -> b mac.verify_slice(&expected).is_ok() } -#[derive(Clone)] -struct WebhookState { - secret: Vec, -} - -async fn webhook_handler( - State(state): State, - headers: HeaderMap, - body: Bytes, -) -> StatusCode { - let delivery_id = headers - .get("x-github-delivery") - .and_then(|v| v.to_str().ok()) - .unwrap_or("unknown"); - - let Some(signature) = headers - .get("x-hub-signature-256") - .and_then(|v| v.to_str().ok()) - else { - warn!(delivery = %delivery_id, "Webhook signature verification failed"); - return StatusCode::UNAUTHORIZED; - }; - - if !verify_signature(&state.secret, &body, signature) { - warn!(delivery = %delivery_id, "Webhook signature verification failed"); - return StatusCode::UNAUTHORIZED; - } - - let event_type = headers - .get("x-github-event") - .and_then(|v| v.to_str().ok()) - .unwrap_or("unknown"); - - if tracing::enabled!(tracing::Level::DEBUG) { - let (repo, action) = parse_event_metadata(&body); - debug!( - event = %event_type, - delivery = %delivery_id, - repo = %repo, - action = %action, - "Webhook received" - ); - } else { - info!( - event = %event_type, - delivery = %delivery_id, - "Webhook received" - ); - } - - StatusCode::OK -} - -fn parse_event_metadata(body: &[u8]) -> (String, String) { +pub(crate) fn parse_event_metadata(body: &[u8]) -> (String, String) { let parsed: serde_json::Value = serde_json::from_slice(body).unwrap_or_default(); let repo = parsed .get("repository") @@ -106,106 +41,41 @@ fn parse_event_metadata(body: &[u8]) -> (String, String) { (repo, action) } -/// A running webhook listener that can be shut down. -pub struct WebhookListener { - port: u16, - shutdown_tx: oneshot::Sender<()>, +/// Manages `tailscale funnel` lifecycle for the main Fabro server port. +pub struct TailscaleFunnelManager { + port: u16, } -impl WebhookListener { - pub fn port(&self) -> u16 { - self.port - } - - pub fn shutdown(self) { - let _ = self.shutdown_tx.send(()); - info!("Webhook listener stopped"); - } -} - -/// Spawn the webhook HTTP listener on a random port (127.0.0.1 only). -pub async fn spawn_webhook_listener( - secret: Vec, - ip_allowlist: Arc, -) -> anyhow::Result { - let listener = TcpListener::bind("127.0.0.1:0").await?; - let port = listener.local_addr()?.port(); - - let state = WebhookState { secret }; - let router = Router::new() - .route("/webhooks/github", post(webhook_handler)) - .with_state(state) - .layer(middleware::from_fn_with_state( - ip_allowlist, - ip_allowlist_middleware, - )); - - let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); - - tokio::spawn(async move { - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(async { - let _ = shutdown_rx.await; - }) - .await - .ok(); - }); - - info!(port = port, "Webhook listener started"); - - Ok(WebhookListener { port, shutdown_tx }) -} - -/// Manage the full webhook lifecycle: listener + tailscale funnel + GitHub API. -pub struct WebhookManager { - listener: WebhookListener, -} - -impl WebhookManager { - /// Start the webhook system: spawn listener, enable Tailscale funnel, - /// and update the GitHub App webhook URL. +impl TailscaleFunnelManager { pub async fn start( - secret: Vec, + main_server_port: u16, app_id: &str, private_key_pem: &str, - ip_allowlist: Arc, ) -> anyhow::Result { - let listener = spawn_webhook_listener(secret, ip_allowlist).await?; - let port = listener.port(); + let funnel_url = enable_tailscale_funnel(main_server_port).await?; + info!(port = main_server_port, url = %funnel_url, "Tailscale funnel enabled"); - // Enable Tailscale funnel - let funnel_url = match enable_tailscale_funnel(port).await { - Ok(url) => url, - Err(err) => { - error!(error = %err, "Failed to enable Tailscale funnel"); - listener.shutdown(); - return Err(err); + let webhook_url = format!("{funnel_url}/api/v1/webhooks/github"); + match update_github_app_webhook(app_id, private_key_pem, &webhook_url).await { + Ok(()) => { + info!(url = %webhook_url, "GitHub App webhook URL updated"); + } + Err(err) => { + warn!( + error = %err, + url = %webhook_url, + "Failed to update GitHub App webhook URL" + ); } - }; - - info!(url = %funnel_url, "Tailscale funnel enabled"); - - // Update GitHub App webhook URL - let webhook_url = format!("{funnel_url}/webhooks/github"); - if let Err(err) = update_github_app_webhook(app_id, private_key_pem, &webhook_url).await { - error!(error = %err, "Failed to update GitHub App webhook URL"); - disable_tailscale_funnel(port).await; - listener.shutdown(); - return Err(err); } - info!(url = %webhook_url, "GitHub App webhook URL updated"); - - Ok(Self { listener }) + Ok(Self { + port: main_server_port, + }) } - /// Shut down: disable funnel, stop listener. pub async fn shutdown(self) { - disable_tailscale_funnel(self.listener.port()).await; - self.listener.shutdown(); + disable_tailscale_funnel(self.port).await; } } @@ -220,28 +90,26 @@ async fn enable_tailscale_funnel(port: u16) -> anyhow::Result { anyhow::bail!("tailscale funnel failed: {stderr}"); } - // Get the funnel URL from `tailscale funnel status` let status_output = Command::new("tailscale") .args(["funnel", "status"]) .output() .await?; + if !status_output.status.success() { + let stderr = String::from_utf8_lossy(&status_output.stderr); + anyhow::bail!("tailscale funnel status failed: {stderr}"); + } let stdout = String::from_utf8_lossy(&status_output.stdout); - // Parse the HTTPS URL from status output — first line typically contains it - let url = stdout - .lines() - .find_map(|line| { - let trimmed = line.trim(); - if trimmed.starts_with("https://") { - // Strip trailing path/colon info - Some(trimmed.trim_end_matches('/').to_string()) - } else { - None - } - }) - .ok_or_else(|| anyhow::anyhow!("Could not parse funnel URL from: {stdout}"))?; + parse_tailscale_funnel_url(&stdout) + .ok_or_else(|| anyhow::anyhow!("Could not parse funnel URL from: {stdout}")) +} - Ok(url) +fn parse_tailscale_funnel_url(status_output: &str) -> Option { + status_output.lines().find_map(|line| { + line.split_whitespace() + .find(|part| part.starts_with("https://")) + .map(|url| url.trim_end_matches('/').to_string()) + }) } async fn disable_tailscale_funnel(port: u16) { @@ -251,19 +119,19 @@ async fn disable_tailscale_funnel(port: u16) { .await { Ok(output) if output.status.success() => { - info!("Tailscale funnel disabled"); + info!(port, "Tailscale funnel disabled"); } Ok(output) => { let stderr = String::from_utf8_lossy(&output.stderr); - warn!(error = %stderr, "Failed to disable Tailscale funnel"); + warn!(port, error = %stderr, "Failed to disable Tailscale funnel"); } Err(err) => { - warn!(error = %err, "Failed to disable Tailscale funnel"); + warn!(port, error = %err, "Failed to disable Tailscale funnel"); } } } -async fn update_github_app_webhook( +pub(crate) async fn update_github_app_webhook( app_id: &str, private_key_pem: &str, webhook_url: &str, @@ -297,27 +165,7 @@ async fn update_github_app_webhook( #[cfg(test)] mod tests { - use axum::body::Body; - use axum::http::Request; - use tower::ServiceExt; - use super::*; - use crate::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; - - fn test_http_client() -> fabro_http::HttpClient { - fabro_http::test_http_client().unwrap() - } - - fn empty_allowlist_config() -> Arc { - Arc::new(IpAllowlistConfig { - allowlist: IpAllowlist::default(), - trusted_proxy_count: 0, - }) - } - - // ----------------------------------------------------------------------- - // verify_signature - // ----------------------------------------------------------------------- fn compute_signature(secret: &[u8], body: &[u8]) -> String { let mut mac = HmacSha256::new_from_slice(secret).unwrap(); @@ -359,138 +207,21 @@ mod tests { assert!(verify_signature(secret, body, &sig)); } - // ----------------------------------------------------------------------- - // webhook_handler - // ----------------------------------------------------------------------- - - fn build_test_router(secret: &[u8]) -> Router { - let state = WebhookState { - secret: secret.to_vec(), - }; - Router::new() - .route("/webhooks/github", post(webhook_handler)) - .with_state(state) - .layer(middleware::from_fn_with_state( - empty_allowlist_config(), - ip_allowlist_middleware, - )) + #[test] + fn parse_event_metadata_defaults_missing_fields() { + assert_eq!( + parse_event_metadata(br#"{"repository":{}}"#), + ("unknown".to_string(), "none".to_string(),) + ); } - #[tokio::test] - async fn rejects_missing_signature() { - let app = build_test_router(b"secret"); - let req = Request::builder() - .method("POST") - .uri("/webhooks/github") - .body(Body::from("{}")) - .unwrap(); + #[test] + fn parse_tailscale_funnel_url_extracts_https_origin() { + let status = "https://fabro.example.ts.net proxy http://127.0.0.1:32276"; - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn rejects_bad_signature() { - let app = build_test_router(b"secret"); - let body = b"{}"; - let bad_sig = compute_signature(b"wrong", body); - - let req = Request::builder() - .method("POST") - .uri("/webhooks/github") - .header("x-hub-signature-256", bad_sig) - .body(Body::from(body.to_vec())) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); - } - - #[tokio::test] - async fn accepts_valid_webhook() { - let secret = b"my-secret"; - let app = build_test_router(secret); - let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#; - let sig = compute_signature(secret, body); - - let req = Request::builder() - .method("POST") - .uri("/webhooks/github") - .header("x-hub-signature-256", sig) - .header("x-github-event", "pull_request") - .header("x-github-delivery", "abc-123") - .body(Body::from(body.to_vec())) - .unwrap(); - - let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - } - - // ----------------------------------------------------------------------- - // spawn_webhook_listener - // ----------------------------------------------------------------------- - - #[tokio::test] - async fn spawn_listener_serves_route() { - let secret = b"integration-secret"; - let listener = spawn_webhook_listener(secret.to_vec(), empty_allowlist_config()) - .await - .unwrap(); - let port = listener.port(); - - // Valid request should return 200 - let body = b"{}"; - let sig = compute_signature(secret, body); - - let client = test_http_client(); - let resp = client - .post(format!("http://127.0.0.1:{port}/webhooks/github")) - .header("x-hub-signature-256", sig) - .body(body.to_vec()) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 200); - - // Missing signature should return 401 - let resp = client - .post(format!("http://127.0.0.1:{port}/webhooks/github")) - .body("{}") - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 401); - - listener.shutdown(); - } - - #[tokio::test] - async fn spawn_listener_blocks_non_allowlisted_ip() { - let secret = b"integration-secret"; - let listener = spawn_webhook_listener( - secret.to_vec(), - Arc::new(IpAllowlistConfig { - allowlist: IpAllowlist::new(vec!["10.0.0.0/8".parse().unwrap()]), - trusted_proxy_count: 0, - }), - ) - .await - .unwrap(); - let port = listener.port(); - - let body = b"{}"; - let sig = compute_signature(secret, body); - - let client = test_http_client(); - let resp = client - .post(format!("http://127.0.0.1:{port}/webhooks/github")) - .header("x-hub-signature-256", sig) - .body(body.to_vec()) - .send() - .await - .unwrap(); - assert_eq!(resp.status(), 403); - - listener.shutdown(); + assert_eq!( + parse_tailscale_funnel_url(status), + Some("https://fabro.example.ts.net".to_string()) + ); } } diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index c962c17da..234c50cbd 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -9,7 +9,9 @@ use fabro_config::merge::combine_files; use fabro_config::user::load_settings_config; use fabro_config::{Storage, resolve_server_from_file}; use fabro_sandbox::SandboxProvider; -use fabro_types::settings::server::{GithubIntegrationStrategy, ServerLayer, ServerListenLayer}; +use fabro_types::settings::server::{ + GithubIntegrationStrategy, ServerLayer, ServerListenLayer, WebhookStrategy, +}; use fabro_types::settings::{ InterpString, ObjectStoreSettings, ServerListenSettings, ServerSettings as ResolvedServerSettings, SettingsLayer, @@ -25,7 +27,7 @@ use tokio::time::interval; use tracing::{error, info, warn}; use crate::bind::{self, Bind, BindRequest}; -use crate::github_webhooks::WebhookManager; +use crate::github_webhooks::{TailscaleFunnelManager, update_github_app_webhook}; use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config}; use crate::jwt_auth::resolve_auth_mode_with_lookup; use crate::server::{ @@ -163,6 +165,20 @@ async fn resolve_github_webhook_ip_allowlist( Ok(Arc::new(config)) } +async fn resolve_startup_github_webhook_ip_allowlist( + resolved_server_settings: &ResolvedServerSettings, + github_meta_resolver: &GitHubMetaResolver, + webhook_secret_present: bool, +) -> anyhow::Result>> { + if !webhook_secret_present { + return Ok(None); + } + + resolve_github_webhook_ip_allowlist(resolved_server_settings, github_meta_resolver) + .await + .map(Some) +} + fn use_in_memory_store() -> bool { !matches!( std::env::var(TEST_IN_MEMORY_STORE_ENV).ok().as_deref(), @@ -335,6 +351,7 @@ where let vault_path = storage.secrets_path(); let server_env_path = storage.server_state().env_path(); let server_secrets = ServerSecrets::load(server_env_path.clone())?; + let webhook_secret_present = server_secrets.get("GITHUB_APP_WEBHOOK_SECRET").is_some(); // Shared config for live reloading let effective_settings = apply_runtime_settings(&disk_settings, &args, &data_dir); @@ -400,67 +417,143 @@ where .await .context("resolving server IP allowlist")?, ); + let github_webhook_ip_allowlist = resolve_startup_github_webhook_ip_allowlist( + &resolved_server_settings, + &github_meta_resolver, + webhook_secret_present, + ) + .await?; let router = build_router_with_options( Arc::clone(&state), auth_mode, Arc::clone(&default_ip_allowlist), - RouterOptions { web_enabled }, + RouterOptions { + web_enabled, + github_webhook_ip_allowlist, + }, ); + let bound_listener = bind_listener(&bind_request).await?; + let bind_addr = bound_listener.bind.clone(); - // Optionally start webhook listener - let webhook_manager = match resolved_server_settings.integrations.github.strategy { - GithubIntegrationStrategy::Token => None, - GithubIntegrationStrategy::App => { + let webhook_manager = match resolved_server_settings + .integrations + .github + .webhooks + .as_ref() + .and_then(|webhooks| webhooks.strategy) + { + None => None, + Some(_) + if resolved_server_settings.integrations.github.strategy + != GithubIntegrationStrategy::App => + { + warn!( + "GitHub webhook strategy is configured but GitHub integration auth is not set to app; skipping webhook startup" + ); + None + } + Some(strategy) => { let webhook_app_id = resolved_server_settings .integrations .github - .webhooks + .app_id .as_ref() - .and(resolved_server_settings.integrations.github.app_id.as_ref()) .map(resolve_interp) .transpose()?; match webhook_app_id { Some(app_id) => { - let secret = server_secrets.get("GITHUB_APP_WEBHOOK_SECRET"); let github_app = state .github_credentials(&resolved_server_settings.integrations.github) .unwrap_or_else(|err| { warn!( error = %err, - "Webhook config present but GitHub credentials are invalid; skipping webhook listener" + "Webhook strategy is configured but GitHub credentials are invalid; skipping webhook startup" ); None }); - if let (Some(secret), Some(fabro_github::GitHubCredentials::App(github_app))) = - (secret, github_app) - { - let webhook_ip_allowlist = resolve_github_webhook_ip_allowlist( - &resolved_server_settings, - &github_meta_resolver, - ) - .await?; - match WebhookManager::start( - secret.into_bytes(), - &app_id, - &github_app.private_key_pem, - webhook_ip_allowlist, - ) - .await - { - Ok(manager) => Some(manager), - Err(err) => { - error!(error = %err, "Failed to start webhook listener"); - None + if webhook_secret_present { + if let Some(fabro_github::GitHubCredentials::App(github_app)) = github_app { + match strategy { + WebhookStrategy::TailscaleFunnel => { + match bound_tcp_port(&bind_addr) { + Some(port) => match TailscaleFunnelManager::start( + port, + &app_id, + &github_app.private_key_pem, + ) + .await + { + Ok(manager) => Some(manager), + Err(err) => { + error!( + error = %err, + "Failed to start Tailscale funnel for GitHub webhooks" + ); + None + } + }, + None => { + warn!( + "GitHub webhook strategy tailscale_funnel requires a TCP server listen address; skipping webhook startup" + ); + None + } + } + } + WebhookStrategy::ServerUrl => { + let server_api_url = resolved_server_settings + .api + .url + .as_ref() + .map(resolve_interp) + .transpose()? + .expect( + "server.api.url should be validated when server_url strategy is configured", + ); + let webhook_url = format!( + "{}/api/v1/webhooks/github", + server_api_url.trim_end_matches('/') + ); + if let Err(err) = update_github_app_webhook( + &app_id, + &github_app.private_key_pem, + &webhook_url, + ) + .await + { + warn!( + error = %err, + url = %webhook_url, + "Failed to update GitHub App webhook URL" + ); + } else { + info!( + url = %webhook_url, + "GitHub App webhook URL updated" + ); + } + None + } } + } else { + warn!( + "Webhook strategy is configured but GITHUB_APP_PRIVATE_KEY is not available; skipping webhook startup" + ); + None } } else { warn!( - "Webhook config present but GITHUB_APP_WEBHOOK_SECRET or GITHUB_APP_PRIVATE_KEY not set; skipping webhook listener" + "Webhook strategy is configured but GITHUB_APP_WEBHOOK_SECRET is not set; skipping webhook startup" ); None } } - None => None, + None => { + warn!( + "Webhook strategy is configured but server.integrations.github.app_id is not set; skipping webhook startup" + ); + None + } } } }; @@ -517,8 +610,6 @@ where } }); - let bound_listener = bind_listener(&bind_request).await?; - let bind_addr = bound_listener.bind.clone(); if bound_listener.used_random_port_fallback { if let BindRequest::TcpHost(host) = bind_request { warn!( @@ -600,6 +691,13 @@ enum BoundListener { Tcp(TcpListener), } +fn bound_tcp_port(bind: &Bind) -> Option { + match bind { + Bind::Tcp(address) => Some(address.port()), + Bind::Unix(_) => None, + } +} + async fn bind_listener(requested: &BindRequest) -> anyhow::Result { match requested { BindRequest::Unix(path) => { @@ -748,7 +846,8 @@ mod tests { GitHubMetaResolver, ServeArgs, ServerTitlePhase, apply_runtime_settings, bind_tcp_host_with_fallback, build_local_object_store_with_preference, build_slatedb_store, resolve_bind_request_from_settings, resolve_github_webhook_ip_allowlist, - resolve_server_settings, router_web_enabled, server_bind_title, server_title, + resolve_server_settings, resolve_startup_github_webhook_ip_allowlist, router_web_enabled, + server_bind_title, server_title, }; use crate::bind::{Bind, BindRequest}; @@ -1080,4 +1179,41 @@ entries = ["github_meta_hooks"] assert!(error.to_string().contains("GitHub webhook IP allowlist")); } + + #[tokio::test] + async fn resolve_startup_github_webhook_ip_allowlist_skips_resolution_without_webhook_secret() { + let settings = resolve_server_settings(&parse_settings( + r#" +_version = 1 + +[server.listen] +type = "tcp" +address = "127.0.0.1:0" + +[server.integrations.github] +strategy = "app" +app_id = "123" + +[server.integrations.github.webhooks.ip_allowlist] +entries = ["github_meta_hooks"] +"#, + )) + .expect("settings should resolve"); + + let cache_dir = tempfile::tempdir().unwrap(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + let resolver = GitHubMetaResolver::new( + fabro_http::test_http_client().unwrap(), + format!("http://127.0.0.1:{port}/meta"), + cache_dir.path().join("github-meta.json"), + ); + + let allowlist = resolve_startup_github_webhook_ip_allowlist(&settings, &resolver, false) + .await + .expect("inactive webhook route should skip GitHub meta resolution"); + + assert!(allowlist.is_none()); + } } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index bc93b6bdc..704f338ff 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -111,6 +111,7 @@ use ulid::Ulid; use crate::bind::Bind; use crate::error::ApiError; +use crate::github_webhooks::{parse_event_metadata, verify_signature}; use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware}; use crate::jwt_auth::{ AuthMode, AuthenticatedService, AuthenticatedSubject, authenticate_service_parts, @@ -922,14 +923,18 @@ pub fn build_router(state: Arc, auth_mode: AuthMode) -> Router { ) } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Debug)] pub struct RouterOptions { - pub web_enabled: bool, + pub web_enabled: bool, + pub github_webhook_ip_allowlist: Option>, } impl Default for RouterOptions { fn default() -> Self { - Self { web_enabled: true } + Self { + web_enabled: true, + github_webhook_ip_allowlist: None, + } } } @@ -945,8 +950,15 @@ pub fn build_router_with_options( options: RouterOptions, ) -> Router { start_optional_slack_service(&state); + let web_enabled = options.web_enabled; + let webhook_ip_allowlist = options.github_webhook_ip_allowlist; let middleware_state = Arc::clone(&state); - let api_common = if options.web_enabled { + let webhook_state = Arc::clone(&state); + let default_webhook_ip_allowlist = Arc::clone(&ip_allowlist_config); + let webhook_secret_present = webhook_state + .server_secret("GITHUB_APP_WEBHOOK_SECRET") + .is_some(); + let api_common = if web_enabled { Router::new() .route("/openapi.json", get(openapi_spec)) .merge(web_auth::api_routes()) @@ -960,7 +972,7 @@ pub fn build_router_with_options( .with_state(state.clone()); let mut real_router = Router::new().nest("/api/v1", api_common.merge(real_routes())); - if options.web_enabled { + if web_enabled { real_router = real_router.nest("/auth", web_auth::routes()); } let real_router = real_router @@ -971,7 +983,7 @@ pub fn build_router_with_options( let demo = demo_router.clone(); let real = real_router.clone(); async move { - if options.web_enabled && req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") { + if web_enabled && req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") { demo.oneshot(req).await } else { real.oneshot(req).await @@ -1000,7 +1012,7 @@ pub fn build_router_with_options( }, ); - let mut router = Router::new() + let mut app_router = Router::new() .route("/health", get(health)) .fallback_service(service_fn(move |req: axum_extract::Request| { let dispatch = dispatch.clone(); @@ -1008,14 +1020,12 @@ pub fn build_router_with_options( let path = req.uri().path().to_string(); let dispatch_path = path.starts_with("/api/") || path == "/health" - || (options.web_enabled && path.starts_with("/auth/")); + || (web_enabled && path.starts_with("/auth/")); if dispatch_path { dispatch.oneshot(req).await - } else if options.web_enabled && removed_web_route(&path) { + } else if web_enabled && removed_web_route(&path) { Ok::<_, std::convert::Infallible>(StatusCode::NOT_FOUND.into_response()) - } else if options.web_enabled - && matches!(req.method(), &Method::GET | &Method::HEAD) - { + } else if web_enabled && matches!(req.method(), &Method::GET | &Method::HEAD) { let headers = req.headers().clone(); Ok::<_, std::convert::Infallible>(static_files::serve(&path, &headers).await) } else { @@ -1024,23 +1034,40 @@ pub fn build_router_with_options( } })); - if options.web_enabled { - router = router.layer(middleware::from_fn_with_state( + if web_enabled { + app_router = app_router.layer(middleware::from_fn_with_state( middleware_state, cookie_and_demo_middleware, )); } - router = router.layer(middleware::from_fn_with_state( + app_router = app_router.layer(middleware::from_fn_with_state( ip_allowlist_config, ip_allowlist_middleware, )); + let mut router = app_router; + if webhook_secret_present { + let webhook_ip_allowlist = webhook_ip_allowlist.unwrap_or(default_webhook_ip_allowlist); + router = github_webhook_routes(webhook_ip_allowlist) + .with_state(webhook_state) + .merge(router); + } + router .layer(middleware::from_fn(security_headers::layer)) .layer(trace_layer) } +fn github_webhook_routes(ip_allowlist_config: Arc) -> Router> { + Router::new() + .route("/api/v1/webhooks/github", post(github_webhook)) + .layer(middleware::from_fn_with_state( + ip_allowlist_config, + ip_allowlist_middleware, + )) +} + fn demo_routes() -> Router> { Router::new() .route("/runs", get(demo::list_runs).post(demo::create_run_stub)) @@ -1211,6 +1238,62 @@ async fn not_implemented() -> Response { ApiError::new(StatusCode::NOT_IMPLEMENTED, "Not implemented.").into_response() } +async fn github_webhook( + State(state): State>, + headers: HeaderMap, + body: Bytes, +) -> StatusCode { + let delivery_id = headers + .get("x-github-delivery") + .and_then(|value| value.to_str().ok()) + .unwrap_or("unknown"); + + let Some(secret) = state.server_secret("GITHUB_APP_WEBHOOK_SECRET") else { + warn!( + delivery = %delivery_id, + "GitHub webhook route reached without a configured webhook secret" + ); + return StatusCode::NOT_FOUND; + }; + + let Some(signature) = headers + .get("x-hub-signature-256") + .and_then(|value| value.to_str().ok()) + else { + warn!(delivery = %delivery_id, "Webhook signature verification failed"); + return StatusCode::UNAUTHORIZED; + }; + + if !verify_signature(secret.as_bytes(), &body, signature) { + warn!(delivery = %delivery_id, "Webhook signature verification failed"); + return StatusCode::UNAUTHORIZED; + } + + let event_type = headers + .get("x-github-event") + .and_then(|value| value.to_str().ok()) + .unwrap_or("unknown"); + + if tracing::enabled!(tracing::Level::DEBUG) { + let (repo, action) = parse_event_metadata(&body); + debug!( + event = %event_type, + delivery = %delivery_id, + repo = %repo, + action = %action, + "Webhook received" + ); + } else { + info!( + event = %event_type, + delivery = %delivery_id, + "Webhook received" + ); + } + + StatusCode::OK +} + async fn health() -> Response { Json(serde_json::json!({ "status": "ok", @@ -7307,7 +7390,9 @@ mod tests { use fabro_model::Provider; use fabro_types::settings::ServerAuthMethod; use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures}; + use hmac::{Hmac, Mac}; use serde_json::json; + use sha2::Sha256; use tower::ServiceExt; use super::*; @@ -7319,6 +7404,13 @@ mod tests { exit [shape=Msquare] start -> exit }"#; + const TEST_WEBHOOK_SECRET: &str = "webhook-secret"; + const TEST_DEV_TOKEN: &str = + "fabro_dev_abababababababababababababababababababababababababababababababab"; + const WRONG_DEV_TOKEN: &str = + "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; + + type TestHmacSha256 = Hmac; fn test_app_with() -> Router { let state = create_app_state(); @@ -7339,6 +7431,28 @@ mod tests { format!("/api/v1{path}") } + fn webhook_signature(secret: &str, body: &[u8]) -> String { + let mut mac = TestHmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + format!("sha256={}", hex::encode(mac.finalize().into_bytes())) + } + + fn webhook_test_app(auth_mode: AuthMode) -> Router { + let secret = TEST_WEBHOOK_SECRET.to_string(); + let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, move |name| { + (name == "GITHUB_APP_WEBHOOK_SECRET").then(|| secret.clone()) + }); + build_router_with_options( + state, + auth_mode, + Arc::new(IpAllowlistConfig::default()), + RouterOptions { + web_enabled: false, + ..RouterOptions::default() + }, + ) + } + #[tokio::test] async fn resolved_settings_view_returns_internal_error_when_runtime_settings_stop_resolving() { let state = create_app_state(); @@ -7403,6 +7517,79 @@ type = "http" )]); } + #[tokio::test] + async fn github_webhook_rejects_missing_or_invalid_signatures() { + let app = webhook_test_app(AuthMode::Disabled); + let body = br#"{"action":"opened"}"#; + + let missing_signature = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(api("/webhooks/github")) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing_signature.status(), StatusCode::UNAUTHORIZED); + + let invalid_signature = app + .oneshot( + Request::builder() + .method("POST") + .uri(api("/webhooks/github")) + .header( + "x-hub-signature-256", + webhook_signature("wrong-secret", body), + ) + .body(Body::from(body.to_vec())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(invalid_signature.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn github_webhook_accepts_valid_signatures_regardless_of_auth_mode() { + let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#; + let signature = webhook_signature(TEST_WEBHOOK_SECRET, body); + + for auth_mode in [ + AuthMode::Disabled, + AuthMode::Enabled(ConfiguredAuth { + methods: vec![ServerAuthMethod::DevToken], + dev_token: Some(TEST_DEV_TOKEN.to_string()), + }), + ] { + let app = webhook_test_app(auth_mode); + + for authorization in [ + None, + Some(format!("Bearer {WRONG_DEV_TOKEN}")), + Some(format!("Bearer {TEST_DEV_TOKEN}")), + ] { + let mut request = Request::builder() + .method("POST") + .uri(api("/webhooks/github")) + .header("x-hub-signature-256", signature.clone()) + .header("x-github-event", "pull_request") + .body(Body::from(body.to_vec())) + .unwrap(); + if let Some(value) = authorization.as_deref() { + request + .headers_mut() + .insert(header::AUTHORIZATION, value.parse().unwrap()); + } + + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + } + } + #[tokio::test] async fn create_secret_stores_valid_credential_entries() { let state = create_app_state(); diff --git a/lib/crates/fabro-server/tests/it/api/docs.rs b/lib/crates/fabro-server/tests/it/api/docs.rs index b694c8d72..346bbd5ac 100644 --- a/lib/crates/fabro-server/tests/it/api/docs.rs +++ b/lib/crates/fabro-server/tests/it/api/docs.rs @@ -58,3 +58,22 @@ fn changelog_marks_removed_mutual_tls_as_historical() { "historical changelog should clarify that inbound mutual TLS is no longer supported" ); } + +#[test] +fn github_docs_describe_webhooks_as_strategy_dependent() { + let github = read_doc("docs/integrations/github.mdx"); + assert!( + !github.contains("enables browser OAuth and webhooks"), + "GitHub integration docs should not imply app auth alone enables webhook delivery" + ); + assert!( + !github.contains("| Webhooks | No | Yes |"), + "GitHub strategy matrix should describe webhook delivery as strategy-dependent" + ); + + let server_configuration = read_doc("docs/administration/server-configuration.mdx"); + assert!( + !server_configuration.contains("enables the GitHub App flow, browser OAuth, and webhooks"), + "server configuration docs should not imply app auth alone enables webhook delivery" + ); +} diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index f628d0343..c5ca35b42 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -310,7 +310,10 @@ enabled = false create_app_state_with_options(settings, 5), AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), - RouterOptions { web_enabled: false }, + RouterOptions { + web_enabled: false, + ..RouterOptions::default() + }, ); for (method, path, body) in [ @@ -369,7 +372,10 @@ enabled = false create_app_state_with_options(settings, 5), AuthMode::Disabled, Arc::new(IpAllowlistConfig::default()), - RouterOptions { web_enabled: false }, + RouterOptions { + web_enabled: false, + ..RouterOptions::default() + }, ); let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; diff --git a/lib/crates/fabro-types/src/settings/server.rs b/lib/crates/fabro-types/src/settings/server.rs index b9280eef7..a40aedaba 100644 --- a/lib/crates/fabro-types/src/settings/server.rs +++ b/lib/crates/fabro-types/src/settings/server.rs @@ -536,4 +536,5 @@ pub enum GithubIntegrationStrategy { #[serde(rename_all = "snake_case")] pub enum WebhookStrategy { TailscaleFunnel, + ServerUrl, } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 42b42198a..2f458c063 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -5,6 +5,7 @@ api/discovery-api.ts api/human-in-the-loop-api.ts api/insights-api.ts api/install-api.ts +api/integrations-api.ts api/models-api.ts api/repos-api.ts api/run-internals-api.ts diff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts index b12f77e9b..b38b6c603 100644 --- a/lib/packages/fabro-api-client/src/api.ts +++ b/lib/packages/fabro-api-client/src/api.ts @@ -20,6 +20,7 @@ export * from './api/discovery-api'; export * from './api/human-in-the-loop-api'; export * from './api/insights-api'; export * from './api/install-api'; +export * from './api/integrations-api'; export * from './api/models-api'; export * from './api/repos-api'; export * from './api/run-internals-api'; diff --git a/lib/packages/fabro-api-client/src/api/integrations-api.ts b/lib/packages/fabro-api-client/src/api/integrations-api.ts new file mode 100644 index 000000000..a0e68aa35 --- /dev/null +++ b/lib/packages/fabro-api-client/src/api/integrations-api.ts @@ -0,0 +1,122 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.1.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + +import type { Configuration } from '../configuration'; +import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios'; +import globalAxios from 'axios'; +// Some imports not used depending on template conditions +// @ts-ignore +import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common'; +// @ts-ignore +import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base'; +/** + * IntegrationsApi - axios parameter creator + */ +export const IntegrationsApiAxiosParamCreator = function (configuration?: Configuration) { + return { + /** + * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. + * @summary Receive GitHub Webhook + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + receiveGithubWebhook: async (body: File, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'body' is not null or undefined + assertParamExists('receiveGithubWebhook', 'body', body) + const localVarPath = `/api/v1/webhooks/github`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options}; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + localVarHeaderParameter['Content-Type'] = 'application/json'; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; + localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + } +}; + +/** + * IntegrationsApi - functional programming interface + */ +export const IntegrationsApiFp = function(configuration?: Configuration) { + const localVarAxiosParamCreator = IntegrationsApiAxiosParamCreator(configuration) + return { + /** + * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. + * @summary Receive GitHub Webhook + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.receiveGithubWebhook(body, options); + const localVarOperationServerIndex = configuration?.serverIndex ?? 0; + const localVarOperationServerBasePath = operationServerMap['IntegrationsApi.receiveGithubWebhook']?.[localVarOperationServerIndex]?.url; + return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); + }, + } +}; + +/** + * IntegrationsApi - factory interface + */ +export const IntegrationsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) { + const localVarFp = IntegrationsApiFp(configuration) + return { + /** + * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. + * @summary Receive GitHub Webhook + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.receiveGithubWebhook(body, options).then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * IntegrationsApi - object-oriented interface + */ +export class IntegrationsApi extends BaseAPI { + /** + * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. + * @summary Receive GitHub Webhook + * @param {File} body + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + public receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig) { + return IntegrationsApiFp(this.configuration).receiveGithubWebhook(body, options).then((request) => request(this.axios, this.basePath)); + } +} + From 0686b82bce52024010a0c27341e4c34e9dfe1fb8 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 20 Apr 2026 07:13:49 -0400 Subject: [PATCH 02/12] fix(api): correct GitHub webhook OpenAPI contract Model the GitHub webhook request body as JSON so the generated TypeScript client exposes a coherent request shape, and add explicit conformance coverage for the secret-gated webhook route. --- docs/api-reference/fabro-api.yaml | 4 +- .../tests/it/openapi_conformance.rs | 73 +++++++++++++++++-- .../src/api/integrations-api.ts | 28 +++---- 3 files changed, 83 insertions(+), 22 deletions(-) diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index e3edd6bea..01876c9fe 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -388,8 +388,8 @@ paths: content: application/json: schema: - type: string - format: binary + type: object + additionalProperties: true responses: "200": description: Webhook accepted diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index e6ed05db4..8812ff6d8 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -16,22 +16,32 @@ use axum::body::Body; use axum::http::{Method, Request, StatusCode}; use fabro_server::install::{InstallAppState, build_install_router}; use fabro_server::jwt_auth::AuthMode; -use fabro_server::server::build_router; +use fabro_server::server::{build_router, create_app_state_with_env_lookup}; use serde_yaml::Value; use tower::ServiceExt; -use super::helpers::test_app_state; +use super::helpers::{test_app_state, test_settings}; -fn load_spec() -> Value { - let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) +fn repo_root() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(3) - .expect("fabro-server crate should be nested under lib/crates/fabro-server"); - let spec_path = repo_root.join("docs/api-reference/fabro-api.yaml"); + .expect("fabro-server crate should be nested under lib/crates/fabro-server") + .to_path_buf() +} + +fn load_spec() -> Value { + let spec_path = repo_root().join("docs/api-reference/fabro-api.yaml"); let text = std::fs::read_to_string(&spec_path).expect("failed to read spec"); serde_yaml::from_str(&text).expect("failed to parse spec") } +fn read_repo_file(relative_path: &str) -> String { + let path = repo_root().join(relative_path); + std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())) +} + fn resolve_path(path: &str) -> String { path.replace("{id}", "test-id") .replace("{qid}", "test-qid") @@ -122,6 +132,57 @@ async fn all_spec_routes_are_routable() { assert!(checked > 0, "No routes were checked — is the spec empty?"); } +#[test] +fn github_webhook_spec_and_sdk_describe_a_json_body() { + let spec = load_spec(); + let webhook_schema = spec["paths"]["/api/v1/webhooks/github"]["post"]["requestBody"]["content"] + ["application/json"]["schema"] + .clone(); + + assert_eq!( + webhook_schema.get("type").and_then(Value::as_str), + Some("object"), + "GitHub webhook request body should be modeled as JSON, not a binary file upload" + ); + assert!( + webhook_schema.get("format").is_none(), + "GitHub webhook JSON schema should not declare a binary format" + ); + + let generated_client = + read_repo_file("lib/packages/fabro-api-client/src/api/integrations-api.ts"); + assert!( + !generated_client.contains("@param {File} body"), + "generated TypeScript client should not expose the webhook body as File" + ); + assert!( + !generated_client.contains("receiveGithubWebhook: async (body: File"), + "generated TypeScript client should not require File for a JSON webhook payload" + ); +} + +#[tokio::test] +async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() { + let secret = "test-webhook-secret".to_string(); + let app = build_router( + create_app_state_with_env_lookup(test_settings(), 5, move |name| { + (name == "GITHUB_APP_WEBHOOK_SECRET").then(|| secret.clone()) + }), + AuthMode::Disabled, + ); + + let response = app + .oneshot(request_for(&Method::POST, "/api/v1/webhooks/github")) + .await + .unwrap(); + + assert_eq!( + response.status(), + StatusCode::UNAUTHORIZED, + "Webhook spec route should be mounted when GITHUB_APP_WEBHOOK_SECRET is present" + ); +} + #[tokio::test] async fn install_and_normal_routes_stay_isolated() { let spec = load_spec(); diff --git a/lib/packages/fabro-api-client/src/api/integrations-api.ts b/lib/packages/fabro-api-client/src/api/integrations-api.ts index a0e68aa35..e49ad9960 100644 --- a/lib/packages/fabro-api-client/src/api/integrations-api.ts +++ b/lib/packages/fabro-api-client/src/api/integrations-api.ts @@ -29,13 +29,13 @@ export const IntegrationsApiAxiosParamCreator = function (configuration?: Config /** * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. * @summary Receive GitHub Webhook - * @param {File} body + * @param {{ [key: string]: any; }} requestBody * @param {*} [options] Override http request option. * @throws {RequiredError} */ - receiveGithubWebhook: async (body: File, options: RawAxiosRequestConfig = {}): Promise => { - // verify required parameter 'body' is not null or undefined - assertParamExists('receiveGithubWebhook', 'body', body) + receiveGithubWebhook: async (requestBody: { [key: string]: any; }, options: RawAxiosRequestConfig = {}): Promise => { + // verify required parameter 'requestBody' is not null or undefined + assertParamExists('receiveGithubWebhook', 'requestBody', requestBody) const localVarPath = `/api/v1/webhooks/github`; // use dummy base URL string because the URL constructor only accepts absolute URLs. const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); @@ -53,7 +53,7 @@ export const IntegrationsApiAxiosParamCreator = function (configuration?: Config setSearchParams(localVarUrlObj, localVarQueryParameter); let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; - localVarRequestOptions.data = serializeDataIfNeeded(body, localVarRequestOptions, configuration) + localVarRequestOptions.data = serializeDataIfNeeded(requestBody, localVarRequestOptions, configuration) return { url: toPathString(localVarUrlObj), @@ -72,12 +72,12 @@ export const IntegrationsApiFp = function(configuration?: Configuration) { /** * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. * @summary Receive GitHub Webhook - * @param {File} body + * @param {{ [key: string]: any; }} requestBody * @param {*} [options] Override http request option. * @throws {RequiredError} */ - async receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { - const localVarAxiosArgs = await localVarAxiosParamCreator.receiveGithubWebhook(body, options); + async receiveGithubWebhook(requestBody: { [key: string]: any; }, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise> { + const localVarAxiosArgs = await localVarAxiosParamCreator.receiveGithubWebhook(requestBody, options); const localVarOperationServerIndex = configuration?.serverIndex ?? 0; const localVarOperationServerBasePath = operationServerMap['IntegrationsApi.receiveGithubWebhook']?.[localVarOperationServerIndex]?.url; return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath); @@ -94,12 +94,12 @@ export const IntegrationsApiFactory = function (configuration?: Configuration, b /** * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. * @summary Receive GitHub Webhook - * @param {File} body + * @param {{ [key: string]: any; }} requestBody * @param {*} [options] Override http request option. * @throws {RequiredError} */ - receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig): AxiosPromise { - return localVarFp.receiveGithubWebhook(body, options).then((request) => request(axios, basePath)); + receiveGithubWebhook(requestBody: { [key: string]: any; }, options?: RawAxiosRequestConfig): AxiosPromise { + return localVarFp.receiveGithubWebhook(requestBody, options).then((request) => request(axios, basePath)); }, }; }; @@ -111,12 +111,12 @@ export class IntegrationsApi extends BaseAPI { /** * Receives GitHub App webhook deliveries. Requests are authenticated by `X-Hub-Signature-256`, not API bearer auth. * @summary Receive GitHub Webhook - * @param {File} body + * @param {{ [key: string]: any; }} requestBody * @param {*} [options] Override http request option. * @throws {RequiredError} */ - public receiveGithubWebhook(body: File, options?: RawAxiosRequestConfig) { - return IntegrationsApiFp(this.configuration).receiveGithubWebhook(body, options).then((request) => request(this.axios, this.basePath)); + public receiveGithubWebhook(requestBody: { [key: string]: any; }, options?: RawAxiosRequestConfig) { + return IntegrationsApiFp(this.configuration).receiveGithubWebhook(requestBody, options).then((request) => request(this.axios, this.basePath)); } } From c8b807f3038b71712e3f8bde6ab94ae28af03642 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 20 Apr 2026 07:32:24 -0400 Subject: [PATCH 03/12] ci: freeze bun lockfile and widen typescript path filter Prevents silent bun.lock drift across CI runs (root cause of the nightly fabro-spa staleness failure) and re-runs typescript.yml when nightly.yml itself changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/nightly.yml | 2 +- .github/workflows/typescript.yml | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index cb7bcff52..abc74c0e5 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -49,7 +49,7 @@ jobs: - name: Install bun deps (for SPA verify) if: steps.skip.outputs.skip != 'true' - run: bun install + run: bun install --frozen-lockfile - name: Set up Rust if: steps.skip.outputs.skip != 'true' diff --git a/.github/workflows/typescript.yml b/.github/workflows/typescript.yml index 84a0e8496..9c5b914cc 100644 --- a/.github/workflows/typescript.yml +++ b/.github/workflows/typescript.yml @@ -12,6 +12,7 @@ on: - ".gitattributes" - "scripts/**" - ".github/workflows/typescript.yml" + - ".github/workflows/nightly.yml" pull_request: branches: [main] paths: @@ -23,6 +24,7 @@ on: - ".gitattributes" - "scripts/**" - ".github/workflows/typescript.yml" + - ".github/workflows/nightly.yml" workflow_dispatch: concurrency: @@ -42,7 +44,7 @@ jobs: with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - - run: bun install + - run: bun install --frozen-lockfile - run: cd apps/fabro-web && bun run typecheck test: @@ -55,7 +57,7 @@ jobs: with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - - run: bun install + - run: bun install --frozen-lockfile - run: cd apps/fabro-web && bun test build: @@ -68,7 +70,7 @@ jobs: with: persist-credentials: false - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 - - run: bun install + - run: bun install --frozen-lockfile - run: scripts/refresh-fabro-spa.sh - run: git diff --exit-code -- lib/crates/fabro-spa/assets - run: scripts/check-fabro-spa-budgets.sh From 1ef10b46ce89d872b3d053c74f8b081a6ee0efc6 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 20 Apr 2026 07:32:38 -0400 Subject: [PATCH 04/12] refactor(server): simplify github webhook wiring - Capture webhook secret at route mount time via Arc<[u8]> router state so the handler drops its per-request server_secret lookup and the dead NOT_FOUND fallback. - Extract WEBHOOK_ROUTE and WEBHOOK_SECRET_ENV constants; apply across serve.rs, server.rs, and TailscaleFunnelManager so the mounted route and the URLs pushed to GitHub cannot drift. - Flatten the seven-level nested webhook startup match in serve.rs into a single start_webhook_strategy helper with early returns, short-circuiting when the secret is absent and replacing the server.api.url .expect with a propagated error. - Share compute_signature and a new read_repo_file helper across tests; delete the duplicated webhook_signature, TestHmacSha256, and read_doc/repo_root copies. - Replace the nested for-loops in the new webhook auth tests with five flat #[tokio::test] cases per CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fabro-server/src/github_webhooks.rs | 22 +- lib/crates/fabro-server/src/serve.rs | 227 ++++++++---------- lib/crates/fabro-server/src/server.rs | 187 ++++++++------- lib/crates/fabro-server/tests/it/api/docs.rs | 15 +- lib/crates/fabro-server/tests/it/helpers.rs | 19 ++ .../tests/it/openapi_conformance.rs | 23 +- 6 files changed, 232 insertions(+), 261 deletions(-) diff --git a/lib/crates/fabro-server/src/github_webhooks.rs b/lib/crates/fabro-server/src/github_webhooks.rs index 640944b02..eeab3dd5a 100644 --- a/lib/crates/fabro-server/src/github_webhooks.rs +++ b/lib/crates/fabro-server/src/github_webhooks.rs @@ -5,6 +5,12 @@ use tracing::{info, warn}; type HmacSha256 = Hmac; +/// Name of the server secret holding the GitHub App webhook HMAC key. +pub(crate) const WEBHOOK_SECRET_ENV: &str = "GITHUB_APP_WEBHOOK_SECRET"; + +/// Route path where Fabro receives GitHub App webhook deliveries. +pub(crate) const WEBHOOK_ROUTE: &str = "/api/v1/webhooks/github"; + /// Verify a GitHub webhook HMAC-SHA256 signature. /// /// `signature_header` is the value of the `X-Hub-Signature-256` header, @@ -55,7 +61,7 @@ impl TailscaleFunnelManager { let funnel_url = enable_tailscale_funnel(main_server_port).await?; info!(port = main_server_port, url = %funnel_url, "Tailscale funnel enabled"); - let webhook_url = format!("{funnel_url}/api/v1/webhooks/github"); + let webhook_url = format!("{funnel_url}{WEBHOOK_ROUTE}"); match update_github_app_webhook(app_id, private_key_pem, &webhook_url).await { Ok(()) => { info!(url = %webhook_url, "GitHub App webhook URL updated"); @@ -163,17 +169,17 @@ pub(crate) async fn update_github_app_webhook( Ok(()) } +#[cfg(test)] +pub(crate) fn compute_signature(secret: &[u8], body: &[u8]) -> String { + let mut mac = HmacSha256::new_from_slice(secret).unwrap(); + mac.update(body); + format!("sha256={}", hex::encode(mac.finalize().into_bytes())) +} + #[cfg(test)] mod tests { use super::*; - fn compute_signature(secret: &[u8], body: &[u8]) -> String { - let mut mac = HmacSha256::new_from_slice(secret).unwrap(); - mac.update(body); - let result = mac.finalize(); - format!("sha256={}", hex::encode(result.into_bytes())) - } - #[test] fn valid_signature() { let secret = b"test-secret"; diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 234c50cbd..605c2b30f 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -27,11 +27,13 @@ use tokio::time::interval; use tracing::{error, info, warn}; use crate::bind::{self, Bind, BindRequest}; -use crate::github_webhooks::{TailscaleFunnelManager, update_github_app_webhook}; +use crate::github_webhooks::{ + TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, update_github_app_webhook, +}; use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config}; use crate::jwt_auth::resolve_auth_mode_with_lookup; use crate::server::{ - AppStateConfig, RouterOptions, build_app_state, build_router_with_options, + AppState, AppStateConfig, RouterOptions, build_app_state, build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers, spawn_scheduler, }; use crate::server_secrets::ServerSecrets; @@ -179,6 +181,96 @@ async fn resolve_startup_github_webhook_ip_allowlist( .map(Some) } +async fn start_webhook_strategy( + resolved_server_settings: &ResolvedServerSettings, + state: &Arc, + bind_addr: &Bind, + webhook_secret_present: bool, +) -> anyhow::Result> { + let github = &resolved_server_settings.integrations.github; + let Some(strategy) = github.webhooks.as_ref().and_then(|w| w.strategy) else { + return Ok(None); + }; + + if github.strategy != GithubIntegrationStrategy::App { + warn!( + "GitHub webhook strategy is configured but GitHub integration auth is not set to app; skipping webhook startup" + ); + return Ok(None); + } + if !webhook_secret_present { + warn!( + "Webhook strategy is configured but {WEBHOOK_SECRET_ENV} is not set; skipping webhook startup" + ); + return Ok(None); + } + let Some(app_id) = github.app_id.as_ref().map(resolve_interp).transpose()? else { + warn!( + "Webhook strategy is configured but server.integrations.github.app_id is not set; skipping webhook startup" + ); + return Ok(None); + }; + let github_app = state.github_credentials(github).unwrap_or_else(|err| { + warn!( + error = %err, + "Webhook strategy is configured but GitHub credentials are invalid; skipping webhook startup" + ); + None + }); + let Some(fabro_github::GitHubCredentials::App(github_app)) = github_app else { + warn!( + "Webhook strategy is configured but GITHUB_APP_PRIVATE_KEY is not available; skipping webhook startup" + ); + return Ok(None); + }; + + match strategy { + WebhookStrategy::TailscaleFunnel => { + let Some(port) = bound_tcp_port(bind_addr) else { + warn!( + "GitHub webhook strategy tailscale_funnel requires a TCP server listen address; skipping webhook startup" + ); + return Ok(None); + }; + match TailscaleFunnelManager::start(port, &app_id, &github_app.private_key_pem).await { + Ok(manager) => Ok(Some(manager)), + Err(err) => { + error!( + error = %err, + "Failed to start Tailscale funnel for GitHub webhooks" + ); + Ok(None) + } + } + } + WebhookStrategy::ServerUrl => { + let server_api_url = resolved_server_settings + .api + .url + .as_ref() + .map(resolve_interp) + .transpose()? + .ok_or_else(|| { + anyhow::anyhow!( + "server.api.url must be set when webhook strategy = \"server_url\" (resolver invariant)" + ) + })?; + let webhook_url = format!("{}{WEBHOOK_ROUTE}", server_api_url.trim_end_matches('/')); + match update_github_app_webhook(&app_id, &github_app.private_key_pem, &webhook_url) + .await + { + Ok(()) => info!(url = %webhook_url, "GitHub App webhook URL updated"), + Err(err) => warn!( + error = %err, + url = %webhook_url, + "Failed to update GitHub App webhook URL" + ), + } + Ok(None) + } + } +} + fn use_in_memory_store() -> bool { !matches!( std::env::var(TEST_IN_MEMORY_STORE_ENV).ok().as_deref(), @@ -351,7 +443,7 @@ where let vault_path = storage.secrets_path(); let server_env_path = storage.server_state().env_path(); let server_secrets = ServerSecrets::load(server_env_path.clone())?; - let webhook_secret_present = server_secrets.get("GITHUB_APP_WEBHOOK_SECRET").is_some(); + let webhook_secret_present = server_secrets.get(WEBHOOK_SECRET_ENV).is_some(); // Shared config for live reloading let effective_settings = apply_runtime_settings(&disk_settings, &args, &data_dir); @@ -435,128 +527,13 @@ where let bound_listener = bind_listener(&bind_request).await?; let bind_addr = bound_listener.bind.clone(); - let webhook_manager = match resolved_server_settings - .integrations - .github - .webhooks - .as_ref() - .and_then(|webhooks| webhooks.strategy) - { - None => None, - Some(_) - if resolved_server_settings.integrations.github.strategy - != GithubIntegrationStrategy::App => - { - warn!( - "GitHub webhook strategy is configured but GitHub integration auth is not set to app; skipping webhook startup" - ); - None - } - Some(strategy) => { - let webhook_app_id = resolved_server_settings - .integrations - .github - .app_id - .as_ref() - .map(resolve_interp) - .transpose()?; - match webhook_app_id { - Some(app_id) => { - let github_app = state - .github_credentials(&resolved_server_settings.integrations.github) - .unwrap_or_else(|err| { - warn!( - error = %err, - "Webhook strategy is configured but GitHub credentials are invalid; skipping webhook startup" - ); - None - }); - if webhook_secret_present { - if let Some(fabro_github::GitHubCredentials::App(github_app)) = github_app { - match strategy { - WebhookStrategy::TailscaleFunnel => { - match bound_tcp_port(&bind_addr) { - Some(port) => match TailscaleFunnelManager::start( - port, - &app_id, - &github_app.private_key_pem, - ) - .await - { - Ok(manager) => Some(manager), - Err(err) => { - error!( - error = %err, - "Failed to start Tailscale funnel for GitHub webhooks" - ); - None - } - }, - None => { - warn!( - "GitHub webhook strategy tailscale_funnel requires a TCP server listen address; skipping webhook startup" - ); - None - } - } - } - WebhookStrategy::ServerUrl => { - let server_api_url = resolved_server_settings - .api - .url - .as_ref() - .map(resolve_interp) - .transpose()? - .expect( - "server.api.url should be validated when server_url strategy is configured", - ); - let webhook_url = format!( - "{}/api/v1/webhooks/github", - server_api_url.trim_end_matches('/') - ); - if let Err(err) = update_github_app_webhook( - &app_id, - &github_app.private_key_pem, - &webhook_url, - ) - .await - { - warn!( - error = %err, - url = %webhook_url, - "Failed to update GitHub App webhook URL" - ); - } else { - info!( - url = %webhook_url, - "GitHub App webhook URL updated" - ); - } - None - } - } - } else { - warn!( - "Webhook strategy is configured but GITHUB_APP_PRIVATE_KEY is not available; skipping webhook startup" - ); - None - } - } else { - warn!( - "Webhook strategy is configured but GITHUB_APP_WEBHOOK_SECRET is not set; skipping webhook startup" - ); - None - } - } - None => { - warn!( - "Webhook strategy is configured but server.integrations.github.app_id is not set; skipping webhook startup" - ); - None - } - } - } - }; + let webhook_manager = start_webhook_strategy( + &resolved_server_settings, + &state, + &bind_addr, + webhook_secret_present, + ) + .await?; let (shutdown_tx, shutdown_rx) = watch::channel(false); let shutdown_state = Arc::clone(&state); diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 704f338ff..8acf43dd6 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -111,7 +111,9 @@ use ulid::Ulid; use crate::bind::Bind; use crate::error::ApiError; -use crate::github_webhooks::{parse_event_metadata, verify_signature}; +use crate::github_webhooks::{ + WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV, parse_event_metadata, verify_signature, +}; use crate::ip_allowlist::{IpAllowlistConfig, ip_allowlist_middleware}; use crate::jwt_auth::{ AuthMode, AuthenticatedService, AuthenticatedSubject, authenticate_service_parts, @@ -953,11 +955,7 @@ pub fn build_router_with_options( let web_enabled = options.web_enabled; let webhook_ip_allowlist = options.github_webhook_ip_allowlist; let middleware_state = Arc::clone(&state); - let webhook_state = Arc::clone(&state); - let default_webhook_ip_allowlist = Arc::clone(&ip_allowlist_config); - let webhook_secret_present = webhook_state - .server_secret("GITHUB_APP_WEBHOOK_SECRET") - .is_some(); + let webhook_secret = state.server_secret(WEBHOOK_SECRET_ENV); let api_common = if web_enabled { Router::new() .route("/openapi.json", get(openapi_spec)) @@ -1042,16 +1040,15 @@ pub fn build_router_with_options( } app_router = app_router.layer(middleware::from_fn_with_state( - ip_allowlist_config, + Arc::clone(&ip_allowlist_config), ip_allowlist_middleware, )); let mut router = app_router; - if webhook_secret_present { - let webhook_ip_allowlist = webhook_ip_allowlist.unwrap_or(default_webhook_ip_allowlist); - router = github_webhook_routes(webhook_ip_allowlist) - .with_state(webhook_state) - .merge(router); + if let Some(secret) = webhook_secret { + let allowlist = webhook_ip_allowlist.unwrap_or(ip_allowlist_config); + let secret: Arc<[u8]> = Arc::from(secret.into_bytes().into_boxed_slice()); + router = github_webhook_routes(secret, allowlist).merge(router); } router @@ -1059,9 +1056,10 @@ pub fn build_router_with_options( .layer(trace_layer) } -fn github_webhook_routes(ip_allowlist_config: Arc) -> Router> { +fn github_webhook_routes(secret: Arc<[u8]>, ip_allowlist_config: Arc) -> Router { Router::new() - .route("/api/v1/webhooks/github", post(github_webhook)) + .route(WEBHOOK_ROUTE, post(github_webhook)) + .with_state(secret) .layer(middleware::from_fn_with_state( ip_allowlist_config, ip_allowlist_middleware, @@ -1239,7 +1237,7 @@ async fn not_implemented() -> Response { } async fn github_webhook( - State(state): State>, + State(secret): State>, headers: HeaderMap, body: Bytes, ) -> StatusCode { @@ -1248,14 +1246,6 @@ async fn github_webhook( .and_then(|value| value.to_str().ok()) .unwrap_or("unknown"); - let Some(secret) = state.server_secret("GITHUB_APP_WEBHOOK_SECRET") else { - warn!( - delivery = %delivery_id, - "GitHub webhook route reached without a configured webhook secret" - ); - return StatusCode::NOT_FOUND; - }; - let Some(signature) = headers .get("x-hub-signature-256") .and_then(|value| value.to_str().ok()) @@ -1264,7 +1254,7 @@ async fn github_webhook( return StatusCode::UNAUTHORIZED; }; - if !verify_signature(secret.as_bytes(), &body, signature) { + if !verify_signature(&secret, &body, signature) { warn!(delivery = %delivery_id, "Webhook signature verification failed"); return StatusCode::UNAUTHORIZED; } @@ -7390,12 +7380,11 @@ mod tests { use fabro_model::Provider; use fabro_types::settings::ServerAuthMethod; use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures}; - use hmac::{Hmac, Mac}; use serde_json::json; - use sha2::Sha256; use tower::ServiceExt; use super::*; + use crate::github_webhooks::compute_signature; use crate::jwt_auth::{AuthMode, ConfiguredAuth}; const MINIMAL_DOT: &str = r#"digraph Test { @@ -7410,8 +7399,6 @@ mod tests { const WRONG_DEV_TOKEN: &str = "fabro_dev_cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"; - type TestHmacSha256 = Hmac; - fn test_app_with() -> Router { let state = create_app_state(); build_router(state, AuthMode::Disabled) @@ -7431,16 +7418,10 @@ mod tests { format!("/api/v1{path}") } - fn webhook_signature(secret: &str, body: &[u8]) -> String { - let mut mac = TestHmacSha256::new_from_slice(secret.as_bytes()).unwrap(); - mac.update(body); - format!("sha256={}", hex::encode(mac.finalize().into_bytes())) - } - fn webhook_test_app(auth_mode: AuthMode) -> Router { let secret = TEST_WEBHOOK_SECRET.to_string(); let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, move |name| { - (name == "GITHUB_APP_WEBHOOK_SECRET").then(|| secret.clone()) + (name == WEBHOOK_SECRET_ENV).then(|| secret.clone()) }); build_router_with_options( state, @@ -7453,6 +7434,31 @@ mod tests { ) } + fn webhook_request( + signature: Option<&str>, + authorization: Option<&str>, + body: &[u8], + ) -> Request { + let mut builder = Request::builder() + .method("POST") + .uri(api("/webhooks/github")) + .header("x-github-event", "pull_request"); + if let Some(sig) = signature { + builder = builder.header("x-hub-signature-256", sig); + } + if let Some(value) = authorization { + builder = builder.header(header::AUTHORIZATION, value); + } + builder.body(Body::from(body.to_vec())).unwrap() + } + + fn dev_token_auth_mode() -> AuthMode { + AuthMode::Enabled(ConfiguredAuth { + methods: vec![ServerAuthMethod::DevToken], + dev_token: Some(TEST_DEV_TOKEN.to_string()), + }) + } + #[tokio::test] async fn resolved_settings_view_returns_internal_error_when_runtime_settings_stop_resolving() { let state = create_app_state(); @@ -7518,76 +7524,71 @@ type = "http" } #[tokio::test] - async fn github_webhook_rejects_missing_or_invalid_signatures() { + async fn github_webhook_rejects_missing_signature() { let app = webhook_test_app(AuthMode::Disabled); let body = br#"{"action":"opened"}"#; - let missing_signature = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(api("/webhooks/github")) - .body(Body::from(body.to_vec())) - .unwrap(), - ) + let response = app + .oneshot(webhook_request(None, None, body)) .await .unwrap(); - assert_eq!(missing_signature.status(), StatusCode::UNAUTHORIZED); - - let invalid_signature = app - .oneshot( - Request::builder() - .method("POST") - .uri(api("/webhooks/github")) - .header( - "x-hub-signature-256", - webhook_signature("wrong-secret", body), - ) - .body(Body::from(body.to_vec())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(invalid_signature.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] - async fn github_webhook_accepts_valid_signatures_regardless_of_auth_mode() { + async fn github_webhook_rejects_signature_signed_with_wrong_secret() { + let app = webhook_test_app(AuthMode::Disabled); + let body = br#"{"action":"opened"}"#; + let bad_signature = compute_signature(b"wrong-secret", body); + + let response = app + .oneshot(webhook_request(Some(&bad_signature), None, body)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + #[tokio::test] + async fn github_webhook_accepts_valid_signature_when_auth_disabled() { let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#; - let signature = webhook_signature(TEST_WEBHOOK_SECRET, body); + let signature = compute_signature(TEST_WEBHOOK_SECRET.as_bytes(), body); + let app = webhook_test_app(AuthMode::Disabled); - for auth_mode in [ - AuthMode::Disabled, - AuthMode::Enabled(ConfiguredAuth { - methods: vec![ServerAuthMethod::DevToken], - dev_token: Some(TEST_DEV_TOKEN.to_string()), - }), - ] { - let app = webhook_test_app(auth_mode); + let response = app + .oneshot(webhook_request(Some(&signature), None, body)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } - for authorization in [ - None, - Some(format!("Bearer {WRONG_DEV_TOKEN}")), - Some(format!("Bearer {TEST_DEV_TOKEN}")), - ] { - let mut request = Request::builder() - .method("POST") - .uri(api("/webhooks/github")) - .header("x-hub-signature-256", signature.clone()) - .header("x-github-event", "pull_request") - .body(Body::from(body.to_vec())) - .unwrap(); - if let Some(value) = authorization.as_deref() { - request - .headers_mut() - .insert(header::AUTHORIZATION, value.parse().unwrap()); - } + #[tokio::test] + async fn github_webhook_accepts_valid_signature_without_bearer_token() { + let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#; + let signature = compute_signature(TEST_WEBHOOK_SECRET.as_bytes(), body); + let app = webhook_test_app(dev_token_auth_mode()); - let response = app.clone().oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - } - } + let response = app + .oneshot(webhook_request(Some(&signature), None, body)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn github_webhook_accepts_valid_signature_with_wrong_bearer_token() { + let body = br#"{"repository":{"full_name":"owner/repo"},"action":"opened"}"#; + let signature = compute_signature(TEST_WEBHOOK_SECRET.as_bytes(), body); + let app = webhook_test_app(dev_token_auth_mode()); + + let response = app + .oneshot(webhook_request( + Some(&signature), + Some(&format!("Bearer {WRONG_DEV_TOKEN}")), + body, + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); } #[tokio::test] diff --git a/lib/crates/fabro-server/tests/it/api/docs.rs b/lib/crates/fabro-server/tests/it/api/docs.rs index 346bbd5ac..e20617f90 100644 --- a/lib/crates/fabro-server/tests/it/api/docs.rs +++ b/lib/crates/fabro-server/tests/it/api/docs.rs @@ -1,17 +1,4 @@ -#![expect( - clippy::disallowed_methods, - reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path" -)] - -use std::path::PathBuf; - -fn read_doc(relative_path: &str) -> String { - let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("../../../") - .join(relative_path); - std::fs::read_to_string(&path) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())) -} +use crate::helpers::read_repo_file as read_doc; #[test] fn active_server_docs_describe_the_unix_socket_default() { diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index e5143d512..1bef10935 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -1,3 +1,4 @@ +use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -77,6 +78,24 @@ pub(crate) fn api(path: &str) -> String { format!("/api/v1{path}") } +pub(crate) fn repo_root() -> PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(3) + .expect("fabro-server crate should be nested under lib/crates/fabro-server") + .to_path_buf() +} + +#[expect( + clippy::disallowed_methods, + reason = "test fixture reads tracked files synchronously" +)] +pub(crate) fn read_repo_file(relative_path: &str) -> String { + let path = repo_root().join(relative_path); + std::fs::read_to_string(&path) + .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())) +} + pub(crate) async fn body_json(body: Body) -> serde_json::Value { let bytes = to_bytes(body, usize::MAX) .await diff --git a/lib/crates/fabro-server/tests/it/openapi_conformance.rs b/lib/crates/fabro-server/tests/it/openapi_conformance.rs index 8812ff6d8..898d6fc2a 100644 --- a/lib/crates/fabro-server/tests/it/openapi_conformance.rs +++ b/lib/crates/fabro-server/tests/it/openapi_conformance.rs @@ -7,10 +7,6 @@ clippy::manual_let_else, reason = "These spec/router conformance tests prefer direct assertions over pedantic style lints." )] -#![expect( - clippy::disallowed_methods, - reason = "integration tests stage fixtures with sync std::fs; test infrastructure, not Tokio-hot path" -)] use axum::body::Body; use axum::http::{Method, Request, StatusCode}; @@ -20,28 +16,13 @@ use fabro_server::server::{build_router, create_app_state_with_env_lookup}; use serde_yaml::Value; use tower::ServiceExt; -use super::helpers::{test_app_state, test_settings}; - -fn repo_root() -> std::path::PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(3) - .expect("fabro-server crate should be nested under lib/crates/fabro-server") - .to_path_buf() -} +use super::helpers::{read_repo_file, test_app_state, test_settings}; fn load_spec() -> Value { - let spec_path = repo_root().join("docs/api-reference/fabro-api.yaml"); - let text = std::fs::read_to_string(&spec_path).expect("failed to read spec"); + let text = read_repo_file("docs/api-reference/fabro-api.yaml"); serde_yaml::from_str(&text).expect("failed to parse spec") } -fn read_repo_file(relative_path: &str) -> String { - let path = repo_root().join(relative_path); - std::fs::read_to_string(&path) - .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display())) -} - fn resolve_path(path: &str) -> String { path.replace("{id}", "test-id") .replace("{qid}", "test-qid") From 773307eec0b664cd6bcfa72c75b18d5c114a0d19 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 20 Apr 2026 07:48:33 -0400 Subject: [PATCH 05/12] chore(spa): refresh embedded bundle to match bun 1.3.13 CI's TypeScript Build job upgraded bun to 1.3.13 (via setup-bun@v2.2.0 pulling the latest release), which produces a different content-hashed entry CSS than the bundle committed under bun 1.3.10. The drift was caught by the widened path filter in c8b807f30 and failed the git diff --exit-code check on lib/crates/fabro-spa/assets. Rebuilds with bun 1.3.13 so the embedded SPA matches the build CI reproduces. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/crates/fabro-spa/assets/assets/entry-g7fj8z7g.css | 1 - lib/crates/fabro-spa/assets/assets/entry-vh02mb6v.css | 1 + lib/crates/fabro-spa/assets/index.html | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 lib/crates/fabro-spa/assets/assets/entry-g7fj8z7g.css create mode 100644 lib/crates/fabro-spa/assets/assets/entry-vh02mb6v.css diff --git a/lib/crates/fabro-spa/assets/assets/entry-g7fj8z7g.css b/lib/crates/fabro-spa/assets/assets/entry-g7fj8z7g.css deleted file mode 100644 index fb903107a..000000000 --- a/lib/crates/fabro-spa/assets/assets/entry-g7fj8z7g.css +++ /dev/null @@ -1 +0,0 @@ -@layer theme{@theme default{--font-sans: ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; --font-serif: ui-serif,Georgia,Cambria,"Times New Roman",Times,serif; --font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; --color-red-50: oklch(97.1% .013 17.38); --color-red-100: oklch(93.6% .032 17.717); --color-red-200: oklch(88.5% .062 18.334); --color-red-300: oklch(80.8% .114 19.571); --color-red-400: oklch(70.4% .191 22.216); --color-red-500: oklch(63.7% .237 25.331); --color-red-600: oklch(57.7% .245 27.325); --color-red-700: oklch(50.5% .213 27.518); --color-red-800: oklch(44.4% .177 26.899); --color-red-900: oklch(39.6% .141 25.723); --color-red-950: oklch(25.8% .092 26.042); --color-orange-50: oklch(98% .016 73.684); --color-orange-100: oklch(95.4% .038 75.164); --color-orange-200: oklch(90.1% .076 70.697); --color-orange-300: oklch(83.7% .128 66.29); --color-orange-400: oklch(75% .183 55.934); --color-orange-500: oklch(70.5% .213 47.604); --color-orange-600: oklch(64.6% .222 41.116); --color-orange-700: oklch(55.3% .195 38.402); --color-orange-800: oklch(47% .157 37.304); --color-orange-900: oklch(40.8% .123 38.172); --color-orange-950: oklch(26.6% .079 36.259); --color-amber-50: oklch(98.7% .022 95.277); --color-amber-100: oklch(96.2% .059 95.617); --color-amber-200: oklch(92.4% .12 95.746); --color-amber-300: oklch(87.9% .169 91.605); --color-amber-400: oklch(82.8% .189 84.429); --color-amber-500: oklch(76.9% .188 70.08); --color-amber-600: oklch(66.6% .179 58.318); --color-amber-700: oklch(55.5% .163 48.998); --color-amber-800: oklch(47.3% .137 46.201); --color-amber-900: oklch(41.4% .112 45.904); --color-amber-950: oklch(27.9% .077 45.635); --color-yellow-50: oklch(98.7% .026 102.212); --color-yellow-100: oklch(97.3% .071 103.193); --color-yellow-200: oklch(94.5% .129 101.54); --color-yellow-300: oklch(90.5% .182 98.111); --color-yellow-400: oklch(85.2% .199 91.936); --color-yellow-500: oklch(79.5% .184 86.047); --color-yellow-600: oklch(68.1% .162 75.834); --color-yellow-700: oklch(55.4% .135 66.442); --color-yellow-800: oklch(47.6% .114 61.907); --color-yellow-900: oklch(42.1% .095 57.708); --color-yellow-950: oklch(28.6% .066 53.813); --color-lime-50: oklch(98.6% .031 120.757); --color-lime-100: oklch(96.7% .067 122.328); --color-lime-200: oklch(93.8% .127 124.321); --color-lime-300: oklch(89.7% .196 126.665); --color-lime-400: oklch(84.1% .238 128.85); --color-lime-500: oklch(76.8% .233 130.85); --color-lime-600: oklch(64.8% .2 131.684); --color-lime-700: oklch(53.2% .157 131.589); --color-lime-800: oklch(45.3% .124 130.933); --color-lime-900: oklch(40.5% .101 131.063); --color-lime-950: oklch(27.4% .072 132.109); --color-green-50: oklch(98.2% .018 155.826); --color-green-100: oklch(96.2% .044 156.743); --color-green-200: oklch(92.5% .084 155.995); --color-green-300: oklch(87.1% .15 154.449); --color-green-400: oklch(79.2% .209 151.711); --color-green-500: oklch(72.3% .219 149.579); --color-green-600: oklch(62.7% .194 149.214); --color-green-700: oklch(52.7% .154 150.069); --color-green-800: oklch(44.8% .119 151.328); --color-green-900: oklch(39.3% .095 152.535); --color-green-950: oklch(26.6% .065 152.934); --color-emerald-50: oklch(97.9% .021 166.113); --color-emerald-100: oklch(95% .052 163.051); --color-emerald-200: oklch(90.5% .093 164.15); --color-emerald-300: oklch(84.5% .143 164.978); --color-emerald-400: oklch(76.5% .177 163.223); --color-emerald-500: oklch(69.6% .17 162.48); --color-emerald-600: oklch(59.6% .145 163.225); --color-emerald-700: oklch(50.8% .118 165.612); --color-emerald-800: oklch(43.2% .095 166.913); --color-emerald-900: oklch(37.8% .077 168.94); --color-emerald-950: oklch(26.2% .051 172.552); --color-teal-50: oklch(98.4% .014 180.72); --color-teal-100: oklch(95.3% .051 180.801); --color-teal-200: oklch(91% .096 180.426); --color-teal-300: oklch(85.5% .138 181.071); --color-teal-400: oklch(77.7% .152 181.912); --color-teal-500: oklch(70.4% .14 182.503); --color-teal-600: oklch(60% .118 184.704); --color-teal-700: oklch(51.1% .096 186.391); --color-teal-800: oklch(43.7% .078 188.216); --color-teal-900: oklch(38.6% .063 188.416); --color-teal-950: oklch(27.7% .046 192.524); --color-cyan-50: oklch(98.4% .019 200.873); --color-cyan-100: oklch(95.6% .045 203.388); --color-cyan-200: oklch(91.7% .08 205.041); --color-cyan-300: oklch(86.5% .127 207.078); --color-cyan-400: oklch(78.9% .154 211.53); --color-cyan-500: oklch(71.5% .143 215.221); --color-cyan-600: oklch(60.9% .126 221.723); --color-cyan-700: oklch(52% .105 223.128); --color-cyan-800: oklch(45% .085 224.283); --color-cyan-900: oklch(39.8% .07 227.392); --color-cyan-950: oklch(30.2% .056 229.695); --color-sky-50: oklch(97.7% .013 236.62); --color-sky-100: oklch(95.1% .026 236.824); --color-sky-200: oklch(90.1% .058 230.902); --color-sky-300: oklch(82.8% .111 230.318); --color-sky-400: oklch(74.6% .16 232.661); --color-sky-500: oklch(68.5% .169 237.323); --color-sky-600: oklch(58.8% .158 241.966); --color-sky-700: oklch(50% .134 242.749); --color-sky-800: oklch(44.3% .11 240.79); --color-sky-900: oklch(39.1% .09 240.876); --color-sky-950: oklch(29.3% .066 243.157); --color-blue-50: oklch(97% .014 254.604); --color-blue-100: oklch(93.2% .032 255.585); --color-blue-200: oklch(88.2% .059 254.128); --color-blue-300: oklch(80.9% .105 251.813); --color-blue-400: oklch(70.7% .165 254.624); --color-blue-500: oklch(62.3% .214 259.815); --color-blue-600: oklch(54.6% .245 262.881); --color-blue-700: oklch(48.8% .243 264.376); --color-blue-800: oklch(42.4% .199 265.638); --color-blue-900: oklch(37.9% .146 265.522); --color-blue-950: oklch(28.2% .091 267.935); --color-indigo-50: oklch(96.2% .018 272.314); --color-indigo-100: oklch(93% .034 272.788); --color-indigo-200: oklch(87% .065 274.039); --color-indigo-300: oklch(78.5% .115 274.713); --color-indigo-400: oklch(67.3% .182 276.935); --color-indigo-500: oklch(58.5% .233 277.117); --color-indigo-600: oklch(51.1% .262 276.966); --color-indigo-700: oklch(45.7% .24 277.023); --color-indigo-800: oklch(39.8% .195 277.366); --color-indigo-900: oklch(35.9% .144 278.697); --color-indigo-950: oklch(25.7% .09 281.288); --color-violet-50: oklch(96.9% .016 293.756); --color-violet-100: oklch(94.3% .029 294.588); --color-violet-200: oklch(89.4% .057 293.283); --color-violet-300: oklch(81.1% .111 293.571); --color-violet-400: oklch(70.2% .183 293.541); --color-violet-500: oklch(60.6% .25 292.717); --color-violet-600: oklch(54.1% .281 293.009); --color-violet-700: oklch(49.1% .27 292.581); --color-violet-800: oklch(43.2% .232 292.759); --color-violet-900: oklch(38% .189 293.745); --color-violet-950: oklch(28.3% .141 291.089); --color-purple-50: oklch(97.7% .014 308.299); --color-purple-100: oklch(94.6% .033 307.174); --color-purple-200: oklch(90.2% .063 306.703); --color-purple-300: oklch(82.7% .119 306.383); --color-purple-400: oklch(71.4% .203 305.504); --color-purple-500: oklch(62.7% .265 303.9); --color-purple-600: oklch(55.8% .288 302.321); --color-purple-700: oklch(49.6% .265 301.924); --color-purple-800: oklch(43.8% .218 303.724); --color-purple-900: oklch(38.1% .176 304.987); --color-purple-950: oklch(29.1% .149 302.717); --color-fuchsia-50: oklch(97.7% .017 320.058); --color-fuchsia-100: oklch(95.2% .037 318.852); --color-fuchsia-200: oklch(90.3% .076 319.62); --color-fuchsia-300: oklch(83.3% .145 321.434); --color-fuchsia-400: oklch(74% .238 322.16); --color-fuchsia-500: oklch(66.7% .295 322.15); --color-fuchsia-600: oklch(59.1% .293 322.896); --color-fuchsia-700: oklch(51.8% .253 323.949); --color-fuchsia-800: oklch(45.2% .211 324.591); --color-fuchsia-900: oklch(40.1% .17 325.612); --color-fuchsia-950: oklch(29.3% .136 325.661); --color-pink-50: oklch(97.1% .014 343.198); --color-pink-100: oklch(94.8% .028 342.258); --color-pink-200: oklch(89.9% .061 343.231); --color-pink-300: oklch(82.3% .12 346.018); --color-pink-400: oklch(71.8% .202 349.761); --color-pink-500: oklch(65.6% .241 354.308); --color-pink-600: oklch(59.2% .249 .584); --color-pink-700: oklch(52.5% .223 3.958); --color-pink-800: oklch(45.9% .187 3.815); --color-pink-900: oklch(40.8% .153 2.432); --color-pink-950: oklch(28.4% .109 3.907); --color-rose-50: oklch(96.9% .015 12.422); --color-rose-100: oklch(94.1% .03 12.58); --color-rose-200: oklch(89.2% .058 10.001); --color-rose-300: oklch(81% .117 11.638); --color-rose-400: oklch(71.2% .194 13.428); --color-rose-500: oklch(64.5% .246 16.439); --color-rose-600: oklch(58.6% .253 17.585); --color-rose-700: oklch(51.4% .222 16.935); --color-rose-800: oklch(45.5% .188 13.697); --color-rose-900: oklch(41% .159 10.272); --color-rose-950: oklch(27.1% .105 12.094); --color-slate-50: oklch(98.4% .003 247.858); --color-slate-100: oklch(96.8% .007 247.896); --color-slate-200: oklch(92.9% .013 255.508); --color-slate-300: oklch(86.9% .022 252.894); --color-slate-400: oklch(70.4% .04 256.788); --color-slate-500: oklch(55.4% .046 257.417); --color-slate-600: oklch(44.6% .043 257.281); --color-slate-700: oklch(37.2% .044 257.287); --color-slate-800: oklch(27.9% .041 260.031); --color-slate-900: oklch(20.8% .042 265.755); --color-slate-950: oklch(12.9% .042 264.695); --color-gray-50: oklch(98.5% .002 247.839); --color-gray-100: oklch(96.7% .003 264.542); --color-gray-200: oklch(92.8% .006 264.531); --color-gray-300: oklch(87.2% .01 258.338); --color-gray-400: oklch(70.7% .022 261.325); --color-gray-500: oklch(55.1% .027 264.364); --color-gray-600: oklch(44.6% .03 256.802); --color-gray-700: oklch(37.3% .034 259.733); --color-gray-800: oklch(27.8% .033 256.848); --color-gray-900: oklch(21% .034 264.665); --color-gray-950: oklch(13% .028 261.692); --color-zinc-50: oklch(98.5% 0 0); --color-zinc-100: oklch(96.7% .001 286.375); --color-zinc-200: oklch(92% .004 286.32); --color-zinc-300: oklch(87.1% .006 286.286); --color-zinc-400: oklch(70.5% .015 286.067); --color-zinc-500: oklch(55.2% .016 285.938); --color-zinc-600: oklch(44.2% .017 285.786); --color-zinc-700: oklch(37% .013 285.805); --color-zinc-800: oklch(27.4% .006 286.033); --color-zinc-900: oklch(21% .006 285.885); --color-zinc-950: oklch(14.1% .005 285.823); --color-neutral-50: oklch(98.5% 0 0); --color-neutral-100: oklch(97% 0 0); --color-neutral-200: oklch(92.2% 0 0); --color-neutral-300: oklch(87% 0 0); --color-neutral-400: oklch(70.8% 0 0); --color-neutral-500: oklch(55.6% 0 0); --color-neutral-600: oklch(43.9% 0 0); --color-neutral-700: oklch(37.1% 0 0); --color-neutral-800: oklch(26.9% 0 0); --color-neutral-900: oklch(20.5% 0 0); --color-neutral-950: oklch(14.5% 0 0); --color-stone-50: oklch(98.5% .001 106.423); --color-stone-100: oklch(97% .001 106.424); --color-stone-200: oklch(92.3% .003 48.717); --color-stone-300: oklch(86.9% .005 56.366); --color-stone-400: oklch(70.9% .01 56.259); --color-stone-500: oklch(55.3% .013 58.071); --color-stone-600: oklch(44.4% .011 73.639); --color-stone-700: oklch(37.4% .01 67.558); --color-stone-800: oklch(26.8% .007 34.298); --color-stone-900: oklch(21.6% .006 56.043); --color-stone-950: oklch(14.7% .004 49.25); --color-mauve-50: oklch(98.5% 0 0); --color-mauve-100: oklch(96% .003 325.6); --color-mauve-200: oklch(92.2% .005 325.62); --color-mauve-300: oklch(86.5% .012 325.68); --color-mauve-400: oklch(71.1% .019 323.02); --color-mauve-500: oklch(54.2% .034 322.5); --color-mauve-600: oklch(43.5% .029 321.78); --color-mauve-700: oklch(36.4% .029 323.89); --color-mauve-800: oklch(26.3% .024 320.12); --color-mauve-900: oklch(21.2% .019 322.12); --color-mauve-950: oklch(14.5% .008 326); --color-olive-50: oklch(98.8% .003 106.5); --color-olive-100: oklch(96.6% .005 106.5); --color-olive-200: oklch(93% .007 106.5); --color-olive-300: oklch(88% .011 106.6); --color-olive-400: oklch(73.7% .021 106.9); --color-olive-500: oklch(58% .031 107.3); --color-olive-600: oklch(46.6% .025 107.3); --color-olive-700: oklch(39.4% .023 107.4); --color-olive-800: oklch(28.6% .016 107.4); --color-olive-900: oklch(22.8% .013 107.4); --color-olive-950: oklch(15.3% .006 107.1); --color-mist-50: oklch(98.7% .002 197.1); --color-mist-100: oklch(96.3% .002 197.1); --color-mist-200: oklch(92.5% .005 214.3); --color-mist-300: oklch(87.2% .007 219.6); --color-mist-400: oklch(72.3% .014 214.4); --color-mist-500: oklch(56% .021 213.5); --color-mist-600: oklch(45% .017 213.2); --color-mist-700: oklch(37.8% .015 216); --color-mist-800: oklch(27.5% .011 216.9); --color-mist-900: oklch(21.8% .008 223.9); --color-mist-950: oklch(14.8% .004 228.8); --color-taupe-50: oklch(98.6% .002 67.8); --color-taupe-100: oklch(96% .002 17.2); --color-taupe-200: oklch(92.2% .005 34.3); --color-taupe-300: oklch(86.8% .007 39.5); --color-taupe-400: oklch(71.4% .014 41.2); --color-taupe-500: oklch(54.7% .021 43.1); --color-taupe-600: oklch(43.8% .017 39.3); --color-taupe-700: oklch(36.7% .016 35.7); --color-taupe-800: oklch(26.8% .011 36.5); --color-taupe-900: oklch(21.4% .009 43.1); --color-taupe-950: oklch(14.7% .004 49.3); --color-black: #000; --color-white: #fff; --spacing: .25rem; --breakpoint-sm: 40rem; --breakpoint-md: 48rem; --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; --container-3xs: 16rem; --container-2xs: 18rem; --container-xs: 20rem; --container-sm: 24rem; --container-md: 28rem; --container-lg: 32rem; --container-xl: 36rem; --container-2xl: 42rem; --container-3xl: 48rem; --container-4xl: 56rem; --container-5xl: 64rem; --container-6xl: 72rem; --container-7xl: 80rem; --text-xs: .75rem; --text-xs--line-height: calc(1/.75); --text-sm: .875rem; --text-sm--line-height: calc(1.25/.875); --text-base: 1rem; --text-base--line-height: calc(1.5/1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75/1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75/1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2/1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25/1.875); --text-4xl: 2.25rem; --text-4xl--line-height: calc(2.5/2.25); --text-5xl: 3rem; --text-5xl--line-height: 1; --text-6xl: 3.75rem; --text-6xl--line-height: 1; --text-7xl: 4.5rem; --text-7xl--line-height: 1; --text-8xl: 6rem; --text-8xl--line-height: 1; --text-9xl: 8rem; --text-9xl--line-height: 1; --font-weight-thin: 100; --font-weight-extralight: 200; --font-weight-light: 300; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; --font-weight-extrabold: 800; --font-weight-black: 900; --tracking-tighter: -.05em; --tracking-tight: -.025em; --tracking-normal: 0em; --tracking-wide: .025em; --tracking-wider: .05em; --tracking-widest: .1em; --leading-tight: 1.25; --leading-snug: 1.375; --leading-normal: 1.5; --leading-relaxed: 1.625; --leading-loose: 2; --radius-xs: .125rem; --radius-sm: .25rem; --radius-md: .375rem; --radius-lg: .5rem; --radius-xl: .75rem; --radius-2xl: 1rem; --radius-3xl: 1.5rem; --radius-4xl: 2rem; --shadow-2xs: 0 1px #0000000d; --shadow-xs: 0 1px 2px 0 #0000000d; --shadow-sm: 0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a; --shadow-md: 0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a; --shadow-lg: 0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a; --shadow-xl: 0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a; --shadow-2xl: 0 25px 50px -12px #00000040; --inset-shadow-2xs: inset 0 1px #0000000d; --inset-shadow-xs: inset 0 1px 1px #0000000d; --inset-shadow-sm: inset 0 2px 4px #0000000d; --drop-shadow-xs: 0 1px 1px #0000000d; --drop-shadow-sm: 0 1px 2px #00000026; --drop-shadow-md: 0 3px 3px #0000001f; --drop-shadow-lg: 0 4px 4px #00000026; --drop-shadow-xl: 0 9px 7px #0000001a; --drop-shadow-2xl: 0 25px 25px #00000026; --text-shadow-2xs: 0px 1px 0px #00000026; --text-shadow-xs: 0px 1px 1px #0003; --text-shadow-sm: 0px 1px 0px #00000013,0px 1px 1px #00000013,0px 2px 2px #00000013; --text-shadow-md: 0px 1px 1px #0000001a,0px 1px 2px #0000001a,0px 2px 4px #0000001a; --text-shadow-lg: 0px 1px 2px #0000001a,0px 3px 2px #0000001a,0px 4px 8px #0000001a; --ease-in: cubic-bezier(.4,0,1,1); --ease-out: cubic-bezier(0,0,.2,1); --ease-in-out: cubic-bezier(.4,0,.2,1); --animate-spin: spin 1s linear infinite; --animate-ping: ping 1s cubic-bezier(0,0,.2,1)infinite; --animate-pulse: pulse 2s cubic-bezier(.4,0,.6,1)infinite; --animate-bounce: bounce 1s infinite; @keyframes spin { to { transform: rotate(360deg); }}@keyframes ping { 75%,100% { transform: scale(2); opacity: 0; }}@keyframes pulse { 50% { opacity: .5; }}@keyframes bounce { 0%,100% { transform: translateY(-25%); animation-timing-function: cubic-bezier(.8,0,1,1); }50% { transform: none; animation-timing-function: cubic-bezier(0,0,.2,1); }}--blur-xs: 4px; --blur-sm: 8px; --blur-md: 12px; --blur-lg: 16px; --blur-xl: 24px; --blur-2xl: 40px; --blur-3xl: 64px; --perspective-dramatic: 100px; --perspective-near: 300px; --perspective-normal: 500px; --perspective-midrange: 800px; --perspective-distant: 1200px; --aspect-video: 16/9; --default-transition-duration: .15s; --default-transition-timing-function: cubic-bezier(.4,0,.2,1); --default-font-family: --theme(--font-sans,initial); --default-font-feature-settings: --theme(--font-sans--font-feature-settings,initial); --default-font-variation-settings: --theme(--font-sans--font-variation-settings,initial); --default-mono-font-family: --theme(--font-mono,initial); --default-mono-font-feature-settings: --theme(--font-mono--font-feature-settings,initial); --default-mono-font-variation-settings: --theme(--font-mono--font-variation-settings,initial);}@theme default inline reference{--blur: 8px; --shadow: 0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a; --shadow-inner: inset 0 2px 4px 0 #0000000d; --drop-shadow: 0 1px 2px #0000001a,0 1px 1px #0000000f; --radius: .25rem; --max-width-prose: 65ch;}}@layer base{*,:after,:before{box-sizing:border-box;border:0 solid;margin:0;padding:0}::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::-webkit-file-upload-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:--theme(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:--theme(--default-font-feature-settings,normal);font-variation-settings:--theme(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{color:inherit;border-top-width:1px;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:--theme(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:--theme(--default-mono-font-feature-settings,normal);font-variation-settings:--theme(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{position:relative;vertical-align:baseline;font-size:75%;line-height:0}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::-webkit-file-upload-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:-webkit-any([multiple],[size])) optgroup{font-weight:bolder}:where(select:-moz-any([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:-webkit-any([multiple],[size])) optgroup option{padding-inline-start:20px}:where(select:-moz-any([multiple],[size])) optgroup option{padding-inline-start:20px}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::-webkit-file-upload-button{margin-inline-end:4px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports ( not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{text-align:inherit;min-height:1lh}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-year-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-month-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-day-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-hour-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-minute-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-second-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-millisecond-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-meridiem-field{padding-block-start:0;padding-block-end:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button{appearance:button}input:where([type=button],[type=reset],[type=submit]){appearance:button}::-webkit-file-upload-button{appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer utilities{@tailwind utilities;}@plugin "@tailwindcss/typography";@layer base{a[href],button,select,summary,[role=button],[role=link],[role=option],[role=menuitem],[role=tab]{cursor:pointer}}@theme{--font-sans: "Geist",ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; --font-mono: "JetBrains Mono",ui-monospace,monospace; --color-teal-300: #b5ddef; --color-teal-500: #67b2d7; --color-teal-700: #357f9e; --color-navy-950: #0f1729; --color-navy-800: #252c3d; --color-navy-600: #4b5768; --color-ice-50: #f7f9fb; --color-ice-100: #e8edf3; --color-ice-300: #a8b5c5; --color-mint: #5ac8a8; --color-amber: #f0a45b; --color-coral: #e86b6b; --color-page: #0f1729; --color-panel: #252c3d; --color-panel-alt: #1a2133; --color-overlay: #ffffff0a; --color-overlay-strong: #ffffff1a; --color-fg: #fff; --color-fg-2: #e8edf3; --color-fg-3: #a8b5c5; --color-fg-muted: #4b5768; --color-line: #ffffff0f; --color-line-strong: #ffffff1f; --color-focus: #67b2d766; --color-on-primary: #0f1729;}.light{--color-page:#eef2f7;--color-panel:#fff;--color-panel-alt:#e4eaf1;--color-overlay:#0f17290d;--color-overlay-strong:#0f17291a;--color-fg:#0f172a;--color-fg-2:#1e293b;--color-fg-3:#475569;--color-fg-muted:#64748b;--color-line:#0f17291a;--color-line-strong:#0f17292e;--color-focus:#0e749080;--color-on-primary:#fff;--color-mint:#0d9488;--color-amber:#d97706;--color-coral:#dc2626;--color-teal-300:#0891b2;--color-teal-500:#0e7490}:root{--chart-gridline:#ffffff0a;--chart-axis-label:#4b5768}.light{--chart-gridline:#0000000f;--chart-axis-label:#475569}.bg-atmosphere{background-color:var(--color-page);background-image:radial-gradient(120% 60% at 15% 5%,#357f9e1f 0%,#0000 50%),radial-gradient(80% 50% at 85% 90%,#5ac8a812 0%,#0000 45%);background-attachment:fixed}.light .bg-atmosphere,.light.bg-atmosphere{background-image:radial-gradient(120% 60% at 15% 5%,#0e74900f 0%,#0000 50%),radial-gradient(80% 50% at 85% 90%,#0d94880a 0%,#0000 45%)}.bg-atmosphere:before{content:"";position:fixed;z-index:1;pointer-events:none;opacity:.07;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");background-repeat:repeat;background-size:200px 200px;inset:0}.light .bg-atmosphere:before,.light.bg-atmosphere:before{opacity:.03}:root{--diffs-font-family:"JetBrains Mono",ui-monospace,monospace;--diffs-header-font-family:"Geist",ui-sans-serif,system-ui,sans-serif;--diffs-font-size:13px;--diffs-line-height:20px;--diffs-bg-buffer-override:#0f1729;--diffs-bg-context-override:#161d2e;--diffs-bg-hover-override:#1c2438;--diffs-bg-separator-override:#252c3d;--diffs-fg-number-override:#4b5768;--diffs-addition-color-override:#5ac8a8;--diffs-fg-number-addition-override:#5ac8a880;--diffs-deletion-color-override:#e86b6b;--diffs-fg-number-deletion-override:#e86b6b80}.dark .graph-svg text{fill:#c6d4e0}.dark .graph-svg [stroke=\#357f9e]{stroke:#67b2d7}.dark .graph-svg [stroke=\#666666]{stroke:#5a7a94}.dark .graph-svg polygon[fill=\#357f9e]{fill:#67b2d7}.dark .graph-svg polygon[fill=\#666666]{fill:#5a7a94}.dark .graph-svg ellipse[fill=\#ffffff],.dark .graph-svg polygon[fill=\#ffffff]{fill:#1a2b3c}.dark .graph-svg ellipse[stroke=\#357f9e],.dark .graph-svg polygon[stroke=\#357f9e]{stroke:#67b2d7}.dark .graph-svg [fill=\#1a1a1a]{fill:#c6d4e0}.light{--diffs-bg-buffer-override:#fff;--diffs-bg-context-override:#f8fafc;--diffs-bg-hover-override:#f1f5f9;--diffs-bg-separator-override:#e2e8f0;--diffs-fg-number-override:#94a3b8;--diffs-addition-color-override:#0d9488;--diffs-fg-number-addition-override:#0d948880;--diffs-deletion-color-override:#dc2626;--diffs-fg-number-deletion-override:#dc262680} diff --git a/lib/crates/fabro-spa/assets/assets/entry-vh02mb6v.css b/lib/crates/fabro-spa/assets/assets/entry-vh02mb6v.css new file mode 100644 index 000000000..0293a69c6 --- /dev/null +++ b/lib/crates/fabro-spa/assets/assets/entry-vh02mb6v.css @@ -0,0 +1 @@ +@layer theme,base,components,utilities;@layer theme{@theme default{--font-sans: ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; --font-serif: ui-serif,Georgia,Cambria,"Times New Roman",Times,serif; --font-mono: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; --color-red-50: oklch(97.1% .013 17.38); --color-red-100: oklch(93.6% .032 17.717); --color-red-200: oklch(88.5% .062 18.334); --color-red-300: oklch(80.8% .114 19.571); --color-red-400: oklch(70.4% .191 22.216); --color-red-500: oklch(63.7% .237 25.331); --color-red-600: oklch(57.7% .245 27.325); --color-red-700: oklch(50.5% .213 27.518); --color-red-800: oklch(44.4% .177 26.899); --color-red-900: oklch(39.6% .141 25.723); --color-red-950: oklch(25.8% .092 26.042); --color-orange-50: oklch(98% .016 73.684); --color-orange-100: oklch(95.4% .038 75.164); --color-orange-200: oklch(90.1% .076 70.697); --color-orange-300: oklch(83.7% .128 66.29); --color-orange-400: oklch(75% .183 55.934); --color-orange-500: oklch(70.5% .213 47.604); --color-orange-600: oklch(64.6% .222 41.116); --color-orange-700: oklch(55.3% .195 38.402); --color-orange-800: oklch(47% .157 37.304); --color-orange-900: oklch(40.8% .123 38.172); --color-orange-950: oklch(26.6% .079 36.259); --color-amber-50: oklch(98.7% .022 95.277); --color-amber-100: oklch(96.2% .059 95.617); --color-amber-200: oklch(92.4% .12 95.746); --color-amber-300: oklch(87.9% .169 91.605); --color-amber-400: oklch(82.8% .189 84.429); --color-amber-500: oklch(76.9% .188 70.08); --color-amber-600: oklch(66.6% .179 58.318); --color-amber-700: oklch(55.5% .163 48.998); --color-amber-800: oklch(47.3% .137 46.201); --color-amber-900: oklch(41.4% .112 45.904); --color-amber-950: oklch(27.9% .077 45.635); --color-yellow-50: oklch(98.7% .026 102.212); --color-yellow-100: oklch(97.3% .071 103.193); --color-yellow-200: oklch(94.5% .129 101.54); --color-yellow-300: oklch(90.5% .182 98.111); --color-yellow-400: oklch(85.2% .199 91.936); --color-yellow-500: oklch(79.5% .184 86.047); --color-yellow-600: oklch(68.1% .162 75.834); --color-yellow-700: oklch(55.4% .135 66.442); --color-yellow-800: oklch(47.6% .114 61.907); --color-yellow-900: oklch(42.1% .095 57.708); --color-yellow-950: oklch(28.6% .066 53.813); --color-lime-50: oklch(98.6% .031 120.757); --color-lime-100: oklch(96.7% .067 122.328); --color-lime-200: oklch(93.8% .127 124.321); --color-lime-300: oklch(89.7% .196 126.665); --color-lime-400: oklch(84.1% .238 128.85); --color-lime-500: oklch(76.8% .233 130.85); --color-lime-600: oklch(64.8% .2 131.684); --color-lime-700: oklch(53.2% .157 131.589); --color-lime-800: oklch(45.3% .124 130.933); --color-lime-900: oklch(40.5% .101 131.063); --color-lime-950: oklch(27.4% .072 132.109); --color-green-50: oklch(98.2% .018 155.826); --color-green-100: oklch(96.2% .044 156.743); --color-green-200: oklch(92.5% .084 155.995); --color-green-300: oklch(87.1% .15 154.449); --color-green-400: oklch(79.2% .209 151.711); --color-green-500: oklch(72.3% .219 149.579); --color-green-600: oklch(62.7% .194 149.214); --color-green-700: oklch(52.7% .154 150.069); --color-green-800: oklch(44.8% .119 151.328); --color-green-900: oklch(39.3% .095 152.535); --color-green-950: oklch(26.6% .065 152.934); --color-emerald-50: oklch(97.9% .021 166.113); --color-emerald-100: oklch(95% .052 163.051); --color-emerald-200: oklch(90.5% .093 164.15); --color-emerald-300: oklch(84.5% .143 164.978); --color-emerald-400: oklch(76.5% .177 163.223); --color-emerald-500: oklch(69.6% .17 162.48); --color-emerald-600: oklch(59.6% .145 163.225); --color-emerald-700: oklch(50.8% .118 165.612); --color-emerald-800: oklch(43.2% .095 166.913); --color-emerald-900: oklch(37.8% .077 168.94); --color-emerald-950: oklch(26.2% .051 172.552); --color-teal-50: oklch(98.4% .014 180.72); --color-teal-100: oklch(95.3% .051 180.801); --color-teal-200: oklch(91% .096 180.426); --color-teal-300: oklch(85.5% .138 181.071); --color-teal-400: oklch(77.7% .152 181.912); --color-teal-500: oklch(70.4% .14 182.503); --color-teal-600: oklch(60% .118 184.704); --color-teal-700: oklch(51.1% .096 186.391); --color-teal-800: oklch(43.7% .078 188.216); --color-teal-900: oklch(38.6% .063 188.416); --color-teal-950: oklch(27.7% .046 192.524); --color-cyan-50: oklch(98.4% .019 200.873); --color-cyan-100: oklch(95.6% .045 203.388); --color-cyan-200: oklch(91.7% .08 205.041); --color-cyan-300: oklch(86.5% .127 207.078); --color-cyan-400: oklch(78.9% .154 211.53); --color-cyan-500: oklch(71.5% .143 215.221); --color-cyan-600: oklch(60.9% .126 221.723); --color-cyan-700: oklch(52% .105 223.128); --color-cyan-800: oklch(45% .085 224.283); --color-cyan-900: oklch(39.8% .07 227.392); --color-cyan-950: oklch(30.2% .056 229.695); --color-sky-50: oklch(97.7% .013 236.62); --color-sky-100: oklch(95.1% .026 236.824); --color-sky-200: oklch(90.1% .058 230.902); --color-sky-300: oklch(82.8% .111 230.318); --color-sky-400: oklch(74.6% .16 232.661); --color-sky-500: oklch(68.5% .169 237.323); --color-sky-600: oklch(58.8% .158 241.966); --color-sky-700: oklch(50% .134 242.749); --color-sky-800: oklch(44.3% .11 240.79); --color-sky-900: oklch(39.1% .09 240.876); --color-sky-950: oklch(29.3% .066 243.157); --color-blue-50: oklch(97% .014 254.604); --color-blue-100: oklch(93.2% .032 255.585); --color-blue-200: oklch(88.2% .059 254.128); --color-blue-300: oklch(80.9% .105 251.813); --color-blue-400: oklch(70.7% .165 254.624); --color-blue-500: oklch(62.3% .214 259.815); --color-blue-600: oklch(54.6% .245 262.881); --color-blue-700: oklch(48.8% .243 264.376); --color-blue-800: oklch(42.4% .199 265.638); --color-blue-900: oklch(37.9% .146 265.522); --color-blue-950: oklch(28.2% .091 267.935); --color-indigo-50: oklch(96.2% .018 272.314); --color-indigo-100: oklch(93% .034 272.788); --color-indigo-200: oklch(87% .065 274.039); --color-indigo-300: oklch(78.5% .115 274.713); --color-indigo-400: oklch(67.3% .182 276.935); --color-indigo-500: oklch(58.5% .233 277.117); --color-indigo-600: oklch(51.1% .262 276.966); --color-indigo-700: oklch(45.7% .24 277.023); --color-indigo-800: oklch(39.8% .195 277.366); --color-indigo-900: oklch(35.9% .144 278.697); --color-indigo-950: oklch(25.7% .09 281.288); --color-violet-50: oklch(96.9% .016 293.756); --color-violet-100: oklch(94.3% .029 294.588); --color-violet-200: oklch(89.4% .057 293.283); --color-violet-300: oklch(81.1% .111 293.571); --color-violet-400: oklch(70.2% .183 293.541); --color-violet-500: oklch(60.6% .25 292.717); --color-violet-600: oklch(54.1% .281 293.009); --color-violet-700: oklch(49.1% .27 292.581); --color-violet-800: oklch(43.2% .232 292.759); --color-violet-900: oklch(38% .189 293.745); --color-violet-950: oklch(28.3% .141 291.089); --color-purple-50: oklch(97.7% .014 308.299); --color-purple-100: oklch(94.6% .033 307.174); --color-purple-200: oklch(90.2% .063 306.703); --color-purple-300: oklch(82.7% .119 306.383); --color-purple-400: oklch(71.4% .203 305.504); --color-purple-500: oklch(62.7% .265 303.9); --color-purple-600: oklch(55.8% .288 302.321); --color-purple-700: oklch(49.6% .265 301.924); --color-purple-800: oklch(43.8% .218 303.724); --color-purple-900: oklch(38.1% .176 304.987); --color-purple-950: oklch(29.1% .149 302.717); --color-fuchsia-50: oklch(97.7% .017 320.058); --color-fuchsia-100: oklch(95.2% .037 318.852); --color-fuchsia-200: oklch(90.3% .076 319.62); --color-fuchsia-300: oklch(83.3% .145 321.434); --color-fuchsia-400: oklch(74% .238 322.16); --color-fuchsia-500: oklch(66.7% .295 322.15); --color-fuchsia-600: oklch(59.1% .293 322.896); --color-fuchsia-700: oklch(51.8% .253 323.949); --color-fuchsia-800: oklch(45.2% .211 324.591); --color-fuchsia-900: oklch(40.1% .17 325.612); --color-fuchsia-950: oklch(29.3% .136 325.661); --color-pink-50: oklch(97.1% .014 343.198); --color-pink-100: oklch(94.8% .028 342.258); --color-pink-200: oklch(89.9% .061 343.231); --color-pink-300: oklch(82.3% .12 346.018); --color-pink-400: oklch(71.8% .202 349.761); --color-pink-500: oklch(65.6% .241 354.308); --color-pink-600: oklch(59.2% .249 .584); --color-pink-700: oklch(52.5% .223 3.958); --color-pink-800: oklch(45.9% .187 3.815); --color-pink-900: oklch(40.8% .153 2.432); --color-pink-950: oklch(28.4% .109 3.907); --color-rose-50: oklch(96.9% .015 12.422); --color-rose-100: oklch(94.1% .03 12.58); --color-rose-200: oklch(89.2% .058 10.001); --color-rose-300: oklch(81% .117 11.638); --color-rose-400: oklch(71.2% .194 13.428); --color-rose-500: oklch(64.5% .246 16.439); --color-rose-600: oklch(58.6% .253 17.585); --color-rose-700: oklch(51.4% .222 16.935); --color-rose-800: oklch(45.5% .188 13.697); --color-rose-900: oklch(41% .159 10.272); --color-rose-950: oklch(27.1% .105 12.094); --color-slate-50: oklch(98.4% .003 247.858); --color-slate-100: oklch(96.8% .007 247.896); --color-slate-200: oklch(92.9% .013 255.508); --color-slate-300: oklch(86.9% .022 252.894); --color-slate-400: oklch(70.4% .04 256.788); --color-slate-500: oklch(55.4% .046 257.417); --color-slate-600: oklch(44.6% .043 257.281); --color-slate-700: oklch(37.2% .044 257.287); --color-slate-800: oklch(27.9% .041 260.031); --color-slate-900: oklch(20.8% .042 265.755); --color-slate-950: oklch(12.9% .042 264.695); --color-gray-50: oklch(98.5% .002 247.839); --color-gray-100: oklch(96.7% .003 264.542); --color-gray-200: oklch(92.8% .006 264.531); --color-gray-300: oklch(87.2% .01 258.338); --color-gray-400: oklch(70.7% .022 261.325); --color-gray-500: oklch(55.1% .027 264.364); --color-gray-600: oklch(44.6% .03 256.802); --color-gray-700: oklch(37.3% .034 259.733); --color-gray-800: oklch(27.8% .033 256.848); --color-gray-900: oklch(21% .034 264.665); --color-gray-950: oklch(13% .028 261.692); --color-zinc-50: oklch(98.5% 0 0); --color-zinc-100: oklch(96.7% .001 286.375); --color-zinc-200: oklch(92% .004 286.32); --color-zinc-300: oklch(87.1% .006 286.286); --color-zinc-400: oklch(70.5% .015 286.067); --color-zinc-500: oklch(55.2% .016 285.938); --color-zinc-600: oklch(44.2% .017 285.786); --color-zinc-700: oklch(37% .013 285.805); --color-zinc-800: oklch(27.4% .006 286.033); --color-zinc-900: oklch(21% .006 285.885); --color-zinc-950: oklch(14.1% .005 285.823); --color-neutral-50: oklch(98.5% 0 0); --color-neutral-100: oklch(97% 0 0); --color-neutral-200: oklch(92.2% 0 0); --color-neutral-300: oklch(87% 0 0); --color-neutral-400: oklch(70.8% 0 0); --color-neutral-500: oklch(55.6% 0 0); --color-neutral-600: oklch(43.9% 0 0); --color-neutral-700: oklch(37.1% 0 0); --color-neutral-800: oklch(26.9% 0 0); --color-neutral-900: oklch(20.5% 0 0); --color-neutral-950: oklch(14.5% 0 0); --color-stone-50: oklch(98.5% .001 106.423); --color-stone-100: oklch(97% .001 106.424); --color-stone-200: oklch(92.3% .003 48.717); --color-stone-300: oklch(86.9% .005 56.366); --color-stone-400: oklch(70.9% .01 56.259); --color-stone-500: oklch(55.3% .013 58.071); --color-stone-600: oklch(44.4% .011 73.639); --color-stone-700: oklch(37.4% .01 67.558); --color-stone-800: oklch(26.8% .007 34.298); --color-stone-900: oklch(21.6% .006 56.043); --color-stone-950: oklch(14.7% .004 49.25); --color-mauve-50: oklch(98.5% 0 0); --color-mauve-100: oklch(96% .003 325.6); --color-mauve-200: oklch(92.2% .005 325.62); --color-mauve-300: oklch(86.5% .012 325.68); --color-mauve-400: oklch(71.1% .019 323.02); --color-mauve-500: oklch(54.2% .034 322.5); --color-mauve-600: oklch(43.5% .029 321.78); --color-mauve-700: oklch(36.4% .029 323.89); --color-mauve-800: oklch(26.3% .024 320.12); --color-mauve-900: oklch(21.2% .019 322.12); --color-mauve-950: oklch(14.5% .008 326); --color-olive-50: oklch(98.8% .003 106.5); --color-olive-100: oklch(96.6% .005 106.5); --color-olive-200: oklch(93% .007 106.5); --color-olive-300: oklch(88% .011 106.6); --color-olive-400: oklch(73.7% .021 106.9); --color-olive-500: oklch(58% .031 107.3); --color-olive-600: oklch(46.6% .025 107.3); --color-olive-700: oklch(39.4% .023 107.4); --color-olive-800: oklch(28.6% .016 107.4); --color-olive-900: oklch(22.8% .013 107.4); --color-olive-950: oklch(15.3% .006 107.1); --color-mist-50: oklch(98.7% .002 197.1); --color-mist-100: oklch(96.3% .002 197.1); --color-mist-200: oklch(92.5% .005 214.3); --color-mist-300: oklch(87.2% .007 219.6); --color-mist-400: oklch(72.3% .014 214.4); --color-mist-500: oklch(56% .021 213.5); --color-mist-600: oklch(45% .017 213.2); --color-mist-700: oklch(37.8% .015 216); --color-mist-800: oklch(27.5% .011 216.9); --color-mist-900: oklch(21.8% .008 223.9); --color-mist-950: oklch(14.8% .004 228.8); --color-taupe-50: oklch(98.6% .002 67.8); --color-taupe-100: oklch(96% .002 17.2); --color-taupe-200: oklch(92.2% .005 34.3); --color-taupe-300: oklch(86.8% .007 39.5); --color-taupe-400: oklch(71.4% .014 41.2); --color-taupe-500: oklch(54.7% .021 43.1); --color-taupe-600: oklch(43.8% .017 39.3); --color-taupe-700: oklch(36.7% .016 35.7); --color-taupe-800: oklch(26.8% .011 36.5); --color-taupe-900: oklch(21.4% .009 43.1); --color-taupe-950: oklch(14.7% .004 49.3); --color-black: #000; --color-white: #fff; --spacing: .25rem; --breakpoint-sm: 40rem; --breakpoint-md: 48rem; --breakpoint-lg: 64rem; --breakpoint-xl: 80rem; --breakpoint-2xl: 96rem; --container-3xs: 16rem; --container-2xs: 18rem; --container-xs: 20rem; --container-sm: 24rem; --container-md: 28rem; --container-lg: 32rem; --container-xl: 36rem; --container-2xl: 42rem; --container-3xl: 48rem; --container-4xl: 56rem; --container-5xl: 64rem; --container-6xl: 72rem; --container-7xl: 80rem; --text-xs: .75rem; --text-xs--line-height: calc(1/.75); --text-sm: .875rem; --text-sm--line-height: calc(1.25/.875); --text-base: 1rem; --text-base--line-height: calc(1.5/1); --text-lg: 1.125rem; --text-lg--line-height: calc(1.75/1.125); --text-xl: 1.25rem; --text-xl--line-height: calc(1.75/1.25); --text-2xl: 1.5rem; --text-2xl--line-height: calc(2/1.5); --text-3xl: 1.875rem; --text-3xl--line-height: calc(2.25/1.875); --text-4xl: 2.25rem; --text-4xl--line-height: calc(2.5/2.25); --text-5xl: 3rem; --text-5xl--line-height: 1; --text-6xl: 3.75rem; --text-6xl--line-height: 1; --text-7xl: 4.5rem; --text-7xl--line-height: 1; --text-8xl: 6rem; --text-8xl--line-height: 1; --text-9xl: 8rem; --text-9xl--line-height: 1; --font-weight-thin: 100; --font-weight-extralight: 200; --font-weight-light: 300; --font-weight-normal: 400; --font-weight-medium: 500; --font-weight-semibold: 600; --font-weight-bold: 700; --font-weight-extrabold: 800; --font-weight-black: 900; --tracking-tighter: -.05em; --tracking-tight: -.025em; --tracking-normal: 0em; --tracking-wide: .025em; --tracking-wider: .05em; --tracking-widest: .1em; --leading-tight: 1.25; --leading-snug: 1.375; --leading-normal: 1.5; --leading-relaxed: 1.625; --leading-loose: 2; --radius-xs: .125rem; --radius-sm: .25rem; --radius-md: .375rem; --radius-lg: .5rem; --radius-xl: .75rem; --radius-2xl: 1rem; --radius-3xl: 1.5rem; --radius-4xl: 2rem; --shadow-2xs: 0 1px #0000000d; --shadow-xs: 0 1px 2px 0 #0000000d; --shadow-sm: 0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a; --shadow-md: 0 4px 6px -1px #0000001a,0 2px 4px -2px #0000001a; --shadow-lg: 0 10px 15px -3px #0000001a,0 4px 6px -4px #0000001a; --shadow-xl: 0 20px 25px -5px #0000001a,0 8px 10px -6px #0000001a; --shadow-2xl: 0 25px 50px -12px #00000040; --inset-shadow-2xs: inset 0 1px #0000000d; --inset-shadow-xs: inset 0 1px 1px #0000000d; --inset-shadow-sm: inset 0 2px 4px #0000000d; --drop-shadow-xs: 0 1px 1px #0000000d; --drop-shadow-sm: 0 1px 2px #00000026; --drop-shadow-md: 0 3px 3px #0000001f; --drop-shadow-lg: 0 4px 4px #00000026; --drop-shadow-xl: 0 9px 7px #0000001a; --drop-shadow-2xl: 0 25px 25px #00000026; --text-shadow-2xs: 0px 1px 0px #00000026; --text-shadow-xs: 0px 1px 1px #0003; --text-shadow-sm: 0px 1px 0px #00000013,0px 1px 1px #00000013,0px 2px 2px #00000013; --text-shadow-md: 0px 1px 1px #0000001a,0px 1px 2px #0000001a,0px 2px 4px #0000001a; --text-shadow-lg: 0px 1px 2px #0000001a,0px 3px 2px #0000001a,0px 4px 8px #0000001a; --ease-in: cubic-bezier(.4,0,1,1); --ease-out: cubic-bezier(0,0,.2,1); --ease-in-out: cubic-bezier(.4,0,.2,1); --animate-spin: spin 1s linear infinite; --animate-ping: ping 1s cubic-bezier(0,0,.2,1)infinite; --animate-pulse: pulse 2s cubic-bezier(.4,0,.6,1)infinite; --animate-bounce: bounce 1s infinite; @keyframes spin { to { transform: rotate(360deg); }}@keyframes ping { 75%,100% { transform: scale(2); opacity: 0; }}@keyframes pulse { 50% { opacity: .5; }}@keyframes bounce { 0%,100% { transform: translateY(-25%); animation-timing-function: cubic-bezier(.8,0,1,1); }50% { transform: none; animation-timing-function: cubic-bezier(0,0,.2,1); }}--blur-xs: 4px; --blur-sm: 8px; --blur-md: 12px; --blur-lg: 16px; --blur-xl: 24px; --blur-2xl: 40px; --blur-3xl: 64px; --perspective-dramatic: 100px; --perspective-near: 300px; --perspective-normal: 500px; --perspective-midrange: 800px; --perspective-distant: 1200px; --aspect-video: 16/9; --default-transition-duration: .15s; --default-transition-timing-function: cubic-bezier(.4,0,.2,1); --default-font-family: --theme(--font-sans,initial); --default-font-feature-settings: --theme(--font-sans--font-feature-settings,initial); --default-font-variation-settings: --theme(--font-sans--font-variation-settings,initial); --default-mono-font-family: --theme(--font-mono,initial); --default-mono-font-feature-settings: --theme(--font-mono--font-feature-settings,initial); --default-mono-font-variation-settings: --theme(--font-mono--font-variation-settings,initial);}@theme default inline reference{--blur: 8px; --shadow: 0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a; --shadow-inner: inset 0 2px 4px 0 #0000000d; --drop-shadow: 0 1px 2px #0000001a,0 1px 1px #0000000f; --radius: .25rem; --max-width-prose: 65ch;}}@layer base{*,:after,:before{box-sizing:border-box;border:0 solid;margin:0;padding:0}::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::-webkit-file-upload-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:--theme(--default-font-family,ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji");font-feature-settings:--theme(--default-font-feature-settings,normal);font-variation-settings:--theme(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{color:inherit;border-top-width:1px;height:0}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:--theme(--default-mono-font-family,ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace);font-feature-settings:--theme(--default-mono-font-feature-settings,normal);font-variation-settings:--theme(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{position:relative;vertical-align:baseline;font-size:75%;line-height:0}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::-webkit-file-upload-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:-webkit-any([multiple],[size])) optgroup{font-weight:bolder}:where(select:-moz-any([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:-webkit-any([multiple],[size])) optgroup option{padding-inline-start:20px}:where(select:-moz-any([multiple],[size])) optgroup option{padding-inline-start:20px}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::-webkit-file-upload-button{margin-inline-end:4px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports ( not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px){::placeholder{color:color-mix(in oklab,currentcolor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{text-align:inherit;min-height:1lh}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-year-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-month-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-day-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-hour-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-minute-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-second-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-millisecond-field{padding-block-start:0;padding-block-end:0}::-webkit-datetime-edit-meridiem-field{padding-block-start:0;padding-block-end:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button{appearance:button}input:where([type=button],[type=reset],[type=submit]){appearance:button}::-webkit-file-upload-button{appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer utilities{@tailwind utilities;}@plugin "@tailwindcss/typography";@layer base{a[href],button,select,summary,[role=button],[role=link],[role=option],[role=menuitem],[role=tab]{cursor:pointer}}@theme{--font-sans: "Geist",ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji"; --font-mono: "JetBrains Mono",ui-monospace,monospace; --color-teal-300: #b5ddef; --color-teal-500: #67b2d7; --color-teal-700: #357f9e; --color-navy-950: #0f1729; --color-navy-800: #252c3d; --color-navy-600: #4b5768; --color-ice-50: #f7f9fb; --color-ice-100: #e8edf3; --color-ice-300: #a8b5c5; --color-mint: #5ac8a8; --color-amber: #f0a45b; --color-coral: #e86b6b; --color-page: #0f1729; --color-panel: #252c3d; --color-panel-alt: #1a2133; --color-overlay: #ffffff0a; --color-overlay-strong: #ffffff1a; --color-fg: #fff; --color-fg-2: #e8edf3; --color-fg-3: #a8b5c5; --color-fg-muted: #4b5768; --color-line: #ffffff0f; --color-line-strong: #ffffff1f; --color-focus: #67b2d766; --color-on-primary: #0f1729;}.light{--color-page:#eef2f7;--color-panel:#fff;--color-panel-alt:#e4eaf1;--color-overlay:#0f17290d;--color-overlay-strong:#0f17291a;--color-fg:#0f172a;--color-fg-2:#1e293b;--color-fg-3:#475569;--color-fg-muted:#64748b;--color-line:#0f17291a;--color-line-strong:#0f17292e;--color-focus:#0e749080;--color-on-primary:#fff;--color-mint:#0d9488;--color-amber:#d97706;--color-coral:#dc2626;--color-teal-300:#0891b2;--color-teal-500:#0e7490}:root{--chart-gridline:#ffffff0a;--chart-axis-label:#4b5768}.light{--chart-gridline:#0000000f;--chart-axis-label:#475569}.bg-atmosphere{background-color:var(--color-page);background-image:radial-gradient(120% 60% at 15% 5%,#357f9e1f 0%,#0000 50%),radial-gradient(80% 50% at 85% 90%,#5ac8a812 0%,#0000 45%);background-attachment:fixed}.light .bg-atmosphere,.light.bg-atmosphere{background-image:radial-gradient(120% 60% at 15% 5%,#0e74900f 0%,#0000 50%),radial-gradient(80% 50% at 85% 90%,#0d94880a 0%,#0000 45%)}.bg-atmosphere:before{content:"";position:fixed;z-index:1;pointer-events:none;opacity:.07;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");background-repeat:repeat;background-size:200px 200px;inset:0}.light .bg-atmosphere:before,.light.bg-atmosphere:before{opacity:.03}:root{--diffs-font-family:"JetBrains Mono",ui-monospace,monospace;--diffs-header-font-family:"Geist",ui-sans-serif,system-ui,sans-serif;--diffs-font-size:13px;--diffs-line-height:20px;--diffs-bg-buffer-override:#0f1729;--diffs-bg-context-override:#161d2e;--diffs-bg-hover-override:#1c2438;--diffs-bg-separator-override:#252c3d;--diffs-fg-number-override:#4b5768;--diffs-addition-color-override:#5ac8a8;--diffs-fg-number-addition-override:#5ac8a880;--diffs-deletion-color-override:#e86b6b;--diffs-fg-number-deletion-override:#e86b6b80}.dark .graph-svg text{fill:#c6d4e0}.dark .graph-svg [stroke=\#357f9e]{stroke:#67b2d7}.dark .graph-svg [stroke=\#666666]{stroke:#5a7a94}.dark .graph-svg polygon[fill=\#357f9e]{fill:#67b2d7}.dark .graph-svg polygon[fill=\#666666]{fill:#5a7a94}.dark .graph-svg ellipse[fill=\#ffffff],.dark .graph-svg polygon[fill=\#ffffff]{fill:#1a2b3c}.dark .graph-svg ellipse[stroke=\#357f9e],.dark .graph-svg polygon[stroke=\#357f9e]{stroke:#67b2d7}.dark .graph-svg [fill=\#1a1a1a]{fill:#c6d4e0}.light{--diffs-bg-buffer-override:#fff;--diffs-bg-context-override:#f8fafc;--diffs-bg-hover-override:#f1f5f9;--diffs-bg-separator-override:#e2e8f0;--diffs-fg-number-override:#94a3b8;--diffs-addition-color-override:#0d9488;--diffs-fg-number-addition-override:#0d948880;--diffs-deletion-color-override:#dc2626;--diffs-fg-number-deletion-override:#dc262680} diff --git a/lib/crates/fabro-spa/assets/index.html b/lib/crates/fabro-spa/assets/index.html index a2dc2e28e..d697f1a79 100644 --- a/lib/crates/fabro-spa/assets/index.html +++ b/lib/crates/fabro-spa/assets/index.html @@ -17,7 +17,7 @@ (function(){try{var t=localStorage.getItem("fabro-theme");if(t!=="light"&&t!=="dark")t="dark";document.documentElement.classList.add(t)}catch(e){document.documentElement.classList.add("dark")}})() - +
From 9c3c66c59a9f29913eb8794d7e96a20a24f404c0 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 20 Apr 2026 08:06:14 -0400 Subject: [PATCH 06/12] test(http): improve HTTP test failure diagnostics Add shared axum/reqwest response assertion helpers in fabro-test, migrate the Rust HTTP test surface to use them, and document the new rule in the testing strategy. --- Cargo.lock | 2 + files-internal/testing-strategy.md | 18 + lib/crates/fabro-cli/tests/it/cmd/attach.rs | 26 +- lib/crates/fabro-cli/tests/it/cmd/runner.rs | 24 +- lib/crates/fabro-cli/tests/it/cmd/support.rs | 12 +- lib/crates/fabro-cli/tests/it/scenario/mod.rs | 9 +- lib/crates/fabro-cli/tests/it/workflow/mod.rs | 9 +- lib/crates/fabro-oauth/Cargo.toml | 1 + lib/crates/fabro-oauth/src/lib.rs | 6 +- lib/crates/fabro-server/Cargo.toml | 1 + lib/crates/fabro-server/src/ip_allowlist.rs | 13 +- lib/crates/fabro-server/src/jwt_auth.rs | 18 +- lib/crates/fabro-server/src/server.rs | 293 ++++---- lib/crates/fabro-server/src/web_auth.rs | 28 +- .../fabro-server/tests/it/api/install.rs | 379 ++++++---- .../tests/it/api/install_openai_compatible.rs | 18 +- .../fabro-server/tests/it/api/routing.rs | 148 ++-- .../fabro-server/tests/it/api/run_files.rs | 57 +- lib/crates/fabro-server/tests/it/api/runs.rs | 10 +- .../fabro-server/tests/it/api/settings.rs | 12 +- .../fabro-server/tests/it/api/system.rs | 65 +- lib/crates/fabro-server/tests/it/api/tcp.rs | 16 +- lib/crates/fabro-server/tests/it/helpers.rs | 69 +- .../fabro-server/tests/it/pagination.rs | 8 +- .../fabro-server/tests/it/scenario/archive.rs | 90 ++- .../fabro-server/tests/it/scenario/dry_run.rs | 45 +- .../tests/it/scenario/lifecycle.rs | 83 ++- .../tests/it/scenario/run_completion.rs | 25 +- .../fabro-server/tests/it/scenario/sse.rs | 34 +- .../fabro-server/tests/it/scenario/usage.rs | 11 +- lib/crates/fabro-test/src/http_assert.rs | 674 ++++++++++++++++++ lib/crates/fabro-test/src/lib.rs | 29 +- 32 files changed, 1666 insertions(+), 567 deletions(-) create mode 100644 lib/crates/fabro-test/src/http_assert.rs diff --git a/Cargo.lock b/Cargo.lock index 11d0a6285..33c5a8282 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1857,6 +1857,7 @@ dependencies = [ "axum", "base64", "fabro-http", + "fabro-test", "hex", "httpmock", "open", @@ -1958,6 +1959,7 @@ dependencies = [ "fabro-slack", "fabro-spa", "fabro-store", + "fabro-test", "fabro-types", "fabro-util", "fabro-validate", diff --git a/files-internal/testing-strategy.md b/files-internal/testing-strategy.md index 264930629..5ec1534f5 100644 --- a/files-internal/testing-strategy.md +++ b/files-internal/testing-strategy.md @@ -190,6 +190,24 @@ Examples: - exact SHA lineage across rewind/fork - exact file existence semantics +### Use shared helpers for HTTP status assertions + +When a Rust test asserts on an HTTP response status, use the shared helpers in +`fabro-test` instead of `assert_eq!(response.status(), ...)`. + +- Use the helper that matches the transport: + - `expect_axum_*` / `assert_axum_*` for Axum `oneshot` responses + - `expect_reqwest_*` / `assert_reqwest_*` for `fabro_http` / reqwest responses +- Use the helper that matches what the test needs next: + - `*_status` when you only care about the status + - `*_json` when a successful response should parse as JSON + - `*_text` / `*_bytes` when the test inspects the raw body +- Pass a context string that names the request shape, for example + `GET /api/v1/runs/{id}/graph`. + +These helpers preserve successful responses but, on failure, include the +status, relevant headers, URL when available, and a readable body preview. + ## Snapshot rules - Prefer inline snapshots unless the payload is too large to read comfortably. diff --git a/lib/crates/fabro-cli/tests/it/cmd/attach.rs b/lib/crates/fabro-cli/tests/it/cmd/attach.rs index 0a9bccb98..52b10c838 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/attach.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/attach.rs @@ -8,7 +8,9 @@ use std::process::{Output, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -use fabro_test::{apply_filters, fabro_snapshot, test_context}; +use fabro_test::{ + apply_filters, assert_reqwest_status, expect_reqwest_json, fabro_snapshot, test_context, +}; use serde_json::Value; use super::support::{ @@ -31,15 +33,12 @@ async fn wait_for_server_question( .send() .await .expect("question request should succeed"); - assert!( - response.status().is_success(), - "question request failed: {}", - response.status() - ); - let body: Value = response - .json() - .await - .expect("question response should parse"); + let body: Value = expect_reqwest_json( + response, + fabro_http::StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/questions?page[limit]=100&page[offset]=0"), + ) + .await; if let Some(question) = body["data"].as_array().and_then(|items| items.first()) { return question.clone(); } @@ -860,7 +859,12 @@ fn attach_json_errors_without_prompting_for_human_input() { .send() .await .expect("answer submission should succeed"); - assert_eq!(response.status(), fabro_http::StatusCode::NO_CONTENT); + assert_reqwest_status( + response, + fabro_http::StatusCode::NO_CONTENT, + format!("POST /api/v1/runs/{run_id}/questions/{question_id}/answer"), + ) + .await; }); wait_for_status(&run.run_dir, &["succeeded"]); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/runner.rs b/lib/crates/fabro-cli/tests/it/cmd/runner.rs index b3dccd424..947e74ab0 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/runner.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/runner.rs @@ -12,7 +12,7 @@ use std::process::{Child, ExitStatus, Output, Stdio}; use std::time::{Duration, Instant}; use fabro_store::EventEnvelope; -use fabro_test::{fabro_snapshot, test_context}; +use fabro_test::{assert_reqwest_status, expect_reqwest_json, fabro_snapshot, test_context}; use fabro_types::{EventBody, RunEvent, StatusReason}; use httpmock::MockServer; @@ -133,15 +133,12 @@ async fn wait_for_server_question( .send() .await .expect("question request should succeed"); - assert!( - response.status().is_success(), - "question request failed: {}", - response.status() - ); - let body: serde_json::Value = response - .json() - .await - .expect("question response should parse"); + let body: serde_json::Value = expect_reqwest_json( + response, + fabro_http::StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/questions?page[limit]=100&page[offset]=0"), + ) + .await; if let Some(question) = body["data"].as_array().and_then(|items| items.first()) { return question.clone(); } @@ -607,7 +604,12 @@ fn detached_run_answers_pending_question_without_interview_scratch_files() { .send() .await .expect("answer submission should succeed"); - assert_eq!(response.status(), fabro_http::StatusCode::NO_CONTENT); + assert_reqwest_status( + response, + fabro_http::StatusCode::NO_CONTENT, + format!("POST /api/v1/runs/{run_id}/questions/{question_id}/answer"), + ) + .await; question_id }); diff --git a/lib/crates/fabro-cli/tests/it/cmd/support.rs b/lib/crates/fabro-cli/tests/it/cmd/support.rs index 52c634cf1..679d0d8e4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/support.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/support.rs @@ -17,7 +17,7 @@ use std::time::{Duration, Instant}; use fabro_config::Storage; use fabro_server::bind::Bind; use fabro_store::EventEnvelope; -use fabro_test::TestContext; +use fabro_test::{TestContext, expect_reqwest_status}; use fabro_types::RunId; use serde_json::Value; use shlex::try_quote; @@ -737,7 +737,8 @@ async fn try_get_server_json_for_storage( ) -> Option { let (client, base_url) = server_endpoint(storage_dir)?; let response = client.get(format!("{base_url}{path}")).send().await.ok()?; - if !response.status().is_success() { + let status = response.status(); + if status != fabro_http::StatusCode::OK { return None; } response.json::().await.ok() @@ -753,11 +754,8 @@ async fn get_server_json_for_storage( .send() .await .expect("server request should succeed"); - assert!( - response.status().is_success(), - "server request failed for {path}: {}", - response.status() - ); + let response = + expect_reqwest_status(response, fabro_http::StatusCode::OK, format!("GET {path}")).await; response .json::() .await diff --git a/lib/crates/fabro-cli/tests/it/scenario/mod.rs b/lib/crates/fabro-cli/tests/it/scenario/mod.rs index 3355ca96a..4218ff136 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/mod.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/mod.rs @@ -14,6 +14,8 @@ mod smoke; use std::path::{Path, PathBuf}; use std::time::Duration; +use fabro_test::expect_reqwest_status; + use crate::cmd::support::{RunProjection, server_endpoint}; pub(super) fn fixture(name: &str) -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -47,11 +49,8 @@ async fn get_server_json_for_storage( .send() .await .expect("server request should succeed"); - assert!( - response.status().is_success(), - "server request failed for {path}: {}", - response.status() - ); + let response = + expect_reqwest_status(response, fabro_http::StatusCode::OK, format!("GET {path}")).await; response .json::() .await diff --git a/lib/crates/fabro-cli/tests/it/workflow/mod.rs b/lib/crates/fabro-cli/tests/it/workflow/mod.rs index 805a36532..adcd4103f 100644 --- a/lib/crates/fabro-cli/tests/it/workflow/mod.rs +++ b/lib/crates/fabro-cli/tests/it/workflow/mod.rs @@ -17,7 +17,7 @@ use std::path::{Path, PathBuf}; use std::time::Duration; use fabro_store::EventEnvelope; -use fabro_test::TestContext; +use fabro_test::{TestContext, expect_reqwest_status}; use serde_json::Value; use crate::cmd::support::{RunProjection, server_endpoint}; @@ -118,11 +118,8 @@ async fn get_server_json_for_storage( .send() .await .expect("server request should succeed"); - assert!( - response.status().is_success(), - "server request failed for {path}: {}", - response.status() - ); + let response = + expect_reqwest_status(response, fabro_http::StatusCode::OK, format!("GET {path}")).await; response .json::() .await diff --git a/lib/crates/fabro-oauth/Cargo.toml b/lib/crates/fabro-oauth/Cargo.toml index a1db69c3b..fe664e243 100644 --- a/lib/crates/fabro-oauth/Cargo.toml +++ b/lib/crates/fabro-oauth/Cargo.toml @@ -28,3 +28,4 @@ open = "5" [dev-dependencies] httpmock = "0.8" tokio = { workspace = true, features = ["test-util", "macros"] } +fabro-test = { workspace = true } diff --git a/lib/crates/fabro-oauth/src/lib.rs b/lib/crates/fabro-oauth/src/lib.rs index 08dbd5741..6d9fa7ca4 100644 --- a/lib/crates/fabro-oauth/src/lib.rs +++ b/lib/crates/fabro-oauth/src/lib.rs @@ -422,6 +422,8 @@ pub async fn run_browser_flow( #[cfg(test)] mod tests { + use fabro_test::assert_reqwest_status; + use super::*; fn test_http_client() -> fabro_http::HttpClient { @@ -843,7 +845,7 @@ mod tests { .await .unwrap(); - assert_eq!(resp.status(), 200); + assert_reqwest_status(resp, StatusCode::OK, "GET /oauth/done").await; } #[tokio::test] @@ -862,7 +864,7 @@ mod tests { .await .unwrap(); - assert_eq!(resp.status(), 400); + assert_reqwest_status(resp, StatusCode::BAD_REQUEST, "GET /oauth/done").await; } #[tokio::test] diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index 3e6dd161d..79467485e 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -87,3 +87,4 @@ tracing-subscriber.workspace = true async-trait.workspace = true tokio-util.workspace = true fabro-sandbox = { path = "../fabro-sandbox", features = ["test-support"] } +fabro-test = { workspace = true } diff --git a/lib/crates/fabro-server/src/ip_allowlist.rs b/lib/crates/fabro-server/src/ip_allowlist.rs index a2762bcfc..ef32b90ed 100644 --- a/lib/crates/fabro-server/src/ip_allowlist.rs +++ b/lib/crates/fabro-server/src/ip_allowlist.rs @@ -334,6 +334,7 @@ mod tests { use axum::http::{Request, StatusCode}; use axum::routing::get; use axum::{Router, middleware}; + use fabro_test::assert_axum_status; use httpmock::MockServer; use tower::ServiceExt; @@ -343,6 +344,10 @@ mod tests { IpAllowEntry::parse_literal(value).unwrap() } + async fn assert_status(response: axum::response::Response, expected: StatusCode) { + assert_axum_status(response, expected, concat!(file!(), ":", line!())).await; + } + #[test] fn effective_scope_inherits_global_fields_and_prefers_override_values() { let global = ServerIpAllowlistSettings { @@ -547,7 +552,7 @@ mod tests { .body(Body::empty()) .unwrap(); let allowed_response = app.clone().oneshot(allowed_request).await.unwrap(); - assert_eq!(allowed_response.status(), StatusCode::OK); + assert_status(allowed_response, StatusCode::OK).await; let blocked_request = Request::builder() .uri("/api/v1/runs") @@ -555,7 +560,7 @@ mod tests { .body(Body::empty()) .unwrap(); let blocked_response = app.oneshot(blocked_request).await.unwrap(); - assert_eq!(blocked_response.status(), StatusCode::FORBIDDEN); + assert_status(blocked_response, StatusCode::FORBIDDEN).await; } #[tokio::test] @@ -580,7 +585,7 @@ mod tests { )) .await .unwrap(); - assert_eq!(health_response.status(), StatusCode::OK); + assert_status(health_response, StatusCode::OK).await; let blocked_response = app .oneshot(request_with_connect_info( @@ -589,7 +594,7 @@ mod tests { )) .await .unwrap(); - assert_eq!(blocked_response.status(), StatusCode::FORBIDDEN); + assert_status(blocked_response, StatusCode::FORBIDDEN).await; } fn request_with_connect_info(path: &str, ip: IpAddr) -> Request { diff --git a/lib/crates/fabro-server/src/jwt_auth.rs b/lib/crates/fabro-server/src/jwt_auth.rs index 3319fe45a..1ae755dd7 100644 --- a/lib/crates/fabro-server/src/jwt_auth.rs +++ b/lib/crates/fabro-server/src/jwt_auth.rs @@ -237,13 +237,14 @@ pub fn auth_method_name(method: ServerAuthMethod) -> &'static str { #[cfg(test)] mod tests { - use axum::body::{Body, to_bytes}; + use axum::body::Body; use axum::http::{Request, StatusCode}; use axum::response::IntoResponse; use axum::routing::get; use axum::{Json, Router}; use cookie::Key; use fabro_config::{parse_settings_layer, resolve_server_from_file}; + use fabro_test::{assert_axum_status, expect_axum_json}; use fabro_types::settings::ServerAuthMethod; use tower::ServiceExt; @@ -298,8 +299,11 @@ mod tests { } async fn response_json(response: axum::response::Response) -> serde_json::Value { - let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - serde_json::from_slice(&bytes).unwrap() + expect_axum_json(response, StatusCode::OK, concat!(file!(), ":", line!())).await + } + + async fn assert_status(response: axum::response::Response, expected: StatusCode) { + assert_axum_status(response, expected, concat!(file!(), ":", line!())).await; } fn dev_token_mode() -> AuthMode { @@ -392,7 +396,7 @@ client_id = "Iv1.test" .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap()) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_status(response, StatusCode::OK).await; } #[tokio::test] @@ -402,7 +406,7 @@ client_id = "Iv1.test" .oneshot(Request::builder().uri("/test").body(Body::empty()).unwrap()) .await .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_status(response, StatusCode::UNAUTHORIZED).await; } #[tokio::test] @@ -421,7 +425,6 @@ client_id = "Iv1.test" ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); let json = response_json(response).await; assert_eq!(json["login"], "dev"); assert_eq!(json["auth_method"], "dev_token"); @@ -457,7 +460,7 @@ client_id = "Iv1.test" ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_status(response, StatusCode::UNAUTHORIZED).await; } #[tokio::test] @@ -476,7 +479,6 @@ client_id = "Iv1.test" ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); let json = response_json(response).await; assert_eq!(json["login"], "alice"); assert_eq!(json["auth_method"], "github"); diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 8acf43dd6..33702a8e8 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -7378,6 +7378,7 @@ mod tests { use axum::http::{Request, header}; use fabro_interview::{AnswerValue, ControlInterviewer, Interviewer, Question, QuestionType}; use fabro_model::Provider; + use fabro_test::{assert_axum_status, expect_axum_bytes, expect_axum_json, expect_axum_status}; use fabro_types::settings::ServerAuthMethod; use fabro_types::{InterviewQuestionRecord, InterviewQuestionType, RunBlobId, RunId, fixtures}; use serde_json::json; @@ -7414,6 +7415,28 @@ mod tests { serde_json::from_slice(&bytes).unwrap() } + async fn assert_status(response: axum::response::Response, expected: StatusCode) { + assert_axum_status(response, expected, concat!(file!(), ":", line!())).await; + } + + async fn checked_response( + response: axum::response::Response, + expected: StatusCode, + ) -> axum::response::Response { + expect_axum_status(response, expected, concat!(file!(), ":", line!())).await + } + + async fn response_json( + response: axum::response::Response, + expected: StatusCode, + ) -> serde_json::Value { + expect_axum_json(response, expected, concat!(file!(), ":", line!())).await + } + + async fn response_bytes(response: axum::response::Response, expected: StatusCode) -> Vec { + expect_axum_bytes(response, expected, concat!(file!(), ":", line!())).await + } + fn api(path: &str) -> String { format!("/api/v1{path}") } @@ -7484,7 +7507,7 @@ type = "http" .await .unwrap(); - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_status(response, StatusCode::INTERNAL_SERVER_ERROR).await; } #[tokio::test] @@ -7508,9 +7531,7 @@ type = "http" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["name"], "/tmp/test.pem"); assert_eq!(body["type"], "file"); assert_eq!(body["description"], "Test certificate"); @@ -7532,7 +7553,7 @@ type = "http" .oneshot(webhook_request(None, None, body)) .await .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_status(response, StatusCode::UNAUTHORIZED).await; } #[tokio::test] @@ -7545,7 +7566,7 @@ type = "http" .oneshot(webhook_request(Some(&bad_signature), None, body)) .await .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_status(response, StatusCode::UNAUTHORIZED).await; } #[tokio::test] @@ -7558,7 +7579,7 @@ type = "http" .oneshot(webhook_request(Some(&signature), None, body)) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_status(response, StatusCode::OK).await; } #[tokio::test] @@ -7571,7 +7592,7 @@ type = "http" .oneshot(webhook_request(Some(&signature), None, body)) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_status(response, StatusCode::OK).await; } #[tokio::test] @@ -7588,7 +7609,7 @@ type = "http" )) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_status(response, StatusCode::OK).await; } #[tokio::test] @@ -7632,7 +7653,7 @@ type = "http" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_status(response, StatusCode::OK).await; let listed = state.vault.read().await.list(); assert_eq!(listed.len(), 1); assert_eq!(listed[0].name, "openai_codex"); @@ -7667,8 +7688,7 @@ type = "http" .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; let data = body["data"].as_array().expect("data should be an array"); let entry = data .iter() @@ -7700,7 +7720,7 @@ type = "http" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_status(response, StatusCode::BAD_REQUEST).await; } #[tokio::test] @@ -7740,7 +7760,7 @@ type = "http" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_status(response, StatusCode::BAD_REQUEST).await; } #[tokio::test] @@ -7762,7 +7782,7 @@ type = "http" )) .unwrap(); let create_response = app.clone().oneshot(create_req).await.unwrap(); - assert_eq!(create_response.status(), StatusCode::OK); + assert_status(create_response, StatusCode::OK).await; let delete_req = Request::builder() .method("DELETE") @@ -7777,7 +7797,7 @@ type = "http" .unwrap(); let delete_response = app.oneshot(delete_req).await.unwrap(); - assert_eq!(delete_response.status(), StatusCode::NO_CONTENT); + assert_status(delete_response, StatusCode::NO_CONTENT).await; assert!(state.vault.read().await.list().is_empty()); } @@ -8014,7 +8034,7 @@ type = "http" .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -8030,9 +8050,7 @@ type = "http" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["model_id"], "claude-sonnet-4-6"); assert_eq!(body["status"], "skip"); } @@ -8050,7 +8068,7 @@ type = "http" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_status(response, StatusCode::BAD_REQUEST).await; } #[tokio::test] @@ -8064,9 +8082,7 @@ type = "http" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; let models = body["data"].as_array().unwrap(); assert!(!models.is_empty()); assert!( @@ -8087,9 +8103,7 @@ type = "http" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; let model_ids = body["data"] .as_array() .unwrap() @@ -8114,7 +8128,7 @@ type = "http" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_status(response, StatusCode::BAD_REQUEST).await; } #[tokio::test] @@ -8156,7 +8170,7 @@ slug = "fabro" .await .unwrap(); - assert_eq!(response.status(), StatusCode::SEE_OTHER); + let response = checked_response(response, StatusCode::SEE_OTHER).await; let location = response .headers() .get(axum::http::header::LOCATION) @@ -8180,7 +8194,7 @@ slug = "fabro" .await .unwrap(); - assert_eq!(response.status(), StatusCode::SEE_OTHER); + let response = checked_response(response, StatusCode::SEE_OTHER).await; assert_eq!( response .headers() @@ -8204,7 +8218,7 @@ slug = "fabro" .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response = checked_response(response, StatusCode::OK).await; assert_eq!( response .headers() @@ -8226,9 +8240,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CREATED); - - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CREATED).await; assert!(body["id"].is_string()); assert!(!body["id"].as_str().unwrap().is_empty()); } @@ -8245,7 +8257,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_status(response, StatusCode::BAD_REQUEST).await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -8266,9 +8278,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["run_id"].as_str().unwrap(), run_id); assert_eq!(body["goal"].as_str().unwrap(), "Test"); assert_eq!(body["title"].as_str().unwrap(), "Test"); @@ -8290,7 +8300,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -8310,8 +8320,7 @@ slug = "fabro" .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["run_id"], run_id); } @@ -8332,8 +8341,7 @@ slug = "fabro" .await .unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::BAD_REQUEST).await; let detail = body["errors"][0]["detail"] .as_str() .expect("error detail should be present"); @@ -8374,8 +8382,7 @@ slug = "fabro" .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["run_id"], newer_id); assert_ne!(body["run_id"], older_id); } @@ -8407,8 +8414,7 @@ slug = "fabro" .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["run_id"], newer_id); assert_ne!(body["run_id"], older_id); } @@ -8428,7 +8434,7 @@ slug = "fabro" .await .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -8456,9 +8462,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert!(body["data"].is_array()); assert_eq!(body["meta"]["has_more"], false); } @@ -8478,7 +8482,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -8510,7 +8514,7 @@ slug = "fabro" .await .unwrap_err(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_status(response, StatusCode::BAD_REQUEST).await; } #[tokio::test] @@ -8525,7 +8529,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -8551,8 +8555,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert!(body["nodes"].is_object()); } @@ -8598,8 +8601,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!( body["pending_interviews"]["q-1"]["question"]["text"].as_str(), Some("Approve deploy?") @@ -8634,8 +8636,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!( body["run"]["provenance"]["server"]["version"], FABRO_VERSION @@ -8683,7 +8684,7 @@ slug = "fabro" ) .await .unwrap(); - assert_eq!(login_response.status(), StatusCode::OK); + let login_response = checked_response(login_response, StatusCode::OK).await; let session_cookie = login_response .headers() .get(header::SET_COOKIE) @@ -8705,8 +8706,7 @@ slug = "fabro" ) .await .unwrap(); - assert_eq!(create_response.status(), StatusCode::CREATED); - let create_body = body_json(create_response.into_body()).await; + let create_body = response_json(create_response, StatusCode::CREATED).await; let run_id = create_body["id"].as_str().unwrap(); let state_response = app @@ -8720,8 +8720,7 @@ slug = "fabro" ) .await .unwrap(); - assert_eq!(state_response.status(), StatusCode::OK); - let state_body = body_json(state_response.into_body()).await; + let state_body = response_json(state_response, StatusCode::OK).await; assert_eq!( state_body["run"]["provenance"]["subject"]["auth_method"], "dev_token" @@ -8744,8 +8743,7 @@ slug = "fabro" .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CREATED); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CREATED).await; let run_id = body["id"].as_str().unwrap().parse::().unwrap(); let run_store = state.store.open_run_reader(&run_id).await.unwrap(); @@ -8812,8 +8810,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert!(body["data"].is_array()); assert!(body["meta"]["has_more"].is_boolean()); } @@ -8851,7 +8848,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_status(response, StatusCode::BAD_REQUEST).await; } #[tokio::test] @@ -8879,8 +8876,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::BAD_REQUEST).await; assert!( body["errors"][0]["detail"] .as_str() @@ -8914,7 +8910,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + checked_response(response, StatusCode::OK).await; } #[tokio::test] @@ -8940,8 +8936,7 @@ slug = "fabro" .body(Body::from("hello blob")) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; let blob_id = body["id"].as_str().unwrap(); let req = Request::builder() @@ -8950,8 +8945,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let bytes = response_bytes(response, StatusCode::OK).await; assert_eq!(&bytes[..], b"hello blob"); } @@ -8972,11 +8966,7 @@ slug = "fabro" .body(Body::from("fn main() {}")) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - if response.status() != StatusCode::NO_CONTENT { - let status = response.status(); - let body = body_json(response.into_body()).await; - panic!("expected 204, got {status}: {body}"); - } + assert_status(response, StatusCode::NO_CONTENT).await; let req = Request::builder() .method("GET") @@ -8984,8 +8974,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["data"][0]["filename"], "src/lib.rs"); let req = Request::builder() @@ -8996,8 +8985,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let bytes = response_bytes(response, StatusCode::OK).await; assert_eq!(&bytes[..], b"fn main() {}"); } @@ -9038,7 +9026,7 @@ slug = "fabro" .body(Body::from("nope")) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_status(response, StatusCode::BAD_REQUEST).await; } #[tokio::test] @@ -9083,11 +9071,7 @@ slug = "fabro" ])) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - if response.status() != StatusCode::NO_CONTENT { - let status = response.status(); - let body = body_json(response.into_body()).await; - panic!("expected 204, got {status}: {body}"); - } + assert_status(response, StatusCode::NO_CONTENT).await; let req = Request::builder() .method("GET") @@ -9095,8 +9079,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["data"][0]["filename"], "logs/output.txt"); assert_eq!(body["data"][1]["filename"], "src/lib.rs"); @@ -9108,8 +9091,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let bytes = response_bytes(response, StatusCode::OK).await; assert_eq!(&bytes[..], log_bytes); } @@ -9134,7 +9116,7 @@ slug = "fabro" .body(Body::from(body)) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_status(response, StatusCode::BAD_REQUEST).await; } #[tokio::test] @@ -9150,8 +9132,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CREATED); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CREATED).await; assert_eq!(body["status"], "submitted"); } @@ -9178,8 +9159,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["status"], "queued"); let status = state @@ -9226,7 +9206,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CONFLICT); + assert_status(response, StatusCode::CONFLICT).await; } #[tokio::test] @@ -9274,7 +9254,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -9319,7 +9299,7 @@ slug = "fabro" let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response = checked_response(response, StatusCode::OK).await; let content_type = response .headers() @@ -9370,7 +9350,7 @@ slug = "fabro" let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response = checked_response(response, StatusCode::OK).await; assert_eq!( response .headers() @@ -9400,7 +9380,7 @@ slug = "fabro" let response = render_graph_bytes_with_exe_override("not valid dot {{{", Some(&script_path)).await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_status(response, StatusCode::BAD_REQUEST).await; } #[cfg(unix)] @@ -9438,7 +9418,7 @@ slug = "fabro" let response = render_graph_with_override("digraph { a -> b }", &script_path).await; - assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_status(response, StatusCode::INTERNAL_SERVER_ERROR).await; } #[cfg(unix)] @@ -9467,7 +9447,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -9483,8 +9463,7 @@ slug = "fabro" .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["data"].as_array().unwrap().len(), 0); assert_eq!(body["meta"]["has_more"].as_bool(), Some(false)); @@ -9508,8 +9487,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; let items = body["data"].as_array().unwrap(); assert_eq!(items.len(), 1); assert_eq!(items[0]["run_id"].as_str().unwrap(), run_id.to_string()); @@ -9561,8 +9539,7 @@ slug = "fabro" ) .await .unwrap(); - assert_eq!(archive_response.status(), StatusCode::OK); - let archive_body = body_json(archive_response.into_body()).await; + let archive_body = response_json(archive_response, StatusCode::OK).await; assert_eq!(archive_body["status"].as_str(), Some("archived")); let hidden_response = app @@ -9576,8 +9553,7 @@ slug = "fabro" ) .await .unwrap(); - assert_eq!(hidden_response.status(), StatusCode::OK); - let hidden_body = body_json(hidden_response.into_body()).await; + let hidden_body = response_json(hidden_response, StatusCode::OK).await; assert!( !hidden_body["data"] .as_array() @@ -9598,8 +9574,7 @@ slug = "fabro" ) .await .unwrap(); - assert_eq!(visible_response.status(), StatusCode::OK); - let visible_body = body_json(visible_response.into_body()).await; + let visible_body = response_json(visible_response, StatusCode::OK).await; let archived_item = visible_body["data"] .as_array() .unwrap() @@ -9619,8 +9594,7 @@ slug = "fabro" ) .await .unwrap(); - assert_eq!(unarchive_response.status(), StatusCode::OK); - let unarchive_body = body_json(unarchive_response.into_body()).await; + let unarchive_body = response_json(unarchive_response, StatusCode::OK).await; assert_eq!(unarchive_body["status"].as_str(), Some("succeeded")); let restored_response = app @@ -9633,8 +9607,7 @@ slug = "fabro" ) .await .unwrap(); - assert_eq!(restored_response.status(), StatusCode::OK); - let restored_body = body_json(restored_response.into_body()).await; + let restored_body = response_json(restored_response, StatusCode::OK).await; let restored_item = restored_body["data"] .as_array() .unwrap() @@ -9660,7 +9633,7 @@ slug = "fabro" .await .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -9685,7 +9658,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_status(response, StatusCode::NO_CONTENT).await; let req = Request::builder() .method("GET") @@ -9693,7 +9666,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -9718,8 +9691,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CONFLICT); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CONFLICT).await; let short_run_id = &run_id[..12.min(run_id.len())]; let expected = format!( "cannot remove active run {short_run_id} (status: submitted, use force=true or --force to force)" @@ -9735,7 +9707,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_status(response, StatusCode::OK).await; } #[tokio::test] @@ -9760,7 +9732,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_status(response, StatusCode::NO_CONTENT).await; let req = Request::builder() .method("GET") @@ -9768,7 +9740,7 @@ slug = "fabro" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -9783,9 +9755,7 @@ slug = "fabro" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["totals"]["runs"].as_i64().unwrap(), 0); assert_eq!(body["totals"]["input_tokens"].as_i64().unwrap(), 0); assert_eq!(body["totals"]["output_tokens"].as_i64().unwrap(), 0); @@ -9807,8 +9777,7 @@ slug = "fabro" .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CREATED); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CREATED).await; let run_id = body["id"].as_str().unwrap().parse::().unwrap(); // Check status is submitted (no start, no scheduler running) @@ -9876,8 +9845,7 @@ level = "debug" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CREATED); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CREATED).await; let run_id = body["id"].as_str().unwrap().parse::().unwrap(); let _run_dir = { @@ -9960,7 +9928,7 @@ level = "debug" .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_status(response, StatusCode::OK).await; // Verify status is cancelled let req = Request::builder() @@ -10028,8 +9996,7 @@ level = "debug" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["pending_control"].as_str(), Some("cancel")); let summary = state.store.runs().find(&run_id).await.unwrap().unwrap(); @@ -10059,7 +10026,7 @@ level = "debug" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CONFLICT); + assert_status(response, StatusCode::CONFLICT).await; let summary = state.store.runs().find(&run_id).await.unwrap().unwrap(); assert_eq!(summary.pending_control, Some(RunControlAction::Cancel)); @@ -10085,8 +10052,7 @@ level = "debug" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["status"].as_str(), Some("running")); assert_eq!(body["pending_control"].as_str(), Some("pause")); @@ -10151,8 +10117,7 @@ level = "debug" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["status"].as_str(), Some("paused")); assert_eq!( body["blocked_reason"].as_str(), @@ -10189,8 +10154,7 @@ level = "debug" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["status"].as_str(), Some("paused")); assert_eq!(body["pending_control"].as_str(), Some("unpause")); @@ -10239,8 +10203,7 @@ level = "debug" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["status"].as_str(), Some("blocked")); assert_eq!( body["blocked_reason"].as_str(), @@ -10533,7 +10496,7 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + assert_status(response, StatusCode::OK).await; runner.await.unwrap(); @@ -10543,8 +10506,7 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = response_bytes(response, StatusCode::OK).await; assert!(body.is_empty(), "expected an empty attach stream"); } @@ -10628,7 +10590,7 @@ timeout = "30s" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CONFLICT); + assert_status(response, StatusCode::CONFLICT).await; } #[tokio::test] @@ -10643,7 +10605,7 @@ timeout = "30s" .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_status(response, StatusCode::UNPROCESSABLE_ENTITY).await; } #[tokio::test] @@ -10657,8 +10619,7 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; let data = body["data"].as_array().expect("data should be array"); assert!(!data.is_empty(), "demo should return runs"); let first = &data[0]; @@ -10684,8 +10645,7 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; // Should have StoreRunSummary fields, not RunStatusResponse fields assert!(body["run_id"].is_string(), "should have run_id field"); assert!(body["goal"].is_string(), "should have goal field"); @@ -10711,7 +10671,7 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_status(response, StatusCode::NOT_FOUND).await; } #[tokio::test] @@ -10734,8 +10694,7 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; let data = body["data"].as_array().expect("data should be array"); let item = data .iter() @@ -10780,8 +10739,7 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; let data = body["data"].as_array().expect("data should be array"); let found = data .iter() @@ -10859,8 +10817,7 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["status"].as_str(), Some(expected_status)); } } @@ -11057,8 +11014,7 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; let data = body["data"].as_array().expect("data should be array"); let item = data .iter() @@ -11108,8 +11064,7 @@ timeout = "30s" .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK).await; assert_eq!(body["meta"]["has_more"].as_bool(), Some(true)); let data = body["data"].as_array().expect("data should be array"); diff --git a/lib/crates/fabro-server/src/web_auth.rs b/lib/crates/fabro-server/src/web_auth.rs index 4153912bc..ed348aff5 100644 --- a/lib/crates/fabro-server/src/web_auth.rs +++ b/lib/crates/fabro-server/src/web_auth.rs @@ -644,10 +644,11 @@ mod tests { use std::sync::Arc; use axum::Extension; - use axum::body::{Body, to_bytes}; + use axum::body::Body; use axum::extract::State; use axum::http::{Request, StatusCode, header}; use axum_extra::extract::cookie::Key; + use fabro_test::{assert_axum_status, expect_axum_json}; use fabro_types::RunAuthMethod; use fabro_types::settings::SettingsLayer; use fabro_types::settings::server::{ @@ -739,7 +740,18 @@ mod tests { } async fn response_json(response: axum::response::Response) -> Value { - serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap() + expect_axum_json(response, StatusCode::OK, concat!(file!(), ":", line!())).await + } + + async fn assert_status(response: axum::response::Response, expected: StatusCode) { + assert_axum_status(response, expected, concat!(file!(), ":", line!())).await; + } + + async fn checked_response( + response: axum::response::Response, + expected: StatusCode, + ) -> axum::response::Response { + fabro_test::expect_axum_status(response, expected, concat!(file!(), ":", line!())).await } #[tokio::test] @@ -759,7 +771,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response = checked_response(response, StatusCode::OK).await; let session_cookie = response .headers() @@ -788,7 +800,6 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); let body = response_json(response).await; assert_eq!(body["provider"], "dev-token"); assert_eq!(body["user"]["login"], "dev"); @@ -813,7 +824,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_status(response, StatusCode::UNAUTHORIZED).await; } #[tokio::test] @@ -830,7 +841,6 @@ mod tests { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); let body = response_json(response).await; assert_eq!(body, json!({ "methods": ["dev-token"] })); } @@ -851,8 +861,7 @@ mod tests { ) .await .unwrap(); - - assert_eq!(response.status(), StatusCode::SEE_OTHER); + let response = checked_response(response, StatusCode::SEE_OTHER).await; let set_cookie = response .headers() .get(header::SET_COOKIE) @@ -881,8 +890,7 @@ mod tests { ) .await .unwrap(); - - assert_eq!(response.status(), StatusCode::SEE_OTHER); + let response = checked_response(response, StatusCode::SEE_OTHER).await; assert_eq!( response .headers() diff --git a/lib/crates/fabro-server/tests/it/api/install.rs b/lib/crates/fabro-server/tests/it/api/install.rs index 12996e78f..1c1f77f53 100644 --- a/lib/crates/fabro-server/tests/it/api/install.rs +++ b/lib/crates/fabro-server/tests/it/api/install.rs @@ -18,7 +18,7 @@ use httpmock::MockServer; use tokio::time::sleep; use tower::ServiceExt; -use crate::helpers::body_json; +use crate::helpers::{checked_response, response_json, response_status, response_text}; async fn configure_token_install(app: &axum::Router, token: &str) { let llm_response = app @@ -36,7 +36,7 @@ async fn configure_token_install(app: &axum::Router, token: &str) { ) .await .unwrap(); - assert_eq!(llm_response.status(), StatusCode::NO_CONTENT); + response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await; let server_response = app .clone() @@ -53,7 +53,12 @@ async fn configure_token_install(app: &axum::Router, token: &str) { ) .await .unwrap(); - assert_eq!(server_response.status(), StatusCode::NO_CONTENT); + response_status( + server_response, + StatusCode::NO_CONTENT, + "PUT /install/server", + ) + .await; let github_response = app .clone() @@ -70,7 +75,12 @@ async fn configure_token_install(app: &axum::Router, token: &str) { ) .await .unwrap(); - assert_eq!(github_response.status(), StatusCode::NO_CONTENT); + response_status( + github_response, + StatusCode::NO_CONTENT, + "PUT /install/github/token", + ) + .await; } #[tokio::test] @@ -88,8 +98,7 @@ async fn install_router_isolated_from_normal_api_surface() { ) .await .unwrap(); - assert_eq!(health_response.status(), StatusCode::OK); - let health_body = body_json(health_response.into_body()).await; + let health_body = response_json(health_response, StatusCode::OK, "GET /health").await; assert_eq!(health_body["status"], "ok"); assert_eq!(health_body["mode"], "install"); @@ -105,14 +114,7 @@ async fn install_router_isolated_from_normal_api_surface() { ) .await .unwrap(); - assert_eq!(root_response.status(), StatusCode::OK); - let root_html = String::from_utf8( - axum::body::to_bytes(root_response.into_body(), usize::MAX) - .await - .unwrap() - .to_vec(), - ) - .unwrap(); + let root_html = response_text(root_response, StatusCode::OK, "GET /").await; assert!( root_html.contains("__FABRO_MODE__ = \"install\""), "install shell should mark the SPA boot mode" @@ -128,7 +130,7 @@ async fn install_router_isolated_from_normal_api_surface() { ) .await .unwrap(); - assert_eq!(api_response.status(), StatusCode::NOT_FOUND); + response_status(api_response, StatusCode::NOT_FOUND, "GET /api/v1/auth/me").await; } #[tokio::test] @@ -146,7 +148,12 @@ async fn install_session_requires_valid_install_token() { ) .await .unwrap(); - assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + response_status( + unauthorized, + StatusCode::UNAUTHORIZED, + "GET /install/session", + ) + .await; let authorized = app .oneshot( @@ -161,8 +168,7 @@ async fn install_session_requires_valid_install_token() { ) .await .unwrap(); - assert_eq!(authorized.status(), StatusCode::OK); - let body = body_json(authorized.into_body()).await; + let body = response_json(authorized, StatusCode::OK, "GET /install/session").await; assert_eq!( body["prefill"]["canonical_url"], "https://fabro.example.com" @@ -229,11 +235,12 @@ async fn install_endpoints_reject_missing_and_wrong_tokens() { ) .await .unwrap(); - assert_eq!( - missing_token_response.status(), + response_status( + missing_token_response, StatusCode::UNAUTHORIZED, - "missing token should be rejected for {method} {path}" - ); + format!("{method} {path} without install token"), + ) + .await; let wrong_token_response = app .clone() @@ -244,11 +251,12 @@ async fn install_endpoints_reject_missing_and_wrong_tokens() { ) .await .unwrap(); - assert_eq!( - wrong_token_response.status(), + response_status( + wrong_token_response, StatusCode::UNAUTHORIZED, - "wrong token should be rejected for {method} {path}" - ); + format!("{method} {path} with wrong install token"), + ) + .await; } } @@ -268,7 +276,7 @@ async fn install_endpoints_accept_query_token_when_authorization_header_is_wrong .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + response_status(response, StatusCode::OK, "GET /install/session?token=...").await; } #[tokio::test] @@ -297,7 +305,7 @@ async fn token_install_finish_persists_settings_env_and_vault() { ) .await .unwrap(); - assert_eq!(llm_response.status(), StatusCode::NO_CONTENT); + response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await; let server_response = app .clone() @@ -314,7 +322,12 @@ async fn token_install_finish_persists_settings_env_and_vault() { ) .await .unwrap(); - assert_eq!(server_response.status(), StatusCode::NO_CONTENT); + response_status( + server_response, + StatusCode::NO_CONTENT, + "PUT /install/server", + ) + .await; let github_response = app .clone() @@ -331,7 +344,12 @@ async fn token_install_finish_persists_settings_env_and_vault() { ) .await .unwrap(); - assert_eq!(github_response.status(), StatusCode::NO_CONTENT); + response_status( + github_response, + StatusCode::NO_CONTENT, + "PUT /install/github/token", + ) + .await; let finish_response = app .oneshot( @@ -344,8 +362,12 @@ async fn token_install_finish_persists_settings_env_and_vault() { ) .await .unwrap(); - assert_eq!(finish_response.status(), StatusCode::ACCEPTED); - let finish_body = body_json(finish_response.into_body()).await; + let finish_body = response_json( + finish_response, + StatusCode::ACCEPTED, + "POST /install/finish", + ) + .await; assert_eq!(finish_body["status"], "completing"); assert_eq!(finish_body["restart_url"], "https://fabro.example.com"); assert!( @@ -434,7 +456,7 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() { ) .await .unwrap(); - assert_eq!(llm_response.status(), StatusCode::NO_CONTENT); + response_status(llm_response, StatusCode::NO_CONTENT, "PUT /install/llm").await; let server_response = app .clone() @@ -451,7 +473,12 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() { ) .await .unwrap(); - assert_eq!(server_response.status(), StatusCode::NO_CONTENT); + response_status( + server_response, + StatusCode::NO_CONTENT, + "PUT /install/server", + ) + .await; let manifest_response = app .clone() @@ -468,8 +495,12 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() { ) .await .unwrap(); - assert_eq!(manifest_response.status(), StatusCode::OK); - let manifest_body = body_json(manifest_response.into_body()).await; + let manifest_body = response_json( + manifest_response, + StatusCode::OK, + "POST /install/github/app/manifest", + ) + .await; let redirect_url = manifest_body["manifest"]["redirect_url"] .as_str() .expect("redirect_url should be present"); @@ -493,7 +524,12 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() { ) .await .unwrap(); - assert_eq!(callback_response.status(), StatusCode::FOUND); + response_status( + callback_response, + StatusCode::FOUND, + "GET /install/github/app/redirect?code=stub-code&state=...", + ) + .await; let finish_response = app .oneshot( @@ -506,8 +542,12 @@ async fn app_install_finish_omits_dev_token_and_does_not_write_it() { ) .await .unwrap(); - assert_eq!(finish_response.status(), StatusCode::ACCEPTED); - let finish_body = body_json(finish_response.into_body()).await; + let finish_body = response_json( + finish_response, + StatusCode::ACCEPTED, + "POST /install/finish", + ) + .await; assert_eq!(finish_body["status"], "completing"); assert_eq!(finish_body["restart_url"], "https://fabro.example.com"); assert!( @@ -561,7 +601,12 @@ async fn token_install_finish_invokes_shutdown_callback_after_accepting() { ) .await .unwrap(); - assert_eq!(finish_response.status(), StatusCode::ACCEPTED); + response_status( + finish_response, + StatusCode::ACCEPTED, + "POST /install/finish", + ) + .await; assert!(!callback_invoked.load(Ordering::Acquire)); sleep(Duration::from_millis(650)).await; @@ -621,8 +666,7 @@ async fn install_validation_endpoints_validate_credentials_and_github_token() { ) .await .unwrap(); - assert_eq!(llm_response.status(), StatusCode::OK); - let llm_body = body_json(llm_response.into_body()).await; + let llm_body = response_json(llm_response, StatusCode::OK, "POST /install/llm/test").await; assert_eq!(llm_body["ok"], true); let github_response = app @@ -637,8 +681,12 @@ async fn install_validation_endpoints_validate_credentials_and_github_token() { ) .await .unwrap(); - assert_eq!(github_response.status(), StatusCode::OK); - let github_body = body_json(github_response.into_body()).await; + let github_body = response_json( + github_response, + StatusCode::OK, + "POST /install/github/token/test", + ) + .await; assert_eq!(github_body["username"], "octocat"); } @@ -684,7 +732,12 @@ async fn github_app_manifest_round_trip_updates_install_session() { ) .await .unwrap(); - assert_eq!(server_response.status(), StatusCode::NO_CONTENT); + response_status( + server_response, + StatusCode::NO_CONTENT, + "PUT /install/server", + ) + .await; let manifest_response = app .clone() @@ -701,8 +754,12 @@ async fn github_app_manifest_round_trip_updates_install_session() { ) .await .unwrap(); - assert_eq!(manifest_response.status(), StatusCode::OK); - let manifest_body = body_json(manifest_response.into_body()).await; + let manifest_body = response_json( + manifest_response, + StatusCode::OK, + "POST /install/github/app/manifest", + ) + .await; assert_eq!( manifest_body["github_form_action"], "https://github.com/settings/apps/new" @@ -722,20 +779,23 @@ async fn github_app_manifest_round_trip_updates_install_session() { .map(|(_, value)| value.into_owned()) .expect("state should be embedded in redirect_url"); - let callback_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(format!( - "/install/github/app/redirect?code=stub-code&state={state}" - )) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(callback_response.status(), StatusCode::FOUND); + let callback_response = checked_response( + app.clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/install/github/app/redirect?code=stub-code&state={state}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(), + StatusCode::FOUND, + "GET /install/github/app/redirect?code=stub-code&state=...", + ) + .await; assert_eq!( callback_response .headers() @@ -756,8 +816,8 @@ async fn github_app_manifest_round_trip_updates_install_session() { ) .await .unwrap(); - assert_eq!(session_response.status(), StatusCode::OK); - let session_body = body_json(session_response.into_body()).await; + let session_body = + response_json(session_response, StatusCode::OK, "GET /install/session").await; assert_eq!(session_body["github"]["strategy"], "app"); assert_eq!(session_body["github"]["slug"], "fabro-test-app"); assert_eq!(session_body["github"]["allowed_username"], "octocat"); @@ -789,7 +849,12 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok ) .await .unwrap(); - assert_eq!(server_response.status(), StatusCode::NO_CONTENT); + response_status( + server_response, + StatusCode::NO_CONTENT, + "PUT /install/server", + ) + .await; let github_response = app .clone() @@ -806,7 +871,12 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok ) .await .unwrap(); - assert_eq!(github_response.status(), StatusCode::NO_CONTENT); + response_status( + github_response, + StatusCode::NO_CONTENT, + "PUT /install/github/token", + ) + .await; let manifest_response = app .clone() @@ -823,7 +893,12 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok ) .await .unwrap(); - assert_eq!(manifest_response.status(), StatusCode::OK); + response_status( + manifest_response, + StatusCode::OK, + "POST /install/github/app/manifest", + ) + .await; let session_response = app .clone() @@ -837,8 +912,8 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok ) .await .unwrap(); - assert_eq!(session_response.status(), StatusCode::OK); - let session_body = body_json(session_response.into_body()).await; + let session_body = + response_json(session_response, StatusCode::OK, "GET /install/session").await; assert_eq!(session_body["github"]["strategy"], "token"); assert_eq!(session_body["github"]["username"], "brynary"); assert!( @@ -863,8 +938,12 @@ async fn github_app_manifest_rejects_retry_while_pending_and_preserves_prior_tok ) .await .unwrap(); - assert_eq!(retry_response.status(), StatusCode::CONFLICT); - let retry_body = body_json(retry_response.into_body()).await; + let retry_body = response_json( + retry_response, + StatusCode::CONFLICT, + "POST /install/github/app/manifest", + ) + .await; assert_eq!( retry_body["errors"][0]["detail"], "GitHub App setup is already pending; finish it or wait for it to expire." @@ -913,7 +992,12 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s ) .await .unwrap(); - assert_eq!(server_response.status(), StatusCode::NO_CONTENT); + response_status( + server_response, + StatusCode::NO_CONTENT, + "PUT /install/server", + ) + .await; let manifest_response = app .clone() @@ -930,8 +1014,12 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s ) .await .unwrap(); - assert_eq!(manifest_response.status(), StatusCode::OK); - let manifest_body = body_json(manifest_response.into_body()).await; + let manifest_body = response_json( + manifest_response, + StatusCode::OK, + "POST /install/github/app/manifest", + ) + .await; let redirect_url = manifest_body["manifest"]["redirect_url"] .as_str() .expect("redirect_url should be present"); @@ -942,18 +1030,21 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s .map(|(_, value)| value.into_owned()) .expect("state should be embedded in redirect_url"); - let wrong_state_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/install/github/app/redirect?code=stub-code&state=wrong-state") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(wrong_state_response.status(), StatusCode::FOUND); + let wrong_state_response = checked_response( + app.clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/install/github/app/redirect?code=stub-code&state=wrong-state") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(), + StatusCode::FOUND, + "GET /install/github/app/redirect?code=stub-code&state=wrong-state", + ) + .await; assert_eq!( wrong_state_response .headers() @@ -975,8 +1066,8 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s ) .await .unwrap(); - assert_eq!(session_response.status(), StatusCode::OK); - let session_body = body_json(session_response.into_body()).await; + let session_body = + response_json(session_response, StatusCode::OK, "GET /install/session").await; assert!(session_body["github"].is_null()); assert!( !session_body["completed_steps"] @@ -986,18 +1077,21 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s .any(|value| value == "github") ); - let missing_state_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/install/github/app/redirect?code=stub-code") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(missing_state_response.status(), StatusCode::FOUND); + let missing_state_response = checked_response( + app.clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/install/github/app/redirect?code=stub-code") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(), + StatusCode::FOUND, + "GET /install/github/app/redirect?code=stub-code", + ) + .await; assert_eq!( missing_state_response .headers() @@ -1019,7 +1113,12 @@ async fn github_app_redirect_rejects_invalid_or_missing_state_without_mutating_s ) .await .unwrap(); - assert_eq!(valid_state_response.status(), StatusCode::FOUND); + response_status( + valid_state_response, + StatusCode::FOUND, + "GET /install/github/app/redirect?code=stub-code&state=...", + ) + .await; conversion_mock.assert_calls_async(1).await; } @@ -1054,7 +1153,12 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin ) .await .unwrap(); - assert_eq!(server_response.status(), StatusCode::NO_CONTENT); + response_status( + server_response, + StatusCode::NO_CONTENT, + "PUT /install/server", + ) + .await; let manifest_response = app .clone() @@ -1071,8 +1175,12 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin ) .await .unwrap(); - assert_eq!(manifest_response.status(), StatusCode::OK); - let manifest_body = body_json(manifest_response.into_body()).await; + let manifest_body = response_json( + manifest_response, + StatusCode::OK, + "POST /install/github/app/manifest", + ) + .await; let redirect_url = manifest_body["manifest"]["redirect_url"] .as_str() .expect("redirect_url should be present"); @@ -1083,20 +1191,23 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin .map(|(_, value)| value.into_owned()) .expect("state should be embedded in redirect_url"); - let callback_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri(format!( - "/install/github/app/redirect?code=stub-code&state={state}" - )) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(callback_response.status(), StatusCode::FOUND); + let callback_response = checked_response( + app.clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/install/github/app/redirect?code=stub-code&state={state}" + )) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(), + StatusCode::FOUND, + "GET /install/github/app/redirect?code=stub-code&state=...", + ) + .await; assert_eq!( callback_response .headers() @@ -1120,7 +1231,12 @@ async fn github_app_redirect_exchange_failure_returns_to_wizard_and_keeps_pendin ) .await .unwrap(); - assert_eq!(retry_response.status(), StatusCode::FOUND); + response_status( + retry_response, + StatusCode::FOUND, + "GET /install/github/app/redirect?code=stub-code&state=...", + ) + .await; conversion_mock.assert_calls_async(2).await; } @@ -1143,8 +1259,12 @@ async fn install_server_rejects_trailing_slash_canonical_urls() { .await .unwrap(); - assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::UNPROCESSABLE_ENTITY, + "PUT /install/server", + ) + .await; assert_eq!( body["errors"][0]["detail"], "canonical_url must not end with a trailing slash" @@ -1188,8 +1308,12 @@ async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys( ) .await .unwrap(); - assert_eq!(finish_response.status(), StatusCode::INTERNAL_SERVER_ERROR); - let finish_body = body_json(finish_response.into_body()).await; + let finish_body = response_json( + finish_response, + StatusCode::INTERNAL_SERVER_ERROR, + "POST /install/finish", + ) + .await; assert!( finish_body["errors"][0]["detail"] .as_str() @@ -1234,8 +1358,8 @@ async fn install_finish_failure_restores_settings_and_vault_but_leaves_env_keys( ) .await .unwrap(); - assert_eq!(session_response.status(), StatusCode::OK); - let session_body = body_json(session_response.into_body()).await; + let session_body = + response_json(session_response, StatusCode::OK, "GET /install/session").await; assert!( session_body["completed_steps"] .as_array() @@ -1277,7 +1401,12 @@ async fn install_finish_failure_leaves_home_dev_token_mirror_written() { ) .await .unwrap(); - assert_eq!(finish_response.status(), StatusCode::INTERNAL_SERVER_ERROR); + response_status( + finish_response, + StatusCode::INTERNAL_SERVER_ERROR, + "POST /install/finish", + ) + .await; let home_dev_token = dev_token::read_dev_token_file(&home.dev_token_path()) .expect("home dev token should exist"); diff --git a/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs b/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs index 3a0d9954e..996ff5ba3 100644 --- a/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs +++ b/lib/crates/fabro-server/tests/it/api/install_openai_compatible.rs @@ -3,7 +3,7 @@ use axum::http::{Request, StatusCode}; use fabro_server::install::{InstallAppState, build_install_router}; use tower::ServiceExt; -use crate::helpers::body_json; +use crate::helpers::response_json; #[tokio::test] async fn install_llm_endpoints_reject_openai_compatible_in_v1() { @@ -24,8 +24,12 @@ async fn install_llm_endpoints_reject_openai_compatible_in_v1() { ) .await .unwrap(); - assert_eq!(test_response.status(), StatusCode::UNPROCESSABLE_ENTITY); - let test_body = body_json(test_response.into_body()).await; + let test_body = response_json( + test_response, + StatusCode::UNPROCESSABLE_ENTITY, + "POST /install/llm/test", + ) + .await; assert_eq!( test_body["errors"][0]["detail"], "openai_compatible is not supported by install in v1" @@ -45,8 +49,12 @@ async fn install_llm_endpoints_reject_openai_compatible_in_v1() { ) .await .unwrap(); - assert_eq!(put_response.status(), StatusCode::UNPROCESSABLE_ENTITY); - let put_body = body_json(put_response.into_body()).await; + let put_body = response_json( + put_response, + StatusCode::UNPROCESSABLE_ENTITY, + "PUT /install/llm", + ) + .await; assert_eq!( put_body["errors"][0]["detail"], "openai_compatible is not supported by install in v1" diff --git a/lib/crates/fabro-server/tests/it/api/routing.rs b/lib/crates/fabro-server/tests/it/api/routing.rs index c5ca35b42..7928ccd1b 100644 --- a/lib/crates/fabro-server/tests/it/api/routing.rs +++ b/lib/crates/fabro-server/tests/it/api/routing.rs @@ -1,7 +1,7 @@ use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use std::sync::Arc; -use axum::body::{Body, to_bytes}; +use axum::body::Body; use axum::extract::ConnectInfo; use axum::http::{Method, Request, StatusCode}; use fabro_config::parse_settings_layer; @@ -14,7 +14,7 @@ use fabro_server::server::{ use fabro_types::settings::SettingsLayer; use tower::ServiceExt; -use crate::helpers::body_json; +use crate::helpers::{checked_response, response_json, response_status, response_text}; #[tokio::test] async fn old_unversioned_routes_return_404() { @@ -29,7 +29,7 @@ async fn old_unversioned_routes_return_404() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {path}"); + response_status(response, StatusCode::NOT_FOUND, format!("{method} {path}")).await; } } @@ -43,11 +43,7 @@ async fn root_and_health_stay_at_root() { .body(Body::empty()) .unwrap(); let root_response = app.clone().oneshot(root_req).await.unwrap(); - assert_eq!(root_response.status(), StatusCode::OK); - let root_body = to_bytes(root_response.into_body(), usize::MAX) - .await - .unwrap(); - let root_html = String::from_utf8(root_body.to_vec()).unwrap(); + let root_html = response_text(root_response, StatusCode::OK, "GET /").await; assert!(root_html.contains("
")); let health_req = Request::builder() @@ -56,8 +52,7 @@ async fn root_and_health_stay_at_root() { .body(Body::empty()) .unwrap(); let health_response = app.oneshot(health_req).await.unwrap(); - assert_eq!(health_response.status(), StatusCode::OK); - let health_body = body_json(health_response.into_body()).await; + let health_body = response_json(health_response, StatusCode::OK, "GET /health").await; assert_eq!(health_body["status"], "ok"); assert!( health_body.get("version").is_none(), @@ -80,7 +75,7 @@ async fn install_routes_are_absent_in_normal_mode() { ) .await .unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + response_status(response, StatusCode::NOT_FOUND, "GET /install").await; } #[tokio::test] @@ -94,7 +89,7 @@ async fn moved_routes_not_at_root_of_api_prefix() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND, "GET {path}"); + response_status(response, StatusCode::NOT_FOUND, format!("GET {path}")).await; } } @@ -109,7 +104,12 @@ async fn source_maps_are_not_served() { .unwrap(); let response = app.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + response_status( + response, + StatusCode::NOT_FOUND, + "GET /assets/entry-abc123.js.map", + ) + .await; } #[tokio::test] @@ -122,7 +122,12 @@ async fn web_enabled_serves_web_only_routes() { .body(Body::empty()) .unwrap(); let auth_me_response = app.clone().oneshot(auth_me_request).await.unwrap(); - assert_eq!(auth_me_response.status(), StatusCode::UNAUTHORIZED); + response_status( + auth_me_response, + StatusCode::UNAUTHORIZED, + "GET /api/v1/auth/me", + ) + .await; // Browser-style navigation to an SPA route falls back to index.html. let setup_request = Request::builder() @@ -132,7 +137,7 @@ async fn web_enabled_serves_web_only_routes() { .body(Body::empty()) .unwrap(); let setup_response = app.clone().oneshot(setup_request).await.unwrap(); - assert_eq!(setup_response.status(), StatusCode::OK); + response_status(setup_response, StatusCode::OK, "GET /setup").await; // Same path without `Accept: text/html` (e.g. curl, fetch default) is // not a browser navigation and must not get the SPA HTML fallback. @@ -142,7 +147,12 @@ async fn web_enabled_serves_web_only_routes() { .body(Body::empty()) .unwrap(); let setup_no_accept_response = app.clone().oneshot(setup_no_accept).await.unwrap(); - assert_eq!(setup_no_accept_response.status(), StatusCode::NOT_FOUND); + response_status( + setup_no_accept_response, + StatusCode::NOT_FOUND, + "GET /setup", + ) + .await; let setup_status_request = Request::builder() .method("GET") @@ -150,7 +160,12 @@ async fn web_enabled_serves_web_only_routes() { .body(Body::empty()) .unwrap(); let setup_status_response = app.clone().oneshot(setup_status_request).await.unwrap(); - assert_eq!(setup_status_response.status(), StatusCode::NOT_FOUND); + response_status( + setup_status_response, + StatusCode::NOT_FOUND, + "GET /api/v1/setup/status", + ) + .await; let setup_complete_request = Request::builder() .method("GET") @@ -158,7 +173,12 @@ async fn web_enabled_serves_web_only_routes() { .body(Body::empty()) .unwrap(); let setup_complete_response = app.clone().oneshot(setup_complete_request).await.unwrap(); - assert_eq!(setup_complete_response.status(), StatusCode::NOT_FOUND); + response_status( + setup_complete_response, + StatusCode::NOT_FOUND, + "GET /setup/complete", + ) + .await; let demo_toggle_request = Request::builder() .method("POST") @@ -166,8 +186,12 @@ async fn web_enabled_serves_web_only_routes() { .header("content-type", "application/json") .body(Body::from(r#"{"enabled":true}"#)) .unwrap(); - let demo_toggle_response = app.clone().oneshot(demo_toggle_request).await.unwrap(); - assert_eq!(demo_toggle_response.status(), StatusCode::OK); + let demo_toggle_response = checked_response( + app.clone().oneshot(demo_toggle_request).await.unwrap(), + StatusCode::OK, + "POST /api/v1/demo/toggle", + ) + .await; assert!( demo_toggle_response.headers().contains_key("set-cookie"), "demo toggle should set a cookie" @@ -183,7 +207,12 @@ async fn web_enabled_serves_web_only_routes() { .body(Body::empty()) .unwrap(); let api_miss_response = app.oneshot(api_miss).await.unwrap(); - assert_eq!(api_miss_response.status(), StatusCode::NOT_FOUND); + response_status( + api_miss_response, + StatusCode::NOT_FOUND, + "GET /api/v2/nonexistent", + ) + .await; } #[tokio::test] @@ -191,17 +220,21 @@ async fn security_headers_are_applied_to_all_responses() { let app = build_router(create_app_state(), AuthMode::Disabled); // Plain HTTP: HSTS must NOT be present. - let api_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/health") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let api_response = checked_response( + app.clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/health") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(), + StatusCode::OK, + "GET /health", + ) + .await; let headers = api_response.headers(); assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff"); assert_eq!(headers.get("x-frame-options").unwrap(), "DENY"); @@ -222,18 +255,22 @@ async fn security_headers_are_applied_to_all_responses() { ); // X-Forwarded-Proto: https signals the request reached an HTTPS edge. - let https_response = app - .clone() - .oneshot( - Request::builder() - .method("GET") - .uri("/health") - .header("x-forwarded-proto", "https") - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); + let https_response = checked_response( + app.clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/health") + .header("x-forwarded-proto", "https") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(), + StatusCode::OK, + "GET /health with x-forwarded-proto=https", + ) + .await; assert_eq!( https_response .headers() @@ -243,8 +280,8 @@ async fn security_headers_are_applied_to_all_responses() { ); // SPA fallback path must also get the headers. - let spa_response = app - .oneshot( + let spa_response = checked_response( + app.oneshot( Request::builder() .method("GET") .uri("/runs/abc123") @@ -253,8 +290,11 @@ async fn security_headers_are_applied_to_all_responses() { .unwrap(), ) .await - .unwrap(); - assert_eq!(spa_response.status(), StatusCode::OK); + .unwrap(), + StatusCode::OK, + "GET /runs/abc123", + ) + .await; assert_eq!( spa_response.headers().get("x-frame-options").unwrap(), "DENY" @@ -337,7 +377,7 @@ enabled = false .unwrap(); let response = app.clone().oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {path}"); + response_status(response, StatusCode::NOT_FOUND, format!("{method} {path}")).await; } let settings_request = Request::builder() @@ -346,7 +386,7 @@ enabled = false .body(Body::empty()) .unwrap(); let settings_response = app.clone().oneshot(settings_request).await.unwrap(); - assert_eq!(settings_response.status(), StatusCode::OK); + response_status(settings_response, StatusCode::OK, "GET /api/v1/settings").await; let health_request = Request::builder() .method("GET") @@ -354,7 +394,7 @@ enabled = false .body(Body::empty()) .unwrap(); let health_response = app.oneshot(health_request).await.unwrap(); - assert_eq!(health_response.status(), StatusCode::OK); + response_status(health_response, StatusCode::OK, "GET /health").await; } #[tokio::test] @@ -387,7 +427,7 @@ enabled = false .unwrap(); let response = app.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + response_status(response, StatusCode::NOT_FOUND, "GET /api/v1/runs/{id}").await; } #[tokio::test] @@ -410,7 +450,7 @@ async fn allowlist_blocks_non_allowlisted_api_requests() { .await .unwrap(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); + response_status(response, StatusCode::FORBIDDEN, "GET /api/v1/runs").await; } #[tokio::test] @@ -433,7 +473,7 @@ async fn allowlist_exempts_health_checks() { .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + response_status(response, StatusCode::OK, "GET /health").await; } fn request_with_connect_info(path: &str, ip: IpAddr) -> Request { diff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs index 65b86098c..ccc54efc9 100644 --- a/lib/crates/fabro-server/tests/it/api/run_files.rs +++ b/lib/crates/fabro-server/tests/it/api/run_files.rs @@ -13,7 +13,9 @@ use fabro_server::jwt_auth::AuthMode; use fabro_server::server::build_router; use tower::ServiceExt; -use crate::helpers::{MINIMAL_DOT, api, body_json, minimal_manifest_json, test_app_state}; +use crate::helpers::{ + MINIMAL_DOT, api, minimal_manifest_json, response_json, response_status, test_app_state, +}; fn files_url(run_id: &str) -> String { api(&format!("/runs/{run_id}/files")) @@ -28,7 +30,12 @@ async fn invalid_run_id_returns_400() { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + response_status( + resp, + StatusCode::BAD_REQUEST, + "GET /api/v1/runs/not-a-ulid/files", + ) + .await; } #[tokio::test] @@ -42,7 +49,12 @@ async fn unknown_run_returns_404() { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::NOT_FOUND); + response_status( + resp, + StatusCode::NOT_FOUND, + format!("GET /api/v1/runs/{fake}/files"), + ) + .await; } #[tokio::test] @@ -55,8 +67,12 @@ async fn malformed_from_sha_query_returns_400() { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); - let body = body_json(resp.into_body()).await; + let body = crate::helpers::response_json( + resp, + StatusCode::BAD_REQUEST, + format!("{}:{}", file!(), line!()), + ) + .await; assert!( body["errors"][0]["detail"] .as_str() @@ -80,7 +96,12 @@ async fn non_default_from_sha_returns_400_even_when_hex() { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + response_status( + resp, + StatusCode::BAD_REQUEST, + format!("GET /api/v1/runs/{fake}/files?from_sha="), + ) + .await; } #[tokio::test] @@ -93,7 +114,12 @@ async fn malformed_to_sha_returns_400() { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + response_status( + resp, + StatusCode::BAD_REQUEST, + format!("GET /api/v1/runs/{fake}/files?to_sha=xyz"), + ) + .await; } #[tokio::test] @@ -110,8 +136,7 @@ async fn submitted_run_without_sandbox_returns_empty_envelope() { .body(Body::from(serde_json::to_string(&manifest).unwrap())) .unwrap(); let create_resp = app.clone().oneshot(create_req).await.unwrap(); - assert_eq!(create_resp.status(), StatusCode::CREATED); - let create_body = body_json(create_resp.into_body()).await; + let create_body = response_json(create_resp, StatusCode::CREATED, "POST /api/v1/runs").await; let run_id = create_body["id"].as_str().unwrap().to_string(); let req = Request::builder() @@ -120,8 +145,12 @@ async fn submitted_run_without_sandbox_returns_empty_envelope() { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp.into_body()).await; + let body = response_json( + resp, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/files"), + ) + .await; assert!( body["data"].as_array().is_some_and(Vec::is_empty), "expected empty data: {body}" @@ -146,8 +175,7 @@ async fn demo_mode_returns_fixture_without_touching_store() { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp.into_body()).await; + let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/files").await; // Demo fixture ships three entries (modified + added + renamed). let data = body["data"].as_array().expect("data array"); @@ -175,8 +203,7 @@ async fn response_envelope_matches_openapi_paginated_run_file_list_shape() { .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); - assert_eq!(resp.status(), StatusCode::OK); - let body = body_json(resp.into_body()).await; + let body = response_json(resp, StatusCode::OK, "GET /api/v1/runs/whatever/files").await; assert!(body["data"].is_array()); assert!(body["meta"].is_object()); diff --git a/lib/crates/fabro-server/tests/it/api/runs.rs b/lib/crates/fabro-server/tests/it/api/runs.rs index ff6edafba..061b87e80 100644 --- a/lib/crates/fabro-server/tests/it/api/runs.rs +++ b/lib/crates/fabro-server/tests/it/api/runs.rs @@ -7,7 +7,7 @@ use serde_json::json; use tower::ServiceExt; use crate::helpers::{ - MINIMAL_DOT, api, body_json, minimal_manifest_json, test_app_state_with_options, + MINIMAL_DOT, api, body_json, minimal_manifest_json, response_json, test_app_state_with_options, }; #[tokio::test] @@ -82,8 +82,12 @@ session_sandboxes = true .unwrap(); let response = app.oneshot(get_request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/settings"), + ) + .await; assert_eq!(body["_version"], 1); assert_eq!(body["run"]["goal"], "Ship it"); assert_eq!(body["cli"]["output"]["verbosity"], "verbose"); diff --git a/lib/crates/fabro-server/tests/it/api/settings.rs b/lib/crates/fabro-server/tests/it/api/settings.rs index c82772f04..5c892313f 100644 --- a/lib/crates/fabro-server/tests/it/api/settings.rs +++ b/lib/crates/fabro-server/tests/it/api/settings.rs @@ -7,7 +7,7 @@ use fabro_types::settings::SettingsLayer; use serde_json::json; use tower::ServiceExt; -use crate::helpers::body_json; +use crate::helpers::{body_json, checked_response, response_json}; #[tokio::test] async fn retrieve_server_settings_default_view_returns_redacted_layer_settings() { @@ -54,8 +54,7 @@ server_only = "1" .unwrap(); let response = app.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK, "GET /api/v1/settings").await; assert_eq!(body["_version"], 1); assert_eq!(body["server"]["storage"]["root"], "/srv/fabro"); assert_eq!(body["server"]["scheduler"]["max_concurrent_runs"], 9); @@ -119,7 +118,12 @@ server_only = "1" .unwrap(); let response = app.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response = checked_response( + response, + StatusCode::OK, + "GET /api/v1/settings?view=resolved", + ) + .await; assert_eq!( response .headers() diff --git a/lib/crates/fabro-server/tests/it/api/system.rs b/lib/crates/fabro-server/tests/it/api/system.rs index 751a13d83..bcf066220 100644 --- a/lib/crates/fabro-server/tests/it/api/system.rs +++ b/lib/crates/fabro-server/tests/it/api/system.rs @@ -19,9 +19,9 @@ use tokio::time::timeout; use tower::ServiceExt; use crate::helpers::{ - MINIMAL_DOT, POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, minimal_manifest_json, - minimal_manifest_json_with_dry_run, test_app_state_with_options, test_app_with_scheduler, - test_settings, wait_for_run_status, + MINIMAL_DOT, POLL_ATTEMPTS, POLL_INTERVAL, api, checked_response, minimal_manifest_json, + minimal_manifest_json_with_dry_run, response_json, response_status, + test_app_state_with_options, test_app_with_scheduler, test_settings, wait_for_run_status, }; const HUMAN_GATE_DOT: &str = r#"digraph GateTest { @@ -61,7 +61,7 @@ async fn create_run(app: &axum::Router, manifest: serde_json::Value) -> String { )) .expect("create-run request should build"); let response = app.clone().oneshot(request).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await; body["id"] .as_str() .expect("create-run response should include an id") @@ -75,7 +75,12 @@ async fn start_run(app: &axum::Router, run_id: &str) { .body(Body::empty()) .expect("start-run request should build"); let response = app.clone().oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + response_status( + response, + StatusCode::OK, + format!("POST /api/v1/runs/{run_id}/start"), + ) + .await; } async fn wait_for_question(app: &axum::Router, run_id: &str) -> serde_json::Value { @@ -86,7 +91,12 @@ async fn wait_for_question(app: &axum::Router, run_id: &str) -> serde_json::Valu .body(Body::empty()) .expect("questions request should build"); let response = app.clone().oneshot(request).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/questions"), + ) + .await; if let Some(question) = body["data"].as_array().and_then(|items| items.first()) { return question.clone(); } @@ -102,8 +112,12 @@ async fn load_questions(app: &axum::Router, run_id: &str) -> serde_json::Value { .body(Body::empty()) .expect("questions request should build"); let response = app.clone().oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - body_json(response.into_body()).await + response_json( + response, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/questions"), + ) + .await } #[tokio::test] @@ -121,8 +135,7 @@ async fn get_system_info_returns_runtime_fields() { .unwrap(); let response = app.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK, "GET /api/v1/system/info").await; assert!(body["version"].as_str().is_some()); assert_eq!(body["storage_engine"], "slatedb"); assert_eq!( @@ -181,8 +194,12 @@ async fn get_system_disk_usage_returns_summary_and_verbose_rows() { .unwrap(); let response = app.oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + "GET /api/v1/system/df?verbose=true", + ) + .await; assert!(body["summary"].is_array()); assert!(body["total_size_bytes"].as_i64().unwrap_or_default() > 0); assert!( @@ -216,8 +233,12 @@ async fn prune_runs_supports_dry_run_and_deletion() { .body(Body::from(r#"{"before":"9999"}"#)) .unwrap(); let dry_run_response = app.clone().oneshot(dry_run_request).await.unwrap(); - assert_eq!(dry_run_response.status(), StatusCode::OK); - let dry_run_body = body_json(dry_run_response.into_body()).await; + let dry_run_body = response_json( + dry_run_response, + StatusCode::OK, + "POST /api/v1/system/prune/runs", + ) + .await; assert_eq!(dry_run_body["dry_run"], true); assert_eq!(dry_run_body["total_count"], 1); assert_eq!(dry_run_body["runs"][0]["run_id"], run_id); @@ -230,8 +251,12 @@ async fn prune_runs_supports_dry_run_and_deletion() { .body(Body::from(r#"{"dry_run":false,"before":"9999"}"#)) .unwrap(); let delete_response = app.clone().oneshot(delete_request).await.unwrap(); - assert_eq!(delete_response.status(), StatusCode::OK); - let delete_body = body_json(delete_response.into_body()).await; + let delete_body = response_json( + delete_response, + StatusCode::OK, + "POST /api/v1/system/prune/runs", + ) + .await; assert_eq!(delete_body["dry_run"], false); assert_eq!(delete_body["deleted_count"], 1); assert!(!run_dir.exists()); @@ -250,8 +275,12 @@ async fn attach_events_streams_only_matching_run_ids() { .uri(api(&format!("/attach?run_id={run_one}"))) .body(Body::empty()) .unwrap(); - let response = app.clone().oneshot(request).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response = checked_response( + app.clone().oneshot(request).await.unwrap(), + StatusCode::OK, + format!("GET /api/v1/attach?run_id={run_one}"), + ) + .await; let content_type = response .headers() .get("content-type") diff --git a/lib/crates/fabro-server/tests/it/api/tcp.rs b/lib/crates/fabro-server/tests/it/api/tcp.rs index 974e19ba3..350f240b0 100644 --- a/lib/crates/fabro-server/tests/it/api/tcp.rs +++ b/lib/crates/fabro-server/tests/it/api/tcp.rs @@ -8,6 +8,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; +use axum::http::StatusCode; use fabro_config::ServerState; use fabro_server::bind::Bind; use fabro_server::ip_allowlist::{IpAllowlist, IpAllowlistConfig}; @@ -21,7 +22,7 @@ use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::sleep; -use crate::helpers::api; +use crate::helpers::{api, reqwest_status}; const TEST_DEV_TOKEN: &str = "fabro_dev_abababababababababababababababababababababababababababababababab"; @@ -111,7 +112,8 @@ async fn spawn_served_listener( async fn wait_for_health(client: &fabro_http::HttpClient, url: &str) { for _ in 0..50 { if let Ok(response) = client.get(url).send().await { - if response.status() == 200 { + let status = response.status(); + if status == 200 { return; } } @@ -150,7 +152,7 @@ methods = ["dev-token"] .await .expect("plain HTTP request should succeed"); - assert_eq!(response.status(), 200); + reqwest_status(response, StatusCode::OK, "GET /api/v1/runs").await; handle.abort(); } @@ -165,7 +167,7 @@ async fn tcp_dev_token_auth_uses_bearer_auth() { let url = format!("http://127.0.0.1:{}{}", addr.port(), api("/runs")); let unauthorized = client.get(&url).send().await.unwrap(); - assert_eq!(unauthorized.status(), 401); + reqwest_status(unauthorized, StatusCode::UNAUTHORIZED, "GET /api/v1/runs").await; let authorized = client .get(url) @@ -173,7 +175,7 @@ async fn tcp_dev_token_auth_uses_bearer_auth() { .send() .await .unwrap(); - assert_eq!(authorized.status(), 200); + reqwest_status(authorized, StatusCode::OK, "GET /api/v1/runs").await; } #[cfg(unix)] @@ -209,7 +211,7 @@ methods = ["dev-token"] .await .expect("Unix-socket HTTP request should succeed"); - assert_eq!(response.status(), 200); + reqwest_status(response, StatusCode::OK, "GET /api/v1/runs").await; handle.abort(); } @@ -231,5 +233,5 @@ async fn tcp_ip_allowlist_uses_connect_info() { .await .unwrap(); - assert_eq!(response.status(), 403); + reqwest_status(response, StatusCode::FORBIDDEN, "GET /api/v1/runs").await; } diff --git a/lib/crates/fabro-server/tests/it/helpers.rs b/lib/crates/fabro-server/tests/it/helpers.rs index 1bef10935..03a3dbcaa 100644 --- a/lib/crates/fabro-server/tests/it/helpers.rs +++ b/lib/crates/fabro-server/tests/it/helpers.rs @@ -9,6 +9,10 @@ use fabro_server::server::{ AppState, build_router, create_app_state, create_app_state_with_env_lookup, create_app_state_with_options_and_registry_factory, spawn_scheduler, }; +use fabro_test::{ + assert_axum_status, assert_reqwest_status, expect_axum_json, expect_axum_status, + expect_axum_status_in, expect_axum_text, +}; use fabro_types::settings::SettingsLayer; use fabro_types::settings::run::{LocalSandboxLayer, RunLayer, RunSandboxLayer, WorktreeMode}; use tokio::time::sleep; @@ -103,6 +107,54 @@ pub(crate) async fn body_json(body: Body) -> serde_json::Value { serde_json::from_slice(&bytes).expect("response body should be valid JSON") } +pub(crate) async fn response_status( + response: axum::response::Response, + expected: StatusCode, + context: impl std::fmt::Display, +) { + assert_axum_status(response, expected, context).await; +} + +pub(crate) async fn response_json( + response: axum::response::Response, + expected: StatusCode, + context: impl std::fmt::Display, +) -> serde_json::Value { + expect_axum_json(response, expected, context).await +} + +pub(crate) async fn response_text( + response: axum::response::Response, + expected: StatusCode, + context: impl std::fmt::Display, +) -> String { + expect_axum_text(response, expected, context).await +} + +pub(crate) async fn checked_response( + response: axum::response::Response, + expected: StatusCode, + context: impl std::fmt::Display, +) -> axum::response::Response { + expect_axum_status(response, expected, context).await +} + +pub(crate) async fn checked_response_in( + response: axum::response::Response, + expected: &[StatusCode], + context: impl std::fmt::Display, +) -> axum::response::Response { + expect_axum_status_in(response, expected, context).await +} + +pub(crate) async fn reqwest_status( + response: fabro_http::Response, + expected: StatusCode, + context: impl std::fmt::Display, +) { + assert_reqwest_status(response, expected, context).await; +} + pub(crate) async fn create_and_start_run_from_manifest( app: &axum::Router, manifest: serde_json::Value, @@ -116,7 +168,7 @@ pub(crate) async fn create_and_start_run_from_manifest( )) .expect("create-run request should build"); let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await; let run_id = body["id"] .as_str() .expect("create-run response should include an id") @@ -127,7 +179,12 @@ pub(crate) async fn create_and_start_run_from_manifest( .uri(api(&format!("/runs/{run_id}/start"))) .body(Body::empty()) .expect("start-run request should build"); - app.clone().oneshot(req).await.unwrap(); + response_status( + app.clone().oneshot(req).await.unwrap(), + StatusCode::OK, + format!("POST /api/v1/runs/{run_id}/start"), + ) + .await; run_id } @@ -162,8 +219,12 @@ pub(crate) async fn run_json(app: &axum::Router, run_id: &str) -> serde_json::Va .body(Body::empty()) .expect("run lookup request should build"); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - body_json(response.into_body()).await + response_json( + response, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}"), + ) + .await } pub(crate) async fn wait_for_run_status( diff --git a/lib/crates/fabro-server/tests/it/pagination.rs b/lib/crates/fabro-server/tests/it/pagination.rs index 5215ff1e6..9ef9192ae 100644 --- a/lib/crates/fabro-server/tests/it/pagination.rs +++ b/lib/crates/fabro-server/tests/it/pagination.rs @@ -11,7 +11,7 @@ use fabro_server::jwt_auth::AuthMode; use fabro_server::server::build_router; use tower::ServiceExt; -use super::helpers::test_app_state; +use super::helpers::{response_json, test_app_state}; async fn get_json(app: axum::Router, uri: &str) -> serde_json::Value { let req = Request::builder() @@ -21,11 +21,7 @@ async fn get_json(app: axum::Router, uri: &str) -> serde_json::Value { .body(Body::empty()) .expect("pagination request should build"); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK, "GET {uri} failed"); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("pagination response body should fit in memory"); - serde_json::from_slice(&body).expect("pagination response should be valid JSON") + response_json(response, StatusCode::OK, format!("GET {uri}")).await } /// Assert that a value has the paginated shape: `{ data: [...], meta: { diff --git a/lib/crates/fabro-server/tests/it/scenario/archive.rs b/lib/crates/fabro-server/tests/it/scenario/archive.rs index 472b60e3c..481621313 100644 --- a/lib/crates/fabro-server/tests/it/scenario/archive.rs +++ b/lib/crates/fabro-server/tests/it/scenario/archive.rs @@ -3,8 +3,8 @@ use axum::http::{Request, StatusCode}; use tower::ServiceExt; use crate::helpers::{ - MINIMAL_DOT, api, body_json, create_and_start_run_from_manifest, - minimal_manifest_json_with_dry_run, test_app_state_with_options, test_app_with_scheduler, + MINIMAL_DOT, api, create_and_start_run_from_manifest, minimal_manifest_json_with_dry_run, + response_json, response_status, test_app_state_with_options, test_app_with_scheduler, test_settings, wait_for_run_status, }; @@ -25,8 +25,12 @@ async fn archived_runs_reject_mutations_with_actionable_body() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + format!("POST /api/v1/runs/{run_id}/archive"), + ) + .await; assert_eq!(body["status"], "archived"); for path in &["/cancel", "/pause", "/unpause", "/start"] { @@ -36,12 +40,12 @@ async fn archived_runs_reject_mutations_with_actionable_body() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!( - response.status(), + let body = response_json( + response, StatusCode::CONFLICT, - "expected 409 for POST /runs/{{id}}{path} on archived run" - ); - let body = body_json(response.into_body()).await; + format!("POST /api/v1/runs/{run_id}{path}"), + ) + .await; let detail = body["errors"][0]["detail"].as_str().unwrap_or_default(); assert!( detail.contains("is archived") && detail.contains("fabro unarchive"), @@ -65,10 +69,16 @@ async fn archived_runs_reject_mutations_with_actionable_body() { )) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!( - response.status(), + let body = response_json( + response, StatusCode::CONFLICT, - "expected 409 for POST /runs/{{id}}/events on archived run" + format!("POST /api/v1/runs/{run_id}/events"), + ) + .await; + let detail = body["errors"][0]["detail"].as_str().unwrap_or_default(); + assert!( + detail.contains("is archived") && detail.contains("fabro unarchive"), + "expected archived-rejection body on /events, got: {body}" ); // The archive guard runs before each endpoint's state-specific lookups, so @@ -107,12 +117,12 @@ async fn archived_runs_reject_mutations_with_actionable_body() { .body(Body::from(body)) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!( - response.status(), + let body = response_json( + response, StatusCode::CONFLICT, - "expected 409 for {method} {path} on archived run" - ); - let body = body_json(response.into_body()).await; + format!("{method} /api/v1{path}"), + ) + .await; let detail = body["errors"][0]["detail"].as_str().unwrap_or_default(); assert!( detail.contains("is archived") && detail.contains("fabro unarchive"), @@ -127,8 +137,12 @@ async fn archived_runs_reject_mutations_with_actionable_body() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + format!("POST /api/v1/runs/{run_id}/unarchive"), + ) + .await; assert_eq!(body["status"], "succeeded"); } @@ -160,8 +174,12 @@ async fn appending_run_archived_event_directly_is_rejected() { )) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - let body = body_json(response.into_body()).await; + let body = crate::helpers::response_json( + response, + StatusCode::BAD_REQUEST, + format!("{}:{}", file!(), line!()), + ) + .await; let detail = body["errors"][0]["detail"].as_str().unwrap_or_default(); assert!( detail.contains("lifecycle event"), @@ -180,7 +198,12 @@ async fn archive_returns_404_for_unknown_run() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + response_status( + response, + StatusCode::NOT_FOUND, + "POST /api/v1/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/archive", + ) + .await; let req = Request::builder() .method("POST") @@ -188,7 +211,12 @@ async fn archive_returns_404_for_unknown_run() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + response_status( + response, + StatusCode::NOT_FOUND, + "POST /api/v1/runs/01ARZ3NDEKTSV4RRFFQ69G5FAV/unarchive", + ) + .await; } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -207,7 +235,12 @@ async fn list_runs_respects_include_archived_flag() { .uri(api(&format!("/runs/{run_id}/archive"))) .body(Body::empty()) .unwrap(); - app.clone().oneshot(req).await.unwrap(); + response_status( + app.clone().oneshot(req).await.unwrap(), + StatusCode::OK, + format!("POST /api/v1/runs/{run_id}/archive"), + ) + .await; // Default listing hides archived. let req = Request::builder() @@ -216,7 +249,7 @@ async fn list_runs_respects_include_archived_flag() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK, "GET /api/v1/runs").await; let ids_visible: Vec = body["data"] .as_array() .unwrap() @@ -235,7 +268,12 @@ async fn list_runs_respects_include_archived_flag() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + "GET /api/v1/runs?include_archived=true", + ) + .await; let ids_all: Vec = body["data"] .as_array() .unwrap() diff --git a/lib/crates/fabro-server/tests/it/scenario/dry_run.rs b/lib/crates/fabro-server/tests/it/scenario/dry_run.rs index 021279016..08b1393d9 100644 --- a/lib/crates/fabro-server/tests/it/scenario/dry_run.rs +++ b/lib/crates/fabro-server/tests/it/scenario/dry_run.rs @@ -4,9 +4,10 @@ use httpmock::MockServer; use tower::ServiceExt; use crate::helpers::{ - MINIMAL_DOT, api, body_json, create_and_start_run_from_manifest, minimal_manifest_json, - minimal_manifest_json_with_dry_run, test_app_state_with_options, test_app_with_mock_anthropic, - test_app_with_no_providers, test_app_with_scheduler, test_settings, wait_for_run_status, + MINIMAL_DOT, api, checked_response, create_and_start_run_from_manifest, minimal_manifest_json, + minimal_manifest_json_with_dry_run, response_json, response_status, + test_app_state_with_options, test_app_with_mock_anthropic, test_app_with_no_providers, + test_app_with_scheduler, test_settings, wait_for_run_status, }; fn completion_request(stream: bool) -> Request { @@ -65,9 +66,12 @@ async fn test_model_skip_when_no_providers() { .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + "POST /api/v1/models/claude-opus-4-6/test", + ) + .await; assert_eq!(body["model_id"], "claude-opus-4-6"); assert_eq!(body["status"], "skip"); } @@ -84,7 +88,12 @@ async fn test_model_unknown_via_full_router() { .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + response_status( + response, + StatusCode::NOT_FOUND, + "POST /api/v1/models/nonexistent-model-xyz/test", + ) + .await; } #[tokio::test] @@ -101,7 +110,7 @@ async fn dry_run_serve_rejects_invalid_dot() { .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + response_status(response, StatusCode::BAD_REQUEST, "POST /api/v1/runs").await; } #[tokio::test] @@ -109,7 +118,12 @@ async fn completion_no_provider_non_streaming_returns_502() { let app = test_app_with_no_providers(); let response = app.oneshot(completion_request(false)).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + response_status( + response, + StatusCode::BAD_GATEWAY, + "POST /api/v1/completions", + ) + .await; } #[tokio::test] @@ -117,7 +131,12 @@ async fn completion_no_provider_streaming_returns_502() { let app = test_app_with_no_providers(); let response = app.oneshot(completion_request(true)).await.unwrap(); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + response_status( + response, + StatusCode::BAD_GATEWAY, + "POST /api/v1/completions?stream=true", + ) + .await; } #[tokio::test] @@ -149,9 +168,7 @@ async fn completion_non_streaming_returns_valid_json() { .oneshot(completion_request_with_model(false, "claude-sonnet-4-5")) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::OK, "POST /api/v1/completions").await; assert!(body["id"].is_string()); assert_eq!(body["model"], "claude-sonnet-4-5"); assert_eq!(body["stop_reason"], "end_turn"); @@ -185,7 +202,7 @@ async fn completion_streaming_returns_sse() { .oneshot(completion_request_with_model(true, "claude-sonnet-4-5")) .await .unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response = checked_response(response, StatusCode::OK, "POST /api/v1/completions").await; let content_type = response .headers() .get("content-type") diff --git a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs index d81b4c625..2ec100f16 100644 --- a/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs +++ b/lib/crates/fabro-server/tests/it/scenario/lifecycle.rs @@ -16,8 +16,8 @@ use tokio::time::sleep; use tower::ServiceExt; use crate::helpers::{ - POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, minimal_manifest_json, run_json, test_settings, - wait_for_run_status, + POLL_ATTEMPTS, POLL_INTERVAL, api, minimal_manifest_json, response_json, response_status, + run_json, test_settings, wait_for_run_status, }; fn gate_registry(interviewer: Arc) -> HandlerRegistry { @@ -37,7 +37,12 @@ async fn wait_for_question_id(app: &axum::Router, run_id: &str) -> String { .body(Body::empty()) .expect("questions request should build"); let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/questions"), + ) + .await; let arr = body["data"] .as_array() .expect("questions response should include a data array"); @@ -61,7 +66,12 @@ async fn wait_for_question(app: &axum::Router, run_id: &str) -> serde_json::Valu .body(Body::empty()) .expect("questions request should build"); let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/questions"), + ) + .await; let arr = body["data"] .as_array() .expect("questions response should include a data array"); @@ -124,8 +134,7 @@ async fn full_http_lifecycle_approve_and_complete() { .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::CREATED); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await; let run_id = body["id"].as_str().unwrap().to_string(); // 1b. Start the run @@ -135,7 +144,12 @@ async fn full_http_lifecycle_approve_and_complete() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + response_status( + response, + StatusCode::OK, + format!("POST /api/v1/runs/{run_id}/start"), + ) + .await; // 2. Poll for question to appear (run goes start -> work -> gate, then blocks) let question = wait_for_question(&app, &run_id).await; @@ -156,7 +170,12 @@ async fn full_http_lifecycle_approve_and_complete() { )) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); + response_status( + response, + StatusCode::NO_CONTENT, + format!("POST /api/v1/runs/{run_id}/questions/{question_id}/answer"), + ) + .await; // 4. Poll until the run reaches a terminal success or failure state. let final_status = wait_for_run_status(&app, &run_id, &["succeeded", "failed"]).await; @@ -169,7 +188,12 @@ async fn full_http_lifecycle_approve_and_complete() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/questions"), + ) + .await; assert!( body["data"].as_array().unwrap().is_empty(), "no pending questions after completion" @@ -192,7 +216,7 @@ async fn full_http_lifecycle_cancel() { )) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await; let run_id = body["id"].as_str().unwrap().to_string(); let req = Request::builder() @@ -200,7 +224,12 @@ async fn full_http_lifecycle_cancel() { .uri(api(&format!("/runs/{run_id}/start"))) .body(Body::empty()) .unwrap(); - app.clone().oneshot(req).await.unwrap(); + response_status( + app.clone().oneshot(req).await.unwrap(), + StatusCode::OK, + format!("POST /api/v1/runs/{run_id}/start"), + ) + .await; // Wait until the worker has reached the human gate so cancel exercises the // live-running path rather than racing the in-memory queue transition. @@ -213,8 +242,12 @@ async fn full_http_lifecycle_cancel() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + format!("POST /api/v1/runs/{run_id}/cancel"), + ) + .await; assert_eq!(body["status"], "running"); assert_eq!(body["pending_control"], "cancel"); @@ -238,7 +271,7 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() { )) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - let body = body_json(response.into_body()).await; + let body = response_json(response, StatusCode::CREATED, "POST /api/v1/runs").await; let run_id = body["id"].as_str().unwrap().to_string(); let req = Request::builder() @@ -246,7 +279,12 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() { .uri(api(&format!("/runs/{run_id}/start"))) .body(Body::empty()) .unwrap(); - app.clone().oneshot(req).await.unwrap(); + response_status( + app.clone().oneshot(req).await.unwrap(), + StatusCode::OK, + format!("POST /api/v1/runs/{run_id}/start"), + ) + .await; let _question_id = wait_for_question_id(&app, &run_id).await; @@ -256,7 +294,12 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() { .body(Body::empty()) .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + response_status( + response, + StatusCode::OK, + format!("POST /api/v1/runs/{run_id}/cancel"), + ) + .await; let status = wait_for_run_status(&app, &run_id, &["failed"]).await; assert_eq!(status, "failed"); @@ -267,8 +310,12 @@ async fn cancel_at_human_gate_persists_cancelled_terminal_event() { .body(Body::empty()) .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - let body = body_json(response.into_body()).await; + let body = response_json( + response, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/events"), + ) + .await; let failed_reasons = body["data"] .as_array() .unwrap() diff --git a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs b/lib/crates/fabro-server/tests/it/scenario/run_completion.rs index 36aa38b98..fa0f6a4a7 100644 --- a/lib/crates/fabro-server/tests/it/scenario/run_completion.rs +++ b/lib/crates/fabro-server/tests/it/scenario/run_completion.rs @@ -1,11 +1,12 @@ -use axum::body::{Body, to_bytes}; +use axum::body::Body; use axum::http::{Request, StatusCode}; use tokio::time::sleep; use tower::ServiceExt; use crate::helpers::{ - MINIMAL_DOT, api, create_and_start_run_from_manifest, minimal_manifest_json_with_dry_run, - test_app_state_with_options, test_app_with_scheduler, test_settings, wait_for_run_status, + MINIMAL_DOT, api, checked_response, create_and_start_run_from_manifest, + minimal_manifest_json_with_dry_run, response_text, test_app_state_with_options, + test_app_with_scheduler, test_settings, wait_for_run_status, }; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -39,8 +40,12 @@ async fn attach_run_events_returns_sse_stream() { .body(Body::empty()) .unwrap(); - let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); + let response = checked_response( + app.oneshot(req).await.unwrap(), + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/attach"), + ) + .await; let content_type = response .headers() .get("content-type") @@ -71,10 +76,12 @@ async fn attach_run_events_replays_terminal_event_after_completion() { .unwrap(); let response = app.oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - let body = String::from_utf8(body.to_vec()).unwrap(); + let body = response_text( + response, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/attach?since_seq=1"), + ) + .await; let event_names = body .lines() .filter_map(|line| line.strip_prefix("data:")) diff --git a/lib/crates/fabro-server/tests/it/scenario/sse.rs b/lib/crates/fabro-server/tests/it/scenario/sse.rs index 87eff7c12..b3854a1a9 100644 --- a/lib/crates/fabro-server/tests/it/scenario/sse.rs +++ b/lib/crates/fabro-server/tests/it/scenario/sse.rs @@ -7,9 +7,10 @@ use tokio::time::{sleep, timeout}; use tower::ServiceExt; use crate::helpers::{ - POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, create_and_start_run_from_manifest, - minimal_manifest_json_with_dry_run, test_app_state_with_options, test_app_with_scheduler, - test_settings, wait_for_run_status_not_in, + POLL_ATTEMPTS, POLL_INTERVAL, api, checked_response, checked_response_in, + create_and_start_run_from_manifest, minimal_manifest_json_with_dry_run, response_json, + test_app_state_with_options, test_app_with_scheduler, test_settings, + wait_for_run_status_not_in, }; const SIMPLE_DOT: &str = r#"digraph SSETest { @@ -28,9 +29,21 @@ async fn wait_for_checkpoint(app: &axum::Router, run_id: &str) -> serde_json::Va .body(Body::empty()) .expect("checkpoint request should build"); let response = app.clone().oneshot(req).await.unwrap(); - if response.status() == StatusCode::OK { - return body_json(response.into_body()).await; + let status = response.status(); + if status == StatusCode::OK { + return response_json( + response, + StatusCode::OK, + format!("GET /api/v1/runs/{run_id}/checkpoint"), + ) + .await; } + checked_response_in( + response, + &[StatusCode::OK, StatusCode::NOT_FOUND], + format!("GET /api/v1/runs/{run_id}/checkpoint"), + ) + .await; sleep(POLL_INTERVAL).await; } panic!("checkpoint did not become available for {run_id}"); @@ -53,13 +66,12 @@ async fn sse_stream_contains_expected_event_types() { .uri(api(&format!("/runs/{run_id}/attach"))) .body(Body::empty()) .unwrap(); - let response = app.clone().oneshot(req).await.unwrap(); - let sse_status = response.status(); - assert_eq!( - sse_status, + let response = checked_response( + app.clone().oneshot(req).await.unwrap(), StatusCode::OK, - "expected 200, got: {sse_status}" - ); + format!("GET /api/v1/runs/{run_id}/attach"), + ) + .await; let content_type = response .headers() diff --git a/lib/crates/fabro-server/tests/it/scenario/usage.rs b/lib/crates/fabro-server/tests/it/scenario/usage.rs index 93a26beaa..7cb6b73df 100644 --- a/lib/crates/fabro-server/tests/it/scenario/usage.rs +++ b/lib/crates/fabro-server/tests/it/scenario/usage.rs @@ -4,7 +4,7 @@ use tokio::time::sleep; use tower::ServiceExt; use crate::helpers::{ - MINIMAL_DOT, POLL_ATTEMPTS, POLL_INTERVAL, api, body_json, create_and_start_run_from_manifest, + MINIMAL_DOT, POLL_ATTEMPTS, POLL_INTERVAL, api, create_and_start_run_from_manifest, minimal_manifest_json_with_dry_run, test_app_state_with_options, test_app_with_scheduler, test_settings, wait_for_run_status, }; @@ -31,9 +31,12 @@ async fn aggregate_billing_increments_after_run_completes() { .unwrap(); let response = app.clone().oneshot(req).await.unwrap(); - assert_eq!(response.status(), StatusCode::OK); - - let body = body_json(response.into_body()).await; + let body = crate::helpers::response_json( + response, + StatusCode::OK, + format!("{}:{}", file!(), line!()), + ) + .await; total_runs = body["totals"]["runs"].as_i64().unwrap(); if total_runs == 1 { break; diff --git a/lib/crates/fabro-test/src/http_assert.rs b/lib/crates/fabro-test/src/http_assert.rs new file mode 100644 index 000000000..49bde95d8 --- /dev/null +++ b/lib/crates/fabro-test/src/http_assert.rs @@ -0,0 +1,674 @@ +use std::fmt::Display; + +use axum::body::to_bytes; +use axum::http::{HeaderMap, StatusCode, header}; + +const BODY_PREVIEW_LIMIT: usize = 4096; +const BINARY_HEX_PREVIEW_BYTES: usize = 64; + +pub async fn expect_axum_status( + response: axum::response::Response, + expected: StatusCode, + context: impl Display, +) -> axum::response::Response { + let context = context.to_string(); + let status = response.status(); + if status == expected { + return response; + } + + let headers = response.headers().clone(); + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should fit in memory"); + panic!( + "{}", + format_status_mismatch( + &context, + &format_status_list(&[expected]), + status, + None, + &headers, + &bytes, + ) + ); +} + +pub async fn assert_axum_status( + response: axum::response::Response, + expected: StatusCode, + context: impl Display, +) { + let _ = expect_axum_status(response, expected, context).await; +} + +pub async fn expect_axum_status_in( + response: axum::response::Response, + expected: &[StatusCode], + context: impl Display, +) -> axum::response::Response { + let context = context.to_string(); + let status = response.status(); + if expected.contains(&status) { + return response; + } + + let headers = response.headers().clone(); + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should fit in memory"); + panic!( + "{}", + format_status_mismatch( + &context, + &format_status_list(expected), + status, + None, + &headers, + &bytes, + ) + ); +} + +pub async fn assert_axum_status_in( + response: axum::response::Response, + expected: &[StatusCode], + context: impl Display, +) { + let _ = expect_axum_status_in(response, expected, context).await; +} + +pub async fn expect_axum_json( + response: axum::response::Response, + expected: StatusCode, + context: impl Display, +) -> serde_json::Value { + let context = context.to_string(); + let response = expect_axum_status(response, expected, &context).await; + let status = response.status(); + let headers = response.headers().clone(); + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should fit in memory"); + parse_json_or_panic(&context, status, None, &headers, &bytes) +} + +pub async fn expect_axum_text( + response: axum::response::Response, + expected: StatusCode, + context: impl Display, +) -> String { + let context = context.to_string(); + let response = expect_axum_status(response, expected, &context).await; + let bytes = to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should fit in memory"); + String::from_utf8_lossy(&bytes).into_owned() +} + +pub async fn expect_axum_bytes( + response: axum::response::Response, + expected: StatusCode, + context: impl Display, +) -> Vec { + let context = context.to_string(); + let response = expect_axum_status(response, expected, &context).await; + to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should fit in memory") + .to_vec() +} + +pub async fn expect_reqwest_status( + response: fabro_http::Response, + expected: StatusCode, + context: impl Display, +) -> fabro_http::Response { + let context = context.to_string(); + let status = response.status(); + if status == expected { + return response; + } + + let url = response.url().clone(); + let headers = response.headers().clone(); + let bytes = response + .bytes() + .await + .expect("response body should fit in memory"); + panic!( + "{}", + format_status_mismatch( + &context, + &format_status_list(&[expected]), + status, + Some(url.as_str()), + &headers, + bytes.as_ref(), + ) + ); +} + +pub async fn assert_reqwest_status( + response: fabro_http::Response, + expected: StatusCode, + context: impl Display, +) { + let _ = expect_reqwest_status(response, expected, context).await; +} + +pub async fn expect_reqwest_status_in( + response: fabro_http::Response, + expected: &[StatusCode], + context: impl Display, +) -> fabro_http::Response { + let context = context.to_string(); + let status = response.status(); + if expected.contains(&status) { + return response; + } + + let url = response.url().clone(); + let headers = response.headers().clone(); + let bytes = response + .bytes() + .await + .expect("response body should fit in memory"); + panic!( + "{}", + format_status_mismatch( + &context, + &format_status_list(expected), + status, + Some(url.as_str()), + &headers, + bytes.as_ref(), + ) + ); +} + +pub async fn assert_reqwest_status_in( + response: fabro_http::Response, + expected: &[StatusCode], + context: impl Display, +) { + let _ = expect_reqwest_status_in(response, expected, context).await; +} + +pub async fn expect_reqwest_json( + response: fabro_http::Response, + expected: StatusCode, + context: impl Display, +) -> serde_json::Value { + let context = context.to_string(); + let response = expect_reqwest_status(response, expected, &context).await; + let status = response.status(); + let url = response.url().clone(); + let headers = response.headers().clone(); + let bytes = response + .bytes() + .await + .expect("response body should fit in memory"); + parse_json_or_panic( + &context, + status, + Some(url.as_str()), + &headers, + bytes.as_ref(), + ) +} + +pub async fn expect_reqwest_text( + response: fabro_http::Response, + expected: StatusCode, + context: impl Display, +) -> String { + let context = context.to_string(); + let response = expect_reqwest_status(response, expected, &context).await; + response.text().await.expect("response text should read") +} + +pub async fn expect_reqwest_bytes( + response: fabro_http::Response, + expected: StatusCode, + context: impl Display, +) -> Vec { + let context = context.to_string(); + let response = expect_reqwest_status(response, expected, &context).await; + response + .bytes() + .await + .expect("response body should fit in memory") + .to_vec() +} + +fn parse_json_or_panic( + context: &str, + status: StatusCode, + url: Option<&str>, + headers: &HeaderMap, + bytes: &[u8], +) -> serde_json::Value { + serde_json::from_slice(bytes).unwrap_or_else(|error| { + panic!( + "{}", + format_json_parse_failure(context, status, url, headers, bytes, &error) + ) + }) +} + +fn format_json_parse_failure( + context: &str, + status: StatusCode, + url: Option<&str>, + headers: &HeaderMap, + bytes: &[u8], + error: &serde_json::Error, +) -> String { + let mut lines = vec![ + context.to_string(), + "expected a JSON response body".to_string(), + format!("status: {status}"), + ]; + if let Some(url) = url { + lines.push(format!("url: {url}")); + } + if let Some(content_type) = header_value(headers, header::CONTENT_TYPE) { + lines.push(format!("content-type: {content_type}")); + } + lines.push(format!("parse error: {error}")); + lines.push("body:".to_string()); + lines.push(format_body_preview(headers, bytes)); + lines.join("\n") +} + +fn format_status_mismatch( + context: &str, + expected: &str, + actual: StatusCode, + url: Option<&str>, + headers: &HeaderMap, + bytes: &[u8], +) -> String { + let mut lines = vec![ + context.to_string(), + format!("expected status: {expected}"), + format!("actual status: {actual}"), + ]; + if let Some(url) = url { + lines.push(format!("url: {url}")); + } + if let Some(content_type) = header_value(headers, header::CONTENT_TYPE) { + lines.push(format!("content-type: {content_type}")); + } + if let Some(location) = header_value(headers, header::LOCATION) { + lines.push(format!("location: {location}")); + } + lines.push("body:".to_string()); + lines.push(format_body_preview(headers, bytes)); + lines.join("\n") +} + +fn format_status_list(statuses: &[StatusCode]) -> String { + statuses + .iter() + .map(ToString::to_string) + .collect::>() + .join(" or ") +} + +fn header_value(headers: &HeaderMap, name: header::HeaderName) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string) +} + +fn format_body_preview(headers: &HeaderMap, bytes: &[u8]) -> String { + if bytes.is_empty() { + return "".to_string(); + } + + if let Ok(json) = serde_json::from_slice::(bytes) { + let rendered = serde_json::to_string_pretty(&json).expect("JSON value should pretty print"); + return truncate_text(&rendered, BODY_PREVIEW_LIMIT); + } + + if std::str::from_utf8(bytes).is_ok() || content_type_is_textual(headers) { + let rendered = String::from_utf8_lossy(bytes); + return truncate_text(&rendered, BODY_PREVIEW_LIMIT); + } + + let preview_len = bytes.len().min(BINARY_HEX_PREVIEW_BYTES); + let hex = bytes[..preview_len] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::>() + .join(" "); + let mut line = format!("binary body ({} bytes)", bytes.len()); + if bytes.len() > preview_len { + line.push_str(&format!(" [truncated to first {preview_len} bytes]")); + } + line.push('\n'); + line.push_str(&hex); + line +} + +fn content_type_is_textual(headers: &HeaderMap) -> bool { + let Some(content_type) = header_value(headers, header::CONTENT_TYPE) else { + return false; + }; + let content_type = content_type.to_ascii_lowercase(); + content_type.starts_with("text/") + || content_type.contains("json") + || content_type.contains("xml") + || content_type.contains("javascript") + || content_type.contains("svg") + || content_type.contains("x-www-form-urlencoded") +} + +fn truncate_text(text: &str, byte_limit: usize) -> String { + if text.len() <= byte_limit { + return text.to_string(); + } + + let mut end = byte_limit; + while !text.is_char_boundary(end) { + end -= 1; + } + format!( + "{}\n[truncated from {} bytes to {} bytes]", + &text[..end], + text.len(), + byte_limit + ) +} + +#[cfg(test)] +mod tests { + use std::panic; + + use axum::Json; + use axum::http::HeaderValue; + use axum::response::IntoResponse; + use axum::routing::get; + use serde_json::json; + use tokio::net::TcpListener; + + use super::*; + + fn panic_message(payload: Box) -> String { + match payload.downcast::() { + Ok(message) => *message, + Err(payload) => match payload.downcast::<&'static str>() { + Ok(message) => (*message).to_string(), + Err(_) => "".to_string(), + }, + } + } + + fn catch_async_panic(future: impl std::future::Future) -> String { + let runtime = tokio::runtime::Runtime::new().expect("test runtime should build"); + let payload = panic::catch_unwind(panic::AssertUnwindSafe(|| runtime.block_on(future))) + .expect_err("future should panic"); + panic_message(payload) + } + + async fn catch_task_panic( + future: impl std::future::Future + Send + 'static, + ) -> String { + let join_error = tokio::spawn(future).await.expect_err("future should panic"); + panic_message(join_error.into_panic()) + } + + async fn start_test_server(router: axum::Router) -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener should bind"); + let addr = listener + .local_addr() + .expect("test listener should expose local addr"); + tokio::spawn(async move { + axum::serve(listener, router) + .await + .expect("test server should stay alive"); + }); + format!("http://{addr}") + } + + #[test] + fn axum_mismatch_pretty_prints_json_body() { + let response = ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "boom", "details": { "request_id": "req-123" } })), + ) + .into_response(); + + let panic = catch_async_panic(async move { + let _ = expect_axum_status(response, StatusCode::OK, "GET /api/v1/runs/1/graph").await; + }); + + assert!(panic.contains("GET /api/v1/runs/1/graph")); + assert!(panic.contains("expected status: 200 OK")); + assert!(panic.contains("actual status: 500 Internal Server Error")); + assert!(panic.contains("\"error\": \"boom\"")); + assert!(panic.contains("\"request_id\": \"req-123\"")); + } + + #[test] + fn axum_mismatch_pretty_prints_text_body() { + let response = ( + StatusCode::BAD_REQUEST, + [( + header::CONTENT_TYPE, + HeaderValue::from_static("text/plain; charset=utf-8"), + )], + "bad request body", + ) + .into_response(); + + let panic = catch_async_panic(async move { + let _ = expect_axum_status(response, StatusCode::OK, "GET /health").await; + }); + + assert!(panic.contains("bad request body")); + assert!(panic.contains("content-type: text/plain; charset=utf-8")); + } + + #[test] + fn axum_mismatch_describes_binary_body() { + let response = ( + StatusCode::BAD_GATEWAY, + [( + header::CONTENT_TYPE, + HeaderValue::from_static("application/octet-stream"), + )], + vec![0, 159, 146, 150, 1, 2, 3, 4], + ) + .into_response(); + + let panic = catch_async_panic(async move { + let _ = expect_axum_status(response, StatusCode::OK, "GET /blob").await; + }); + + assert!(panic.contains("binary body (8 bytes)")); + assert!(panic.contains("00 9f 92 96 01 02 03 04")); + } + + #[test] + fn axum_status_preserves_successful_response() { + let response = ( + StatusCode::OK, + [(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], + "still here", + ) + .into_response(); + + let runtime = tokio::runtime::Runtime::new().expect("test runtime should build"); + runtime.block_on(async move { + let response = expect_axum_status(response, StatusCode::OK, "GET /ok").await; + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .expect("content-type should exist"); + assert_eq!(content_type, "text/plain"); + + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body should fit in memory"); + assert_eq!(&body[..], b"still here"); + }); + } + + #[test] + fn axum_status_in_accepts_multiple_statuses() { + let response = (StatusCode::NO_CONTENT, axum::body::Body::empty()).into_response(); + + let runtime = tokio::runtime::Runtime::new().expect("test runtime should build"); + runtime.block_on(async move { + let response = expect_axum_status_in( + response, + &[StatusCode::OK, StatusCode::NO_CONTENT], + "DELETE /api/v1/runs/1", + ) + .await; + let status = response.status(); + assert_eq!(status, StatusCode::NO_CONTENT); + }); + } + + #[test] + fn axum_json_parse_failure_includes_raw_body() { + let response = ( + StatusCode::OK, + [(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], + "not-json", + ) + .into_response(); + + let panic = catch_async_panic(async move { + let _ = expect_axum_json(response, StatusCode::OK, "GET /api/v1/settings").await; + }); + + assert!(panic.contains("expected a JSON response body")); + assert!(panic.contains("not-json")); + } + + #[tokio::test] + async fn reqwest_mismatch_includes_url_and_json_body() { + let base_url = start_test_server(axum::Router::new().route( + "/fail", + get(|| async { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "boom", "request_id": "req-9" })), + ) + }), + )) + .await; + let client = fabro_http::test_http_client().expect("test client should build"); + let response = client + .get(format!("{base_url}/fail")) + .send() + .await + .expect("request should complete"); + + let panic = catch_task_panic(async move { + let _ = expect_reqwest_status(response, StatusCode::OK, "GET /fail").await; + }) + .await; + + assert!(panic.contains("GET /fail")); + assert!(panic.contains("url:")); + assert!(panic.contains("/fail")); + assert!(panic.contains("\"error\": \"boom\"")); + assert!(panic.contains("\"request_id\": \"req-9\"")); + } + + #[tokio::test] + async fn reqwest_status_preserves_successful_response() { + let base_url = + start_test_server(axum::Router::new().route("/ok", get(|| async { "ok" }))).await; + let client = fabro_http::test_http_client().expect("test client should build"); + let response = client + .get(format!("{base_url}/ok")) + .send() + .await + .expect("request should complete"); + + let response = expect_reqwest_status(response, StatusCode::OK, "GET /ok").await; + let text = response.text().await.expect("text body should read"); + assert_eq!(text, "ok"); + } + + #[tokio::test] + async fn reqwest_status_in_accepts_multiple_statuses() { + let base_url = start_test_server(axum::Router::new().route( + "/delete", + get(|| async { (StatusCode::NO_CONTENT, axum::body::Body::empty()) }), + )) + .await; + let client = fabro_http::test_http_client().expect("test client should build"); + let response = client + .get(format!("{base_url}/delete")) + .send() + .await + .expect("request should complete"); + + let response = expect_reqwest_status_in( + response, + &[StatusCode::OK, StatusCode::NO_CONTENT], + "DELETE /delete", + ) + .await; + let status = response.status(); + assert_eq!(status, StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn reqwest_json_parse_failure_includes_raw_body() { + let base_url = start_test_server(axum::Router::new().route( + "/text", + get(|| async { + ( + StatusCode::OK, + [(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], + "not-json", + ) + }), + )) + .await; + let client = fabro_http::test_http_client().expect("test client should build"); + let response = client + .get(format!("{base_url}/text")) + .send() + .await + .expect("request should complete"); + + let panic = catch_task_panic(async move { + let _ = expect_reqwest_json(response, StatusCode::OK, "GET /text").await; + }) + .await; + + assert!(panic.contains("expected a JSON response body")); + assert!(panic.contains("not-json")); + } + + #[test] + fn truncated_bodies_say_they_are_truncated() { + let long_text = "x".repeat(5000); + let response = ( + StatusCode::INTERNAL_SERVER_ERROR, + [(header::CONTENT_TYPE, HeaderValue::from_static("text/plain"))], + long_text, + ) + .into_response(); + + let panic = catch_async_panic(async move { + let _ = expect_axum_status(response, StatusCode::OK, "GET /too-large").await; + }); + + assert!(panic.contains("[truncated")); + } +} diff --git a/lib/crates/fabro-test/src/lib.rs b/lib/crates/fabro-test/src/lib.rs index 67ad787d8..301e41b4d 100644 --- a/lib/crates/fabro-test/src/lib.rs +++ b/lib/crates/fabro-test/src/lib.rs @@ -21,6 +21,15 @@ use serde_json::{Map, Value, json}; use toml::Value as TomlValue; use toml::map::Map as TomlMap; +mod http_assert; + +pub use http_assert::{ + assert_axum_status, assert_axum_status_in, assert_reqwest_status, assert_reqwest_status_in, + expect_axum_bytes, expect_axum_json, expect_axum_status, expect_axum_status_in, + expect_axum_text, expect_reqwest_bytes, expect_reqwest_json, expect_reqwest_status, + expect_reqwest_status_in, expect_reqwest_text, +}; + /// Re-export `LLVM_PROFILE_FILE` into a `Command` whose env was just cleared, /// so subprocess coverage data lands in the profile path that /// `cargo-llvm-cov` configured for the parent test process. Accepts both @@ -1620,11 +1629,7 @@ impl TwinOpenAi { .send() .await .expect("reset twin-openai namespace"); - assert!( - response.status().is_success(), - "reset twin-openai namespace failed: {}", - response.status() - ); + assert_reqwest_status(response, fabro_http::StatusCode::OK, "POST /__admin/reset").await; } } @@ -1661,11 +1666,12 @@ impl TwinScenarios { .send() .await .expect("load twin-openai scenarios"); - assert!( - response.status().is_success(), - "load twin-openai scenarios failed: {}", - response.status() - ); + assert_reqwest_status( + response, + fabro_http::StatusCode::OK, + "POST /__admin/scenarios", + ) + .await; } } @@ -1880,7 +1886,8 @@ pub async fn twin_openai() -> &'static TwinOpenAi { let healthz_url = format!("http://127.0.0.1:{}/healthz", addr.port()); for _ in 0..50 { if let Ok(resp) = client.get(&healthz_url).send().await { - if resp.status().is_success() { + let status = resp.status(); + if status == fabro_http::StatusCode::OK { return TwinOpenAi { base_url }; } } From be74f91b7ab1024af79a9aaa302e07db50aa28da Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 20 Apr 2026 08:14:53 -0400 Subject: [PATCH 07/12] plans --- ...9-web-ui-lifecycle-actions-requirements.md | 126 ++++ ...-002-feat-web-ui-lifecycle-actions-plan.md | 548 ++++++++++++++++++ 2 files changed, 674 insertions(+) create mode 100644 docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md create mode 100644 docs/plans/2026-04-19-002-feat-web-ui-lifecycle-actions-plan.md diff --git a/docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md b/docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md new file mode 100644 index 000000000..f71f4a350 --- /dev/null +++ b/docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md @@ -0,0 +1,126 @@ +--- +date: 2026-04-19 +topic: web-ui-lifecycle-actions +--- + +# Expose CLI Lifecycle Actions in the Web UI + +## Problem Frame + +The Fabro web UI today is essentially read-only for run management. The only mutating action exposed is **Preview** (opens a sandbox port URL). Every other run lifecycle action — cancelling a stuck run, cleaning up the board, revisiting archived runs — requires dropping to the CLI. + +Two concrete user problems drive this work: + +1. **Daily friction for CLI users.** People live in the board view and detail pages but have to context-switch to a terminal to manage state. +2. **Excludes non-CLI teammates.** PMs, reviewers, and stakeholders can watch runs but cannot participate in managing them, cutting the UI off as a collaboration surface. + +The web UI should own the **everyday lifecycle operations** these users hit. Rarer or more dangerous CLI verbs (force-delete of active runs, checkpoint ops, etc.) can remain CLI-only by design; the goal is user value per surface, not parity for parity's sake. + +Most server infrastructure already exists — `POST /runs/{id}/{cancel,archive,unarchive}` are live, wired to the workflow engine's operations, and emit SSE events. The remaining work is UI surface + a handful of concrete SSE reconciliation gaps (see Dependencies). + +## Requirements + +**Action set** +- R1. Expose three lifecycle actions on the run detail page (`/runs/{id}`): **cancel**, **archive**, **unarchive**. +- R2. Do not expose `pause`, `unpause`, or `delete` in this first pass. Pause/unpause has no evidenced daily-user need; delete's tab-close-mid-undo failure mode is unacceptable for a non-CLI teammate because observability is destroyed (there's no run to revisit to self-verify). +- R3. Do not expose checkpoint operations (resume, rewind, fork) or HITL question answering in this first pass. +- R4. Do not expose these actions on the board kanban cards or as bulk selection in this first pass. + +**State-aware visibility** +- R5. Only show an action when the run's current status makes it valid: + - **cancel (primary)**: visible as a primary affordance when status is `submitted`, `queued`, `starting`, `running`, or `paused`. Not shown as primary when `blocked` — see R6. The server also accepts cancel on `blocked` runs; that path is only reachable via the secondary surface from R6. + - **archive**: visible only when status is terminal (`succeeded`, `failed`, `dead`) AND not already archived. + - **unarchive**: visible only when `archived`. +- R6. When a run is `blocked` (waiting on an HITL question), do not show cancel as a primary action. Instead, show an inline notice with the pending question text (from `GET /api/v1/runs/{id}/questions`, whose `ApiQuestion.text` field is already human-readable) and the instruction: "Answer this question via `fabro` CLI to continue." Cancel remains reachable via an overflow/secondary affordance (e.g., a "…" menu) for users who truly want to abandon the run. This protects the common case (non-CLI teammate accidentally cancelling work that was waiting for them) without fully hiding the escape hatch. +- R7. Visibility updates live when the run status changes. The detail page must subscribe to the run's SSE event stream (`GET /runs/{id}/attach`) so action affordances appear/disappear without a manual refresh. + +**Interaction & feedback** + +Two toast patterns, matched to the reversibility of each action: + +- R8. **Cancel uses a client-side deferred toast with a fixed 5-second countdown.** Cancel has partially-irreversible side effects (the agent stops mid-stage) so the undo window guards against misclicks. + - Clicking cancel shows a toast with the action description, a 5-second countdown, and an **Undo** button. The cancel affordance enters a disabled/pending state during the window (not hidden — hiding mid-window misrepresents actual run state). + - If the user clicks **Undo** before the countdown expires, the pending client-side timer is cancelled and no API call is made. + - If the countdown expires, the client fires the API call. **Before firing**, the UI performs a `GET /api/v1/runs/{id}` refetch: if the run's status is no longer one where cancel is valid (e.g., the run completed or was cancelled elsewhere), the pending action is aborted and a brief "Run transitioned — cancel aborted" notice is shown instead. This covers the SSE-channel-unreachable case where R9's event-driven abort cannot fire. + - If the tab is closed or navigated away (including SPA navigation to another route) mid-window, the pending action is silently cancelled. Accepted tradeoff of the client-only approach. + +- R9. **Archive and unarchive fire immediately with an inverse-action toast.** Both are fully reversible by the opposite API call, so the 5-second countdown is overhead with no safety benefit. Gmail-archive shape: + - Clicking archive fires `POST /runs/{id}/archive` immediately (optimistic UI: the run disappears from terminal views / moves to archived views right away). + - On success, show a toast: "Run archived. **Unarchive**" with a visible action button. The toast remains dismissable for ~8 seconds. + - Clicking **Unarchive** in the toast fires `POST /runs/{id}/unarchive`. The toast updates to "Run restored" briefly, then dismisses. + - Unarchive-triggered-from-the-primary-affordance works identically, with the inverse verb. + +- R10. **Undo-window collisions (cancel only).** While the cancel timer is pending for a run, other primary affordances for that run are disabled (not hidden). If an SSE event arrives for that run that would change its status (another tab, a CLI user, the run finishing naturally), the pending client-side timer is cancelled, the toast is dismissed with a brief notice ("Run transitioned — action cancelled"), and the UI reconciles to the new status. If a newly-valid action (e.g., archive becomes valid because the run just completed) results from the transition, its affordance lights up immediately rather than waiting for the toast to fully dismiss. + +- R11. On a successful async cancel (status unchanged in the response body), the UI relies on SSE for the final status flip. No additional success toast beyond the deferred-toast already shown. + +- R12. On API failure (including 409 precondition failures — e.g., the run transitioned out of a valid state between toast-expiry and API call), show an error toast that includes the server's error message, and refetch the run so the UI reconciles to actual state. For the archive/unarchive fire-immediately path, additionally roll back the optimistic UI change on failure. + +- R13. **Multi-tab behavior (single-client rules apply per tab).** R8/R10 are scoped per-client: each tab runs its own timer. An SSE-delivered status transition in tab B (caused by tab A firing cancel) will cancel tab B's own pending timer for the same run per R10 and surface the transition notice. Cross-tab action coordination beyond what SSE already provides is not a requirement for this pass. + +**Accessibility** +- R14. Both toast patterns (deferred cancel toast; immediate archive/unarchive toast) must meet baseline a11y expectations: + - Toast container uses `role="status"` with `aria-live="polite"` (assertive interrupts screen-reader output and is wrong here). Announcement names the action, e.g., "Cancel run requested, undoing in 5 seconds" or "Run archived. Press Unarchive to undo." + - Toast does **not** steal focus, but is reachable via keyboard (tab order places the action button — Undo or Unarchive — immediately after the triggering affordance). + - For the cancel deferred toast, while keyboard focus is on the **Undo** button, the 5-second countdown **pauses** and resumes counting down from the paused value when focus leaves. Focus leaving the Undo button after the countdown would have expired does not auto-fire the action — the user must still explicitly close the toast or navigate away for the countdown to resume its final tick. + - Archive/unarchive toasts do not count down (they fire on click); they remain dismissable and focusable for ~8 seconds. + - Touch targets (Undo / Unarchive buttons) meet 44×44 CSS px minimum. The detail page is expected to work on tablet and larger; phone-size support is not a requirement for this pass. + - The action cluster is keyboard-operable end to end (no mouse-only affordances). Keyboard shortcuts for individual actions are out of scope for this pass. + +## Success Criteria + +- A user managing runs day-to-day can complete a full session (cancelling a stuck run, archiving finished ones, revisiting an archived one) without touching the CLI. +- A non-CLI teammate can cancel or archive a run in the web UI without onboarding documentation beyond "click the button." +- A non-CLI teammate who lands on a `blocked` run understands the run is waiting on a human answer, sees the question text, and does not accidentally cancel work in progress. **Note:** unblocking the teammate so they can actually *answer* the question requires HITL-answering in the web UI, which is deferred — this first pass prevents destruction, not participation. If blocked-teammate-can't-proceed proves to be a real painful pattern in usage, HITL answering should be the next scope to pick up. +- When a run transitions state while the detail page is open (e.g., finishes, gets archived from the CLI, gets cancelled by someone else), the available actions update live without a page refresh. +- The cancel deferred-toast component (toast container + Undo + pause-on-focus countdown + polite aria-live) is shaped so it can host future destructive actions (delete if we solve the observability problem; force-cancel if ever added) without redesign. The archive/unarchive immediate-inverse toast is a simpler shape that other reversible fire-and-forget actions can reuse. + +## Scope Boundaries + +**Out of scope for this first pass:** +- `pause` and `unpause` (no evidenced daily-user pain for the target personas; revisit if a real workflow surfaces). +- `delete` (tab-close-silently-cancels destroys observability — there's no run to revisit to self-verify — which is unacceptable for the non-CLI teammate persona; revisit with server-side soft-delete or a different UX). +- Force-delete of active runs (`rm --force`). +- Checkpoint operations: `resume`, `rewind`, `fork`. These need parameter input (which checkpoint? which branch?) that doesn't fit the uniform button+toast pattern. +- Answering HITL questions from the web UI. R6 surfaces the question read-only and points to the CLI. +- Board-card actions and bulk multi-select on the board. +- Dense table/list view of runs. +- Server-side deferred/pending states. +- Keyboard shortcuts for individual actions. +- Role-based authorization (assumed unchanged from today; all authenticated users can take all actions). +- Phone-size responsive layout. + +## Key Decisions + +- **Cancel + archive + unarchive only.** Cancel addresses the clearest daily pain (stuck runs). Archive/unarchive addresses board clutter and is fully reversible by the opposite action — lowest-risk place to validate the interaction pattern. Pause/unpause are deferred for lack of evidenced need; delete is deferred because its client-side-undo failure mode destroys observability for the non-CLI teammate persona. +- **Run detail page only, not the board.** Keep the first pass tight. +- **Two toast patterns matched to reversibility, not one uniform pattern.** Cancel is partially irreversible → deferred-toast with a bound 5-second countdown. Archive/unarchive are fully reversible via the opposite API call → fire-immediately with an inverse-action toast (Gmail-archive shape). This is more spec than "one pattern for everything" but maps to a real property of the actions and eliminates a pointless friction tax on the reversible ones. The shared toast infrastructure (container, action-button slot, aria-live, keyboard reachability) is reused across both; only the cancel path carries the countdown + focus-pause machinery. +- **Pre-fire status recheck on the deferred cancel path.** At countdown expiry, the client refetches `GET /runs/{id}` before firing the cancel API call. This covers the SSE-unreachable failure mode where R10's event-driven abort cannot fire: without the recheck, a dead SSE channel would silently degrade to "timer fires, 409 error, user sees error toast for a race they didn't create." One extra GET per cancel is a cheap premium for reliable UX. +- **Blocked runs suppress cancel as a primary affordance and show the pending question.** Protects non-CLI teammates from the worst failure mode (cancelling work that was waiting for them). Cancel remains reachable via a secondary/overflow affordance for users who genuinely want to abandon. This solves the destruction problem but leaves the participation problem for HITL-in-the-web to solve later. +- **Client-side deferral, not server-side.** Chosen on shipping speed; does not add new lifecycle states. Accepted tradeoff: the "undo" promise is soft — tab close or SPA nav silently cancels. This is tolerable precisely because the action with the worst silent-cancellation consequence (delete) was scoped out. +- **Accessibility is in the requirements, not deferred to implementation.** ARIA semantics, keyboard reachability, focus-pauses-countdown, touch-target sizing, and aria-live politeness are specified rather than left as "standard best practices." +- **Pattern reuse is claimed only within this action family.** We do not claim "adding resume/rewind/fork/HITL later is same shape, new button." Those need parameter input (checkpoint choice, answer text) that the button+toast pattern doesn't host. That's fine — these patterns are for fire-and-forget lifecycle mutations, and the rest of the verbs will need their own UX. + +## Dependencies / Assumptions + +- The existing API endpoints (`POST /runs/{id}/{cancel,archive,unarchive}`) are stable and will not require spec changes. Verified against `docs/api-reference/fabro-api.yaml`. +- The generated TypeScript client in `lib/packages/fabro-api-client` exposes (or will trivially expose after regeneration) methods for these endpoints. +- **SSE coverage is not uniform.** The server emits `run.*` events for status transitions including `run.archived` / `run.unarchived` (`fabro-workflow/src/event.rs`, `fabro-server/src/server.rs`). Known gaps the plan must address: + - The board's event allowlist (`apps/fabro-web/app/routes/runs.tsx` `BOARD_STATUS_EVENTS`) does not currently include `run.archived` / `run.unarchived`. Adding them is in scope. + - The per-run `/attach` SSE stream terminates on `RunCompleted` / `RunFailed`. Archive/unarchive events fire on already-terminal runs, so the detail page must either reconnect to a non-terminating channel, refetch on the successful archive/unarchive response, or listen at a layer above `/attach`. Planning should pick an approach. +- **Run detail page is not yet SSE-subscribed.** `apps/fabro-web/app/routes/run-detail.tsx` currently fetches the run once via the React Router loader and does not subscribe to `/api/v1/runs/{id}/attach`. Wiring this subscription at the detail-page level (the owner of `run.status` that drives R5 visibility) is net-new work for R7. Individual tab components (stage-sidebar, run-files) already have per-run SSE subscriptions that can be used as a pattern. +- **No undo-capable toast system exists yet.** The only Toast in `apps/fabro-web` is a read-only live-region banner (`apps/fabro-web/app/routes/run-files/states.tsx`) with local `useState`/`setTimeout`. R8 + R9 + R12 together require a shared toast component with: a countdown, action-button slot, programmatic dismiss, multi-toast coexistence, polite aria-live, and focus-pauses-countdown behavior. This is net-new UI infrastructure. +- **Cancel semantics.** For `submitted` and `queued` runs, cancel synchronously flips lifecycle `status` to `failed` with `status_reason: cancelled` and returns that on the response. For `starting`/`running`/`blocked`/`paused` runs, cancel returns 200 with unchanged status and the transition lands asynchronously via the workflow engine. The UI should treat cancel as "request accepted" and rely on SSE for the final status flip — R10 covers this implicitly, but the plan should make the optimistic-UI behavior explicit (e.g., the cancel affordance stays in its disabled/pending state until either the response body carries the synchronous `failed`/`cancelled` result or the SSE-driven reconciliation arrives). +- **Per-run `/attach` stream has silent termination paths beyond the terminal-event case.** `attach_event_is_terminal` only matches `RunCompleted | RunFailed`, but the task that drives the stream can also exit without a terminal marker if the store read errors, if the run projection becomes non-active mid-replay, or if cancel lands on a queued run that never transitioned to running (covered by the `cancel_before_run_transitions_to_running_returns_empty_attach_stream` test in `fabro-server/src/server.rs`). R7 and R10 must therefore not assume a terminal event will always land: the plan needs a fallback refetch path for "SSE stream ended without a terminal marker" and for "SSE channel unreachable during an undo window." +- Authorization is a non-issue today (single-user / trusted deployment assumption). If multi-tenant auth lands, the action affordances will need to respect it, but that's a separate workstream. + +## Outstanding Questions + +### Deferred to Planning +- [Affects R8][Design] Exact visual placement of the action cluster in the detail page header: single "Actions" dropdown vs. inline buttons vs. split primary+overflow. Behavior is specified here; visual placement resolves in design/implementation. Consider how cancel-on-blocked lives in the overflow while the primary slot is occupied by the R6 inline notice. +- [Affects R11][Technical] Confirm the 409 error-body shape from the server so error-toast copy can use the server-provided message verbatim. +- [Affects R7, R10][Technical] Decide the post-terminal reconciliation mechanism for archive/unarchive: reconnect SSE after terminal close, refetch on 2xx archive/unarchive response, or subscribe on a non-terminating channel. Either the per-run `/attach` contract extends or the UI uses response-driven reconciliation. + +## Next Steps + +→ `/ce:plan` for structured implementation planning diff --git a/docs/plans/2026-04-19-002-feat-web-ui-lifecycle-actions-plan.md b/docs/plans/2026-04-19-002-feat-web-ui-lifecycle-actions-plan.md new file mode 100644 index 000000000..2e98d4e37 --- /dev/null +++ b/docs/plans/2026-04-19-002-feat-web-ui-lifecycle-actions-plan.md @@ -0,0 +1,548 @@ +--- +title: "feat: Expose CLI lifecycle actions (cancel, archive, unarchive) in the web UI" +type: feat +status: active +date: 2026-04-19 +updated: 2026-04-20 +origin: docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md +--- + +# feat: Expose CLI lifecycle actions (cancel, archive, unarchive) in the web UI + +## Overview + +Today the Fabro web UI at `apps/fabro-web` is essentially read-only for run management: only Preview mutates state. This plan adds three lifecycle actions to the run detail page (`/runs/{id}`): **cancel**, **archive**, and **unarchive**. + +The plan also closes three supporting gaps the requirements and review surfaced: + +- a shared action-capable toast system +- a run-detail SSE subscription so action visibility updates live +- two missing event strings in the board revalidation allowlist + +As of **April 20, 2026**, product explicitly chose to remove the deferred cancel timer and undo window from the earlier requirements doc. In this plan, cancel fires immediately. + +Pause/unpause and delete remain out of scope (see origin: `docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md`). + +## Problem Frame + +Two user pains from the origin document drive this work: + +1. **Daily friction for CLI users** who live in the web UI but must context-switch to a terminal to cancel, archive, or unarchive runs. +2. **Exclusion of non-CLI teammates** (PMs, reviewers, stakeholders) who can observe runs but cannot participate in managing them. + +The server endpoints already exist. The remaining work is UI surface, route wiring, SSE reconciliation, and user-facing error handling. + +## Requirements Trace + +All IDs reference the origin document, but this plan is the source of truth for implementation. The origin document's deferred-cancel requirements were superseded on **April 20, 2026** when product chose immediate cancel with no undo window. + +- **R1** Expose cancel, archive, and unarchive on the run detail page. +- **R2** Do not expose pause, unpause, or delete in this pass. +- **R3** Do not expose checkpoint operations (resume, rewind, fork) or HITL question answering in this pass. +- **R4** Do not expose these actions on board kanban cards or as bulk selection in this pass. +- **R5** State-aware visibility: + cancel visible for `submitted|queued|starting|running|paused` as the primary affordance; archive for terminal non-archived runs; unarchive for archived runs. +- **R6** Blocked runs hide the primary cancel button and instead show an inline notice with question text plus CLI guidance. Cancel remains reachable through a de-emphasized secondary affordance inside that notice. +- **R7** The detail page subscribes to the run's SSE stream so affordances update live without a manual refresh. +- **R8** Cancel fires immediately. There is no client-side pending timer, no undo window, and no pre-fire `GET /runs/{id}` recheck in this plan. +- **R9** Archive and unarchive fire immediately and surface an inverse-action toast ("Run archived. Unarchive"). +- **R10** While a lifecycle action request is in flight, only the submitting control disables/spins. There is no cross-action disable window because there is no pending cancel timer. +- **R11** There is no optimistic local archived/unarchived flip. All three actions reconcile through the action response, normal route revalidation, and SSE for later lifecycle transitions. +- **R12** On 404/409/network failures, show a user-facing mapped error toast and revalidate so the page reconciles to actual server state. Because there is no optimistic local state, there is no rollback layer. +- **R13** Multi-tab behavior has no per-client timers. SSE still reconciles non-terminal state transitions across tabs. **Known limitation:** if tab A is showing an already-terminal run and tab B archives/unarchives it, tab A may stay stale because the per-run `/attach` stream has already closed on `RunCompleted`/`RunFailed`. Users recover on navigation/refresh or on a failed action attempt surfaced through the 409/error toast path. +- **R14** Accessibility: + toasts use `role="status"` and `aria-live="polite"`, do not steal focus, action buttons are keyboard-operable and touch-friendly, and the blocked-run secondary cancel affordance expands to a 48x48 CSS px touch target on coarse pointers. Countdown-specific pause/resume behavior is no longer part of scope. + +## Scope Boundaries + +- Actions exposed: cancel, archive, unarchive. Nothing else. +- Surface: run detail page (`/runs/{id}`) only. Not the board, not bulk. +- Not exposed: pause, unpause, delete, force-delete, resume/rewind/fork, HITL answering. +- No OpenAPI changes. +- No new server event types. +- No changes to authorization (single-tenant trusted deployment assumption). +- No phone-size responsive layout work; tablet and up only. +- No keyboard shortcuts for individual actions. + +## Context & Research + +### Relevant Code and Patterns + +- **Board SSE + revalidation allowlist:** `apps/fabro-web/app/routes/runs.tsx` (`BOARD_STATUS_EVENTS` at lines 63-79). `run.archived` and `run.unarchived` are missing. Test pattern in `apps/fabro-web/app/routes/runs.test.tsx`. +- **Existing per-run SSE subscriptions:** `apps/fabro-web/app/routes/run-files.tsx` and `apps/fabro-web/app/components/stage-sidebar.tsx`. Both parse `msg.data` JSON and gate on `payload.event`; they do **not** use `EventSource` message type names. +- **Run detail page header and Preview button:** `apps/fabro-web/app/routes/run-detail.tsx`. The route already uses React Router `useFetcher` plus a route `action` for Preview, so lifecycle actions can follow the same pattern instead of introducing a second mutation model. +- **Run detail loader shape:** the loader receives raw API `summary.status` and maps it onto `run.lifecycleStatus` in loader data. UI visibility logic in this plan keys off `run.lifecycleStatus`; loader-only branching is described explicitly as checking `summary.status` before mapping. +- **Existing toast primitive:** `apps/fabro-web/app/routes/run-files/states.tsx` has a simple read-only live-region toast. It is a useful visual/a11y baseline but is not reusable for stacked toasts with action buttons. +- **UI primitives:** `apps/fabro-web/app/components/ui.tsx` exports `PRIMARY_BUTTON_CLASS` and `SECONDARY_BUTTON_CLASS`. No generic ` + + ); +} diff --git a/apps/fabro-web/app/components/stage-sidebar.tsx b/apps/fabro-web/app/components/stage-sidebar.tsx index 7bbfe1fe0..f6cc55105 100644 --- a/apps/fabro-web/app/components/stage-sidebar.tsx +++ b/apps/fabro-web/app/components/stage-sidebar.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from "react"; -import { Link, useRevalidator } from "react-router"; +import { Link } from "react-router"; import { ArrowPathIcon, CheckCircleIcon, @@ -9,6 +9,7 @@ import { } from "@heroicons/react/24/solid"; import { DocumentTextIcon, MapIcon } from "@heroicons/react/24/outline"; import { formatDurationSecs } from "../lib/format"; +import { useRunEventSource } from "../lib/sse"; export type StageStatus = "completed" | "running" | "pending" | "failed" | "cancelled"; @@ -42,34 +43,15 @@ const STAGE_EVENTS = new Set([ ]); export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) { - const revalidator = useRevalidator(); - // Track when we first observed each running stage (for ticking timer) const runningStartRef = useRef>(new Map()); const [, setTick] = useState(0); // Subscribe to run-specific SSE for live stage updates - useEffect(() => { - const source = new EventSource(`/api/v1/runs/${runId}/attach?since_seq=1`); - let debounceTimer: ReturnType | undefined; - - source.onmessage = (msg) => { - try { - const payload = JSON.parse(msg.data); - if (STAGE_EVENTS.has(payload.event)) { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => revalidator.revalidate(), 300); - } - } catch { - // ignore malformed events - } - }; - - return () => { - clearTimeout(debounceTimer); - source.close(); - }; - }, [runId]); + useRunEventSource(runId, { + allowlist: STAGE_EVENTS, + debounceMs: 300, + }); // Track start times for running stages useEffect(() => { diff --git a/apps/fabro-web/app/components/toast.test.tsx b/apps/fabro-web/app/components/toast.test.tsx new file mode 100644 index 000000000..d23bf64d8 --- /dev/null +++ b/apps/fabro-web/app/components/toast.test.tsx @@ -0,0 +1,196 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { useEffect } from "react"; +import TestRenderer, { act } from "react-test-renderer"; + +import { ToastProvider, useToast } from "./toast"; + +function textFromNode(node: ReturnType): string { + if (!node) return ""; + if (typeof node === "string") return node; + if (Array.isArray(node)) return node.map(textFromNode).join(""); + return (node.children ?? []).map(textFromNode).join(""); +} + +function textFromInstance(node: TestRenderer.ReactTestInstance): string { + return node.children + .map((child) => (typeof child === "string" ? child : textFromInstance(child))) + .join(""); +} + +function PushOnMount({ + toast, + onReady, +}: { + toast: Parameters["push"]>[0]; + onReady?: (api: ReturnType) => void; +}) { + const api = useToast(); + + useEffect(() => { + onReady?.(api); + api.push(toast); + }, [api, onReady, toast]); + + return null; +} + +describe("ToastProvider", () => { + afterEach(() => { + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + }); + + test("push renders a toast with the message", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create( + + + , + ); + }); + + expect(textFromNode(renderer!.toJSON())).toContain("Run archived."); + const liveRegions = renderer!.root.findAll( + (node) => node.props?.role === "status" && node.props?.["aria-live"] === "polite", + ); + expect(liveRegions.length).toBeGreaterThan(0); + + await act(async () => { + renderer?.unmount(); + }); + }); + + test("action toasts render a button and fire onClick", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + let clicked = 0; + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create( + + { + clicked += 1; + }, + }, + }} + /> + , + ); + }); + + const button = renderer!.root.findByType("button"); + expect(textFromNode(renderer!.toJSON())).toContain("Unarchive"); + + await act(async () => { + button.props.onClick(); + }); + + expect(clicked).toBe(1); + + await act(async () => { + renderer?.unmount(); + }); + }); + + test("error toasts do not auto-dismiss", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + let renderer: TestRenderer.ReactTestRenderer | null = null; + await act(async () => { + renderer = TestRenderer.create( + + + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + expect(textFromNode(renderer!.toJSON())).toContain("Conflict"); + + await act(async () => { + renderer?.unmount(); + }); + }); + + test("multiple toasts stack in insertion order", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + let api: ReturnType | null = null; + let renderer: TestRenderer.ReactTestRenderer | null = null; + + await act(async () => { + renderer = TestRenderer.create( + + { + api = value; + }} + /> + , + ); + }); + + await act(async () => { + api!.push({ message: "Second" }); + }); + + const toasts = renderer!.root.findAll( + (node) => node.props?.["data-toast-id"] != null, + ); + expect(toasts).toHaveLength(2); + expect(textFromInstance(toasts[0]!)).toContain("First"); + expect(textFromInstance(toasts[1]!)).toContain("Second"); + + await act(async () => { + renderer?.unmount(); + }); + }); + + test("dismiss removes a toast and leaves the rest reflowed", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + + let api: ReturnType | null = null; + let firstId = ""; + let renderer: TestRenderer.ReactTestRenderer | null = null; + + await act(async () => { + renderer = TestRenderer.create( + + { + api = value; + }} + /> + , + ); + }); + + await act(async () => { + firstId = api!.push({ message: "Second" }); + }); + + await act(async () => { + api!.dismiss(firstId); + }); + + const text = textFromNode(renderer!.toJSON()); + expect(text).toContain("First"); + expect(text).not.toContain("Second"); + + await act(async () => { + renderer?.unmount(); + }); + }); +}); diff --git a/apps/fabro-web/app/components/toast.tsx b/apps/fabro-web/app/components/toast.tsx new file mode 100644 index 000000000..5c7eb2407 --- /dev/null +++ b/apps/fabro-web/app/components/toast.tsx @@ -0,0 +1,165 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { XMarkIcon } from "@heroicons/react/20/solid"; + +export type ToastTone = "info" | "error"; + +export interface ToastAction { + label: string; + onClick: () => void; +} + +export interface ToastInput { + message: string; + tone?: ToastTone; + action?: ToastAction; + autoDismissMs?: number; +} + +interface ToastRecord extends ToastInput { + id: string; + tone: ToastTone; +} + +interface ToastContextValue { + push: (toast: ToastInput) => string; + dismiss: (id: string) => void; + clear: () => void; +} + +const ToastContext = createContext(null); + +function toastClassName(tone: ToastTone): string { + return tone === "error" + ? "border-coral/40 bg-rose-950/90 text-rose-50" + : "border-line bg-panel/95 text-fg-2"; +} + +export function ToastRoot({ + toasts, + onDismiss, +}: { + toasts: ToastRecord[]; + onDismiss: (id: string) => void; +}) { + if (toasts.length === 0) return null; + + return ( +
+ {toasts.map((toast) => ( +
+
+

{toast.message}

+ {toast.tone === "error" && ( + + )} +
+ {toast.action && ( +
+ +
+ )} +
+ ))} +
+ ); +} + +export function ToastProvider({ + children, + autoDismissMs = 3500, +}: { + children: ReactNode; + autoDismissMs?: number; +}) { + const [toasts, setToasts] = useState([]); + const nextIdRef = useRef(0); + const timeoutIdsRef = useRef(new Map>()); + + const dismiss = useCallback((id: string) => { + const timeoutId = timeoutIdsRef.current.get(id); + if (timeoutId) { + clearTimeout(timeoutId); + timeoutIdsRef.current.delete(id); + } + setToasts((current) => current.filter((toast) => toast.id !== id)); + }, []); + + const clear = useCallback(() => { + for (const timeoutId of timeoutIdsRef.current.values()) { + clearTimeout(timeoutId); + } + timeoutIdsRef.current.clear(); + setToasts([]); + }, []); + + const push = useCallback((toast: ToastInput) => { + const id = `toast-${nextIdRef.current++}`; + const record: ToastRecord = { + ...toast, + id, + tone: toast.tone ?? "info", + }; + + setToasts((current) => [...current, record]); + + if (record.tone !== "error") { + const timeoutId = setTimeout(() => dismiss(id), toast.autoDismissMs ?? autoDismissMs); + timeoutIdsRef.current.set(id, timeoutId); + } + + return id; + }, [autoDismissMs, dismiss]); + + useEffect(() => clear, [clear]); + + const value = useMemo(() => ({ push, dismiss, clear }), [push, dismiss, clear]); + + return ( + + {children} + + + ); +} + +export function useToast(): ToastContextValue { + const value = useContext(ToastContext); + if (!value) { + throw new Error("useToast must be used within a ToastProvider"); + } + return value; +} diff --git a/apps/fabro-web/app/layouts/app-shell.tsx b/apps/fabro-web/app/layouts/app-shell.tsx index 896808750..cf6e523a1 100644 --- a/apps/fabro-web/app/layouts/app-shell.tsx +++ b/apps/fabro-web/app/layouts/app-shell.tsx @@ -20,6 +20,7 @@ import { } from "@heroicons/react/24/outline"; import { Link, Outlet, useLocation, useMatches, useRevalidator } from "react-router"; import { getAuthMe } from "../api"; +import { ToastProvider } from "../components/toast"; import { DemoModeProvider } from "../lib/demo-mode"; import { useTheme } from "../lib/theme"; @@ -72,6 +73,7 @@ export default function AppShell({ loaderData }: any) { return ( +
@@ -274,6 +276,7 @@ export default function AppShell({ loaderData }: any) {
+
); } diff --git a/apps/fabro-web/app/lib/run-actions.test.ts b/apps/fabro-web/app/lib/run-actions.test.ts new file mode 100644 index 000000000..c50116614 --- /dev/null +++ b/apps/fabro-web/app/lib/run-actions.test.ts @@ -0,0 +1,179 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { + archiveRun, + canArchive, + canCancel, + canUnarchive, + cancelRun, + isTerminalCancelledRun, + mapError, + unarchiveRun, +} from "./run-actions"; + +type StubResponseInit = { + status: number; + body?: string; + statusText?: string; +}; + +function stubFetchOnce(init: StubResponseInit) { + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => + Promise.resolve( + new Response(init.body ?? "", { + status: init.status, + statusText: init.statusText ?? "", + headers: { "Content-Type": "application/json" }, + }), + )) as typeof fetch; + + return () => { + globalThis.fetch = originalFetch; + }; +} + +async function expectLifecycleError( + input: Promise, +): Promise<{ status: number; errors: Array<{ status: string; title: string; detail: string }> }> { + try { + await input; + throw new Error("expected promise to reject"); + } catch (error) { + return error as { status: number; errors: Array<{ status: string; title: string; detail: string }> }; + } +} + +describe("run lifecycle actions", () => { + let restoreFetch: (() => void) | undefined; + + afterEach(() => { + restoreFetch?.(); + restoreFetch = undefined; + delete (globalThis as { window?: unknown }).window; + }); + + test("cancelRun parses a 200 response", async () => { + restoreFetch = stubFetchOnce({ + status: 200, + body: JSON.stringify({ + id: "run-1", + status: "failed", + status_reason: "cancelled", + created_at: "2026-04-20T12:00:00Z", + }), + }); + + const result = await cancelRun("run-1"); + expect(result.status).toBe("failed"); + expect(result.status_reason).toBe("cancelled"); + }); + + test("archiveRun parses a 200 response", async () => { + restoreFetch = stubFetchOnce({ + status: 200, + body: JSON.stringify({ + id: "run-1", + status: "archived", + created_at: "2026-04-20T12:00:00Z", + }), + }); + + const result = await archiveRun("run-1"); + expect(result.status).toBe("archived"); + }); + + test("unarchiveRun parses a 200 response", async () => { + restoreFetch = stubFetchOnce({ + status: 200, + body: JSON.stringify({ + id: "run-1", + status: "succeeded", + created_at: "2026-04-20T12:00:00Z", + }), + }); + + const result = await unarchiveRun("run-1"); + expect(result.status).toBe("succeeded"); + }); + + test("404 and 409 preserve the parsed error envelope", async () => { + restoreFetch = stubFetchOnce({ + status: 404, + body: JSON.stringify({ + errors: [{ status: "404", title: "Not Found", detail: "Run not found." }], + }), + }); + const notFound = await expectLifecycleError(cancelRun("missing-run")); + expect(notFound).toEqual({ + status: 404, + errors: [{ status: "404", title: "Not Found", detail: "Run not found." }], + }); + + restoreFetch = stubFetchOnce({ + status: 409, + body: JSON.stringify({ + errors: [{ status: "409", title: "Conflict", detail: "Run is not terminal." }], + }), + }); + const conflict = await expectLifecycleError(archiveRun("run-1")); + expect(conflict).toEqual({ + status: 409, + errors: [{ status: "409", title: "Conflict", detail: "Run is not terminal." }], + }); + }); + + test("non-JSON error bodies fall back to an empty error list", async () => { + restoreFetch = stubFetchOnce({ + status: 409, + body: "conflict", + statusText: "Conflict", + }); + + const error = await expectLifecycleError(unarchiveRun("run-1")); + expect(error).toEqual({ status: 409, errors: [] }); + }); + + test("mapError returns user-facing copy for lifecycle conflicts", () => { + expect(mapError({ status: 409, errors: [] }, "cancel")).toBe("This run can no longer be cancelled."); + expect(mapError({ status: 409, errors: [] }, "archive")).toBe("Only terminal runs can be archived."); + expect(mapError({ status: 409, errors: [] }, "unarchive")).toBe("Active runs can't be unarchived."); + }); + + test("status predicates align with the documented run statuses", () => { + expect(canCancel("submitted")).toBe(true); + expect(canCancel("queued")).toBe(true); + expect(canCancel("starting")).toBe(true); + expect(canCancel("running")).toBe(true); + expect(canCancel("paused")).toBe(true); + expect(canCancel("blocked")).toBe(false); + expect(canCancel("archived")).toBe(false); + + expect(canArchive("succeeded")).toBe(true); + expect(canArchive("failed")).toBe(true); + expect(canArchive("dead")).toBe(true); + expect(canArchive("archived")).toBe(false); + + expect(canUnarchive("archived")).toBe(true); + expect(canUnarchive("failed")).toBe(false); + }); + + test("isTerminalCancelledRun distinguishes immediate cancel success from in-flight cancellation", () => { + expect( + isTerminalCancelledRun({ + id: "run-1", + status: "failed", + status_reason: "cancelled", + created_at: "2026-04-20T12:00:00Z", + }), + ).toBe(true); + expect( + isTerminalCancelledRun({ + id: "run-1", + status: "running", + pending_control: "cancel", + created_at: "2026-04-20T12:00:00Z", + }), + ).toBe(false); + }); +}); diff --git a/apps/fabro-web/app/lib/run-actions.ts b/apps/fabro-web/app/lib/run-actions.ts new file mode 100644 index 000000000..a7276dbae --- /dev/null +++ b/apps/fabro-web/app/lib/run-actions.ts @@ -0,0 +1,145 @@ +import type { ErrorResponseEntry, RunStatusResponse } from "@qltysh/fabro-api-client"; + +import { apiFetch } from "../api"; +import type { RunStatus } from "../data/runs"; + +export type LifecycleAction = "cancel" | "archive" | "unarchive"; + +export interface LifecycleActionError { + status: number; + errors: ErrorResponseEntry[]; +} + +const CANCELABLE_STATUSES = new Set([ + "submitted", + "queued", + "starting", + "running", + "paused", +]); + +const ARCHIVABLE_STATUSES = new Set([ + "succeeded", + "failed", + "dead", +]); + +export async function cancelRun(id: string, request?: Request): Promise { + return runLifecycleAction(id, "cancel", request); +} + +export async function archiveRun(id: string, request?: Request): Promise { + return runLifecycleAction(id, "archive", request); +} + +export async function unarchiveRun(id: string, request?: Request): Promise { + return runLifecycleAction(id, "unarchive", request); +} + +export function canCancel(status: string | null | undefined): boolean { + return !!status && CANCELABLE_STATUSES.has(status as RunStatus); +} + +export function canArchive(status: string | null | undefined): boolean { + return !!status && ARCHIVABLE_STATUSES.has(status as RunStatus); +} + +export function canUnarchive(status: string | null | undefined): boolean { + return status === "archived"; +} + +export function isTerminalCancelledRun(run: RunStatusResponse): boolean { + return (run.status === "failed" || run.status === "dead") && run.status_reason === "cancelled"; +} + +export function mapError(error: unknown, action: LifecycleAction): string { + if (isLifecycleActionError(error)) { + if (error.status === 404) { + return "This run no longer exists."; + } + if (error.status === 409) { + switch (action) { + case "cancel": + return "This run can no longer be cancelled."; + case "archive": + return "Only terminal runs can be archived."; + case "unarchive": + return "Active runs can't be unarchived."; + } + } + + const detail = error.errors[0]?.detail?.trim(); + if (detail) { + return detail; + } + } + + switch (action) { + case "cancel": + return "Couldn't cancel the run right now. Try again."; + case "archive": + return "Couldn't archive the run right now. Try again."; + case "unarchive": + return "Couldn't unarchive the run right now. Try again."; + } +} + +async function runLifecycleAction( + id: string, + action: LifecycleAction, + request?: Request, +): Promise { + const response = await apiFetch(`/runs/${id}/${action}`, { + init: { + method: "POST", + ...(request?.signal ? { signal: request.signal } : {}), + }, + }); + + if (!response.ok) { + throw await parseLifecycleActionError(response); + } + + return response.json() as Promise; +} + +async function parseLifecycleActionError(response: Response): Promise { + let bodyText = ""; + try { + bodyText = await response.text(); + } catch { + // Ignore body read failures and fall back to the status only. + } + + if (!bodyText) { + return { status: response.status, errors: [] }; + } + + try { + const body = JSON.parse(bodyText) as { errors?: unknown }; + if (!Array.isArray(body.errors)) { + return { status: response.status, errors: [] }; + } + + const errors = body.errors.filter(isErrorResponseEntry); + return { status: response.status, errors }; + } catch { + return { status: response.status, errors: [] }; + } +} + +function isLifecycleActionError(value: unknown): value is LifecycleActionError { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return typeof record.status === "number" && Array.isArray(record.errors); +} + +function isErrorResponseEntry(value: unknown): value is ErrorResponseEntry { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return ( + typeof record.status === "string" + && typeof record.title === "string" + && typeof record.detail === "string" + ); +} diff --git a/apps/fabro-web/app/lib/sse.test.ts b/apps/fabro-web/app/lib/sse.test.ts new file mode 100644 index 000000000..4d7fce2f5 --- /dev/null +++ b/apps/fabro-web/app/lib/sse.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; + +import { subscribeToRunEventSource } from "./sse"; + +type MessageHandler = ((event: { data: string }) => void) | null; + +class FakeEventSource { + onmessage: MessageHandler = null; + closed = false; + + emit(payload: unknown) { + this.onmessage?.({ data: JSON.stringify(payload) }); + } + + emitRaw(data: string) { + this.onmessage?.({ data }); + } + + close() { + this.closed = true; + } +} + +describe("subscribeToRunEventSource", () => { + test("allowlisted events trigger debounced revalidation and onEvent", async () => { + const source = new FakeEventSource(); + let revalidations = 0; + const events: Array<{ event?: string }> = []; + + const cleanup = subscribeToRunEventSource("run-1", { + allowlist: new Set(["run.completed"]), + debounceMs: 5, + revalidate: () => { + revalidations += 1; + }, + onEvent: (payload) => { + events.push(payload); + }, + eventSourceFactory: () => source, + }); + + source.emit({ event: "run.completed", seq: 42 }); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(revalidations).toBe(1); + expect(events).toEqual([{ event: "run.completed", seq: 42 }]); + + cleanup(); + }); + + test("non-allowlisted and malformed events are ignored", async () => { + const source = new FakeEventSource(); + let revalidations = 0; + let calls = 0; + + const cleanup = subscribeToRunEventSource("run-1", { + allowlist: new Set(["checkpoint.completed"]), + debounceMs: 5, + revalidate: () => { + revalidations += 1; + }, + onEvent: () => { + calls += 1; + }, + eventSourceFactory: () => source, + }); + + source.emit({ event: "run.completed" }); + source.emitRaw("{broken"); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(revalidations).toBe(0); + expect(calls).toBe(0); + + cleanup(); + }); + + test("cleanup closes the source and clears a pending debounce", async () => { + const source = new FakeEventSource(); + let revalidations = 0; + + const cleanup = subscribeToRunEventSource("run-1", { + allowlist: new Set(["run.completed"]), + debounceMs: 20, + revalidate: () => { + revalidations += 1; + }, + eventSourceFactory: () => source, + }); + + source.emit({ event: "run.completed" }); + cleanup(); + + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(source.closed).toBe(true); + expect(revalidations).toBe(0); + }); +}); diff --git a/apps/fabro-web/app/lib/sse.ts b/apps/fabro-web/app/lib/sse.ts new file mode 100644 index 000000000..f13c020e4 --- /dev/null +++ b/apps/fabro-web/app/lib/sse.ts @@ -0,0 +1,81 @@ +import { useEffect } from "react"; +import { useRevalidator } from "react-router"; + +export interface RunEventPayload { + event?: string; + [key: string]: unknown; +} + +export interface RunEventSourceLike { + onmessage: ((event: { data: string }) => void) | null; + close: () => void; +} + +interface SubscribeOptions { + allowlist: ReadonlySet; + debounceMs?: number; + onEvent?: (payload: RunEventPayload) => void; + revalidate: () => void; + eventSourceFactory?: (url: string) => RunEventSourceLike; +} + +function createBrowserEventSource(url: string): RunEventSourceLike { + return new EventSource(url); +} + +export function subscribeToRunEventSource(runId: string, options: SubscribeOptions): () => void { + const { + allowlist, + debounceMs = 300, + onEvent, + revalidate, + eventSourceFactory = createBrowserEventSource, + } = options; + + const source = eventSourceFactory(`/api/v1/runs/${runId}/attach?since_seq=1`); + let debounceTimer: ReturnType | undefined; + + source.onmessage = (message) => { + try { + const payload = JSON.parse(message.data) as RunEventPayload; + if (!payload.event || !allowlist.has(payload.event)) { + return; + } + onEvent?.(payload); + clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => revalidate(), debounceMs); + } catch { + // ignore malformed events + } + }; + + return () => { + clearTimeout(debounceTimer); + source.close(); + }; +} + +export function useRunEventSource( + runId: string | undefined, + { + allowlist, + debounceMs = 300, + onEvent, + }: { + allowlist: ReadonlySet; + debounceMs?: number; + onEvent?: (payload: RunEventPayload) => void; + }, +) { + const revalidator = useRevalidator(); + + useEffect(() => { + if (!runId) return; + return subscribeToRunEventSource(runId, { + allowlist, + debounceMs, + onEvent, + revalidate: () => revalidator.revalidate(), + }); + }, [allowlist, debounceMs, onEvent, revalidator, runId]); +} diff --git a/apps/fabro-web/app/routes/run-detail.test.ts b/apps/fabro-web/app/routes/run-detail.test.ts new file mode 100644 index 000000000..fc9f3cc73 --- /dev/null +++ b/apps/fabro-web/app/routes/run-detail.test.ts @@ -0,0 +1,198 @@ +import { afterEach, describe, expect, test } from "bun:test"; + +import { action, lifecycleActionVisibility, loader } from "./run-detail"; + +type StubFetchEntry = { + status: number; + body?: unknown; +}; + +function stubFetchSequence(entries: StubFetchEntry[]) { + const originalFetch = globalThis.fetch; + let index = 0; + + globalThis.fetch = ((input: RequestInfo | URL) => { + const next = entries[index++]; + if (!next) { + throw new Error(`unexpected fetch for ${String(input)}`); + } + return Promise.resolve( + new Response(next.body == null ? "" : JSON.stringify(next.body), { + status: next.status, + headers: { "Content-Type": "application/json" }, + }), + ); + }) as typeof fetch; + + return () => { + globalThis.fetch = originalFetch; + }; +} + +function buildActionRequest(data: Record) { + const formData = new FormData(); + for (const [key, value] of Object.entries(data)) { + formData.set(key, value); + } + return new Request("http://fabro.test/runs/run-1", { + method: "POST", + body: formData, + }); +} + +describe("run-detail loader", () => { + let restoreFetch: (() => void) | undefined; + + afterEach(() => { + restoreFetch?.(); + restoreFetch = undefined; + delete (globalThis as { window?: unknown }).window; + }); + + test("loads the first blocked question when the run is blocked", async () => { + restoreFetch = stubFetchSequence([ + { + status: 200, + body: { + run_id: "run-1", + title: "Blocked run", + repository: { name: "repo" }, + status: "blocked", + workflow_name: "review", + }, + }, + { + status: 200, + body: { + data: [{ id: "q-1", text: "Ship this change?", stage: "review", question_type: "single_select", options: [], allow_freeform: false }], + meta: { has_more: false }, + }, + }, + ]); + + const result = await loader({ + request: new Request("http://fabro.test/runs/run-1"), + params: { id: "run-1" }, + }); + + expect(result.blockedQuestionText).toBe("Ship this change?"); + expect(result.run?.lifecycleStatus).toBe("blocked"); + }); + + test("falls back to null blockedQuestionText when no question is available", async () => { + restoreFetch = stubFetchSequence([ + { + status: 200, + body: { + run_id: "run-1", + title: "Blocked run", + repository: { name: "repo" }, + status: "blocked", + workflow_name: "review", + }, + }, + { + status: 200, + body: { + data: [], + meta: { has_more: false }, + }, + }, + ]); + + const result = await loader({ + request: new Request("http://fabro.test/runs/run-1"), + params: { id: "run-1" }, + }); + + expect(result.blockedQuestionText).toBeNull(); + }); +}); + +describe("run-detail action", () => { + let restoreFetch: (() => void) | undefined; + + afterEach(() => { + restoreFetch?.(); + restoreFetch = undefined; + delete (globalThis as { window?: unknown }).window; + }); + + test("preview still dispatches through intent=preview", async () => { + restoreFetch = stubFetchSequence([ + { + status: 200, + body: { url: "https://preview.example.com" }, + }, + ]); + + const result = await action({ + params: { id: "run-1" }, + request: buildActionRequest({ + intent: "preview", + port: "3000", + expires_in_secs: "3600", + }), + }); + + expect(result).toEqual({ + intent: "preview", + url: "https://preview.example.com", + }); + }); + + test("cancel dispatches through the lifecycle helper path", async () => { + restoreFetch = stubFetchSequence([ + { + status: 200, + body: { + id: "run-1", + status: "failed", + status_reason: "cancelled", + created_at: "2026-04-20T12:00:00Z", + }, + }, + ]); + + const result = await action({ + params: { id: "run-1" }, + request: buildActionRequest({ intent: "cancel" }), + }); + + expect(result).toEqual({ + intent: "cancel", + ok: true, + run: { + id: "run-1", + status: "failed", + status_reason: "cancelled", + created_at: "2026-04-20T12:00:00Z", + }, + }); + }); +}); + +describe("lifecycleActionVisibility", () => { + test("shows cancel for active cancellable states and hides it elsewhere", () => { + expect(lifecycleActionVisibility("submitted").showPrimaryCancel).toBe(true); + expect(lifecycleActionVisibility("queued").showPrimaryCancel).toBe(true); + expect(lifecycleActionVisibility("starting").showPrimaryCancel).toBe(true); + expect(lifecycleActionVisibility("running").showPrimaryCancel).toBe(true); + expect(lifecycleActionVisibility("paused").showPrimaryCancel).toBe(true); + expect(lifecycleActionVisibility("blocked").showPrimaryCancel).toBe(false); + expect(lifecycleActionVisibility("succeeded").showPrimaryCancel).toBe(false); + expect(lifecycleActionVisibility("failed").showPrimaryCancel).toBe(false); + expect(lifecycleActionVisibility("dead").showPrimaryCancel).toBe(false); + expect(lifecycleActionVisibility("archived").showPrimaryCancel).toBe(false); + }); + + test("shows archive and unarchive in the expected terminal states", () => { + expect(lifecycleActionVisibility("succeeded").showArchive).toBe(true); + expect(lifecycleActionVisibility("failed").showArchive).toBe(true); + expect(lifecycleActionVisibility("dead").showArchive).toBe(true); + expect(lifecycleActionVisibility("archived").showArchive).toBe(false); + expect(lifecycleActionVisibility("archived").showUnarchive).toBe(true); + expect(lifecycleActionVisibility("running").showUnarchive).toBe(false); + expect(lifecycleActionVisibility("blocked").showBlockedNotice).toBe(true); + }); +}); diff --git a/apps/fabro-web/app/routes/run-detail.tsx b/apps/fabro-web/app/routes/run-detail.tsx index 776689534..c5f9f87e2 100644 --- a/apps/fabro-web/app/routes/run-detail.tsx +++ b/apps/fabro-web/app/routes/run-detail.tsx @@ -1,12 +1,38 @@ import { useEffect } from "react"; -import { ChevronRightIcon } from "@heroicons/react/20/solid"; +import { ArrowPathIcon, ChevronRightIcon } from "@heroicons/react/20/solid"; import { Link, Outlet, useFetcher, useLocation } from "react-router"; -import { mapRunSummaryToRunItem, runStatusDisplay, isRunStatus } from "../data/runs"; -import type { RunSummaryResponse } from "../data/runs"; +import type { + ErrorResponseEntry, + PaginatedApiQuestionList, + PreviewUrlResponse, + RunStatusResponse, +} from "@qltysh/fabro-api-client"; + import { apiJson } from "../api"; +import { BlockedRunNotice } from "../components/blocked-run-notice"; import { ErrorState } from "../components/state"; +import { useToast } from "../components/toast"; +import { PRIMARY_BUTTON_CLASS, SECONDARY_BUTTON_CLASS } from "../components/ui"; +import { + isRunStatus, + mapRunSummaryToRunItem, + runStatusDisplay, + type RunSummaryResponse, +} from "../data/runs"; import { useDemoMode } from "../lib/demo-mode"; -import type { PreviewUrlResponse } from "@qltysh/fabro-api-client"; +import { useRunEventSource } from "../lib/sse"; +import { + archiveRun, + canArchive, + canCancel, + canUnarchive, + cancelRun, + isTerminalCancelledRun, + mapError, + type LifecycleAction, + type LifecycleActionError, + unarchiveRun, +} from "../lib/run-actions"; const allTabs = [ { name: "Overview", path: "", count: null, demoOnly: false }, @@ -18,17 +44,82 @@ const allTabs = [ export const handle = { hideHeader: true }; -export async function loader({ request, params }: any) { +const RUN_DETAIL_EVENTS = new Set([ + "run.submitted", + "run.queued", + "run.starting", + "run.running", + "run.paused", + "run.unpaused", + "run.blocked", + "run.unblocked", + "run.completed", + "run.failed", + "run.archived", + "run.unarchived", +]); + +const CANCEL_BUTTON_CLASS = + "inline-flex items-center justify-center gap-2 rounded-lg border border-coral/30 bg-coral/10 px-4 py-2 text-sm font-medium text-coral transition-colors hover:bg-coral/15 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-coral/10"; + +const MUTATION_BUTTON_CLASS = + `${SECONDARY_BUTTON_CLASS} disabled:cursor-not-allowed disabled:opacity-60`; + +type RunDetailRun = ReturnType & { + statusLabel: string; + statusDot: string; + statusText: string; +}; + +export interface RunDetailLoaderData { + run: RunDetailRun | null; + blockedQuestionText: string | null; +} + +type PreviewActionResult = { + intent: "preview"; + url: string; +}; + +type LifecycleActionResult = + | { + intent: LifecycleAction; + ok: true; + run: RunStatusResponse; + } + | { + intent: LifecycleAction; + ok: false; + error: LifecycleActionError | null; + }; + +export type RunDetailActionResult = PreviewActionResult | LifecycleActionResult; + +export function lifecycleActionVisibility(status: string | null | undefined) { + return { + showPrimaryCancel: canCancel(status), + showArchive: canArchive(status), + showUnarchive: canUnarchive(status), + showBlockedNotice: status === "blocked", + }; +} + +export async function loader({ request, params }: any): Promise { const response = await fetch(`/api/v1/runs/${params.id}`, { credentials: "include", + ...(request?.signal ? { signal: request.signal } : {}), }); - if (!response.ok) return { run: null }; + if (!response.ok) { + return { run: null, blockedQuestionText: null }; + } + const summary: RunSummaryResponse = await response.json(); const item = mapRunSummaryToRunItem(summary); const rawStatus = summary.status; const display = isRunStatus(rawStatus) ? runStatusDisplay[rawStatus] : { label: rawStatus, dot: "bg-fg-muted", text: "text-fg-muted" }; + return { run: { ...item, @@ -36,22 +127,39 @@ export async function loader({ request, params }: any) { statusDot: display.dot, statusText: display.text, }, + blockedQuestionText: + rawStatus === "blocked" + ? await loadBlockedQuestionText(params.id, request?.signal) + : null, }; } -export async function action({ params, request }: any) { +export async function action({ params, request }: any): Promise { const formData = await request.formData(); - const port = formData.get("port"); - const expiresInSecs = formData.get("expires_in_secs"); - const result = await apiJson(`/runs/${params.id}/preview`, { - request, - init: { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ port: Number(port), expires_in_secs: Number(expiresInSecs) }), - }, - }); - return result; + const intent = String(formData.get("intent") ?? "preview"); + + if (intent === "preview") { + const port = formData.get("port"); + const expiresInSecs = formData.get("expires_in_secs"); + const result = await apiJson(`/runs/${params.id}/preview`, { + request, + init: { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ port: Number(port), expires_in_secs: Number(expiresInSecs) }), + }, + }); + return { + intent: "preview", + url: result.url, + }; + } + + if (intent === "cancel" || intent === "archive" || intent === "unarchive") { + return runLifecycleIntent(params.id, intent, request); + } + + throw new Response(null, { status: 400, statusText: `Unsupported intent: ${intent}` }); } export function meta({ data }: any) { @@ -59,20 +167,69 @@ export function meta({ data }: any) { return [{ title: run ? `${run.title} — Fabro` : "Run — Fabro" }]; } -export default function RunDetail({ loaderData, params }: any) { - const { run } = loaderData; +export default function RunDetail({ loaderData, params }: { loaderData: RunDetailLoaderData; params: { id: string } }) { + const { run, blockedQuestionText } = loaderData; const { pathname } = useLocation(); const basePath = `/runs/${params.id}`; - const previewFetcher = useFetcher(); + const previewFetcher = useFetcher(); + const cancelFetcher = useFetcher(); + const archiveFetcher = useFetcher(); + const unarchiveFetcher = useFetcher(); + const { push } = useToast(); const demoMode = useDemoMode(); const tabs = allTabs.filter((t) => !t.demoOnly || demoMode); + useRunEventSource(run?.id ?? undefined, { + allowlist: RUN_DETAIL_EVENTS, + debounceMs: 300, + }); + useEffect(() => { - if (previewFetcher.data?.url) { + if (previewFetcher.data?.intent === "preview") { window.open(previewFetcher.data.url, "_blank"); } }, [previewFetcher.data]); + useEffect(() => { + const result = cancelFetcher.data; + if (!result || result.intent !== "cancel") return; + if (isLifecycleActionFailure(result)) { + push({ message: mapError(result.error, "cancel"), tone: "error" }); + return; + } + push({ + message: isTerminalCancelledRun(result.run) + ? "Run cancelled." + : "Cancellation requested.", + }); + }, [cancelFetcher.data, push]); + + useEffect(() => { + const result = archiveFetcher.data; + if (!result || result.intent !== "archive") return; + if (isLifecycleActionFailure(result)) { + push({ message: mapError(result.error, "archive"), tone: "error" }); + return; + } + push({ + message: "Run archived.", + action: { + label: "Unarchive", + onClick: () => submitIntent(unarchiveFetcher, "unarchive"), + }, + }); + }, [archiveFetcher, archiveFetcher.data, push, unarchiveFetcher]); + + useEffect(() => { + const result = unarchiveFetcher.data; + if (!result || result.intent !== "unarchive") return; + if (isLifecycleActionFailure(result)) { + push({ message: mapError(result.error, "unarchive"), tone: "error" }); + return; + } + push({ message: "Run restored." }); + }, [push, unarchiveFetcher.data]); + if (!run) { return (
@@ -84,6 +241,12 @@ export default function RunDetail({ loaderData, params }: any) { ); } + const visibility = lifecycleActionVisibility(run.lifecycleStatus); + const previewPending = previewFetcher.state !== "idle"; + const cancelPending = cancelFetcher.state !== "idle"; + const archivePending = archiveFetcher.state !== "idle"; + const unarchivePending = unarchiveFetcher.state !== "idle"; + return (
-
+

{run.title}

@@ -114,26 +277,76 @@ export default function RunDetail({ loaderData, params }: any) { )}
- {/* TODO: restore an Open PR button when RunPullRequest gains a url field */} - {run.sandboxId && ( - - - - - - )} + +
+ {visibility.showPrimaryCancel && ( + + + + + )} + + {visibility.showArchive && ( + + + + + )} + + {visibility.showUnarchive && ( + + + + + )} + + {run.sandboxId && ( + + + + + + + )} +
+ {visibility.showBlockedNotice && ( + submitIntent(cancelFetcher, "cancel")} + /> + )} +
); } + +async function loadBlockedQuestionText(id: string, signal?: AbortSignal): Promise { + try { + const url = new URL(`/api/v1/runs/${id}/questions`, "http://fabro.local"); + url.searchParams.set("page[limit]", "1"); + url.searchParams.set("page[offset]", "0"); + + const response = await fetch(`${url.pathname}${url.search}`, { + credentials: "include", + ...(signal ? { signal } : {}), + }); + if (!response.ok) { + return null; + } + + const payload = await response.json() as PaginatedApiQuestionList; + return payload.data[0]?.text ?? null; + } catch { + return null; + } +} + +async function runLifecycleIntent( + id: string, + intent: LifecycleAction, + request: Request, +): Promise { + try { + switch (intent) { + case "cancel": + return { intent, ok: true, run: await cancelRun(id, request) }; + case "archive": + return { intent, ok: true, run: await archiveRun(id, request) }; + case "unarchive": + return { intent, ok: true, run: await unarchiveRun(id, request) }; + } + } catch (error) { + return { + intent, + ok: false, + error: serializeLifecycleActionError(error), + }; + } +} + +function serializeLifecycleActionError(error: unknown): LifecycleActionError | null { + if (!error || typeof error !== "object") return null; + const record = error as Record; + if (typeof record.status !== "number" || !Array.isArray(record.errors)) { + return null; + } + + return { + status: record.status, + errors: record.errors.filter(isErrorResponseEntry), + }; +} + +function isErrorResponseEntry(value: unknown): value is ErrorResponseEntry { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return ( + typeof record.status === "string" + && typeof record.title === "string" + && typeof record.detail === "string" + ); +} + +function isLifecycleActionFailure( + value: LifecycleActionResult, +): value is Extract { + return value.ok === false; +} + +function submitIntent( + fetcher: { submit: (target: FormData, options: { method: "post" }) => void }, + intent: LifecycleAction, +) { + const formData = new FormData(); + formData.set("intent", intent); + fetcher.submit(formData, { method: "post" }); +} diff --git a/apps/fabro-web/app/routes/run-files.test.ts b/apps/fabro-web/app/routes/run-files.test.ts index 649258893..1f9da4ec2 100644 --- a/apps/fabro-web/app/routes/run-files.test.ts +++ b/apps/fabro-web/app/routes/run-files.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { extractRequestId, loader } from "./run-files"; +import { + deepLinkToastMessage, + emptyTransitionToastMessage, + extractRequestId, + loader, +} from "./run-files"; type StubResponseInit = { status: number; @@ -8,6 +13,30 @@ type StubResponseInit = { headers?: Record; }; +function buildRunFilesPayload({ + files = [], + degraded = false, + patch = null, +}: { + files?: string[]; + degraded?: boolean; + patch?: string | null; +}) { + return { + data: files.map((name) => ({ + change_kind: "modified", + old_file: { name }, + new_file: { name }, + })), + meta: { + degraded, + patch, + total_changed: files.length, + truncated: false, + }, + } as any; +} + function stubFetchOnce(init: StubResponseInit) { const original = globalThis.fetch; globalThis.fetch = (() => { @@ -76,6 +105,50 @@ describe("extractRequestId", () => { }); }); +describe("emptyTransitionToastMessage", () => { + test("returns the no-changes toast when a populated diff becomes empty", () => { + expect(emptyTransitionToastMessage(3, 0)).toBe("No changes in this run."); + }); + + test("returns null when the diff was already empty", () => { + expect(emptyTransitionToastMessage(0, 0)).toBeNull(); + expect(emptyTransitionToastMessage(null, 0)).toBeNull(); + expect(emptyTransitionToastMessage(2, 1)).toBeNull(); + }); +}); + +describe("deepLinkToastMessage", () => { + test("returns the patch-only message when file navigation is unavailable", () => { + expect( + deepLinkToastMessage( + "src/app.tsx", + buildRunFilesPayload({ + degraded: true, + patch: "@@ -1 +1 @@", + }), + ), + ).toBe("File-level navigation isn't available in the patch-only view."); + }); + + test("returns the missing-file message when the requested file is absent", () => { + expect( + deepLinkToastMessage( + "src/missing.ts", + buildRunFilesPayload({ files: ["src/present.ts"] }), + ), + ).toBe("File src/missing.ts is not in this run."); + }); + + test("returns null when the deep-linked file exists", () => { + expect( + deepLinkToastMessage( + "src/present.ts", + buildRunFilesPayload({ files: ["src/present.ts"] }), + ), + ).toBeNull(); + }); +}); + describe("loader", () => { let restoreFetch: (() => void) | undefined; diff --git a/apps/fabro-web/app/routes/run-files.tsx b/apps/fabro-web/app/routes/run-files.tsx index ebd11df8c..fa28edded 100644 --- a/apps/fabro-web/app/routes/run-files.tsx +++ b/apps/fabro-web/app/routes/run-files.tsx @@ -4,6 +4,7 @@ import { useRef, useState, type ReactElement, + type ReactNode, } from "react"; import { useMatches, @@ -11,7 +12,8 @@ import { useParams, useRevalidator, } from "react-router"; -import { MultiFileDiff, PatchDiff, Virtualizer } from "@pierre/diffs/react"; +import * as PierreDiffs from "@pierre/diffs/react"; +import { useToast } from "../components/toast"; import { useTheme } from "../lib/theme"; import type { FileDiff as ApiFileDiff, @@ -28,10 +30,18 @@ import { LoadingSkeleton, renderStatusError, RunFilesErrorBoundary, - Toast, } from "./run-files/states"; import { useFileKeyboardNav } from "./run-files/keyboard"; import { Toolbar, type DiffStyle } from "./run-files/toolbar"; +import { useRunEventSource } from "../lib/sse"; + +const { MultiFileDiff, PatchDiff } = PierreDiffs; +const maybeVirtualizer = (PierreDiffs as Record).Virtualizer; +const Virtualizer = typeof maybeVirtualizer === "function" + ? maybeVirtualizer as ({ children }: { children: ReactNode }) => ReactElement + : function VirtualizerFallback({ children }: { children: ReactNode }) { + return <>{children}; + }; export const handle = { wide: true }; @@ -157,28 +167,10 @@ function useNarrowViewport(): boolean { } function useSseRevalidation(runId: string | undefined) { - const revalidator = useRevalidator(); - useEffect(() => { - if (!runId) return; - const source = new EventSource(`/api/v1/runs/${runId}/attach?since_seq=1`); - let debounce: ReturnType | undefined; - source.onmessage = (msg) => { - try { - const payload = JSON.parse(msg.data); - if (REFRESH_EVENTS.has(payload.event)) { - clearTimeout(debounce); - debounce = setTimeout(() => revalidator.revalidate(), 500); - } - } catch { - // ignore malformed payloads - } - }; - return () => { - clearTimeout(debounce); - source.close(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [runId]); + useRunEventSource(runId, { + allowlist: REFRESH_EVENTS, + debounceMs: 500, + }); } function useFreshness( @@ -261,6 +253,47 @@ function decodeDeepLinkFile(hash: string): string | null { } } +export function emptyTransitionToastMessage( + previousFileCount: number | null, + nextFileCount: number, +): string | null { + return previousFileCount !== null && previousFileCount > 0 && nextFileCount === 0 + ? "No changes in this run." + : null; +} + +function resolveDeepLinkToast( + hashFile: string | null, + data: PaginatedRunFileList | null, +): { key: string; message: string } | null { + if (!hashFile || !data) return null; + if (data.meta.degraded && data.meta.patch) { + return { + key: `patch-only:${hashFile}`, + message: "File-level navigation isn't available in the patch-only view.", + }; + } + + const exists = data.data.some( + (file) => file.new_file.name === hashFile || file.old_file.name === hashFile, + ); + if (!exists) { + return { + key: `missing:${hashFile}`, + message: `File ${hashFile} is not in this run.`, + }; + } + + return null; +} + +export function deepLinkToastMessage( + hashFile: string | null, + data: PaginatedRunFileList | null, +): string | null { + return resolveDeepLinkToast(hashFile, data)?.message ?? null; +} + /** * Extract the lifecycle status from whichever ancestor match carries it. * The Run Detail loader (apps/fabro-web/app/routes/run-detail.tsx) returns @@ -290,6 +323,7 @@ export default function RunFiles({ loaderData }: any) { const navigation = useNavigation(); const revalidator = useRevalidator(); const matches = useMatches(); + const { push } = useToast(); const result = loaderData as RunFilesLoaderResult | null; const narrow = useNarrowViewport(); const runStatus = resolveRunStatus(matches); @@ -298,23 +332,19 @@ export default function RunFiles({ loaderData }: any) { // rendering the previous files while surfacing an inline banner. const lastGoodDataRef = useRef(null); const lastFetchedAtRef = useRef(null); - const [emptyToast, setEmptyToast] = useState(null); - const [deepLinkToast, setDeepLinkToast] = useState(null); useEffect(() => { if (!result?.data) return; - const prev = lastGoodDataRef.current; - if (prev && prev.data.length > 0 && result.data.data.length === 0) { - setEmptyToast("No changes in this run."); - const id = setTimeout(() => setEmptyToast(null), 3500); - lastGoodDataRef.current = result.data; - lastFetchedAtRef.current = Date.now(); - return () => clearTimeout(id); + const message = emptyTransitionToastMessage( + lastGoodDataRef.current?.data.length ?? null, + result.data.data.length, + ); + if (message) { + push({ message }); } lastGoodDataRef.current = result.data; lastFetchedAtRef.current = Date.now(); - return undefined; - }, [result?.data]); + }, [push, result?.data]); const data: PaginatedRunFileList | null = result?.data ?? lastGoodDataRef.current; @@ -357,6 +387,7 @@ export default function RunFiles({ loaderData }: any) { const refreshButtonRef = useRef(null); const containerRef = useRef(null); + const lastDeepLinkToastRef = useRef(null); // Return focus to the Refresh button after a revalidation completes so // keyboard-first users stay oriented. @@ -389,28 +420,22 @@ export default function RunFiles({ loaderData }: any) { }, []); useEffect(() => { + const toast = resolveDeepLinkToast(hashFile, data); + if (toast) { + if (lastDeepLinkToastRef.current !== toast.key) { + push({ message: toast.message, autoDismissMs: 5000 }); + lastDeepLinkToastRef.current = toast.key; + } + return; + } + lastDeepLinkToastRef.current = null; if (!hashFile || !data) return; - if (data.meta.degraded && data.meta.patch) { - setDeepLinkToast( - "File-level navigation isn't available in the patch-only view.", - ); - const id = setTimeout(() => setDeepLinkToast(null), 5000); - return () => clearTimeout(id); - } - const exists = data.data.some( - (f) => f.new_file.name === hashFile || f.old_file.name === hashFile, - ); - if (!exists) { - setDeepLinkToast(`File ${hashFile} is not in this run.`); - const id = setTimeout(() => setDeepLinkToast(null), 5000); - return () => clearTimeout(id); - } const el = document.getElementById(fileRowId(hashFile)); if (el) { el.scrollIntoView({ block: "start", behavior: "smooth" }); el.focus({ preventScroll: true }); } - }, [hashFile, data]); + }, [data, hashFile, push]); const renderFiles = useCallback( (files: ApiFileDiff[]): ReactElement[] => @@ -531,8 +556,6 @@ export default function RunFiles({ loaderData }: any) { theme: pierreTheme, }} /> - {emptyToast && {emptyToast}} - {deepLinkToast && {deepLinkToast}}
); } @@ -548,8 +571,6 @@ export default function RunFiles({ loaderData }: any) { degraded: meta.degraded ?? false, })} /> - {emptyToast && {emptyToast}} - {deepLinkToast && {deepLinkToast}}
); } @@ -573,8 +594,6 @@ export default function RunFiles({ loaderData }: any) { /> ) : null} {body} - {emptyToast && {emptyToast}} - {deepLinkToast && {deepLinkToast}}
); } diff --git a/apps/fabro-web/app/routes/run-files/pierre-smoke.test.tsx b/apps/fabro-web/app/routes/run-files/pierre-smoke.test.tsx index c7a13d1d0..90373eb48 100644 --- a/apps/fabro-web/app/routes/run-files/pierre-smoke.test.tsx +++ b/apps/fabro-web/app/routes/run-files/pierre-smoke.test.tsx @@ -1,12 +1,20 @@ import { describe, expect, test } from "bun:test"; -import { MultiFileDiff, PatchDiff, Virtualizer } from "@pierre/diffs/react"; +import type { ReactNode } from "react"; +import * as PierreDiffs from "@pierre/diffs/react"; + +const { MultiFileDiff, PatchDiff } = PierreDiffs; +const maybeVirtualizer = (PierreDiffs as Record).Virtualizer; +const Virtualizer = typeof maybeVirtualizer === "function" + ? maybeVirtualizer + : function VirtualizerFallback({ children }: { children: ReactNode }) { + return <>{children}; + }; // Regression coverage for the @pierre/diffs 1.0 -> 1.1 upgrade. We assert -// only that the public React components the Run Files route uses remain -// exported as callable function components — a full mount-under-test hits -// pierre's useLayoutEffect teardown path, which is incompatible with -// react-test-renderer under React 19. Functional mount coverage lives in -// the dev-server smoke check. +// only that the React components the Run Files route consumes remain +// callable. `Virtualizer` is optional across installed pierre versions, so +// the route carries a no-op fallback and the smoke test mirrors that +// compatibility layer rather than assuming a specific package export set. describe("@pierre/diffs public API", () => { test("MultiFileDiff is a callable component export", () => { diff --git a/apps/fabro-web/app/routes/run-files/states.test.tsx b/apps/fabro-web/app/routes/run-files/states.test.tsx index 5a222e522..e8fe40eb1 100644 --- a/apps/fabro-web/app/routes/run-files/states.test.tsx +++ b/apps/fabro-web/app/routes/run-files/states.test.tsx @@ -8,7 +8,6 @@ import { EmptyState, InlineErrorBanner, LoadingSkeleton, - Toast, } from "./states"; function renderToJson(element: React.ReactElement): any { @@ -178,15 +177,4 @@ describe("component rendering", () => { }); expect(clicked).toBe(1); }); - - test("Toast renders its children in an aria-live region", () => { - let tree: TestRenderer.ReactTestRenderer | undefined; - TestRenderer.act(() => { - tree = TestRenderer.create(hello); - }); - const live = tree!.root.findAll( - (node) => node.props?.["aria-live"] === "polite", - ); - expect(live.length).toBeGreaterThan(0); - }); }); diff --git a/apps/fabro-web/app/routes/run-files/states.tsx b/apps/fabro-web/app/routes/run-files/states.tsx index a44f2ac7b..fc1966292 100644 --- a/apps/fabro-web/app/routes/run-files/states.tsx +++ b/apps/fabro-web/app/routes/run-files/states.tsx @@ -128,18 +128,6 @@ export function InlineErrorBanner({ ); } -export function Toast({ children }: { children: React.ReactNode }) { - return ( -
- {children} -
- ); -} - /** * Shared helper for rendering the documented status-code taxonomy. Consumed * by both the inline `initialError` branch in run-files.tsx and the diff --git a/apps/fabro-web/app/routes/runs.test.tsx b/apps/fabro-web/app/routes/runs.test.tsx index 9c15a9c95..0cf1c93a9 100644 --- a/apps/fabro-web/app/routes/runs.test.tsx +++ b/apps/fabro-web/app/routes/runs.test.tsx @@ -43,6 +43,8 @@ describe("runs route board mapping", () => { expect(shouldRefreshBoardForEvent("run.queued")).toBe(true); expect(shouldRefreshBoardForEvent("run.blocked")).toBe(true); expect(shouldRefreshBoardForEvent("run.unblocked")).toBe(true); + expect(shouldRefreshBoardForEvent("run.archived")).toBe(true); + expect(shouldRefreshBoardForEvent("run.unarchived")).toBe(true); expect(shouldRefreshBoardForEvent("interview.started")).toBe(true); expect(shouldRefreshBoardForEvent("interview.completed")).toBe(true); expect(shouldRefreshBoardForEvent("run.created")).toBe(false); diff --git a/apps/fabro-web/app/routes/runs.tsx b/apps/fabro-web/app/routes/runs.tsx index ad63af0c6..5e184ec42 100644 --- a/apps/fabro-web/app/routes/runs.tsx +++ b/apps/fabro-web/app/routes/runs.tsx @@ -72,6 +72,8 @@ const BOARD_STATUS_EVENTS = new Set([ "run.unblocked", "run.completed", "run.failed", + "run.archived", + "run.unarchived", "interview.started", "interview.completed", "interview.timeout", diff --git a/docs/plans/2026-04-19-002-feat-web-ui-lifecycle-actions-plan.md b/docs/plans/2026-04-19-002-feat-web-ui-lifecycle-actions-plan.md index 2e98d4e37..ff7002164 100644 --- a/docs/plans/2026-04-19-002-feat-web-ui-lifecycle-actions-plan.md +++ b/docs/plans/2026-04-19-002-feat-web-ui-lifecycle-actions-plan.md @@ -1,7 +1,7 @@ --- title: "feat: Expose CLI lifecycle actions (cancel, archive, unarchive) in the web UI" type: feat -status: active +status: completed date: 2026-04-19 updated: 2026-04-20 origin: docs/brainstorms/2026-04-19-web-ui-lifecycle-actions-requirements.md @@ -224,7 +224,7 @@ graph TB --- -- [ ] **Unit 1: Extend `BOARD_STATUS_EVENTS` with `run.archived` and `run.unarchived`** +- [x] **Unit 1: Extend `BOARD_STATUS_EVENTS` with `run.archived` and `run.unarchived`** **Goal:** Ensure the board revalidates when a run is archived or unarchived from any source. @@ -255,7 +255,7 @@ graph TB --- -- [ ] **Unit 2: Shared toast infrastructure (`ToastProvider`, `useToast`)** +- [x] **Unit 2: Shared toast infrastructure (`ToastProvider`, `useToast`)** **Goal:** Introduce a stackable app-level toast system for passive toasts, error toasts, and inverse-action toasts. @@ -296,7 +296,7 @@ graph TB --- -- [ ] **Unit 3: Shared SSE hook (`useRunEventSource`) and migration of existing call sites** +- [x] **Unit 3: Shared SSE hook (`useRunEventSource`) and migration of existing call sites** **Goal:** Standardize run-scoped SSE parsing and revalidation behavior across the app. @@ -339,7 +339,7 @@ graph TB --- -- [ ] **Unit 4: Lifecycle action helpers and route action dispatch** +- [x] **Unit 4: Lifecycle action helpers and route action dispatch** **Goal:** Centralize request handling, error parsing, and status predicates for cancel/archive/unarchive. @@ -383,7 +383,7 @@ graph TB --- -- [ ] **Unit 5: Immediate cancel UI** +- [x] **Unit 5: Immediate cancel UI** **Goal:** Add the cancel button to the run detail header using the same route-action/fetcher pattern as the other lifecycle actions. @@ -423,7 +423,7 @@ graph TB --- -- [ ] **Unit 6: Archive / unarchive UI** +- [x] **Unit 6: Archive / unarchive UI** **Goal:** Add archive and unarchive controls plus the inverse-action toast flow. @@ -463,7 +463,7 @@ graph TB --- -- [ ] **Unit 7: Blocked-run notice + secondary cancel affordance** +- [x] **Unit 7: Blocked-run notice + secondary cancel affordance** **Goal:** Show non-CLI users why the run is blocked without presenting cancel as the main action. diff --git a/lib/crates/fabro-spa/assets/assets/app.css b/lib/crates/fabro-spa/assets/assets/app.css index 9e0dc0c80..92efdc1a4 100644 --- a/lib/crates/fabro-spa/assets/assets/app.css +++ b/lib/crates/fabro-spa/assets/assets/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:"";--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:"Geist", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", ui-monospace, monospace;--color-amber-100:oklch(96.2% .059 95.617);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-950:oklch(27.9% .077 45.635);--color-teal-300:#b5ddef;--color-teal-500:#67b2d7;--color-teal-700:#357f9e;--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-950:oklch(27.1% .105 12.094);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-navy-950:#0f1729;--color-mint:#5ac8a8;--color-amber:#f0a45b;--color-coral:#e86b6b;--color-page:#0f1729;--color-panel:#252c3d;--color-panel-alt:#1a2133;--color-overlay:#ffffff0a;--color-overlay-strong:#ffffff1a;--color-fg:#fff;--color-fg-2:#e8edf3;--color-fg-3:#a8b5c5;--color-fg-muted:#4b5768;--color-line:#ffffff0f;--color-line-strong:#ffffff1f;--color-focus:#67b2d766;--color-on-primary:#0f1729}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}a[href],button,select,summary,[role=button],[role=link],[role=option],[role=menuitem],[role=tab]{cursor:pointer}}@layer components;@layer utilities{.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.-inset-0\.5{inset:calc(var(--spacing) * -.5)}.-inset-1\.5{inset:calc(var(--spacing) * -1.5)}.-inset-px{inset:-1px}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.inset-x-3{inset-inline:calc(var(--spacing) * 3)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.top-1\/2{top:50%}.top-3{top:calc(var(--spacing) * 3)}.top-6{top:calc(var(--spacing) * 6)}.top-full{top:100%}.right-0{right:calc(var(--spacing) * 0)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-6{right:calc(var(--spacing) * 6)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-6{bottom:calc(var(--spacing) * 6)}.left-3{left:calc(var(--spacing) * 3)}.isolate{isolation:isolate}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.-my-1{margin-block:calc(var(--spacing) * -1)}.-my-6{margin-block:calc(var(--spacing) * -6)}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);margin-top:1.2em;margin-bottom:1.2em;font-size:1.25em;line-height:1.6}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);font-weight:500;text-decoration:underline}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em;list-style-type:decimal}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em;list-style-type:disc}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-counters);font-weight:400}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.25em;font-weight:600}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"“""”""‘""’";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em;font-style:italic;font-weight:500}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:0;margin-bottom:.888889em;font-size:2.25em;font-weight:800;line-height:1.11111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:900}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:2em;margin-bottom:1em;font-size:1.5em;font-weight:700;line-height:1.33333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:800}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.6em;margin-bottom:.6em;font-size:1.25em;font-weight:600;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.5em;margin-bottom:.5em;font-weight:600;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em;display:block}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows), 0 3px 0 var(--tw-prose-kbd-shadows);padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;border-radius:.3125rem;padding-inline-start:.375em;font-family:inherit;font-size:.875em;font-weight:500}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-size:.875em;font-weight:600}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);padding-top:.857143em;padding-inline-end:1.14286em;padding-bottom:.857143em;border-radius:.375rem;margin-top:1.71429em;margin-bottom:1.71429em;padding-inline-start:1.14286em;font-size:.875em;font-weight:400;line-height:1.71429;overflow-x:auto}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit;background-color:#0000;border-width:0;border-radius:0;padding:0}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){table-layout:auto;width:100%;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.71429}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);vertical-align:bottom;padding-inline-end:.571429em;padding-bottom:.571429em;padding-inline-start:.571429em;font-weight:600}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);margin-top:.857143em;font-size:.875em;line-height:1.42857}.prose{--tw-prose-body:oklch(37.3% .034 259.733);--tw-prose-headings:oklch(21% .034 264.665);--tw-prose-lead:oklch(44.6% .03 256.802);--tw-prose-links:oklch(21% .034 264.665);--tw-prose-bold:oklch(21% .034 264.665);--tw-prose-counters:oklch(55.1% .027 264.364);--tw-prose-bullets:oklch(87.2% .01 258.338);--tw-prose-hr:oklch(92.8% .006 264.531);--tw-prose-quotes:oklch(21% .034 264.665);--tw-prose-quote-borders:oklch(92.8% .006 264.531);--tw-prose-captions:oklch(55.1% .027 264.364);--tw-prose-kbd:oklch(21% .034 264.665);--tw-prose-kbd-shadows:oklab(21% -.00316127 -.0338527/.1);--tw-prose-code:oklch(21% .034 264.665);--tw-prose-pre-code:oklch(92.8% .006 264.531);--tw-prose-pre-bg:oklch(27.8% .033 256.848);--tw-prose-th-borders:oklch(87.2% .01 258.338);--tw-prose-td-borders:oklch(92.8% .006 264.531);--tw-prose-invert-body:oklch(87.2% .01 258.338);--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:oklch(70.7% .022 261.325);--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:oklch(70.7% .022 261.325);--tw-prose-invert-bullets:oklch(44.6% .03 256.802);--tw-prose-invert-hr:oklch(37.3% .034 259.733);--tw-prose-invert-quotes:oklch(96.7% .003 264.542);--tw-prose-invert-quote-borders:oklch(37.3% .034 259.733);--tw-prose-invert-captions:oklch(70.7% .022 261.325);--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:#ffffff1a;--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:oklch(87.2% .01 258.338);--tw-prose-invert-pre-bg:#00000080;--tw-prose-invert-th-borders:oklch(44.6% .03 256.802);--tw-prose-invert-td-borders:oklch(37.3% .034 259.733);font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.571429em;padding-inline-end:.571429em;padding-bottom:.571429em;padding-inline-start:.571429em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-sm{font-size:.875rem;line-height:1.71429}.prose-sm :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em;margin-bottom:1.14286em}.prose-sm :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.888889em;margin-bottom:.888889em;font-size:1.28571em;line-height:1.55556}.prose-sm :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.33333em;margin-bottom:1.33333em;padding-inline-start:1.11111em}.prose-sm :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:.8em;font-size:2.14286em;line-height:1.2}.prose-sm :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.6em;margin-bottom:.8em;font-size:1.42857em;line-height:1.4}.prose-sm :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.55556em;margin-bottom:.444444em;font-size:1.28571em;line-height:1.55556}.prose-sm :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.42857em;margin-bottom:.571429em;line-height:1.42857}.prose-sm :where(img):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.71429em;margin-bottom:1.71429em}.prose-sm :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose-sm :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.71429em;margin-bottom:1.71429em}.prose-sm :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.142857em;padding-inline-end:.357143em;padding-bottom:.142857em;border-radius:.3125rem;padding-inline-start:.357143em;font-size:.857143em}.prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.857143em}.prose-sm :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.9em}.prose-sm :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.888889em}.prose-sm :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.666667em;padding-inline-end:1em;padding-bottom:.666667em;border-radius:.25rem;margin-top:1.66667em;margin-bottom:1.66667em;padding-inline-start:1em;font-size:.857143em;line-height:1.66667}.prose-sm :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em;margin-bottom:1.14286em;padding-inline-start:1.57143em}.prose-sm :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.285714em;margin-bottom:.285714em}.prose-sm :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.428571em}.prose-sm :where(.prose-sm>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.571429em;margin-bottom:.571429em}.prose-sm :where(.prose-sm>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em}.prose-sm :where(.prose-sm>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.14286em}.prose-sm :where(.prose-sm>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em}.prose-sm :where(.prose-sm>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.14286em}.prose-sm :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.571429em;margin-bottom:.571429em}.prose-sm :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em;margin-bottom:1.14286em}.prose-sm :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em}.prose-sm :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.285714em;padding-inline-start:1.57143em}.prose-sm :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2.85714em;margin-bottom:2.85714em}.prose-sm :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-sm :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.857143em;line-height:1.5}.prose-sm :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:1em;padding-bottom:.666667em;padding-inline-start:1em}.prose-sm :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose-sm :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose-sm :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.666667em;padding-inline-end:1em;padding-bottom:.666667em;padding-inline-start:1em}.prose-sm :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose-sm :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose-sm :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.71429em;margin-bottom:1.71429em}.prose-sm :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose-sm :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.666667em;font-size:.857143em;line-height:1.33333}.prose-sm :where(.prose-sm>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-sm :where(.prose-sm>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.-mr-2{margin-right:calc(var(--spacing) * -2)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.-ml-px{margin-left:-1px}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-10{margin-left:calc(var(--spacing) * 10)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.h-1{height:calc(var(--spacing) * 1)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-8{height:calc(var(--spacing) * 8)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-32{height:calc(var(--spacing) * 32)}.h-48{height:calc(var(--spacing) * 48)}.h-64{height:calc(var(--spacing) * 64)}.h-full{height:100%}.h-px{height:1px}.max-h-64{max-height:calc(var(--spacing) * 64)}.min-h-28{min-height:calc(var(--spacing) * 28)}.min-h-\[7lh\]{min-height:7lh}.min-h-\[32px\]{min-height:32px}.min-h-\[44px\]{min-height:44px}.min-h-dvh{min-height:100dvh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-12{width:calc(var(--spacing) * 12)}.w-24{width:calc(var(--spacing) * 24)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-72{width:calc(var(--spacing) * 72)}.w-auto{width:auto}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-\[48ch\]{max-width:48ch}.max-w-\[56ch\]{max-width:56ch}.max-w-\[60ch\]{max-width:60ch}.max-w-\[120px\]{max-width:120px}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-prose{max-width:65ch}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[44px\]{min-width:44px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.origin-top-right{transform-origin:100% 0}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.resize-y{resize:vertical}.list-none{list-style-type:none}.appearance-none{appearance:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-\[0fr\]{grid-template-rows:0fr}.grid-rows-\[1fr\]{grid-template-rows:1fr}.flex-col{flex-direction:column}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-3\.5{gap:calc(var(--spacing) * 3.5)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-px>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(1px * var(--tw-space-y-reverse));margin-block-end:calc(1px * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-line>:not(:last-child)){border-color:var(--color-line)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-r-lg{border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-r-md{border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-amber\/20{border-color:#f0a45b33}@supports (color:color-mix(in lab, red, red)){.border-amber\/20{border-color:color-mix(in oklab, var(--color-amber) 20%, transparent)}}.border-amber\/50{border-color:#f0a45b80}@supports (color:color-mix(in lab, red, red)){.border-amber\/50{border-color:color-mix(in oklab, var(--color-amber) 50%, transparent)}}.border-coral\/15{border-color:#e86b6b26}@supports (color:color-mix(in lab, red, red)){.border-coral\/15{border-color:color-mix(in oklab, var(--color-coral) 15%, transparent)}}.border-fg-muted{border-color:var(--color-fg-muted)}.border-focus{border-color:var(--color-focus)}.border-line{border-color:var(--color-line)}.border-line-strong{border-color:var(--color-line-strong)}.border-mint\/15{border-color:#5ac8a826}@supports (color:color-mix(in lab, red, red)){.border-mint\/15{border-color:color-mix(in oklab, var(--color-mint) 15%, transparent)}}.border-mint\/20{border-color:#5ac8a833}@supports (color:color-mix(in lab, red, red)){.border-mint\/20{border-color:color-mix(in oklab, var(--color-mint) 20%, transparent)}}.border-rose-500\/30{border-color:#ff23574d}@supports (color:color-mix(in lab, red, red)){.border-rose-500\/30{border-color:color-mix(in oklab, var(--color-rose-500) 30%, transparent)}}.border-rose-500\/40{border-color:#ff235766}@supports (color:color-mix(in lab, red, red)){.border-rose-500\/40{border-color:color-mix(in oklab, var(--color-rose-500) 40%, transparent)}}.border-teal-500{border-color:var(--color-teal-500)}.border-teal-500\/20{border-color:#67b2d733}@supports (color:color-mix(in lab, red, red)){.border-teal-500\/20{border-color:color-mix(in oklab, var(--color-teal-500) 20%, transparent)}}.border-teal-500\/30{border-color:#67b2d74d}@supports (color:color-mix(in lab, red, red)){.border-teal-500\/30{border-color:color-mix(in oklab, var(--color-teal-500) 30%, transparent)}}.border-teal-500\/50{border-color:#67b2d780}@supports (color:color-mix(in lab, red, red)){.border-teal-500\/50{border-color:color-mix(in oklab, var(--color-teal-500) 50%, transparent)}}.border-transparent{border-color:#0000}.bg-amber{background-color:var(--color-amber)}.bg-amber-950\/20{background-color:#46190133}@supports (color:color-mix(in lab, red, red)){.bg-amber-950\/20{background-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-coral{background-color:var(--color-coral)}.bg-coral\/5{background-color:#e86b6b0d}@supports (color:color-mix(in lab, red, red)){.bg-coral\/5{background-color:color-mix(in oklab, var(--color-coral) 5%, transparent)}}.bg-coral\/10{background-color:#e86b6b1a}@supports (color:color-mix(in lab, red, red)){.bg-coral\/10{background-color:color-mix(in oklab, var(--color-coral) 10%, transparent)}}.bg-coral\/15{background-color:#e86b6b26}@supports (color:color-mix(in lab, red, red)){.bg-coral\/15{background-color:color-mix(in oklab, var(--color-coral) 15%, transparent)}}.bg-fg-muted{background-color:var(--color-fg-muted)}.bg-line-strong{background-color:var(--color-line-strong)}.bg-mint{background-color:var(--color-mint)}.bg-mint\/5{background-color:#5ac8a80d}@supports (color:color-mix(in lab, red, red)){.bg-mint\/5{background-color:color-mix(in oklab, var(--color-mint) 5%, transparent)}}.bg-mint\/15{background-color:#5ac8a826}@supports (color:color-mix(in lab, red, red)){.bg-mint\/15{background-color:color-mix(in oklab, var(--color-mint) 15%, transparent)}}.bg-mint\/40{background-color:#5ac8a866}@supports (color:color-mix(in lab, red, red)){.bg-mint\/40{background-color:color-mix(in oklab, var(--color-mint) 40%, transparent)}}.bg-navy-950{background-color:var(--color-navy-950)}.bg-overlay{background-color:var(--color-overlay)}.bg-overlay-strong{background-color:var(--color-overlay-strong)}.bg-page{background-color:var(--color-page)}.bg-page\/50{background-color:#0f172980}@supports (color:color-mix(in lab, red, red)){.bg-page\/50{background-color:color-mix(in oklab, var(--color-page) 50%, transparent)}}.bg-page\/60{background-color:#0f172999}@supports (color:color-mix(in lab, red, red)){.bg-page\/60{background-color:color-mix(in oklab, var(--color-page) 60%, transparent)}}.bg-page\/80{background-color:#0f1729cc}@supports (color:color-mix(in lab, red, red)){.bg-page\/80{background-color:color-mix(in oklab, var(--color-page) 80%, transparent)}}.bg-page\/85{background-color:#0f1729d9}@supports (color:color-mix(in lab, red, red)){.bg-page\/85{background-color:color-mix(in oklab, var(--color-page) 85%, transparent)}}.bg-panel{background-color:var(--color-panel)}.bg-panel-alt{background-color:var(--color-panel-alt)}.bg-panel-alt\/40{background-color:#1a213366}@supports (color:color-mix(in lab, red, red)){.bg-panel-alt\/40{background-color:color-mix(in oklab, var(--color-panel-alt) 40%, transparent)}}.bg-panel-alt\/60{background-color:#1a213399}@supports (color:color-mix(in lab, red, red)){.bg-panel-alt\/60{background-color:color-mix(in oklab, var(--color-panel-alt) 60%, transparent)}}.bg-panel-alt\/80{background-color:#1a2133cc}@supports (color:color-mix(in lab, red, red)){.bg-panel-alt\/80{background-color:color-mix(in oklab, var(--color-panel-alt) 80%, transparent)}}.bg-panel\/40{background-color:#252c3d66}@supports (color:color-mix(in lab, red, red)){.bg-panel\/40{background-color:color-mix(in oklab, var(--color-panel) 40%, transparent)}}.bg-panel\/50{background-color:#252c3d80}@supports (color:color-mix(in lab, red, red)){.bg-panel\/50{background-color:color-mix(in oklab, var(--color-panel) 50%, transparent)}}.bg-panel\/60{background-color:#252c3d99}@supports (color:color-mix(in lab, red, red)){.bg-panel\/60{background-color:color-mix(in oklab, var(--color-panel) 60%, transparent)}}.bg-panel\/80{background-color:#252c3dcc}@supports (color:color-mix(in lab, red, red)){.bg-panel\/80{background-color:color-mix(in oklab, var(--color-panel) 80%, transparent)}}.bg-panel\/90{background-color:#252c3de6}@supports (color:color-mix(in lab, red, red)){.bg-panel\/90{background-color:color-mix(in oklab, var(--color-panel) 90%, transparent)}}.bg-panel\/95{background-color:#252c3df2}@supports (color:color-mix(in lab, red, red)){.bg-panel\/95{background-color:color-mix(in oklab, var(--color-panel) 95%, transparent)}}.bg-rose-950\/20{background-color:#4d021833}@supports (color:color-mix(in lab, red, red)){.bg-rose-950\/20{background-color:color-mix(in oklab, var(--color-rose-950) 20%, transparent)}}.bg-rose-950\/40{background-color:#4d021866}@supports (color:color-mix(in lab, red, red)){.bg-rose-950\/40{background-color:color-mix(in oklab, var(--color-rose-950) 40%, transparent)}}.bg-teal-300{background-color:var(--color-teal-300)}.bg-teal-500{background-color:var(--color-teal-500)}.bg-teal-500\/5{background-color:#67b2d70d}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/5{background-color:color-mix(in oklab, var(--color-teal-500) 5%, transparent)}}.bg-teal-500\/10{background-color:#67b2d71a}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/10{background-color:color-mix(in oklab, var(--color-teal-500) 10%, transparent)}}.bg-teal-500\/15{background-color:#67b2d726}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/15{background-color:color-mix(in oklab, var(--color-teal-500) 15%, transparent)}}.bg-transparent{background-color:#0000}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-teal-500\/30{--tw-gradient-from:#67b2d74d}@supports (color:color-mix(in lab, red, red)){.from-teal-500\/30{--tw-gradient-from:color-mix(in oklab, var(--color-teal-500) 30%, transparent)}}.from-teal-500\/30{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-mint\/20{--tw-gradient-to:#5ac8a833}@supports (color:color-mix(in lab, red, red)){.to-mint\/20{--tw-gradient-to:color-mix(in oklab, var(--color-mint) 20%, transparent)}}.to-mint\/20{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-24{padding-block:calc(var(--spacing) * 24)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-\[12vh\]{padding-top:12vh}.pt-\[15vh\]{padding-top:15vh}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-11{padding-right:calc(var(--spacing) * 11)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-14{padding-bottom:calc(var(--spacing) * 14)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-base\/7{font-size:var(--text-base);line-height:calc(var(--spacing) * 7)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/6{font-size:var(--text-sm);line-height:calc(var(--spacing) * 6)}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\/5{font-size:var(--text-xs);line-height:calc(var(--spacing) * 5)}.text-\[2rem\]{font-size:2rem}.text-\[8rem\]{font-size:8rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[15px\]{font-size:15px}.leading-\[1\.625rem\]{--tw-leading:1.625rem;line-height:1.625rem}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-balance{text-wrap:balance}.text-pretty{text-wrap:pretty}.break-words{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber{color:var(--color-amber)}.text-amber-100{color:var(--color-amber-100)}.text-amber\/70{color:#f0a45bb3}@supports (color:color-mix(in lab, red, red)){.text-amber\/70{color:color-mix(in oklab, var(--color-amber) 70%, transparent)}}.text-coral{color:var(--color-coral)}.text-fg{color:var(--color-fg)}.text-fg-2{color:var(--color-fg-2)}.text-fg-3{color:var(--color-fg-3)}.text-fg-muted{color:var(--color-fg-muted)}.text-fg-muted\/60{color:#4b576899}@supports (color:color-mix(in lab, red, red)){.text-fg-muted\/60{color:color-mix(in oklab, var(--color-fg-muted) 60%, transparent)}}.text-mint{color:var(--color-mint)}.text-on-primary{color:var(--color-on-primary)}.text-rose-50{color:var(--color-rose-50)}.text-rose-100{color:var(--color-rose-100)}.text-rose-200{color:var(--color-rose-200)}.text-teal-300{color:var(--color-teal-300)}.text-teal-500{color:var(--color-teal-500)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-fg-muted::placeholder{color:var(--color-fg-muted)}.opacity-0{opacity:0}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-black\/30{--tw-shadow-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.shadow-black\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-black\/40{--tw-shadow-color:#0006}@supports (color:color-mix(in lab, red, red)){.shadow-black\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.outline,.outline-1{outline-style:var(--tw-outline-style);outline-width:1px}.-outline-offset-1{outline-offset:calc(1px * -1)}.outline-amber\/30{outline-color:#f0a45b4d}@supports (color:color-mix(in lab, red, red)){.outline-amber\/30{outline-color:color-mix(in oklab, var(--color-amber) 30%, transparent)}}.outline-coral\/40{outline-color:#e86b6b66}@supports (color:color-mix(in lab, red, red)){.outline-coral\/40{outline-color:color-mix(in oklab, var(--color-coral) 40%, transparent)}}.outline-line-strong{outline-color:var(--color-line-strong)}.outline-teal-500{outline-color:var(--color-teal-500)}.outline-teal-500\/60{outline-color:#67b2d799}@supports (color:color-mix(in lab, red, red)){.outline-teal-500\/60{outline-color:color-mix(in oklab, var(--color-teal-500) 60%, transparent)}}.outline-white\/10{outline-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.outline-white\/10{outline-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.outline-white\/20{outline-color:#fff3}@supports (color:color-mix(in lab, red, red)){.outline-white\/20{outline-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.blur-sm{--tw-blur:blur(var(--blur-sm));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[grid-template-rows\]{transition-property:grid-template-rows;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[--anchor-gap\:4px\]{--anchor-gap:4px}.group-open\:-rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:-180deg}.group-focus-within\:border-focus:is(:where(.group):focus-within *){border-color:var(--color-focus)}.group-focus-within\:opacity-100:is(:where(.group):focus-within *){opacity:1}@media (hover:hover){.group-hover\:text-fg:is(:where(.group):hover *){color:var(--color-fg)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.group-data-open\:block:is(:where(.group)[data-open] *){display:block}.group-data-open\:hidden:is(:where(.group)[data-open] *){display:none}.placeholder\:text-fg-muted::placeholder{color:var(--color-fg-muted)}.after\:pointer-events-none:after{content:var(--tw-content);pointer-events:none}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:inset-x-0:after{content:var(--tw-content);inset-inline:calc(var(--spacing) * 0)}.after\:bottom-0:after{content:var(--tw-content);bottom:calc(var(--spacing) * 0)}.after\:border-b:after{content:var(--tw-content);border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.after\:border-line-strong:after{content:var(--tw-content);border-color:var(--color-line-strong)}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.focus-within\:opacity-100:focus-within{opacity:1}@media (hover:hover){.hover\:border-amber\/50:hover{border-color:#f0a45b80}@supports (color:color-mix(in lab, red, red)){.hover\:border-amber\/50:hover{border-color:color-mix(in oklab, var(--color-amber) 50%, transparent)}}.hover\:border-line-strong:hover{border-color:var(--color-line-strong)}.hover\:border-mint\/50:hover{border-color:#5ac8a880}@supports (color:color-mix(in lab, red, red)){.hover\:border-mint\/50:hover{border-color:color-mix(in oklab, var(--color-mint) 50%, transparent)}}.hover\:border-teal-500\/30:hover{border-color:#67b2d74d}@supports (color:color-mix(in lab, red, red)){.hover\:border-teal-500\/30:hover{border-color:color-mix(in oklab, var(--color-teal-500) 30%, transparent)}}.hover\:border-teal-500\/40:hover{border-color:#67b2d766}@supports (color:color-mix(in lab, red, red)){.hover\:border-teal-500\/40:hover{border-color:color-mix(in oklab, var(--color-teal-500) 40%, transparent)}}.hover\:border-teal-500\/50:hover{border-color:#67b2d780}@supports (color:color-mix(in lab, red, red)){.hover\:border-teal-500\/50:hover{border-color:color-mix(in oklab, var(--color-teal-500) 50%, transparent)}}.hover\:bg-amber\/10:hover{background-color:#f0a45b1a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-amber\/10:hover{background-color:color-mix(in oklab, var(--color-amber) 10%, transparent)}}.hover\:bg-mint\/10:hover{background-color:#5ac8a81a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mint\/10:hover{background-color:color-mix(in oklab, var(--color-mint) 10%, transparent)}}.hover\:bg-overlay:hover{background-color:var(--color-overlay)}.hover\:bg-overlay-strong:hover{background-color:var(--color-overlay-strong)}.hover\:bg-page\/80:hover{background-color:#0f1729cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-page\/80:hover{background-color:color-mix(in oklab, var(--color-page) 80%, transparent)}}.hover\:bg-panel:hover{background-color:var(--color-panel)}.hover\:bg-panel\/80:hover{background-color:#252c3dcc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-panel\/80:hover{background-color:color-mix(in oklab, var(--color-panel) 80%, transparent)}}.hover\:bg-rose-950\/60:hover{background-color:#4d021899}@supports (color:color-mix(in lab, red, red)){.hover\:bg-rose-950\/60:hover{background-color:color-mix(in oklab, var(--color-rose-950) 60%, transparent)}}.hover\:bg-teal-300:hover{background-color:var(--color-teal-300)}.hover\:bg-teal-500\/15:hover{background-color:#67b2d726}@supports (color:color-mix(in lab, red, red)){.hover\:bg-teal-500\/15:hover{background-color:color-mix(in oklab, var(--color-teal-500) 15%, transparent)}}.hover\:bg-teal-500\/20:hover{background-color:#67b2d733}@supports (color:color-mix(in lab, red, red)){.hover\:bg-teal-500\/20:hover{background-color:color-mix(in oklab, var(--color-teal-500) 20%, transparent)}}.hover\:text-fg:hover{color:var(--color-fg)}.hover\:text-fg-2:hover{color:var(--color-fg-2)}.hover\:text-fg-3:hover{color:var(--color-fg-3)}.hover\:text-teal-300:hover{color:var(--color-teal-300)}.hover\:text-teal-500:hover{color:var(--color-teal-500)}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-black\/20:hover{--tw-shadow-color:#0003}@supports (color:color-mix(in lab, red, red)){.hover\:shadow-black\/20:hover{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.hover\:outline-white\/15:hover{outline-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:outline-white\/15:hover{outline-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}}.focus\:border-focus:focus{border-color:var(--color-focus)}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:-outline-offset-1:focus{outline-offset:calc(1px * -1)}.focus\:outline-offset-2:focus{outline-offset:2px}.focus\:outline-focus:focus{outline-color:var(--color-focus)}.focus\:outline-teal-500:focus{outline-color:var(--color-teal-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:-outline-offset-1:focus-visible{outline-offset:calc(1px * -1)}.focus-visible\:-outline-offset-2:focus-visible{outline-offset:calc(2px * -1)}.focus-visible\:outline-offset-1:focus-visible{outline-offset:1px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-offset-4:focus-visible{outline-offset:4px}.focus-visible\:outline-teal-500:focus-visible{outline-color:var(--color-teal-500)}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:border-line:disabled{border-color:var(--color-line)}.disabled\:text-fg-muted:disabled{color:var(--color-fg-muted)}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-60:disabled{opacity:.6}@media (hover:hover){.disabled\:hover\:border-mint\/20:disabled:hover{border-color:#5ac8a833}@supports (color:color-mix(in lab, red, red)){.disabled\:hover\:border-mint\/20:disabled:hover{border-color:color-mix(in oklab, var(--color-mint) 20%, transparent)}}.disabled\:hover\:bg-mint\/5:disabled:hover{background-color:#5ac8a80d}@supports (color:color-mix(in lab, red, red)){.disabled\:hover\:bg-mint\/5:disabled:hover{background-color:color-mix(in oklab, var(--color-mint) 5%, transparent)}}.disabled\:hover\:bg-teal-500:disabled:hover{background-color:var(--color-teal-500)}.disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}.disabled\:hover\:text-fg-muted:disabled:hover{color:var(--color-fg-muted)}.disabled\:hover\:text-mint:disabled:hover{color:var(--color-mint)}}.data-closed\:scale-95[data-closed]{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-closed\:transform[data-closed]{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.data-closed\:opacity-0[data-closed]{opacity:0}.data-enter\:duration-100[data-enter]{--tw-duration:.1s;transition-duration:.1s}.data-enter\:ease-out[data-enter]{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.data-focus\:bg-overlay[data-focus]{background-color:var(--color-overlay)}.data-focus\:outline-hidden[data-focus]{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.data-focus\:outline-hidden[data-focus]{outline-offset:2px;outline:2px solid #0000}}.data-leave\:duration-75[data-leave]{--tw-duration:75ms;transition-duration:75ms}.data-leave\:ease-in[data-leave]{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.data-selected\:text-teal-300[data-selected]{color:var(--color-teal-300)}@media (prefers-reduced-motion:no-preference){.motion-safe\:animate-pulse{animation:var(--animate-pulse)}}@media (min-width:40rem){.sm\:-mx-6{margin-inline:calc(var(--spacing) * -6)}.sm\:mt-12{margin-top:calc(var(--spacing) * 12)}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-baseline{align-items:baseline}.sm\:justify-between{justify-content:space-between}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:px-3{padding-inline:calc(var(--spacing) * 3)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-16{padding-block:calc(var(--spacing) * 16)}.sm\:py-20{padding-block:calc(var(--spacing) * 20)}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-\[0\.9375rem\]\/7{font-size:.9375rem;line-height:calc(var(--spacing) * 7)}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.sm\:text-\[1\.75rem\]{font-size:1.75rem}}@media (min-width:48rem){.md\:ml-6{margin-left:calc(var(--spacing) * 6)}.md\:block{display:block}.md\:hidden{display:none}}@media (min-width:64rem){.lg\:-mx-8{margin-inline:calc(var(--spacing) * -8)}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}.prose-headings\:text-fg-2 :where(h1,h2,h3,h4,h5,h6,th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-fg-2)}.prose-a\:text-teal-500 :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-teal-500)}.prose-strong\:text-fg-2 :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-fg-2)}.prose-code\:rounded :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.25rem}.prose-code\:bg-overlay-strong :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--color-overlay-strong)}.prose-code\:px-1 :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline:calc(var(--spacing) * 1)}.prose-code\:py-0\.5 :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){padding-block:calc(var(--spacing) * .5)}.prose-code\:font-mono :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-family:var(--font-mono)}.prose-code\:text-\[0\.8em\] :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8em}.prose-code\:text-fg-3 :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-fg-3)}.prose-code\:before\:content-none :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose-code\:after\:content-none :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:var(--tw-content);--tw-content:none;content:none}.prose-pre\:bg-overlay-strong :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--color-overlay-strong)}.prose-pre\:text-fg-3 :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-fg-3)}.\[\&_svg\]\:mx-auto svg{margin-inline:auto}.\[\&_svg\]\:block svg{display:block}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}.\[\&\:not\(\:first-child\)\]\:ml-1:not(:first-child){margin-left:calc(var(--spacing) * 1)}.\[\&\:not\(\:first-child\)\]\:border-l:not(:first-child){border-left-style:var(--tw-border-style);border-left-width:1px}.\[\&\:not\(\:first-child\)\]\:border-line-strong:not(:first-child){border-color:var(--color-line-strong)}.\[\&\:not\(\:first-child\)\]\:pl-1:not(:first-child){padding-left:calc(var(--spacing) * 1)}}.light{--color-page:#eef2f7;--color-panel:#fff;--color-panel-alt:#e4eaf1;--color-overlay:#0f17290d;--color-overlay-strong:#0f17291a;--color-fg:#0f172a;--color-fg-2:#1e293b;--color-fg-3:#475569;--color-fg-muted:#64748b;--color-line:#0f17291a;--color-line-strong:#0f17292e;--color-focus:#0e749080;--color-on-primary:#fff;--color-mint:#0d9488;--color-amber:#d97706;--color-coral:#dc2626;--color-teal-300:#0891b2;--color-teal-500:#0e7490}:root{--chart-gridline:#ffffff0a;--chart-axis-label:#4b5768}.light{--chart-gridline:#0000000f;--chart-axis-label:#475569}.bg-atmosphere{background-color:var(--color-page);background-image:radial-gradient(120% 60% at 15% 5%,#357f9e1f 0%,#0000 50%),radial-gradient(80% 50% at 85% 90%,#5ac8a812 0%,#0000 45%);background-attachment:fixed}.light .bg-atmosphere,.light.bg-atmosphere{background-image:radial-gradient(120% 60% at 15% 5%,#0e74900f 0%,#0000 50%),radial-gradient(80% 50% at 85% 90%,#0d94880a 0%,#0000 45%)}.bg-atmosphere:before{content:"";z-index:1;pointer-events:none;opacity:.07;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");background-repeat:repeat;background-size:200px 200px;position:fixed;inset:0}.light .bg-atmosphere:before,.light.bg-atmosphere:before{opacity:.03}:root{--diffs-font-family:"JetBrains Mono", ui-monospace, monospace;--diffs-header-font-family:"Geist", ui-sans-serif, system-ui, sans-serif;--diffs-font-size:13px;--diffs-line-height:20px;--diffs-bg-buffer-override:#0f1729;--diffs-bg-context-override:#161d2e;--diffs-bg-hover-override:#1c2438;--diffs-bg-separator-override:#252c3d;--diffs-fg-number-override:#4b5768;--diffs-addition-color-override:#5ac8a8;--diffs-fg-number-addition-override:#5ac8a880;--diffs-deletion-color-override:#e86b6b;--diffs-fg-number-deletion-override:#e86b6b80}.dark .graph-svg text{fill:#c6d4e0}.dark .graph-svg [stroke=\#357f9e]{stroke:#67b2d7}.dark .graph-svg [stroke=\#666666]{stroke:#5a7a94}.dark .graph-svg polygon[fill=\#357f9e]{fill:#67b2d7}.dark .graph-svg polygon[fill=\#666666]{fill:#5a7a94}.dark .graph-svg ellipse[fill=\#ffffff],.dark .graph-svg polygon[fill=\#ffffff]{fill:#1a2b3c}.dark .graph-svg ellipse[stroke=\#357f9e],.dark .graph-svg polygon[stroke=\#357f9e]{stroke:#67b2d7}.dark .graph-svg [fill=\#1a1a1a]{fill:#c6d4e0}.light{--diffs-bg-buffer-override:#fff;--diffs-bg-context-override:#f8fafc;--diffs-bg-hover-override:#f1f5f9;--diffs-bg-separator-override:#e2e8f0;--diffs-fg-number-override:#94a3b8;--diffs-addition-color-override:#0d9488;--diffs-fg-number-addition-override:#0d948880;--diffs-deletion-color-override:#dc2626;--diffs-fg-number-deletion-override:#dc262680}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-space-x-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-gradient-position:initial;--tw-gradient-from:#0000;--tw-gradient-via:#0000;--tw-gradient-to:#0000;--tw-gradient-stops:initial;--tw-gradient-via-stops:initial;--tw-gradient-from-position:0%;--tw-gradient-via-position:50%;--tw-gradient-to-position:100%;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-outline-style:solid;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-content:"";--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:"Geist", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:"JetBrains Mono", ui-monospace, monospace;--color-amber-100:oklch(96.2% .059 95.617);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-950:oklch(27.9% .077 45.635);--color-teal-300:#b5ddef;--color-teal-500:#67b2d7;--color-teal-700:#357f9e;--color-rose-50:oklch(96.9% .015 12.422);--color-rose-100:oklch(94.1% .03 12.58);--color-rose-200:oklch(89.2% .058 10.001);--color-rose-500:oklch(64.5% .246 16.439);--color-rose-950:oklch(27.1% .105 12.094);--color-black:#000;--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-md:28rem;--container-lg:32rem;--container-xl:36rem;--container-2xl:42rem;--container-3xl:48rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-4xl:2.25rem;--text-4xl--line-height:calc(2.5 / 2.25);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tighter:-.05em;--tracking-tight:-.025em;--tracking-wide:.025em;--tracking-wider:.05em;--leading-snug:1.375;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--ease-in:cubic-bezier(.4, 0, 1, 1);--ease-out:cubic-bezier(0, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-navy-950:#0f1729;--color-mint:#5ac8a8;--color-amber:#f0a45b;--color-coral:#e86b6b;--color-page:#0f1729;--color-panel:#252c3d;--color-panel-alt:#1a2133;--color-overlay:#ffffff0a;--color-overlay-strong:#ffffff1a;--color-fg:#fff;--color-fg-2:#e8edf3;--color-fg-3:#a8b5c5;--color-fg-muted:#4b5768;--color-line:#ffffff0f;--color-line-strong:#ffffff1f;--color-focus:#67b2d766;--color-on-primary:#0f1729}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}a[href],button,select,summary,[role=button],[role=link],[role=option],[role=menuitem],[role=tab]{cursor:pointer}}@layer components;@layer utilities{.pointer-events-auto{pointer-events:auto}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.-inset-0\.5{inset:calc(var(--spacing) * -.5)}.-inset-1\.5{inset:calc(var(--spacing) * -1.5)}.-inset-px{inset:-1px}.inset-0{inset:calc(var(--spacing) * 0)}.inset-x-0{inset-inline:calc(var(--spacing) * 0)}.inset-x-3{inset-inline:calc(var(--spacing) * 3)}.inset-y-0{inset-block:calc(var(--spacing) * 0)}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.top-1\/2{top:50%}.top-3{top:calc(var(--spacing) * 3)}.top-6{top:calc(var(--spacing) * 6)}.top-full{top:100%}.right-0{right:calc(var(--spacing) * 0)}.right-2{right:calc(var(--spacing) * 2)}.right-3{right:calc(var(--spacing) * 3)}.right-4{right:calc(var(--spacing) * 4)}.bottom-3{bottom:calc(var(--spacing) * 3)}.bottom-6{bottom:calc(var(--spacing) * 6)}.left-3{left:calc(var(--spacing) * 3)}.isolate{isolation:isolate}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-2{margin-inline:calc(var(--spacing) * -2)}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-3{margin-inline:calc(var(--spacing) * 3)}.mx-auto{margin-inline:auto}.-my-1{margin-block:calc(var(--spacing) * -1)}.-my-6{margin-block:calc(var(--spacing) * -6)}.prose{color:var(--tw-prose-body);max-width:65ch}.prose :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-lead);margin-top:1.2em;margin-bottom:1.2em;font-size:1.25em;line-height:1.6}.prose :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-links);font-weight:500;text-decoration:underline}.prose :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-bold);font-weight:600}.prose :where(a strong):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(blockquote strong):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(thead th strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em;list-style-type:decimal}.prose :where(ol[type=A]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=A s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-alpha}.prose :where(ol[type=a s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-alpha}.prose :where(ol[type=I]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type=I s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:upper-roman}.prose :where(ol[type=i s]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:lower-roman}.prose :where(ol[type="1"]):not(:where([class~=not-prose],[class~=not-prose] *)){list-style-type:decimal}.prose :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em;padding-inline-start:1.625em;list-style-type:disc}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-counters);font-weight:400}.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *))::marker{color:var(--tw-prose-bullets)}.prose :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.25em;font-weight:600}.prose :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){border-color:var(--tw-prose-hr);border-top-width:1px;margin-top:3em;margin-bottom:3em}.prose :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-quotes);border-inline-start-width:.25rem;border-inline-start-color:var(--tw-prose-quote-borders);quotes:"“""”""‘""’";margin-top:1.6em;margin-bottom:1.6em;padding-inline-start:1em;font-style:italic;font-weight:500}.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):before{content:open-quote}.prose :where(blockquote p:last-of-type):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:close-quote}.prose :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:0;margin-bottom:.888889em;font-size:2.25em;font-weight:800;line-height:1.11111}.prose :where(h1 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:900}.prose :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:2em;margin-bottom:1em;font-size:1.5em;font-weight:700;line-height:1.33333}.prose :where(h2 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:800}.prose :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.6em;margin-bottom:.6em;font-size:1.25em;font-weight:600;line-height:1.6}.prose :where(h3 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);margin-top:1.5em;margin-bottom:.5em;font-weight:600;line-height:1.5}.prose :where(h4 strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-weight:700}.prose :where(img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em;display:block}.prose :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-kbd);box-shadow:0 0 0 1px var(--tw-prose-kbd-shadows), 0 3px 0 var(--tw-prose-kbd-shadows);padding-top:.1875em;padding-inline-end:.375em;padding-bottom:.1875em;border-radius:.3125rem;padding-inline-start:.375em;font-family:inherit;font-size:.875em;font-weight:500}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-code);font-size:.875em;font-weight:600}.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:"`"}.prose :where(a code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h1 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.875em}.prose :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit;font-size:.9em}.prose :where(h4 code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(blockquote code):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(thead th code):not(:where([class~=not-prose],[class~=not-prose] *)){color:inherit}.prose :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-pre-code);background-color:var(--tw-prose-pre-bg);padding-top:.857143em;padding-inline-end:1.14286em;padding-bottom:.857143em;border-radius:.375rem;margin-top:1.71429em;margin-bottom:1.71429em;padding-inline-start:1.14286em;font-size:.875em;font-weight:400;line-height:1.71429;overflow-x:auto}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)){font-weight:inherit;color:inherit;font-size:inherit;font-family:inherit;line-height:inherit;background-color:#0000;border-width:0;border-radius:0;padding:0}.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose :where(pre code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:none}.prose :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){table-layout:auto;width:100%;margin-top:2em;margin-bottom:2em;font-size:.875em;line-height:1.71429}.prose :where(thead):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-th-borders)}.prose :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-headings);vertical-align:bottom;padding-inline-end:.571429em;padding-bottom:.571429em;padding-inline-start:.571429em;font-weight:600}.prose :where(tbody tr):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:1px;border-bottom-color:var(--tw-prose-td-borders)}.prose :where(tbody tr:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){border-bottom-width:0}.prose :where(tbody td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:baseline}.prose :where(tfoot):not(:where([class~=not-prose],[class~=not-prose] *)){border-top-width:1px;border-top-color:var(--tw-prose-th-borders)}.prose :where(tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){vertical-align:top}.prose :where(th,td):not(:where([class~=not-prose],[class~=not-prose] *)){text-align:start}.prose :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--tw-prose-captions);margin-top:.857143em;font-size:.875em;line-height:1.42857}.prose{--tw-prose-body:oklch(37.3% .034 259.733);--tw-prose-headings:oklch(21% .034 264.665);--tw-prose-lead:oklch(44.6% .03 256.802);--tw-prose-links:oklch(21% .034 264.665);--tw-prose-bold:oklch(21% .034 264.665);--tw-prose-counters:oklch(55.1% .027 264.364);--tw-prose-bullets:oklch(87.2% .01 258.338);--tw-prose-hr:oklch(92.8% .006 264.531);--tw-prose-quotes:oklch(21% .034 264.665);--tw-prose-quote-borders:oklch(92.8% .006 264.531);--tw-prose-captions:oklch(55.1% .027 264.364);--tw-prose-kbd:oklch(21% .034 264.665);--tw-prose-kbd-shadows:oklab(21% -.00316127 -.0338527/.1);--tw-prose-code:oklch(21% .034 264.665);--tw-prose-pre-code:oklch(92.8% .006 264.531);--tw-prose-pre-bg:oklch(27.8% .033 256.848);--tw-prose-th-borders:oklch(87.2% .01 258.338);--tw-prose-td-borders:oklch(92.8% .006 264.531);--tw-prose-invert-body:oklch(87.2% .01 258.338);--tw-prose-invert-headings:#fff;--tw-prose-invert-lead:oklch(70.7% .022 261.325);--tw-prose-invert-links:#fff;--tw-prose-invert-bold:#fff;--tw-prose-invert-counters:oklch(70.7% .022 261.325);--tw-prose-invert-bullets:oklch(44.6% .03 256.802);--tw-prose-invert-hr:oklch(37.3% .034 259.733);--tw-prose-invert-quotes:oklch(96.7% .003 264.542);--tw-prose-invert-quote-borders:oklch(37.3% .034 259.733);--tw-prose-invert-captions:oklch(70.7% .022 261.325);--tw-prose-invert-kbd:#fff;--tw-prose-invert-kbd-shadows:#ffffff1a;--tw-prose-invert-code:#fff;--tw-prose-invert-pre-code:oklch(87.2% .01 258.338);--tw-prose-invert-pre-bg:#00000080;--tw-prose-invert-th-borders:oklch(44.6% .03 256.802);--tw-prose-invert-td-borders:oklch(37.3% .034 259.733);font-size:1rem;line-height:1.75}.prose :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;margin-bottom:.5em}.prose :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.375em}.prose :where(.prose>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(.prose>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(.prose>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em}.prose :where(.prose>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.25em}.prose :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.75em;margin-bottom:.75em}.prose :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.25em;margin-bottom:1.25em}.prose :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.5em;padding-inline-start:1.625em}.prose :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.571429em;padding-inline-end:.571429em;padding-bottom:.571429em;padding-inline-start:.571429em}.prose :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2em;margin-bottom:2em}.prose :where(.prose>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose :where(.prose>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.prose-sm{font-size:.875rem;line-height:1.71429}.prose-sm :where(p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em;margin-bottom:1.14286em}.prose-sm :where([class~=lead]):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.888889em;margin-bottom:.888889em;font-size:1.28571em;line-height:1.55556}.prose-sm :where(blockquote):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.33333em;margin-bottom:1.33333em;padding-inline-start:1.11111em}.prose-sm :where(h1):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:.8em;font-size:2.14286em;line-height:1.2}.prose-sm :where(h2):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.6em;margin-bottom:.8em;font-size:1.42857em;line-height:1.4}.prose-sm :where(h3):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.55556em;margin-bottom:.444444em;font-size:1.28571em;line-height:1.55556}.prose-sm :where(h4):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.42857em;margin-bottom:.571429em;line-height:1.42857}.prose-sm :where(img):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(picture):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.71429em;margin-bottom:1.71429em}.prose-sm :where(picture>img):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose-sm :where(video):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.71429em;margin-bottom:1.71429em}.prose-sm :where(kbd):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.142857em;padding-inline-end:.357143em;padding-bottom:.142857em;border-radius:.3125rem;padding-inline-start:.357143em;font-size:.857143em}.prose-sm :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.857143em}.prose-sm :where(h2 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.9em}.prose-sm :where(h3 code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.888889em}.prose-sm :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.666667em;padding-inline-end:1em;padding-bottom:.666667em;border-radius:.25rem;margin-top:1.66667em;margin-bottom:1.66667em;padding-inline-start:1em;font-size:.857143em;line-height:1.66667}.prose-sm :where(ol):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(ul):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em;margin-bottom:1.14286em;padding-inline-start:1.57143em}.prose-sm :where(li):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.285714em;margin-bottom:.285714em}.prose-sm :where(ol>li):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(ul>li):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:.428571em}.prose-sm :where(.prose-sm>ul>li p):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.571429em;margin-bottom:.571429em}.prose-sm :where(.prose-sm>ul>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em}.prose-sm :where(.prose-sm>ul>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.14286em}.prose-sm :where(.prose-sm>ol>li>p:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em}.prose-sm :where(.prose-sm>ol>li>p:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:1.14286em}.prose-sm :where(ul ul,ul ol,ol ul,ol ol):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.571429em;margin-bottom:.571429em}.prose-sm :where(dl):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em;margin-bottom:1.14286em}.prose-sm :where(dt):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.14286em}.prose-sm :where(dd):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.285714em;padding-inline-start:1.57143em}.prose-sm :where(hr):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:2.85714em;margin-bottom:2.85714em}.prose-sm :where(hr+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(h2+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(h3+*):not(:where([class~=not-prose],[class~=not-prose] *)),.prose-sm :where(h4+*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-sm :where(table):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.857143em;line-height:1.5}.prose-sm :where(thead th):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:1em;padding-bottom:.666667em;padding-inline-start:1em}.prose-sm :where(thead th:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose-sm :where(thead th:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose-sm :where(tbody td,tfoot td):not(:where([class~=not-prose],[class~=not-prose] *)){padding-top:.666667em;padding-inline-end:1em;padding-bottom:.666667em;padding-inline-start:1em}.prose-sm :where(tbody td:first-child,tfoot td:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-start:0}.prose-sm :where(tbody td:last-child,tfoot td:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline-end:0}.prose-sm :where(figure):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:1.71429em;margin-bottom:1.71429em}.prose-sm :where(figure>*):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0;margin-bottom:0}.prose-sm :where(figcaption):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:.666667em;font-size:.857143em;line-height:1.33333}.prose-sm :where(.prose-sm>:first-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-top:0}.prose-sm :where(.prose-sm>:last-child):not(:where([class~=not-prose],[class~=not-prose] *)){margin-bottom:0}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-5{margin-top:calc(var(--spacing) * 5)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mt-10{margin-top:calc(var(--spacing) * 10)}.mt-12{margin-top:calc(var(--spacing) * 12)}.-mr-2{margin-right:calc(var(--spacing) * -2)}.-mb-px{margin-bottom:-1px}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.-ml-px{margin-left:-1px}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-1\.5{margin-left:calc(var(--spacing) * 1.5)}.ml-3{margin-left:calc(var(--spacing) * 3)}.ml-4{margin-left:calc(var(--spacing) * 4)}.ml-10{margin-left:calc(var(--spacing) * 10)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.size-1\.5{width:calc(var(--spacing) * 1.5);height:calc(var(--spacing) * 1.5)}.size-2{width:calc(var(--spacing) * 2);height:calc(var(--spacing) * 2)}.size-3{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-5{width:calc(var(--spacing) * 5);height:calc(var(--spacing) * 5)}.size-6{width:calc(var(--spacing) * 6);height:calc(var(--spacing) * 6)}.size-7{width:calc(var(--spacing) * 7);height:calc(var(--spacing) * 7)}.size-8{width:calc(var(--spacing) * 8);height:calc(var(--spacing) * 8)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.h-1{height:calc(var(--spacing) * 1)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-8{height:calc(var(--spacing) * 8)}.h-12{height:calc(var(--spacing) * 12)}.h-16{height:calc(var(--spacing) * 16)}.h-32{height:calc(var(--spacing) * 32)}.h-48{height:calc(var(--spacing) * 48)}.h-64{height:calc(var(--spacing) * 64)}.h-full{height:100%}.h-px{height:1px}.max-h-64{max-height:calc(var(--spacing) * 64)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-12{min-height:calc(var(--spacing) * 12)}.min-h-28{min-height:calc(var(--spacing) * 28)}.min-h-\[7lh\]{min-height:7lh}.min-h-\[32px\]{min-height:32px}.min-h-\[44px\]{min-height:44px}.min-h-dvh{min-height:100dvh}.min-h-full{min-height:100%}.min-h-screen{min-height:100vh}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-12{width:calc(var(--spacing) * 12)}.w-24{width:calc(var(--spacing) * 24)}.w-48{width:calc(var(--spacing) * 48)}.w-56{width:calc(var(--spacing) * 56)}.w-72{width:calc(var(--spacing) * 72)}.w-\[min\(24rem\,calc\(100vw-2rem\)\)\]{width:min(24rem,100vw - 2rem)}.w-auto{width:auto}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-3xl{max-width:var(--container-3xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-\[48ch\]{max-width:48ch}.max-w-\[56ch\]{max-width:56ch}.max-w-\[60ch\]{max-width:60ch}.max-w-\[120px\]{max-width:120px}.max-w-lg{max-width:var(--container-lg)}.max-w-md{max-width:var(--container-md)}.max-w-none{max-width:none}.max-w-prose{max-width:65ch}.max-w-sm{max-width:var(--container-sm)}.max-w-xl{max-width:var(--container-xl)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-11{min-width:calc(var(--spacing) * 11)}.min-w-\[44px\]{min-width:44px}.flex-1{flex:1}.shrink-0{flex-shrink:0}.origin-top-right{transform-origin:100% 0}.-translate-y-1\/2{--tw-translate-y:calc(calc(1 / 2 * 100%) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.rotate-90{rotate:90deg}.rotate-180{rotate:180deg}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.resize-y{resize:vertical}.list-none{list-style-type:none}.appearance-none{appearance:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-\[0fr\]{grid-template-rows:0fr}.grid-rows-\[1fr\]{grid-template-rows:1fr}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-baseline{align-items:baseline}.items-center{align-items:center}.items-start{align-items:flex-start}.items-stretch{align-items:stretch}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-3\.5{gap:calc(var(--spacing) * 3.5)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-5{gap:calc(var(--spacing) * 5)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * .5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * .5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1\.5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1.5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1.5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-8>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 8) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 8) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-px>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(1px * var(--tw-space-y-reverse));margin-block-end:calc(1px * calc(1 - var(--tw-space-y-reverse)))}:where(.space-x-4>:not(:last-child)){--tw-space-x-reverse:0;margin-inline-start:calc(calc(var(--spacing) * 4) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-x-reverse)))}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-line>:not(:last-child)){border-color:var(--color-line)}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.rounded-l-md{border-top-left-radius:var(--radius-md);border-bottom-left-radius:var(--radius-md)}.rounded-r-lg{border-top-right-radius:var(--radius-lg);border-bottom-right-radius:var(--radius-lg)}.rounded-r-md{border-top-right-radius:var(--radius-md);border-bottom-right-radius:var(--radius-md)}.border{border-style:var(--tw-border-style);border-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-amber-500\/30{border-color:#f99c004d}@supports (color:color-mix(in lab, red, red)){.border-amber-500\/30{border-color:color-mix(in oklab, var(--color-amber-500) 30%, transparent)}}.border-amber\/20{border-color:#f0a45b33}@supports (color:color-mix(in lab, red, red)){.border-amber\/20{border-color:color-mix(in oklab, var(--color-amber) 20%, transparent)}}.border-amber\/30{border-color:#f0a45b4d}@supports (color:color-mix(in lab, red, red)){.border-amber\/30{border-color:color-mix(in oklab, var(--color-amber) 30%, transparent)}}.border-amber\/50{border-color:#f0a45b80}@supports (color:color-mix(in lab, red, red)){.border-amber\/50{border-color:color-mix(in oklab, var(--color-amber) 50%, transparent)}}.border-coral\/15{border-color:#e86b6b26}@supports (color:color-mix(in lab, red, red)){.border-coral\/15{border-color:color-mix(in oklab, var(--color-coral) 15%, transparent)}}.border-coral\/30{border-color:#e86b6b4d}@supports (color:color-mix(in lab, red, red)){.border-coral\/30{border-color:color-mix(in oklab, var(--color-coral) 30%, transparent)}}.border-coral\/40{border-color:#e86b6b66}@supports (color:color-mix(in lab, red, red)){.border-coral\/40{border-color:color-mix(in oklab, var(--color-coral) 40%, transparent)}}.border-fg-muted{border-color:var(--color-fg-muted)}.border-focus{border-color:var(--color-focus)}.border-line{border-color:var(--color-line)}.border-line-strong{border-color:var(--color-line-strong)}.border-mint\/15{border-color:#5ac8a826}@supports (color:color-mix(in lab, red, red)){.border-mint\/15{border-color:color-mix(in oklab, var(--color-mint) 15%, transparent)}}.border-mint\/20{border-color:#5ac8a833}@supports (color:color-mix(in lab, red, red)){.border-mint\/20{border-color:color-mix(in oklab, var(--color-mint) 20%, transparent)}}.border-rose-500\/30{border-color:#ff23574d}@supports (color:color-mix(in lab, red, red)){.border-rose-500\/30{border-color:color-mix(in oklab, var(--color-rose-500) 30%, transparent)}}.border-rose-500\/40{border-color:#ff235766}@supports (color:color-mix(in lab, red, red)){.border-rose-500\/40{border-color:color-mix(in oklab, var(--color-rose-500) 40%, transparent)}}.border-teal-500{border-color:var(--color-teal-500)}.border-teal-500\/20{border-color:#67b2d733}@supports (color:color-mix(in lab, red, red)){.border-teal-500\/20{border-color:color-mix(in oklab, var(--color-teal-500) 20%, transparent)}}.border-teal-500\/30{border-color:#67b2d74d}@supports (color:color-mix(in lab, red, red)){.border-teal-500\/30{border-color:color-mix(in oklab, var(--color-teal-500) 30%, transparent)}}.border-teal-500\/50{border-color:#67b2d780}@supports (color:color-mix(in lab, red, red)){.border-teal-500\/50{border-color:color-mix(in oklab, var(--color-teal-500) 50%, transparent)}}.border-transparent{border-color:#0000}.bg-amber{background-color:var(--color-amber)}.bg-amber-950\/20{background-color:#46190133}@supports (color:color-mix(in lab, red, red)){.bg-amber-950\/20{background-color:color-mix(in oklab, var(--color-amber-950) 20%, transparent)}}.bg-amber\/10{background-color:#f0a45b1a}@supports (color:color-mix(in lab, red, red)){.bg-amber\/10{background-color:color-mix(in oklab, var(--color-amber) 10%, transparent)}}.bg-black\/60{background-color:#0009}@supports (color:color-mix(in lab, red, red)){.bg-black\/60{background-color:color-mix(in oklab, var(--color-black) 60%, transparent)}}.bg-coral{background-color:var(--color-coral)}.bg-coral\/5{background-color:#e86b6b0d}@supports (color:color-mix(in lab, red, red)){.bg-coral\/5{background-color:color-mix(in oklab, var(--color-coral) 5%, transparent)}}.bg-coral\/10{background-color:#e86b6b1a}@supports (color:color-mix(in lab, red, red)){.bg-coral\/10{background-color:color-mix(in oklab, var(--color-coral) 10%, transparent)}}.bg-coral\/15{background-color:#e86b6b26}@supports (color:color-mix(in lab, red, red)){.bg-coral\/15{background-color:color-mix(in oklab, var(--color-coral) 15%, transparent)}}.bg-fg-muted{background-color:var(--color-fg-muted)}.bg-line-strong{background-color:var(--color-line-strong)}.bg-mint{background-color:var(--color-mint)}.bg-mint\/5{background-color:#5ac8a80d}@supports (color:color-mix(in lab, red, red)){.bg-mint\/5{background-color:color-mix(in oklab, var(--color-mint) 5%, transparent)}}.bg-mint\/15{background-color:#5ac8a826}@supports (color:color-mix(in lab, red, red)){.bg-mint\/15{background-color:color-mix(in oklab, var(--color-mint) 15%, transparent)}}.bg-mint\/40{background-color:#5ac8a866}@supports (color:color-mix(in lab, red, red)){.bg-mint\/40{background-color:color-mix(in oklab, var(--color-mint) 40%, transparent)}}.bg-navy-950{background-color:var(--color-navy-950)}.bg-overlay{background-color:var(--color-overlay)}.bg-overlay-strong{background-color:var(--color-overlay-strong)}.bg-page{background-color:var(--color-page)}.bg-page\/50{background-color:#0f172980}@supports (color:color-mix(in lab, red, red)){.bg-page\/50{background-color:color-mix(in oklab, var(--color-page) 50%, transparent)}}.bg-page\/60{background-color:#0f172999}@supports (color:color-mix(in lab, red, red)){.bg-page\/60{background-color:color-mix(in oklab, var(--color-page) 60%, transparent)}}.bg-page\/80{background-color:#0f1729cc}@supports (color:color-mix(in lab, red, red)){.bg-page\/80{background-color:color-mix(in oklab, var(--color-page) 80%, transparent)}}.bg-page\/85{background-color:#0f1729d9}@supports (color:color-mix(in lab, red, red)){.bg-page\/85{background-color:color-mix(in oklab, var(--color-page) 85%, transparent)}}.bg-panel{background-color:var(--color-panel)}.bg-panel-alt{background-color:var(--color-panel-alt)}.bg-panel-alt\/40{background-color:#1a213366}@supports (color:color-mix(in lab, red, red)){.bg-panel-alt\/40{background-color:color-mix(in oklab, var(--color-panel-alt) 40%, transparent)}}.bg-panel-alt\/60{background-color:#1a213399}@supports (color:color-mix(in lab, red, red)){.bg-panel-alt\/60{background-color:color-mix(in oklab, var(--color-panel-alt) 60%, transparent)}}.bg-panel-alt\/80{background-color:#1a2133cc}@supports (color:color-mix(in lab, red, red)){.bg-panel-alt\/80{background-color:color-mix(in oklab, var(--color-panel-alt) 80%, transparent)}}.bg-panel\/40{background-color:#252c3d66}@supports (color:color-mix(in lab, red, red)){.bg-panel\/40{background-color:color-mix(in oklab, var(--color-panel) 40%, transparent)}}.bg-panel\/50{background-color:#252c3d80}@supports (color:color-mix(in lab, red, red)){.bg-panel\/50{background-color:color-mix(in oklab, var(--color-panel) 50%, transparent)}}.bg-panel\/60{background-color:#252c3d99}@supports (color:color-mix(in lab, red, red)){.bg-panel\/60{background-color:color-mix(in oklab, var(--color-panel) 60%, transparent)}}.bg-panel\/80{background-color:#252c3dcc}@supports (color:color-mix(in lab, red, red)){.bg-panel\/80{background-color:color-mix(in oklab, var(--color-panel) 80%, transparent)}}.bg-panel\/90{background-color:#252c3de6}@supports (color:color-mix(in lab, red, red)){.bg-panel\/90{background-color:color-mix(in oklab, var(--color-panel) 90%, transparent)}}.bg-panel\/95{background-color:#252c3df2}@supports (color:color-mix(in lab, red, red)){.bg-panel\/95{background-color:color-mix(in oklab, var(--color-panel) 95%, transparent)}}.bg-rose-950\/20{background-color:#4d021833}@supports (color:color-mix(in lab, red, red)){.bg-rose-950\/20{background-color:color-mix(in oklab, var(--color-rose-950) 20%, transparent)}}.bg-rose-950\/40{background-color:#4d021866}@supports (color:color-mix(in lab, red, red)){.bg-rose-950\/40{background-color:color-mix(in oklab, var(--color-rose-950) 40%, transparent)}}.bg-rose-950\/90{background-color:#4d0218e6}@supports (color:color-mix(in lab, red, red)){.bg-rose-950\/90{background-color:color-mix(in oklab, var(--color-rose-950) 90%, transparent)}}.bg-teal-300{background-color:var(--color-teal-300)}.bg-teal-500{background-color:var(--color-teal-500)}.bg-teal-500\/5{background-color:#67b2d70d}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/5{background-color:color-mix(in oklab, var(--color-teal-500) 5%, transparent)}}.bg-teal-500\/10{background-color:#67b2d71a}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/10{background-color:color-mix(in oklab, var(--color-teal-500) 10%, transparent)}}.bg-teal-500\/15{background-color:#67b2d726}@supports (color:color-mix(in lab, red, red)){.bg-teal-500\/15{background-color:color-mix(in oklab, var(--color-teal-500) 15%, transparent)}}.bg-transparent{background-color:#0000}.bg-white\/10{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.bg-white\/10{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.bg-gradient-to-b{--tw-gradient-position:to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-teal-500\/30{--tw-gradient-from:#67b2d74d}@supports (color:color-mix(in lab, red, red)){.from-teal-500\/30{--tw-gradient-from:color-mix(in oklab, var(--color-teal-500) 30%, transparent)}}.from-teal-500\/30{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-mint\/20{--tw-gradient-to:#5ac8a833}@supports (color:color-mix(in lab, red, red)){.to-mint\/20{--tw-gradient-to:color-mix(in oklab, var(--color-mint) 20%, transparent)}}.to-mint\/20{--tw-gradient-stops:var(--tw-gradient-via-stops,var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-3\.5{padding-inline:calc(var(--spacing) * 3.5)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-4{padding-block:calc(var(--spacing) * 4)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-10{padding-block:calc(var(--spacing) * 10)}.py-12{padding-block:calc(var(--spacing) * 12)}.py-16{padding-block:calc(var(--spacing) * 16)}.py-24{padding-block:calc(var(--spacing) * 24)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pt-1\.5{padding-top:calc(var(--spacing) * 1.5)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pt-\[12vh\]{padding-top:12vh}.pt-\[15vh\]{padding-top:15vh}.pr-2{padding-right:calc(var(--spacing) * 2)}.pr-3{padding-right:calc(var(--spacing) * 3)}.pr-4{padding-right:calc(var(--spacing) * 4)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pr-11{padding-right:calc(var(--spacing) * 11)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-14{padding-bottom:calc(var(--spacing) * 14)}.pl-3{padding-left:calc(var(--spacing) * 3)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-base\/7{font-size:var(--text-base);line-height:calc(var(--spacing) * 7)}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-sm\/6{font-size:var(--text-sm);line-height:calc(var(--spacing) * 6)}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-xs\/5{font-size:var(--text-xs);line-height:calc(var(--spacing) * 5)}.text-\[2rem\]{font-size:2rem}.text-\[8rem\]{font-size:8rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[15px\]{font-size:15px}.leading-5{--tw-leading:calc(var(--spacing) * 5);line-height:calc(var(--spacing) * 5)}.leading-6{--tw-leading:calc(var(--spacing) * 6);line-height:calc(var(--spacing) * 6)}.leading-\[1\.625rem\]{--tw-leading:1.625rem;line-height:1.625rem}.leading-none{--tw-leading:1;line-height:1}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-snug{--tw-leading:var(--leading-snug);line-height:var(--leading-snug)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-tighter{--tw-tracking:var(--tracking-tighter);letter-spacing:var(--tracking-tighter)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking:var(--tracking-wider);letter-spacing:var(--tracking-wider)}.text-balance{text-wrap:balance}.text-pretty{text-wrap:pretty}.break-words{overflow-wrap:break-word}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.text-amber{color:var(--color-amber)}.text-amber-100{color:var(--color-amber-100)}.text-amber\/70{color:#f0a45bb3}@supports (color:color-mix(in lab, red, red)){.text-amber\/70{color:color-mix(in oklab, var(--color-amber) 70%, transparent)}}.text-coral{color:var(--color-coral)}.text-current,.text-current\/70{color:currentColor}@supports (color:color-mix(in lab, red, red)){.text-current\/70{color:color-mix(in oklab, currentcolor 70%, transparent)}}.text-fg{color:var(--color-fg)}.text-fg-2{color:var(--color-fg-2)}.text-fg-3{color:var(--color-fg-3)}.text-fg-muted{color:var(--color-fg-muted)}.text-fg-muted\/60{color:#4b576899}@supports (color:color-mix(in lab, red, red)){.text-fg-muted\/60{color:color-mix(in oklab, var(--color-fg-muted) 60%, transparent)}}.text-mint{color:var(--color-mint)}.text-on-primary{color:var(--color-on-primary)}.text-rose-50{color:var(--color-rose-50)}.text-rose-100{color:var(--color-rose-100)}.text-rose-200{color:var(--color-rose-200)}.text-teal-300{color:var(--color-teal-300)}.text-teal-500{color:var(--color-teal-500)}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-fg-muted::placeholder{color:var(--color-fg-muted)}.opacity-0{opacity:0}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-lg{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-xl{--tw-shadow:0 20px 25px -5px var(--tw-shadow-color,#0000001a), 0 8px 10px -6px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-black\/30{--tw-shadow-color:#0000004d}@supports (color:color-mix(in lab, red, red)){.shadow-black\/30{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 30%, transparent) var(--tw-shadow-alpha), transparent)}}.shadow-black\/40{--tw-shadow-color:#0006}@supports (color:color-mix(in lab, red, red)){.shadow-black\/40{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 40%, transparent) var(--tw-shadow-alpha), transparent)}}.outline,.outline-1{outline-style:var(--tw-outline-style);outline-width:1px}.-outline-offset-1{outline-offset:calc(1px * -1)}.outline-amber\/30{outline-color:#f0a45b4d}@supports (color:color-mix(in lab, red, red)){.outline-amber\/30{outline-color:color-mix(in oklab, var(--color-amber) 30%, transparent)}}.outline-coral\/40{outline-color:#e86b6b66}@supports (color:color-mix(in lab, red, red)){.outline-coral\/40{outline-color:color-mix(in oklab, var(--color-coral) 40%, transparent)}}.outline-line-strong{outline-color:var(--color-line-strong)}.outline-teal-500{outline-color:var(--color-teal-500)}.outline-teal-500\/60{outline-color:#67b2d799}@supports (color:color-mix(in lab, red, red)){.outline-teal-500\/60{outline-color:color-mix(in oklab, var(--color-teal-500) 60%, transparent)}}.outline-white\/10{outline-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.outline-white\/10{outline-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.outline-white\/20{outline-color:#fff3}@supports (color:color-mix(in lab, red, red)){.outline-white\/20{outline-color:color-mix(in oklab, var(--color-white) 20%, transparent)}}.blur-sm{--tw-blur:blur(var(--blur-sm));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[grid-template-rows\]{transition-property:grid-template-rows;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-\[width\]{transition-property:width;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.duration-200{--tw-duration:.2s;transition-duration:.2s}.duration-300{--tw-duration:.3s;transition-duration:.3s}.ease-out{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.outline-none{--tw-outline-style:none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.\[--anchor-gap\:4px\]{--anchor-gap:4px}.group-open\:-rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:-180deg}.group-focus-within\:border-focus:is(:where(.group):focus-within *){border-color:var(--color-focus)}.group-focus-within\:opacity-100:is(:where(.group):focus-within *){opacity:1}@media (hover:hover){.group-hover\:text-fg:is(:where(.group):hover *){color:var(--color-fg)}.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}.group-data-open\:block:is(:where(.group)[data-open] *){display:block}.group-data-open\:hidden:is(:where(.group)[data-open] *){display:none}.placeholder\:text-fg-muted::placeholder{color:var(--color-fg-muted)}.after\:pointer-events-none:after{content:var(--tw-content);pointer-events:none}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:inset-x-0:after{content:var(--tw-content);inset-inline:calc(var(--spacing) * 0)}.after\:bottom-0:after{content:var(--tw-content);bottom:calc(var(--spacing) * 0)}.after\:border-b:after{content:var(--tw-content);border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.after\:border-line-strong:after{content:var(--tw-content);border-color:var(--color-line-strong)}.last\:border-b-0:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}.focus-within\:opacity-100:focus-within{opacity:1}@media (hover:hover){.hover\:border-amber\/50:hover{border-color:#f0a45b80}@supports (color:color-mix(in lab, red, red)){.hover\:border-amber\/50:hover{border-color:color-mix(in oklab, var(--color-amber) 50%, transparent)}}.hover\:border-line-strong:hover{border-color:var(--color-line-strong)}.hover\:border-mint\/50:hover{border-color:#5ac8a880}@supports (color:color-mix(in lab, red, red)){.hover\:border-mint\/50:hover{border-color:color-mix(in oklab, var(--color-mint) 50%, transparent)}}.hover\:border-teal-500\/30:hover{border-color:#67b2d74d}@supports (color:color-mix(in lab, red, red)){.hover\:border-teal-500\/30:hover{border-color:color-mix(in oklab, var(--color-teal-500) 30%, transparent)}}.hover\:border-teal-500\/40:hover{border-color:#67b2d766}@supports (color:color-mix(in lab, red, red)){.hover\:border-teal-500\/40:hover{border-color:color-mix(in oklab, var(--color-teal-500) 40%, transparent)}}.hover\:border-teal-500\/50:hover{border-color:#67b2d780}@supports (color:color-mix(in lab, red, red)){.hover\:border-teal-500\/50:hover{border-color:color-mix(in oklab, var(--color-teal-500) 50%, transparent)}}.hover\:bg-amber\/10:hover{background-color:#f0a45b1a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-amber\/10:hover{background-color:color-mix(in oklab, var(--color-amber) 10%, transparent)}}.hover\:bg-coral\/15:hover{background-color:#e86b6b26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-coral\/15:hover{background-color:color-mix(in oklab, var(--color-coral) 15%, transparent)}}.hover\:bg-mint\/10:hover{background-color:#5ac8a81a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-mint\/10:hover{background-color:color-mix(in oklab, var(--color-mint) 10%, transparent)}}.hover\:bg-overlay:hover{background-color:var(--color-overlay)}.hover\:bg-overlay-strong:hover{background-color:var(--color-overlay-strong)}.hover\:bg-page\/80:hover{background-color:#0f1729cc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-page\/80:hover{background-color:color-mix(in oklab, var(--color-page) 80%, transparent)}}.hover\:bg-panel:hover{background-color:var(--color-panel)}.hover\:bg-panel\/80:hover{background-color:#252c3dcc}@supports (color:color-mix(in lab, red, red)){.hover\:bg-panel\/80:hover{background-color:color-mix(in oklab, var(--color-panel) 80%, transparent)}}.hover\:bg-rose-950\/60:hover{background-color:#4d021899}@supports (color:color-mix(in lab, red, red)){.hover\:bg-rose-950\/60:hover{background-color:color-mix(in oklab, var(--color-rose-950) 60%, transparent)}}.hover\:bg-teal-300:hover{background-color:var(--color-teal-300)}.hover\:bg-teal-500\/15:hover{background-color:#67b2d726}@supports (color:color-mix(in lab, red, red)){.hover\:bg-teal-500\/15:hover{background-color:color-mix(in oklab, var(--color-teal-500) 15%, transparent)}}.hover\:bg-teal-500\/20:hover{background-color:#67b2d733}@supports (color:color-mix(in lab, red, red)){.hover\:bg-teal-500\/20:hover{background-color:color-mix(in oklab, var(--color-teal-500) 20%, transparent)}}.hover\:bg-white\/10:hover{background-color:#ffffff1a}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/10:hover{background-color:color-mix(in oklab, var(--color-white) 10%, transparent)}}.hover\:bg-white\/15:hover{background-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:bg-white\/15:hover{background-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}.hover\:text-current:hover{color:currentColor}.hover\:text-fg:hover{color:var(--color-fg)}.hover\:text-fg-2:hover{color:var(--color-fg-2)}.hover\:text-fg-3:hover{color:var(--color-fg-3)}.hover\:text-teal-300:hover{color:var(--color-teal-300)}.hover\:text-teal-500:hover{color:var(--color-teal-500)}.hover\:opacity-100:hover{opacity:1}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.hover\:shadow-black\/20:hover{--tw-shadow-color:#0003}@supports (color:color-mix(in lab, red, red)){.hover\:shadow-black\/20:hover{--tw-shadow-color:color-mix(in oklab, color-mix(in oklab, var(--color-black) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.hover\:outline-white\/15:hover{outline-color:#ffffff26}@supports (color:color-mix(in lab, red, red)){.hover\:outline-white\/15:hover{outline-color:color-mix(in oklab, var(--color-white) 15%, transparent)}}}.focus\:border-focus:focus{border-color:var(--color-focus)}.focus\:ring-0:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:outline-2:focus{outline-style:var(--tw-outline-style);outline-width:2px}.focus\:-outline-offset-1:focus{outline-offset:calc(1px * -1)}.focus\:outline-offset-2:focus{outline-offset:2px}.focus\:outline-focus:focus{outline-color:var(--color-focus)}.focus\:outline-teal-500:focus{outline-color:var(--color-teal-500)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:outline-2:focus-visible{outline-style:var(--tw-outline-style);outline-width:2px}.focus-visible\:-outline-offset-1:focus-visible{outline-offset:calc(1px * -1)}.focus-visible\:-outline-offset-2:focus-visible{outline-offset:calc(2px * -1)}.focus-visible\:outline-offset-1:focus-visible{outline-offset:1px}.focus-visible\:outline-offset-2:focus-visible{outline-offset:2px}.focus-visible\:outline-offset-4:focus-visible{outline-offset:4px}.focus-visible\:outline-teal-500:focus-visible{outline-color:var(--color-teal-500)}.disabled\:cursor-default:disabled{cursor:default}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:border-line:disabled{border-color:var(--color-line)}.disabled\:text-fg-muted:disabled{color:var(--color-fg-muted)}.disabled\:opacity-30:disabled{opacity:.3}.disabled\:opacity-40:disabled{opacity:.4}.disabled\:opacity-60:disabled{opacity:.6}@media (hover:hover){.disabled\:hover\:border-mint\/20:disabled:hover{border-color:#5ac8a833}@supports (color:color-mix(in lab, red, red)){.disabled\:hover\:border-mint\/20:disabled:hover{border-color:color-mix(in oklab, var(--color-mint) 20%, transparent)}}.disabled\:hover\:bg-coral\/10:disabled:hover{background-color:#e86b6b1a}@supports (color:color-mix(in lab, red, red)){.disabled\:hover\:bg-coral\/10:disabled:hover{background-color:color-mix(in oklab, var(--color-coral) 10%, transparent)}}.disabled\:hover\:bg-mint\/5:disabled:hover{background-color:#5ac8a80d}@supports (color:color-mix(in lab, red, red)){.disabled\:hover\:bg-mint\/5:disabled:hover{background-color:color-mix(in oklab, var(--color-mint) 5%, transparent)}}.disabled\:hover\:bg-teal-500:disabled:hover{background-color:var(--color-teal-500)}.disabled\:hover\:bg-transparent:disabled:hover{background-color:#0000}.disabled\:hover\:text-fg-muted:disabled:hover{color:var(--color-fg-muted)}.disabled\:hover\:text-mint:disabled:hover{color:var(--color-mint)}}.data-closed\:scale-95[data-closed]{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.data-closed\:transform[data-closed]{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.data-closed\:opacity-0[data-closed]{opacity:0}.data-enter\:duration-100[data-enter]{--tw-duration:.1s;transition-duration:.1s}.data-enter\:ease-out[data-enter]{--tw-ease:var(--ease-out);transition-timing-function:var(--ease-out)}.data-focus\:bg-overlay[data-focus]{background-color:var(--color-overlay)}.data-focus\:outline-hidden[data-focus]{--tw-outline-style:none;outline-style:none}@media (forced-colors:active){.data-focus\:outline-hidden[data-focus]{outline-offset:2px;outline:2px solid #0000}}.data-leave\:duration-75[data-leave]{--tw-duration:75ms;transition-duration:75ms}.data-leave\:ease-in[data-leave]{--tw-ease:var(--ease-in);transition-timing-function:var(--ease-in)}.data-selected\:text-teal-300[data-selected]{color:var(--color-teal-300)}@media (prefers-reduced-motion:no-preference){.motion-safe\:animate-pulse{animation:var(--animate-pulse)}}@media (min-width:40rem){.sm\:right-6{right:calc(var(--spacing) * 6)}.sm\:-mx-6{margin-inline:calc(var(--spacing) * -6)}.sm\:mt-12{margin-top:calc(var(--spacing) * 12)}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-baseline{align-items:baseline}.sm\:justify-between{justify-content:space-between}.sm\:gap-4{gap:calc(var(--spacing) * 4)}.sm\:px-3{padding-inline:calc(var(--spacing) * 3)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-16{padding-block:calc(var(--spacing) * 16)}.sm\:py-20{padding-block:calc(var(--spacing) * 20)}.sm\:text-4xl{font-size:var(--text-4xl);line-height:var(--tw-leading,var(--text-4xl--line-height))}.sm\:text-\[0\.9375rem\]\/7{font-size:.9375rem;line-height:calc(var(--spacing) * 7)}.sm\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.sm\:text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.sm\:text-\[1\.75rem\]{font-size:1.75rem}}@media (min-width:48rem){.md\:ml-6{margin-left:calc(var(--spacing) * 6)}.md\:block{display:block}.md\:hidden{display:none}}@media (min-width:64rem){.lg\:-mx-8{margin-inline:calc(var(--spacing) * -8)}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}.prose-headings\:text-fg-2 :where(h1,h2,h3,h4,h5,h6,th):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-fg-2)}.prose-a\:text-teal-500 :where(a):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-teal-500)}.prose-strong\:text-fg-2 :where(strong):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-fg-2)}.prose-code\:rounded :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){border-radius:.25rem}.prose-code\:bg-overlay-strong :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--color-overlay-strong)}.prose-code\:px-1 :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){padding-inline:calc(var(--spacing) * 1)}.prose-code\:py-0\.5 :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){padding-block:calc(var(--spacing) * .5)}.prose-code\:font-mono :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-family:var(--font-mono)}.prose-code\:text-\[0\.8em\] :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){font-size:.8em}.prose-code\:text-fg-3 :where(code):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-fg-3)}.prose-code\:before\:content-none :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):before,.prose-code\:after\:content-none :where(code):not(:where([class~=not-prose],[class~=not-prose] *)):after{content:var(--tw-content);--tw-content:none;content:none}.prose-pre\:bg-overlay-strong :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){background-color:var(--color-overlay-strong)}.prose-pre\:text-fg-3 :where(pre):not(:where([class~=not-prose],[class~=not-prose] *)){color:var(--color-fg-3)}.\[\&_svg\]\:mx-auto svg{margin-inline:auto}.\[\&_svg\]\:block svg{display:block}.\[\&\:\:-webkit-details-marker\]\:hidden::-webkit-details-marker{display:none}.\[\&\:not\(\:first-child\)\]\:ml-1:not(:first-child){margin-left:calc(var(--spacing) * 1)}.\[\&\:not\(\:first-child\)\]\:border-l:not(:first-child){border-left-style:var(--tw-border-style);border-left-width:1px}.\[\&\:not\(\:first-child\)\]\:border-line-strong:not(:first-child){border-color:var(--color-line-strong)}.\[\&\:not\(\:first-child\)\]\:pl-1:not(:first-child){padding-left:calc(var(--spacing) * 1)}}.light{--color-page:#eef2f7;--color-panel:#fff;--color-panel-alt:#e4eaf1;--color-overlay:#0f17290d;--color-overlay-strong:#0f17291a;--color-fg:#0f172a;--color-fg-2:#1e293b;--color-fg-3:#475569;--color-fg-muted:#64748b;--color-line:#0f17291a;--color-line-strong:#0f17292e;--color-focus:#0e749080;--color-on-primary:#fff;--color-mint:#0d9488;--color-amber:#d97706;--color-coral:#dc2626;--color-teal-300:#0891b2;--color-teal-500:#0e7490}:root{--chart-gridline:#ffffff0a;--chart-axis-label:#4b5768}.light{--chart-gridline:#0000000f;--chart-axis-label:#475569}.bg-atmosphere{background-color:var(--color-page);background-image:radial-gradient(120% 60% at 15% 5%,#357f9e1f 0%,#0000 50%),radial-gradient(80% 50% at 85% 90%,#5ac8a812 0%,#0000 45%);background-attachment:fixed}.light .bg-atmosphere,.light.bg-atmosphere{background-image:radial-gradient(120% 60% at 15% 5%,#0e74900f 0%,#0000 50%),radial-gradient(80% 50% at 85% 90%,#0d94880a 0%,#0000 45%)}.bg-atmosphere:before{content:"";z-index:1;pointer-events:none;opacity:.07;background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");background-repeat:repeat;background-size:200px 200px;position:fixed;inset:0}.light .bg-atmosphere:before,.light.bg-atmosphere:before{opacity:.03}:root{--diffs-font-family:"JetBrains Mono", ui-monospace, monospace;--diffs-header-font-family:"Geist", ui-sans-serif, system-ui, sans-serif;--diffs-font-size:13px;--diffs-line-height:20px;--diffs-bg-buffer-override:#0f1729;--diffs-bg-context-override:#161d2e;--diffs-bg-hover-override:#1c2438;--diffs-bg-separator-override:#252c3d;--diffs-fg-number-override:#4b5768;--diffs-addition-color-override:#5ac8a8;--diffs-fg-number-addition-override:#5ac8a880;--diffs-deletion-color-override:#e86b6b;--diffs-fg-number-deletion-override:#e86b6b80}.dark .graph-svg text{fill:#c6d4e0}.dark .graph-svg [stroke=\#357f9e]{stroke:#67b2d7}.dark .graph-svg [stroke=\#666666]{stroke:#5a7a94}.dark .graph-svg polygon[fill=\#357f9e]{fill:#67b2d7}.dark .graph-svg polygon[fill=\#666666]{fill:#5a7a94}.dark .graph-svg ellipse[fill=\#ffffff],.dark .graph-svg polygon[fill=\#ffffff]{fill:#1a2b3c}.dark .graph-svg ellipse[stroke=\#357f9e],.dark .graph-svg polygon[stroke=\#357f9e]{stroke:#67b2d7}.dark .graph-svg [fill=\#1a1a1a]{fill:#c6d4e0}.light{--diffs-bg-buffer-override:#fff;--diffs-bg-context-override:#f8fafc;--diffs-bg-hover-override:#f1f5f9;--diffs-bg-separator-override:#e2e8f0;--diffs-fg-number-override:#94a3b8;--diffs-addition-color-override:#0d9488;--diffs-fg-number-addition-override:#0d948880;--diffs-deletion-color-override:#dc2626;--diffs-fg-number-deletion-override:#dc262680}@property --tw-translate-x{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-y{syntax:"*";inherits:false;initial-value:0}@property --tw-translate-z{syntax:"*";inherits:false;initial-value:0}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-space-x-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-gradient-position{syntax:"*";inherits:false}@property --tw-gradient-from{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-via{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-to{syntax:"";inherits:false;initial-value:#0000}@property --tw-gradient-stops{syntax:"*";inherits:false}@property --tw-gradient-via-stops{syntax:"*";inherits:false}@property --tw-gradient-from-position{syntax:"";inherits:false;initial-value:0%}@property --tw-gradient-via-position{syntax:"";inherits:false;initial-value:50%}@property --tw-gradient-to-position{syntax:"";inherits:false;initial-value:100%}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-outline-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-content{syntax:"*";inherits:false;initial-value:""}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/lib/crates/fabro-spa/assets/assets/chunk-7fpy4pmb.js b/lib/crates/fabro-spa/assets/assets/chunk-7fpy4pmb.js deleted file mode 100644 index 271497c14..000000000 --- a/lib/crates/fabro-spa/assets/assets/chunk-7fpy4pmb.js +++ /dev/null @@ -1 +0,0 @@ -import"./chunk-q07bg6gn.js";var e=Object.freeze(JSON.parse('{"name":"Pierre Dark","type":"dark","colors":{"editor.background":"#070707","editor.foreground":"#fbfbfb","foreground":"#fbfbfb","focusBorder":"#009fff","selection.background":"#19283c","editor.selectionBackground":"#009fff4d","editor.lineHighlightBackground":"#19283c8c","editorCursor.foreground":"#009fff","editorLineNumber.foreground":"#84848A","editorLineNumber.activeForeground":"#adadb1","editorIndentGuide.background":"#1F1F21","editorIndentGuide.activeBackground":"#2e2e30","diffEditor.insertedTextBackground":"#00cab11a","diffEditor.deletedTextBackground":"#ff2e3f1a","sideBar.background":"#141415","sideBar.foreground":"#adadb1","sideBar.border":"#070707","sideBarTitle.foreground":"#fbfbfb","sideBarSectionHeader.background":"#141415","sideBarSectionHeader.foreground":"#adadb1","sideBarSectionHeader.border":"#070707","activityBar.background":"#141415","activityBar.foreground":"#fbfbfb","activityBar.border":"#070707","activityBar.activeBorder":"#009fff","activityBarBadge.background":"#009fff","activityBarBadge.foreground":"#070707","titleBar.activeBackground":"#141415","titleBar.activeForeground":"#fbfbfb","titleBar.inactiveBackground":"#141415","titleBar.inactiveForeground":"#84848A","titleBar.border":"#070707","list.activeSelectionBackground":"#19283c99","list.activeSelectionForeground":"#fbfbfb","list.inactiveSelectionBackground":"#19283c73","list.hoverBackground":"#19283c59","list.focusOutline":"#009fff","tab.activeBackground":"#070707","tab.activeForeground":"#fbfbfb","tab.activeBorderTop":"#009fff","tab.inactiveBackground":"#141415","tab.inactiveForeground":"#84848A","tab.border":"#070707","editorGroupHeader.tabsBackground":"#141415","editorGroupHeader.tabsBorder":"#070707","panel.background":"#141415","panel.border":"#070707","panelTitle.activeBorder":"#009fff","panelTitle.activeForeground":"#fbfbfb","panelTitle.inactiveForeground":"#84848A","statusBar.background":"#141415","statusBar.foreground":"#adadb1","statusBar.border":"#070707","statusBar.noFolderBackground":"#141415","statusBar.debuggingBackground":"#ffca00","statusBar.debuggingForeground":"#070707","statusBarItem.remoteBackground":"#141415","statusBarItem.remoteForeground":"#adadb1","input.background":"#1F1F21","input.border":"#1F1F21","input.foreground":"#fbfbfb","input.placeholderForeground":"#79797F","dropdown.background":"#1F1F21","dropdown.border":"#1F1F21","dropdown.foreground":"#fbfbfb","button.background":"#009fff","button.foreground":"#070707","button.hoverBackground":"#0190e6","textLink.foreground":"#009fff","textLink.activeForeground":"#009fff","gitDecoration.addedResourceForeground":"#00cab1","gitDecoration.conflictingResourceForeground":"#ffca00","gitDecoration.modifiedResourceForeground":"#009fff","gitDecoration.deletedResourceForeground":"#ff2e3f","gitDecoration.untrackedResourceForeground":"#00cab1","gitDecoration.ignoredResourceForeground":"#84848A","terminal.titleForeground":"#adadb1","terminal.titleInactiveForeground":"#84848A","terminal.background":"#141415","terminal.foreground":"#adadb1","terminal.ansiBlack":"#141415","terminal.ansiRed":"#ff2e3f","terminal.ansiGreen":"#0dbe4e","terminal.ansiYellow":"#ffca00","terminal.ansiBlue":"#009fff","terminal.ansiMagenta":"#c635e4","terminal.ansiCyan":"#08c0ef","terminal.ansiWhite":"#c6c6c8","terminal.ansiBrightBlack":"#141415","terminal.ansiBrightRed":"#ff2e3f","terminal.ansiBrightGreen":"#0dbe4e","terminal.ansiBrightYellow":"#ffca00","terminal.ansiBrightBlue":"#009fff","terminal.ansiBrightMagenta":"#c635e4","terminal.ansiBrightCyan":"#08c0ef","terminal.ansiBrightWhite":"#c6c6c8"},"tokenColors":[{"scope":["comment","punctuation.definition.comment"],"settings":{"foreground":"#84848A"}},{"scope":"comment markup.link","settings":{"foreground":"#84848A"}},{"scope":["string","constant.other.symbol"],"settings":{"foreground":"#5ecc71"}},{"scope":["punctuation.definition.string.begin","punctuation.definition.string.end"],"settings":{"foreground":"#5ecc71"}},{"scope":["constant.numeric","constant.language.boolean"],"settings":{"foreground":"#68cdf2"}},{"scope":"constant","settings":{"foreground":"#ffd452"}},{"scope":"punctuation.definition.constant","settings":{"foreground":"#ffd452"}},{"scope":"constant.language","settings":{"foreground":"#68cdf2"}},{"scope":"variable.other.constant","settings":{"foreground":"#ffca00"}},{"scope":"keyword","settings":{"foreground":"#ff678d"}},{"scope":"keyword.control","settings":{"foreground":"#ff678d"}},{"scope":["storage","storage.type","storage.modifier"],"settings":{"foreground":"#ff678d"}},{"scope":"token.storage","settings":{"foreground":"#ff678d"}},{"scope":["keyword.operator.new","keyword.operator.expression.instanceof","keyword.operator.expression.typeof","keyword.operator.expression.void","keyword.operator.expression.delete","keyword.operator.expression.in","keyword.operator.expression.of","keyword.operator.expression.keyof"],"settings":{"foreground":"#ff678d"}},{"scope":"keyword.operator.delete","settings":{"foreground":"#ff678d"}},{"scope":["variable","identifier","meta.definition.variable"],"settings":{"foreground":"#ffa359"}},{"scope":["variable.other.readwrite","meta.object-literal.key","support.variable.property","support.variable.object.process","support.variable.object.node"],"settings":{"foreground":"#ffa359"}},{"scope":"variable.language","settings":{"foreground":"#ffca00"}},{"scope":"variable.parameter.function","settings":{"foreground":"#adadb1"}},{"scope":"function.parameter","settings":{"foreground":"#adadb1"}},{"scope":"variable.parameter","settings":{"foreground":"#adadb1"}},{"scope":"variable.parameter.function.language.python","settings":{"foreground":"#ffd452"}},{"scope":"variable.parameter.function.python","settings":{"foreground":"#ffd452"}},{"scope":["support.function","entity.name.function","meta.function-call","meta.require","support.function.any-method","variable.function"],"settings":{"foreground":"#9d6afb"}},{"scope":"keyword.other.special-method","settings":{"foreground":"#9d6afb"}},{"scope":"entity.name.function","settings":{"foreground":"#9d6afb"}},{"scope":"support.function.console","settings":{"foreground":"#9d6afb"}},{"scope":["support.type","entity.name.type","entity.name.class","storage.type"],"settings":{"foreground":"#d568ea"}},{"scope":["support.class","entity.name.type.class"],"settings":{"foreground":"#d568ea"}},{"scope":["entity.name.class","variable.other.class.js","variable.other.class.ts"],"settings":{"foreground":"#d568ea"}},{"scope":"entity.name.class.identifier.namespace.type","settings":{"foreground":"#d568ea"}},{"scope":"entity.name.type.namespace","settings":{"foreground":"#ffca00"}},{"scope":"entity.other.inherited-class","settings":{"foreground":"#d568ea"}},{"scope":"entity.name.namespace","settings":{"foreground":"#ffca00"}},{"scope":"keyword.operator","settings":{"foreground":"#79797F"}},{"scope":["keyword.operator.logical","keyword.operator.bitwise","keyword.operator.channel"],"settings":{"foreground":"#08c0ef"}},{"scope":["keyword.operator.arithmetic","keyword.operator.comparison","keyword.operator.relational","keyword.operator.increment","keyword.operator.decrement"],"settings":{"foreground":"#08c0ef"}},{"scope":"keyword.operator.assignment","settings":{"foreground":"#08c0ef"}},{"scope":"keyword.operator.assignment.compound","settings":{"foreground":"#ff678d"}},{"scope":["keyword.operator.assignment.compound.js","keyword.operator.assignment.compound.ts"],"settings":{"foreground":"#08c0ef"}},{"scope":"keyword.operator.ternary","settings":{"foreground":"#ff678d"}},{"scope":"keyword.operator.optional","settings":{"foreground":"#ff678d"}},{"scope":"punctuation","settings":{"foreground":"#79797F"}},{"scope":"punctuation.separator.delimiter","settings":{"foreground":"#79797F"}},{"scope":"punctuation.separator.key-value","settings":{"foreground":"#79797F"}},{"scope":"punctuation.terminator","settings":{"foreground":"#79797F"}},{"scope":"meta.brace","settings":{"foreground":"#79797F"}},{"scope":"meta.brace.square","settings":{"foreground":"#79797F"}},{"scope":"meta.brace.round","settings":{"foreground":"#79797F"}},{"scope":"function.brace","settings":{"foreground":"#79797F"}},{"scope":["punctuation.definition.parameters","punctuation.definition.typeparameters"],"settings":{"foreground":"#79797F"}},{"scope":["punctuation.definition.block","punctuation.definition.tag"],"settings":{"foreground":"#79797F"}},{"scope":["meta.tag.tsx","meta.tag.jsx","meta.tag.js","meta.tag.ts"],"settings":{"foreground":"#79797F"}},{"scope":"keyword.operator.expression.import","settings":{"foreground":"#9d6afb"}},{"scope":"keyword.operator.module","settings":{"foreground":"#ff678d"}},{"scope":"support.type.object.console","settings":{"foreground":"#ffa359"}},{"scope":["support.module.node","support.type.object.module","entity.name.type.module"],"settings":{"foreground":"#ffca00"}},{"scope":"support.constant.math","settings":{"foreground":"#ffca00"}},{"scope":"support.constant.property.math","settings":{"foreground":"#ffd452"}},{"scope":"support.constant.json","settings":{"foreground":"#ffd452"}},{"scope":"support.type.object.dom","settings":{"foreground":"#08c0ef"}},{"scope":["support.variable.dom","support.variable.property.dom"],"settings":{"foreground":"#ffa359"}},{"scope":"support.variable.property.process","settings":{"foreground":"#ffd452"}},{"scope":"meta.property.object","settings":{"foreground":"#ffa359"}},{"scope":"variable.parameter.function.js","settings":{"foreground":"#ffa359"}},{"scope":["keyword.other.template.begin","keyword.other.template.end"],"settings":{"foreground":"#5ecc71"}},{"scope":["keyword.other.substitution.begin","keyword.other.substitution.end"],"settings":{"foreground":"#5ecc71"}},{"scope":["punctuation.definition.template-expression.begin","punctuation.definition.template-expression.end"],"settings":{"foreground":"#ff678d"}},{"scope":"meta.template.expression","settings":{"foreground":"#79797F"}},{"scope":"punctuation.section.embedded","settings":{"foreground":"#ffa359"}},{"scope":"variable.interpolation","settings":{"foreground":"#ffa359"}},{"scope":["punctuation.section.embedded.begin","punctuation.section.embedded.end"],"settings":{"foreground":"#ff678d"}},{"scope":"punctuation.quasi.element","settings":{"foreground":"#ff678d"}},{"scope":["support.type.primitive.ts","support.type.builtin.ts","support.type.primitive.tsx","support.type.builtin.tsx"],"settings":{"foreground":"#d568ea"}},{"scope":"support.type.type.flowtype","settings":{"foreground":"#9d6afb"}},{"scope":"support.type.primitive","settings":{"foreground":"#d568ea"}},{"scope":"support.variable.magic.python","settings":{"foreground":"#ff6762"}},{"scope":"variable.parameter.function.language.special.self.python","settings":{"foreground":"#ffca00"}},{"scope":["punctuation.separator.period.python","punctuation.separator.element.python","punctuation.parenthesis.begin.python","punctuation.parenthesis.end.python"],"settings":{"foreground":"#79797F"}},{"scope":["punctuation.definition.arguments.begin.python","punctuation.definition.arguments.end.python","punctuation.separator.arguments.python","punctuation.definition.list.begin.python","punctuation.definition.list.end.python"],"settings":{"foreground":"#79797F"}},{"scope":"support.type.python","settings":{"foreground":"#08c0ef"}},{"scope":"keyword.operator.logical.python","settings":{"foreground":"#ff678d"}},{"scope":"meta.function-call.generic.python","settings":{"foreground":"#9d6afb"}},{"scope":"constant.character.format.placeholder.other.python","settings":{"foreground":"#ffd452"}},{"scope":"meta.function.decorator.python","settings":{"foreground":"#9d6afb"}},{"scope":["support.token.decorator.python","meta.function.decorator.identifier.python"],"settings":{"foreground":"#08c0ef"}},{"scope":"storage.modifier.lifetime.rust","settings":{"foreground":"#79797F"}},{"scope":"support.function.std.rust","settings":{"foreground":"#9d6afb"}},{"scope":"entity.name.lifetime.rust","settings":{"foreground":"#ffca00"}},{"scope":"variable.language.rust","settings":{"foreground":"#ff6762"}},{"scope":"keyword.operator.misc.rust","settings":{"foreground":"#79797F"}},{"scope":"keyword.operator.sigil.rust","settings":{"foreground":"#ff678d"}},{"scope":"support.constant.core.rust","settings":{"foreground":"#ffd452"}},{"scope":["meta.function.c","meta.function.cpp"],"settings":{"foreground":"#ff6762"}},{"scope":["punctuation.section.block.begin.bracket.curly.cpp","punctuation.section.block.end.bracket.curly.cpp","punctuation.terminator.statement.c","punctuation.section.block.begin.bracket.curly.c","punctuation.section.block.end.bracket.curly.c","punctuation.section.parens.begin.bracket.round.c","punctuation.section.parens.end.bracket.round.c","punctuation.section.parameters.begin.bracket.round.c","punctuation.section.parameters.end.bracket.round.c"],"settings":{"foreground":"#79797F"}},{"scope":["keyword.operator.assignment.c","keyword.operator.comparison.c","keyword.operator.c","keyword.operator.increment.c","keyword.operator.decrement.c","keyword.operator.bitwise.shift.c"],"settings":{"foreground":"#ff678d"}},{"scope":["keyword.operator.assignment.cpp","keyword.operator.comparison.cpp","keyword.operator.cpp","keyword.operator.increment.cpp","keyword.operator.decrement.cpp","keyword.operator.bitwise.shift.cpp"],"settings":{"foreground":"#ff678d"}},{"scope":["punctuation.separator.c","punctuation.separator.cpp"],"settings":{"foreground":"#ff678d"}},{"scope":["support.type.posix-reserved.c","support.type.posix-reserved.cpp"],"settings":{"foreground":"#08c0ef"}},{"scope":["keyword.operator.sizeof.c","keyword.operator.sizeof.cpp"],"settings":{"foreground":"#ff678d"}},{"scope":"variable.c","settings":{"foreground":"#79797F"}},{"scope":["storage.type.annotation.java","storage.type.object.array.java"],"settings":{"foreground":"#ffca00"}},{"scope":"source.java","settings":{"foreground":"#ff6762"}},{"scope":["punctuation.section.block.begin.java","punctuation.section.block.end.java","punctuation.definition.method-parameters.begin.java","punctuation.definition.method-parameters.end.java","meta.method.identifier.java","punctuation.section.method.begin.java","punctuation.section.method.end.java","punctuation.terminator.java","punctuation.section.class.begin.java","punctuation.section.class.end.java","punctuation.section.inner-class.begin.java","punctuation.section.inner-class.end.java","meta.method-call.java","punctuation.section.class.begin.bracket.curly.java","punctuation.section.class.end.bracket.curly.java","punctuation.section.method.begin.bracket.curly.java","punctuation.section.method.end.bracket.curly.java","punctuation.separator.period.java","punctuation.bracket.angle.java","punctuation.definition.annotation.java","meta.method.body.java"],"settings":{"foreground":"#79797F"}},{"scope":"meta.method.java","settings":{"foreground":"#9d6afb"}},{"scope":["storage.modifier.import.java","storage.type.java","storage.type.generic.java"],"settings":{"foreground":"#ffca00"}},{"scope":"keyword.operator.instanceof.java","settings":{"foreground":"#ff678d"}},{"scope":"meta.definition.variable.name.java","settings":{"foreground":"#ff6762"}},{"scope":"token.variable.parameter.java","settings":{"foreground":"#79797F"}},{"scope":"import.storage.java","settings":{"foreground":"#ffca00"}},{"scope":"token.package.keyword","settings":{"foreground":"#ff678d"}},{"scope":"token.package","settings":{"foreground":"#79797F"}},{"scope":"token.storage.type.java","settings":{"foreground":"#ffca00"}},{"scope":"keyword.operator.assignment.go","settings":{"foreground":"#ffca00"}},{"scope":["keyword.operator.arithmetic.go","keyword.operator.address.go"],"settings":{"foreground":"#ff678d"}},{"scope":"entity.name.package.go","settings":{"foreground":"#ffca00"}},{"scope":["support.other.namespace.use.php","support.other.namespace.use-as.php","support.other.namespace.php","entity.other.alias.php","meta.interface.php"],"settings":{"foreground":"#ffca00"}},{"scope":"keyword.operator.error-control.php","settings":{"foreground":"#ff678d"}},{"scope":"keyword.operator.type.php","settings":{"foreground":"#ff678d"}},{"scope":["punctuation.section.array.begin.php","punctuation.section.array.end.php"],"settings":{"foreground":"#79797F"}},{"scope":["storage.type.php","meta.other.type.phpdoc.php","keyword.other.type.php","keyword.other.array.phpdoc.php"],"settings":{"foreground":"#ffca00"}},{"scope":["meta.function-call.php","meta.function-call.object.php","meta.function-call.static.php"],"settings":{"foreground":"#9d6afb"}},{"scope":["punctuation.definition.parameters.begin.bracket.round.php","punctuation.definition.parameters.end.bracket.round.php","punctuation.separator.delimiter.php","punctuation.section.scope.begin.php","punctuation.section.scope.end.php","punctuation.terminator.expression.php","punctuation.definition.arguments.begin.bracket.round.php","punctuation.definition.arguments.end.bracket.round.php","punctuation.definition.storage-type.begin.bracket.round.php","punctuation.definition.storage-type.end.bracket.round.php","punctuation.definition.array.begin.bracket.round.php","punctuation.definition.array.end.bracket.round.php","punctuation.definition.begin.bracket.round.php","punctuation.definition.end.bracket.round.php","punctuation.definition.begin.bracket.curly.php","punctuation.definition.end.bracket.curly.php","punctuation.definition.section.switch-block.end.bracket.curly.php","punctuation.definition.section.switch-block.start.bracket.curly.php","punctuation.definition.section.switch-block.begin.bracket.curly.php","punctuation.definition.section.switch-block.end.bracket.curly.php"],"settings":{"foreground":"#79797F"}},{"scope":["support.constant.ext.php","support.constant.std.php","support.constant.core.php","support.constant.parser-token.php"],"settings":{"foreground":"#ffd452"}},{"scope":["entity.name.goto-label.php","support.other.php"],"settings":{"foreground":"#9d6afb"}},{"scope":["keyword.operator.logical.php","keyword.operator.bitwise.php","keyword.operator.arithmetic.php"],"settings":{"foreground":"#08c0ef"}},{"scope":"keyword.operator.regexp.php","settings":{"foreground":"#ff678d"}},{"scope":"keyword.operator.comparison.php","settings":{"foreground":"#08c0ef"}},{"scope":["keyword.operator.heredoc.php","keyword.operator.nowdoc.php"],"settings":{"foreground":"#ff678d"}},{"scope":"variable.other.class.php","settings":{"foreground":"#ff6762"}},{"scope":"invalid.illegal.non-null-typehinted.php","settings":{"foreground":"#f44747"}},{"scope":"variable.other.generic-type.haskell","settings":{"foreground":"#ff678d"}},{"scope":"storage.type.haskell","settings":{"foreground":"#ffd452"}},{"scope":"storage.type.cs","settings":{"foreground":"#ffca00"}},{"scope":"entity.name.variable.local.cs","settings":{"foreground":"#ff6762"}},{"scope":"entity.name.label.cs","settings":{"foreground":"#ffca00"}},{"scope":["entity.name.scope-resolution.function.call","entity.name.scope-resolution.function.definition"],"settings":{"foreground":"#ffca00"}},{"scope":["punctuation.definition.delayed.unison","punctuation.definition.list.begin.unison","punctuation.definition.list.end.unison","punctuation.definition.ability.begin.unison","punctuation.definition.ability.end.unison","punctuation.operator.assignment.as.unison","punctuation.separator.pipe.unison","punctuation.separator.delimiter.unison","punctuation.definition.hash.unison"],"settings":{"foreground":"#ff6762"}},{"scope":"support.constant.edge","settings":{"foreground":"#ff678d"}},{"scope":"support.type.prelude.elm","settings":{"foreground":"#08c0ef"}},{"scope":"support.constant.elm","settings":{"foreground":"#ffd452"}},{"scope":"entity.global.clojure","settings":{"foreground":"#ffca00"}},{"scope":"meta.symbol.clojure","settings":{"foreground":"#ff6762"}},{"scope":"constant.keyword.clojure","settings":{"foreground":"#08c0ef"}},{"scope":["meta.arguments.coffee","variable.parameter.function.coffee"],"settings":{"foreground":"#ff6762"}},{"scope":"storage.modifier.import.groovy","settings":{"foreground":"#ffca00"}},{"scope":"meta.method.groovy","settings":{"foreground":"#9d6afb"}},{"scope":"meta.definition.variable.name.groovy","settings":{"foreground":"#ff6762"}},{"scope":"meta.definition.class.inherited.classes.groovy","settings":{"foreground":"#5ecc71"}},{"scope":"support.variable.semantic.hlsl","settings":{"foreground":"#ffca00"}},{"scope":["support.type.texture.hlsl","support.type.sampler.hlsl","support.type.object.hlsl","support.type.object.rw.hlsl","support.type.fx.hlsl","support.type.object.hlsl"],"settings":{"foreground":"#ff678d"}},{"scope":["text.variable","text.bracketed"],"settings":{"foreground":"#ff6762"}},{"scope":["support.type.swift","support.type.vb.asp"],"settings":{"foreground":"#ffca00"}},{"scope":"meta.scope.prerequisites.makefile","settings":{"foreground":"#ff6762"}},{"scope":"source.makefile","settings":{"foreground":"#ffca00"}},{"scope":"source.ini","settings":{"foreground":"#5ecc71"}},{"scope":"constant.language.symbol.ruby","settings":{"foreground":"#08c0ef"}},{"scope":["function.parameter.ruby","function.parameter.cs"],"settings":{"foreground":"#79797F"}},{"scope":"constant.language.symbol.elixir","settings":{"foreground":"#08c0ef"}},{"scope":"text.html.laravel-blade source.php.embedded.line.html entity.name.tag.laravel-blade","settings":{"foreground":"#ff678d"}},{"scope":"text.html.laravel-blade source.php.embedded.line.html support.constant.laravel-blade","settings":{"foreground":"#ff678d"}},{"scope":"entity.name.function.xi","settings":{"foreground":"#ffca00"}},{"scope":"entity.name.class.xi","settings":{"foreground":"#08c0ef"}},{"scope":"constant.character.character-class.regexp.xi","settings":{"foreground":"#ff6762"}},{"scope":"constant.regexp.xi","settings":{"foreground":"#ff678d"}},{"scope":"keyword.control.xi","settings":{"foreground":"#08c0ef"}},{"scope":"invalid.xi","settings":{"foreground":"#79797F"}},{"scope":"beginning.punctuation.definition.quote.markdown.xi","settings":{"foreground":"#5ecc71"}},{"scope":"beginning.punctuation.definition.list.markdown.xi","settings":{"foreground":"#84848A"}},{"scope":"constant.character.xi","settings":{"foreground":"#9d6afb"}},{"scope":"accent.xi","settings":{"foreground":"#9d6afb"}},{"scope":"wikiword.xi","settings":{"foreground":"#ffd452"}},{"scope":"constant.other.color.rgb-value.xi","settings":{"foreground":"#fbfbfb"}},{"scope":"punctuation.definition.tag.xi","settings":{"foreground":"#84848A"}},{"scope":["support.constant.property-value.scss","support.constant.property-value.css"],"settings":{"foreground":"#ffd452"}},{"scope":["keyword.operator.css","keyword.operator.scss","keyword.operator.less"],"settings":{"foreground":"#08c0ef"}},{"scope":["support.constant.color.w3c-standard-color-name.css","support.constant.color.w3c-standard-color-name.scss"],"settings":{"foreground":"#ffd452"}},{"scope":"punctuation.separator.list.comma.css","settings":{"foreground":"#79797F"}},{"scope":"support.type.vendored.property-name.css","settings":{"foreground":"#08c0ef"}},{"scope":"support.type.property-name.css","settings":{"foreground":"#08c0ef"}},{"scope":"support.type.property-name","settings":{"foreground":"#79797F"}},{"scope":"support.constant.property-value","settings":{"foreground":"#79797F"}},{"scope":"support.constant.font-name","settings":{"foreground":"#ffd452"}},{"scope":"entity.other.attribute-name.class.css","settings":{"foreground":"#61d5c0","fontStyle":"normal"}},{"scope":"entity.other.attribute-name.id","settings":{"foreground":"#9d6afb","fontStyle":"normal"}},{"scope":["entity.other.attribute-name.pseudo-element","entity.other.attribute-name.pseudo-class"],"settings":{"foreground":"#08c0ef"}},{"scope":"meta.selector","settings":{"foreground":"#ff678d"}},{"scope":"selector.sass","settings":{"foreground":"#ff6762"}},{"scope":"rgb-value","settings":{"foreground":"#08c0ef"}},{"scope":"inline-color-decoration rgb-value","settings":{"foreground":"#ffd452"}},{"scope":"less rgb-value","settings":{"foreground":"#ffd452"}},{"scope":"control.elements","settings":{"foreground":"#ffd452"}},{"scope":"keyword.operator.less","settings":{"foreground":"#ffd452"}},{"scope":"entity.name.tag","settings":{"foreground":"#ff6762"}},{"scope":"entity.other.attribute-name","settings":{"foreground":"#61d5c0","fontStyle":"normal"}},{"scope":"constant.character.entity","settings":{"foreground":"#ff6762"}},{"scope":"meta.tag","settings":{"foreground":"#79797F"}},{"scope":"invalid.illegal.bad-ampersand.html","settings":{"foreground":"#79797F"}},{"scope":"markup.heading","settings":{"foreground":"#ff6762"}},{"scope":["markup.heading punctuation.definition.heading","entity.name.section"],"settings":{"foreground":"#9d6afb"}},{"scope":"entity.name.section.markdown","settings":{"foreground":"#ff6762"}},{"scope":"punctuation.definition.heading.markdown","settings":{"foreground":"#ff6762"}},{"scope":"markup.heading.setext","settings":{"foreground":"#79797F"}},{"scope":["markup.heading.setext.1.markdown","markup.heading.setext.2.markdown"],"settings":{"foreground":"#ff6762"}},{"scope":["markup.bold","todo.bold"],"settings":{"foreground":"#ffd452"}},{"scope":"punctuation.definition.bold","settings":{"foreground":"#ffca00"}},{"scope":"punctuation.definition.bold.markdown","settings":{"foreground":"#ffd452"}},{"scope":["markup.italic","punctuation.definition.italic","todo.emphasis"],"settings":{"foreground":"#ff678d","fontStyle":"italic"}},{"scope":"emphasis md","settings":{"foreground":"#ff678d"}},{"scope":"markup.italic.markdown","settings":{"fontStyle":"italic"}},{"scope":["markup.underline.link.markdown","markup.underline.link.image.markdown"],"settings":{"foreground":"#ff678d"}},{"scope":["string.other.link.title.markdown","string.other.link.description.markdown"],"settings":{"foreground":"#9d6afb"}},{"scope":"punctuation.definition.metadata.markdown","settings":{"foreground":"#ff6762"}},{"scope":["markup.inline.raw.markdown","markup.inline.raw.string.markdown"],"settings":{"foreground":"#5ecc71"}},{"scope":"punctuation.definition.list.begin.markdown","settings":{"foreground":"#ff6762"}},{"scope":"punctuation.definition.list.markdown","settings":{"foreground":"#ff6762"}},{"scope":"beginning.punctuation.definition.list.markdown","settings":{"foreground":"#ff6762"}},{"scope":["punctuation.definition.string.begin.markdown","punctuation.definition.string.end.markdown"],"settings":{"foreground":"#ff6762"}},{"scope":"markup.quote.markdown","settings":{"foreground":"#84848A"}},{"scope":"keyword.other.unit","settings":{"foreground":"#ff6762"}},{"scope":"markup.changed.diff","settings":{"foreground":"#ffca00"}},{"scope":["meta.diff.header.from-file","meta.diff.header.to-file","punctuation.definition.from-file.diff","punctuation.definition.to-file.diff"],"settings":{"foreground":"#9d6afb"}},{"scope":"markup.inserted.diff","settings":{"foreground":"#5ecc71"}},{"scope":"markup.deleted.diff","settings":{"foreground":"#ff6762"}},{"scope":"string.regexp","settings":{"foreground":"#64d1db"}},{"scope":"constant.other.character-class.regexp","settings":{"foreground":"#ff6762"}},{"scope":"keyword.operator.quantifier.regexp","settings":{"foreground":"#ffd452"}},{"scope":"constant.character.escape","settings":{"foreground":"#68cdf2"}},{"scope":"source.json meta.structure.dictionary.json > string.quoted.json","settings":{"foreground":"#ff6762"}},{"scope":"source.json meta.structure.dictionary.json > string.quoted.json > punctuation.string","settings":{"foreground":"#ff6762"}},{"scope":["source.json meta.structure.dictionary.json > value.json > string.quoted.json","source.json meta.structure.array.json > value.json > string.quoted.json","source.json meta.structure.dictionary.json > value.json > string.quoted.json > punctuation","source.json meta.structure.array.json > value.json > string.quoted.json > punctuation"],"settings":{"foreground":"#5ecc71"}},{"scope":["source.json meta.structure.dictionary.json > constant.language.json","source.json meta.structure.array.json > constant.language.json"],"settings":{"foreground":"#08c0ef"}},{"scope":"support.type.property-name.json","settings":{"foreground":"#ff6762"}},{"scope":"support.type.property-name.json punctuation","settings":{"foreground":"#ff6762"}},{"scope":"punctuation.definition.block.sequence.item.yaml","settings":{"foreground":"#79797F"}},{"scope":"block.scope.end","settings":{"foreground":"#79797F"}},{"scope":"block.scope.begin","settings":{"foreground":"#79797F"}},{"scope":"token.info-token","settings":{"foreground":"#9d6afb"}},{"scope":"token.warn-token","settings":{"foreground":"#ffd452"}},{"scope":"token.error-token","settings":{"foreground":"#f44747"}},{"scope":"token.debug-token","settings":{"foreground":"#ff678d"}},{"scope":"invalid.illegal","settings":{"foreground":"#fbfbfb"}},{"scope":"invalid.broken","settings":{"foreground":"#fbfbfb"}},{"scope":"invalid.deprecated","settings":{"foreground":"#fbfbfb"}},{"scope":"invalid.unimplemented","settings":{"foreground":"#fbfbfb"}}],"semanticTokenColors":{"comment":"#84848A","string":"#5ecc71","number":"#68cdf2","regexp":"#64d1db","keyword":"#ff678d","variable":"#ffa359","parameter":"#adadb1","property":"#ffa359","function":"#9d6afb","method":"#9d6afb","type":"#d568ea","class":"#d568ea","namespace":"#ffca00","enumMember":"#08c0ef","variable.constant":"#ffd452","variable.defaultLibrary":"#ffca00"}}'));export{e as default}; diff --git a/lib/crates/fabro-spa/assets/assets/chunk-nghb2mxb.js b/lib/crates/fabro-spa/assets/assets/chunk-nghb2mxb.js deleted file mode 100644 index db154f1f0..000000000 --- a/lib/crates/fabro-spa/assets/assets/chunk-nghb2mxb.js +++ /dev/null @@ -1 +0,0 @@ -import"./chunk-q07bg6gn.js";var e=Object.freeze(JSON.parse('{"name":"Pierre Light","type":"light","colors":{"editor.background":"#ffffff","editor.foreground":"#070707","foreground":"#070707","focusBorder":"#009fff","selection.background":"#dfebff","editor.selectionBackground":"#009fff2e","editor.lineHighlightBackground":"#dfebff8c","editorCursor.foreground":"#009fff","editorLineNumber.foreground":"#84848A","editorLineNumber.activeForeground":"#6C6C71","editorIndentGuide.background":"#eeeeef","editorIndentGuide.activeBackground":"#dbdbdd","diffEditor.insertedTextBackground":"#00cab133","diffEditor.deletedTextBackground":"#ff2e3f33","sideBar.background":"#f8f8f8","sideBar.foreground":"#6C6C71","sideBar.border":"#eeeeef","sideBarTitle.foreground":"#070707","sideBarSectionHeader.background":"#f8f8f8","sideBarSectionHeader.foreground":"#6C6C71","sideBarSectionHeader.border":"#eeeeef","activityBar.background":"#f8f8f8","activityBar.foreground":"#070707","activityBar.border":"#eeeeef","activityBar.activeBorder":"#009fff","activityBarBadge.background":"#009fff","activityBarBadge.foreground":"#ffffff","titleBar.activeBackground":"#f8f8f8","titleBar.activeForeground":"#070707","titleBar.inactiveBackground":"#f8f8f8","titleBar.inactiveForeground":"#84848A","titleBar.border":"#eeeeef","list.activeSelectionBackground":"#dfebffcc","list.activeSelectionForeground":"#070707","list.inactiveSelectionBackground":"#dfebff73","list.hoverBackground":"#dfebff59","list.focusOutline":"#009fff","tab.activeBackground":"#ffffff","tab.activeForeground":"#070707","tab.activeBorderTop":"#009fff","tab.inactiveBackground":"#f8f8f8","tab.inactiveForeground":"#84848A","tab.border":"#eeeeef","editorGroupHeader.tabsBackground":"#f8f8f8","editorGroupHeader.tabsBorder":"#eeeeef","panel.background":"#f8f8f8","panel.border":"#eeeeef","panelTitle.activeBorder":"#009fff","panelTitle.activeForeground":"#070707","panelTitle.inactiveForeground":"#84848A","statusBar.background":"#f8f8f8","statusBar.foreground":"#6C6C71","statusBar.border":"#eeeeef","statusBar.noFolderBackground":"#f8f8f8","statusBar.debuggingBackground":"#ffca00","statusBar.debuggingForeground":"#ffffff","statusBarItem.remoteBackground":"#f8f8f8","statusBarItem.remoteForeground":"#6C6C71","input.background":"#f2f2f3","input.border":"#dbdbdd","input.foreground":"#070707","input.placeholderForeground":"#8E8E95","dropdown.background":"#f2f2f3","dropdown.border":"#dbdbdd","dropdown.foreground":"#070707","button.background":"#009fff","button.foreground":"#ffffff","button.hoverBackground":"#1aa9ff","textLink.foreground":"#009fff","textLink.activeForeground":"#009fff","gitDecoration.addedResourceForeground":"#00cab1","gitDecoration.conflictingResourceForeground":"#ffca00","gitDecoration.modifiedResourceForeground":"#009fff","gitDecoration.deletedResourceForeground":"#ff2e3f","gitDecoration.untrackedResourceForeground":"#00cab1","gitDecoration.ignoredResourceForeground":"#84848A","terminal.titleForeground":"#6C6C71","terminal.titleInactiveForeground":"#84848A","terminal.background":"#f8f8f8","terminal.foreground":"#6C6C71","terminal.ansiBlack":"#1F1F21","terminal.ansiRed":"#ff2e3f","terminal.ansiGreen":"#0dbe4e","terminal.ansiYellow":"#ffca00","terminal.ansiBlue":"#009fff","terminal.ansiMagenta":"#c635e4","terminal.ansiCyan":"#08c0ef","terminal.ansiWhite":"#c6c6c8","terminal.ansiBrightBlack":"#1F1F21","terminal.ansiBrightRed":"#ff2e3f","terminal.ansiBrightGreen":"#0dbe4e","terminal.ansiBrightYellow":"#ffca00","terminal.ansiBrightBlue":"#009fff","terminal.ansiBrightMagenta":"#c635e4","terminal.ansiBrightCyan":"#08c0ef","terminal.ansiBrightWhite":"#c6c6c8"},"tokenColors":[{"scope":["comment","punctuation.definition.comment"],"settings":{"foreground":"#84848A"}},{"scope":"comment markup.link","settings":{"foreground":"#84848A"}},{"scope":["string","constant.other.symbol"],"settings":{"foreground":"#199f43"}},{"scope":["punctuation.definition.string.begin","punctuation.definition.string.end"],"settings":{"foreground":"#199f43"}},{"scope":["constant.numeric","constant.language.boolean"],"settings":{"foreground":"#1ca1c7"}},{"scope":"constant","settings":{"foreground":"#d5a910"}},{"scope":"punctuation.definition.constant","settings":{"foreground":"#d5a910"}},{"scope":"constant.language","settings":{"foreground":"#1ca1c7"}},{"scope":"variable.other.constant","settings":{"foreground":"#d5a910"}},{"scope":"keyword","settings":{"foreground":"#fc2b73"}},{"scope":"keyword.control","settings":{"foreground":"#fc2b73"}},{"scope":["storage","storage.type","storage.modifier"],"settings":{"foreground":"#fc2b73"}},{"scope":"token.storage","settings":{"foreground":"#fc2b73"}},{"scope":["keyword.operator.new","keyword.operator.expression.instanceof","keyword.operator.expression.typeof","keyword.operator.expression.void","keyword.operator.expression.delete","keyword.operator.expression.in","keyword.operator.expression.of","keyword.operator.expression.keyof"],"settings":{"foreground":"#fc2b73"}},{"scope":"keyword.operator.delete","settings":{"foreground":"#fc2b73"}},{"scope":["variable","identifier","meta.definition.variable"],"settings":{"foreground":"#d47628"}},{"scope":["variable.other.readwrite","meta.object-literal.key","support.variable.property","support.variable.object.process","support.variable.object.node"],"settings":{"foreground":"#d47628"}},{"scope":"variable.language","settings":{"foreground":"#d5a910"}},{"scope":"variable.parameter.function","settings":{"foreground":"#79797F"}},{"scope":"function.parameter","settings":{"foreground":"#79797F"}},{"scope":"variable.parameter","settings":{"foreground":"#79797F"}},{"scope":"variable.parameter.function.language.python","settings":{"foreground":"#d5a910"}},{"scope":"variable.parameter.function.python","settings":{"foreground":"#d5a910"}},{"scope":["support.function","entity.name.function","meta.function-call","meta.require","support.function.any-method","variable.function"],"settings":{"foreground":"#7b43f8"}},{"scope":"keyword.other.special-method","settings":{"foreground":"#7b43f8"}},{"scope":"entity.name.function","settings":{"foreground":"#7b43f8"}},{"scope":"support.function.console","settings":{"foreground":"#7b43f8"}},{"scope":["support.type","entity.name.type","entity.name.class","storage.type"],"settings":{"foreground":"#c635e4"}},{"scope":["support.class","entity.name.type.class"],"settings":{"foreground":"#c635e4"}},{"scope":["entity.name.class","variable.other.class.js","variable.other.class.ts"],"settings":{"foreground":"#c635e4"}},{"scope":"entity.name.class.identifier.namespace.type","settings":{"foreground":"#c635e4"}},{"scope":"entity.name.type.namespace","settings":{"foreground":"#d5a910"}},{"scope":"entity.other.inherited-class","settings":{"foreground":"#c635e4"}},{"scope":"entity.name.namespace","settings":{"foreground":"#d5a910"}},{"scope":"keyword.operator","settings":{"foreground":"#79797F"}},{"scope":["keyword.operator.logical","keyword.operator.bitwise","keyword.operator.channel"],"settings":{"foreground":"#08c0ef"}},{"scope":["keyword.operator.arithmetic","keyword.operator.comparison","keyword.operator.relational","keyword.operator.increment","keyword.operator.decrement"],"settings":{"foreground":"#08c0ef"}},{"scope":"keyword.operator.assignment","settings":{"foreground":"#08c0ef"}},{"scope":"keyword.operator.assignment.compound","settings":{"foreground":"#fc2b73"}},{"scope":["keyword.operator.assignment.compound.js","keyword.operator.assignment.compound.ts"],"settings":{"foreground":"#08c0ef"}},{"scope":"keyword.operator.ternary","settings":{"foreground":"#fc2b73"}},{"scope":"keyword.operator.optional","settings":{"foreground":"#fc2b73"}},{"scope":"punctuation","settings":{"foreground":"#79797F"}},{"scope":"punctuation.separator.delimiter","settings":{"foreground":"#79797F"}},{"scope":"punctuation.separator.key-value","settings":{"foreground":"#79797F"}},{"scope":"punctuation.terminator","settings":{"foreground":"#79797F"}},{"scope":"meta.brace","settings":{"foreground":"#79797F"}},{"scope":"meta.brace.square","settings":{"foreground":"#79797F"}},{"scope":"meta.brace.round","settings":{"foreground":"#79797F"}},{"scope":"function.brace","settings":{"foreground":"#79797F"}},{"scope":["punctuation.definition.parameters","punctuation.definition.typeparameters"],"settings":{"foreground":"#79797F"}},{"scope":["punctuation.definition.block","punctuation.definition.tag"],"settings":{"foreground":"#79797F"}},{"scope":["meta.tag.tsx","meta.tag.jsx","meta.tag.js","meta.tag.ts"],"settings":{"foreground":"#79797F"}},{"scope":"keyword.operator.expression.import","settings":{"foreground":"#7b43f8"}},{"scope":"keyword.operator.module","settings":{"foreground":"#fc2b73"}},{"scope":"support.type.object.console","settings":{"foreground":"#d47628"}},{"scope":["support.module.node","support.type.object.module","entity.name.type.module"],"settings":{"foreground":"#d5a910"}},{"scope":"support.constant.math","settings":{"foreground":"#d5a910"}},{"scope":"support.constant.property.math","settings":{"foreground":"#d5a910"}},{"scope":"support.constant.json","settings":{"foreground":"#d5a910"}},{"scope":"support.type.object.dom","settings":{"foreground":"#08c0ef"}},{"scope":["support.variable.dom","support.variable.property.dom"],"settings":{"foreground":"#d47628"}},{"scope":"support.variable.property.process","settings":{"foreground":"#d5a910"}},{"scope":"meta.property.object","settings":{"foreground":"#d47628"}},{"scope":"variable.parameter.function.js","settings":{"foreground":"#d47628"}},{"scope":["keyword.other.template.begin","keyword.other.template.end"],"settings":{"foreground":"#199f43"}},{"scope":["keyword.other.substitution.begin","keyword.other.substitution.end"],"settings":{"foreground":"#199f43"}},{"scope":["punctuation.definition.template-expression.begin","punctuation.definition.template-expression.end"],"settings":{"foreground":"#fc2b73"}},{"scope":"meta.template.expression","settings":{"foreground":"#79797F"}},{"scope":"punctuation.section.embedded","settings":{"foreground":"#d47628"}},{"scope":"variable.interpolation","settings":{"foreground":"#d47628"}},{"scope":["punctuation.section.embedded.begin","punctuation.section.embedded.end"],"settings":{"foreground":"#fc2b73"}},{"scope":"punctuation.quasi.element","settings":{"foreground":"#fc2b73"}},{"scope":["support.type.primitive.ts","support.type.builtin.ts","support.type.primitive.tsx","support.type.builtin.tsx"],"settings":{"foreground":"#c635e4"}},{"scope":"support.type.type.flowtype","settings":{"foreground":"#7b43f8"}},{"scope":"support.type.primitive","settings":{"foreground":"#c635e4"}},{"scope":"support.variable.magic.python","settings":{"foreground":"#d52c36"}},{"scope":"variable.parameter.function.language.special.self.python","settings":{"foreground":"#d5a910"}},{"scope":["punctuation.separator.period.python","punctuation.separator.element.python","punctuation.parenthesis.begin.python","punctuation.parenthesis.end.python"],"settings":{"foreground":"#79797F"}},{"scope":["punctuation.definition.arguments.begin.python","punctuation.definition.arguments.end.python","punctuation.separator.arguments.python","punctuation.definition.list.begin.python","punctuation.definition.list.end.python"],"settings":{"foreground":"#79797F"}},{"scope":"support.type.python","settings":{"foreground":"#08c0ef"}},{"scope":"keyword.operator.logical.python","settings":{"foreground":"#fc2b73"}},{"scope":"meta.function-call.generic.python","settings":{"foreground":"#7b43f8"}},{"scope":"constant.character.format.placeholder.other.python","settings":{"foreground":"#d5a910"}},{"scope":"meta.function.decorator.python","settings":{"foreground":"#7b43f8"}},{"scope":["support.token.decorator.python","meta.function.decorator.identifier.python"],"settings":{"foreground":"#08c0ef"}},{"scope":"storage.modifier.lifetime.rust","settings":{"foreground":"#79797F"}},{"scope":"support.function.std.rust","settings":{"foreground":"#7b43f8"}},{"scope":"entity.name.lifetime.rust","settings":{"foreground":"#d5a910"}},{"scope":"variable.language.rust","settings":{"foreground":"#d52c36"}},{"scope":"keyword.operator.misc.rust","settings":{"foreground":"#79797F"}},{"scope":"keyword.operator.sigil.rust","settings":{"foreground":"#fc2b73"}},{"scope":"support.constant.core.rust","settings":{"foreground":"#d5a910"}},{"scope":["meta.function.c","meta.function.cpp"],"settings":{"foreground":"#d52c36"}},{"scope":["punctuation.section.block.begin.bracket.curly.cpp","punctuation.section.block.end.bracket.curly.cpp","punctuation.terminator.statement.c","punctuation.section.block.begin.bracket.curly.c","punctuation.section.block.end.bracket.curly.c","punctuation.section.parens.begin.bracket.round.c","punctuation.section.parens.end.bracket.round.c","punctuation.section.parameters.begin.bracket.round.c","punctuation.section.parameters.end.bracket.round.c"],"settings":{"foreground":"#79797F"}},{"scope":["keyword.operator.assignment.c","keyword.operator.comparison.c","keyword.operator.c","keyword.operator.increment.c","keyword.operator.decrement.c","keyword.operator.bitwise.shift.c"],"settings":{"foreground":"#fc2b73"}},{"scope":["keyword.operator.assignment.cpp","keyword.operator.comparison.cpp","keyword.operator.cpp","keyword.operator.increment.cpp","keyword.operator.decrement.cpp","keyword.operator.bitwise.shift.cpp"],"settings":{"foreground":"#fc2b73"}},{"scope":["punctuation.separator.c","punctuation.separator.cpp"],"settings":{"foreground":"#fc2b73"}},{"scope":["support.type.posix-reserved.c","support.type.posix-reserved.cpp"],"settings":{"foreground":"#08c0ef"}},{"scope":["keyword.operator.sizeof.c","keyword.operator.sizeof.cpp"],"settings":{"foreground":"#fc2b73"}},{"scope":"variable.c","settings":{"foreground":"#79797F"}},{"scope":["storage.type.annotation.java","storage.type.object.array.java"],"settings":{"foreground":"#d5a910"}},{"scope":"source.java","settings":{"foreground":"#d52c36"}},{"scope":["punctuation.section.block.begin.java","punctuation.section.block.end.java","punctuation.definition.method-parameters.begin.java","punctuation.definition.method-parameters.end.java","meta.method.identifier.java","punctuation.section.method.begin.java","punctuation.section.method.end.java","punctuation.terminator.java","punctuation.section.class.begin.java","punctuation.section.class.end.java","punctuation.section.inner-class.begin.java","punctuation.section.inner-class.end.java","meta.method-call.java","punctuation.section.class.begin.bracket.curly.java","punctuation.section.class.end.bracket.curly.java","punctuation.section.method.begin.bracket.curly.java","punctuation.section.method.end.bracket.curly.java","punctuation.separator.period.java","punctuation.bracket.angle.java","punctuation.definition.annotation.java","meta.method.body.java"],"settings":{"foreground":"#79797F"}},{"scope":"meta.method.java","settings":{"foreground":"#7b43f8"}},{"scope":["storage.modifier.import.java","storage.type.java","storage.type.generic.java"],"settings":{"foreground":"#d5a910"}},{"scope":"keyword.operator.instanceof.java","settings":{"foreground":"#fc2b73"}},{"scope":"meta.definition.variable.name.java","settings":{"foreground":"#d52c36"}},{"scope":"token.variable.parameter.java","settings":{"foreground":"#79797F"}},{"scope":"import.storage.java","settings":{"foreground":"#d5a910"}},{"scope":"token.package.keyword","settings":{"foreground":"#fc2b73"}},{"scope":"token.package","settings":{"foreground":"#79797F"}},{"scope":"token.storage.type.java","settings":{"foreground":"#d5a910"}},{"scope":"keyword.operator.assignment.go","settings":{"foreground":"#d5a910"}},{"scope":["keyword.operator.arithmetic.go","keyword.operator.address.go"],"settings":{"foreground":"#fc2b73"}},{"scope":"entity.name.package.go","settings":{"foreground":"#d5a910"}},{"scope":["support.other.namespace.use.php","support.other.namespace.use-as.php","support.other.namespace.php","entity.other.alias.php","meta.interface.php"],"settings":{"foreground":"#d5a910"}},{"scope":"keyword.operator.error-control.php","settings":{"foreground":"#fc2b73"}},{"scope":"keyword.operator.type.php","settings":{"foreground":"#fc2b73"}},{"scope":["punctuation.section.array.begin.php","punctuation.section.array.end.php"],"settings":{"foreground":"#79797F"}},{"scope":["storage.type.php","meta.other.type.phpdoc.php","keyword.other.type.php","keyword.other.array.phpdoc.php"],"settings":{"foreground":"#d5a910"}},{"scope":["meta.function-call.php","meta.function-call.object.php","meta.function-call.static.php"],"settings":{"foreground":"#7b43f8"}},{"scope":["punctuation.definition.parameters.begin.bracket.round.php","punctuation.definition.parameters.end.bracket.round.php","punctuation.separator.delimiter.php","punctuation.section.scope.begin.php","punctuation.section.scope.end.php","punctuation.terminator.expression.php","punctuation.definition.arguments.begin.bracket.round.php","punctuation.definition.arguments.end.bracket.round.php","punctuation.definition.storage-type.begin.bracket.round.php","punctuation.definition.storage-type.end.bracket.round.php","punctuation.definition.array.begin.bracket.round.php","punctuation.definition.array.end.bracket.round.php","punctuation.definition.begin.bracket.round.php","punctuation.definition.end.bracket.round.php","punctuation.definition.begin.bracket.curly.php","punctuation.definition.end.bracket.curly.php","punctuation.definition.section.switch-block.end.bracket.curly.php","punctuation.definition.section.switch-block.start.bracket.curly.php","punctuation.definition.section.switch-block.begin.bracket.curly.php","punctuation.definition.section.switch-block.end.bracket.curly.php"],"settings":{"foreground":"#79797F"}},{"scope":["support.constant.ext.php","support.constant.std.php","support.constant.core.php","support.constant.parser-token.php"],"settings":{"foreground":"#d5a910"}},{"scope":["entity.name.goto-label.php","support.other.php"],"settings":{"foreground":"#7b43f8"}},{"scope":["keyword.operator.logical.php","keyword.operator.bitwise.php","keyword.operator.arithmetic.php"],"settings":{"foreground":"#08c0ef"}},{"scope":"keyword.operator.regexp.php","settings":{"foreground":"#fc2b73"}},{"scope":"keyword.operator.comparison.php","settings":{"foreground":"#08c0ef"}},{"scope":["keyword.operator.heredoc.php","keyword.operator.nowdoc.php"],"settings":{"foreground":"#fc2b73"}},{"scope":"variable.other.class.php","settings":{"foreground":"#d52c36"}},{"scope":"invalid.illegal.non-null-typehinted.php","settings":{"foreground":"#f44747"}},{"scope":"variable.other.generic-type.haskell","settings":{"foreground":"#fc2b73"}},{"scope":"storage.type.haskell","settings":{"foreground":"#d5a910"}},{"scope":"storage.type.cs","settings":{"foreground":"#d5a910"}},{"scope":"entity.name.variable.local.cs","settings":{"foreground":"#d52c36"}},{"scope":"entity.name.label.cs","settings":{"foreground":"#d5a910"}},{"scope":["entity.name.scope-resolution.function.call","entity.name.scope-resolution.function.definition"],"settings":{"foreground":"#d5a910"}},{"scope":["punctuation.definition.delayed.unison","punctuation.definition.list.begin.unison","punctuation.definition.list.end.unison","punctuation.definition.ability.begin.unison","punctuation.definition.ability.end.unison","punctuation.operator.assignment.as.unison","punctuation.separator.pipe.unison","punctuation.separator.delimiter.unison","punctuation.definition.hash.unison"],"settings":{"foreground":"#d52c36"}},{"scope":"support.constant.edge","settings":{"foreground":"#fc2b73"}},{"scope":"support.type.prelude.elm","settings":{"foreground":"#08c0ef"}},{"scope":"support.constant.elm","settings":{"foreground":"#d5a910"}},{"scope":"entity.global.clojure","settings":{"foreground":"#d5a910"}},{"scope":"meta.symbol.clojure","settings":{"foreground":"#d52c36"}},{"scope":"constant.keyword.clojure","settings":{"foreground":"#08c0ef"}},{"scope":["meta.arguments.coffee","variable.parameter.function.coffee"],"settings":{"foreground":"#d52c36"}},{"scope":"storage.modifier.import.groovy","settings":{"foreground":"#d5a910"}},{"scope":"meta.method.groovy","settings":{"foreground":"#7b43f8"}},{"scope":"meta.definition.variable.name.groovy","settings":{"foreground":"#d52c36"}},{"scope":"meta.definition.class.inherited.classes.groovy","settings":{"foreground":"#199f43"}},{"scope":"support.variable.semantic.hlsl","settings":{"foreground":"#d5a910"}},{"scope":["support.type.texture.hlsl","support.type.sampler.hlsl","support.type.object.hlsl","support.type.object.rw.hlsl","support.type.fx.hlsl","support.type.object.hlsl"],"settings":{"foreground":"#fc2b73"}},{"scope":["text.variable","text.bracketed"],"settings":{"foreground":"#d52c36"}},{"scope":["support.type.swift","support.type.vb.asp"],"settings":{"foreground":"#d5a910"}},{"scope":"meta.scope.prerequisites.makefile","settings":{"foreground":"#d52c36"}},{"scope":"source.makefile","settings":{"foreground":"#d5a910"}},{"scope":"source.ini","settings":{"foreground":"#199f43"}},{"scope":"constant.language.symbol.ruby","settings":{"foreground":"#08c0ef"}},{"scope":["function.parameter.ruby","function.parameter.cs"],"settings":{"foreground":"#79797F"}},{"scope":"constant.language.symbol.elixir","settings":{"foreground":"#08c0ef"}},{"scope":"text.html.laravel-blade source.php.embedded.line.html entity.name.tag.laravel-blade","settings":{"foreground":"#fc2b73"}},{"scope":"text.html.laravel-blade source.php.embedded.line.html support.constant.laravel-blade","settings":{"foreground":"#fc2b73"}},{"scope":"entity.name.function.xi","settings":{"foreground":"#d5a910"}},{"scope":"entity.name.class.xi","settings":{"foreground":"#08c0ef"}},{"scope":"constant.character.character-class.regexp.xi","settings":{"foreground":"#d52c36"}},{"scope":"constant.regexp.xi","settings":{"foreground":"#fc2b73"}},{"scope":"keyword.control.xi","settings":{"foreground":"#08c0ef"}},{"scope":"invalid.xi","settings":{"foreground":"#79797F"}},{"scope":"beginning.punctuation.definition.quote.markdown.xi","settings":{"foreground":"#199f43"}},{"scope":"beginning.punctuation.definition.list.markdown.xi","settings":{"foreground":"#84848A"}},{"scope":"constant.character.xi","settings":{"foreground":"#7b43f8"}},{"scope":"accent.xi","settings":{"foreground":"#7b43f8"}},{"scope":"wikiword.xi","settings":{"foreground":"#d5a910"}},{"scope":"constant.other.color.rgb-value.xi","settings":{"foreground":"#070707"}},{"scope":"punctuation.definition.tag.xi","settings":{"foreground":"#84848A"}},{"scope":["support.constant.property-value.scss","support.constant.property-value.css"],"settings":{"foreground":"#d5a910"}},{"scope":["keyword.operator.css","keyword.operator.scss","keyword.operator.less"],"settings":{"foreground":"#08c0ef"}},{"scope":["support.constant.color.w3c-standard-color-name.css","support.constant.color.w3c-standard-color-name.scss"],"settings":{"foreground":"#d5a910"}},{"scope":"punctuation.separator.list.comma.css","settings":{"foreground":"#79797F"}},{"scope":"support.type.vendored.property-name.css","settings":{"foreground":"#08c0ef"}},{"scope":"support.type.property-name.css","settings":{"foreground":"#08c0ef"}},{"scope":"support.type.property-name","settings":{"foreground":"#79797F"}},{"scope":"support.constant.property-value","settings":{"foreground":"#79797F"}},{"scope":"support.constant.font-name","settings":{"foreground":"#d5a910"}},{"scope":"entity.other.attribute-name.class.css","settings":{"foreground":"#16a994","fontStyle":"normal"}},{"scope":"entity.other.attribute-name.id","settings":{"foreground":"#7b43f8","fontStyle":"normal"}},{"scope":["entity.other.attribute-name.pseudo-element","entity.other.attribute-name.pseudo-class"],"settings":{"foreground":"#08c0ef"}},{"scope":"meta.selector","settings":{"foreground":"#fc2b73"}},{"scope":"selector.sass","settings":{"foreground":"#d52c36"}},{"scope":"rgb-value","settings":{"foreground":"#08c0ef"}},{"scope":"inline-color-decoration rgb-value","settings":{"foreground":"#d5a910"}},{"scope":"less rgb-value","settings":{"foreground":"#d5a910"}},{"scope":"control.elements","settings":{"foreground":"#d5a910"}},{"scope":"keyword.operator.less","settings":{"foreground":"#d5a910"}},{"scope":"entity.name.tag","settings":{"foreground":"#d52c36"}},{"scope":"entity.other.attribute-name","settings":{"foreground":"#16a994","fontStyle":"normal"}},{"scope":"constant.character.entity","settings":{"foreground":"#d52c36"}},{"scope":"meta.tag","settings":{"foreground":"#79797F"}},{"scope":"invalid.illegal.bad-ampersand.html","settings":{"foreground":"#79797F"}},{"scope":"markup.heading","settings":{"foreground":"#d52c36"}},{"scope":["markup.heading punctuation.definition.heading","entity.name.section"],"settings":{"foreground":"#7b43f8"}},{"scope":"entity.name.section.markdown","settings":{"foreground":"#d52c36"}},{"scope":"punctuation.definition.heading.markdown","settings":{"foreground":"#d52c36"}},{"scope":"markup.heading.setext","settings":{"foreground":"#79797F"}},{"scope":["markup.heading.setext.1.markdown","markup.heading.setext.2.markdown"],"settings":{"foreground":"#d52c36"}},{"scope":["markup.bold","todo.bold"],"settings":{"foreground":"#d5a910"}},{"scope":"punctuation.definition.bold","settings":{"foreground":"#d5a910"}},{"scope":"punctuation.definition.bold.markdown","settings":{"foreground":"#d5a910"}},{"scope":["markup.italic","punctuation.definition.italic","todo.emphasis"],"settings":{"foreground":"#fc2b73","fontStyle":"italic"}},{"scope":"emphasis md","settings":{"foreground":"#fc2b73"}},{"scope":"markup.italic.markdown","settings":{"fontStyle":"italic"}},{"scope":["markup.underline.link.markdown","markup.underline.link.image.markdown"],"settings":{"foreground":"#fc2b73"}},{"scope":["string.other.link.title.markdown","string.other.link.description.markdown"],"settings":{"foreground":"#7b43f8"}},{"scope":"punctuation.definition.metadata.markdown","settings":{"foreground":"#d52c36"}},{"scope":["markup.inline.raw.markdown","markup.inline.raw.string.markdown"],"settings":{"foreground":"#199f43"}},{"scope":"punctuation.definition.list.begin.markdown","settings":{"foreground":"#d52c36"}},{"scope":"punctuation.definition.list.markdown","settings":{"foreground":"#d52c36"}},{"scope":"beginning.punctuation.definition.list.markdown","settings":{"foreground":"#d52c36"}},{"scope":["punctuation.definition.string.begin.markdown","punctuation.definition.string.end.markdown"],"settings":{"foreground":"#d52c36"}},{"scope":"markup.quote.markdown","settings":{"foreground":"#84848A"}},{"scope":"keyword.other.unit","settings":{"foreground":"#d52c36"}},{"scope":"markup.changed.diff","settings":{"foreground":"#d5a910"}},{"scope":["meta.diff.header.from-file","meta.diff.header.to-file","punctuation.definition.from-file.diff","punctuation.definition.to-file.diff"],"settings":{"foreground":"#7b43f8"}},{"scope":"markup.inserted.diff","settings":{"foreground":"#199f43"}},{"scope":"markup.deleted.diff","settings":{"foreground":"#d52c36"}},{"scope":"string.regexp","settings":{"foreground":"#17a5af"}},{"scope":"constant.other.character-class.regexp","settings":{"foreground":"#d52c36"}},{"scope":"keyword.operator.quantifier.regexp","settings":{"foreground":"#d5a910"}},{"scope":"constant.character.escape","settings":{"foreground":"#1ca1c7"}},{"scope":"source.json meta.structure.dictionary.json > string.quoted.json","settings":{"foreground":"#d52c36"}},{"scope":"source.json meta.structure.dictionary.json > string.quoted.json > punctuation.string","settings":{"foreground":"#d52c36"}},{"scope":["source.json meta.structure.dictionary.json > value.json > string.quoted.json","source.json meta.structure.array.json > value.json > string.quoted.json","source.json meta.structure.dictionary.json > value.json > string.quoted.json > punctuation","source.json meta.structure.array.json > value.json > string.quoted.json > punctuation"],"settings":{"foreground":"#199f43"}},{"scope":["source.json meta.structure.dictionary.json > constant.language.json","source.json meta.structure.array.json > constant.language.json"],"settings":{"foreground":"#08c0ef"}},{"scope":"support.type.property-name.json","settings":{"foreground":"#d52c36"}},{"scope":"support.type.property-name.json punctuation","settings":{"foreground":"#d52c36"}},{"scope":"punctuation.definition.block.sequence.item.yaml","settings":{"foreground":"#79797F"}},{"scope":"block.scope.end","settings":{"foreground":"#79797F"}},{"scope":"block.scope.begin","settings":{"foreground":"#79797F"}},{"scope":"token.info-token","settings":{"foreground":"#7b43f8"}},{"scope":"token.warn-token","settings":{"foreground":"#d5a910"}},{"scope":"token.error-token","settings":{"foreground":"#f44747"}},{"scope":"token.debug-token","settings":{"foreground":"#fc2b73"}},{"scope":"invalid.illegal","settings":{"foreground":"#070707"}},{"scope":"invalid.broken","settings":{"foreground":"#070707"}},{"scope":"invalid.deprecated","settings":{"foreground":"#070707"}},{"scope":"invalid.unimplemented","settings":{"foreground":"#070707"}}],"semanticTokenColors":{"comment":"#84848A","string":"#199f43","number":"#1ca1c7","regexp":"#17a5af","keyword":"#fc2b73","variable":"#d47628","parameter":"#79797F","property":"#d47628","function":"#7b43f8","method":"#7b43f8","type":"#c635e4","class":"#c635e4","namespace":"#d5a910","enumMember":"#08c0ef","variable.constant":"#d5a910","variable.defaultLibrary":"#d5a910"}}'));export{e as default}; diff --git a/lib/crates/fabro-spa/assets/assets/chunk-q2qv5qr4.js b/lib/crates/fabro-spa/assets/assets/chunk-q2qv5qr4.js new file mode 100644 index 000000000..8d5674785 --- /dev/null +++ b/lib/crates/fabro-spa/assets/assets/chunk-q2qv5qr4.js @@ -0,0 +1 @@ +import"./chunk-q07bg6gn.js";var b="pierre-dark",h="dark",q={"editor.background":"#070707","editor.foreground":"#fbfbfb",foreground:"#fbfbfb",focusBorder:"#009fff","selection.background":"#19283c","editor.selectionBackground":"#009fff4d","editor.lineHighlightBackground":"#19283c8c","editorCursor.foreground":"#009fff","editorLineNumber.foreground":"#84848A","editorLineNumber.activeForeground":"#adadb1","editorIndentGuide.background":"#39393c","editorIndentGuide.activeBackground":"#2e2e30","diffEditor.insertedTextBackground":"#00cab11a","diffEditor.deletedTextBackground":"#ff2e3f1a","sideBar.background":"#141415","sideBar.foreground":"#adadb1","sideBar.border":"#070707","sideBarTitle.foreground":"#fbfbfb","sideBarSectionHeader.background":"#141415","sideBarSectionHeader.foreground":"#adadb1","sideBarSectionHeader.border":"#070707","activityBar.background":"#141415","activityBar.foreground":"#fbfbfb","activityBar.border":"#070707","activityBar.activeBorder":"#009fff","activityBarBadge.background":"#009fff","activityBarBadge.foreground":"#070707","titleBar.activeBackground":"#141415","titleBar.activeForeground":"#fbfbfb","titleBar.inactiveBackground":"#141415","titleBar.inactiveForeground":"#84848A","titleBar.border":"#070707","list.activeSelectionBackground":"#19283c99","list.activeSelectionForeground":"#fbfbfb","list.inactiveSelectionBackground":"#19283c73","list.hoverBackground":"#19283c59","list.focusOutline":"#009fff","tab.activeBackground":"#070707","tab.activeForeground":"#fbfbfb","tab.activeBorderTop":"#009fff","tab.inactiveBackground":"#141415","tab.inactiveForeground":"#84848A","tab.border":"#070707","editorGroupHeader.tabsBackground":"#141415","editorGroupHeader.tabsBorder":"#070707","panel.background":"#141415","panel.border":"#070707","panelTitle.activeBorder":"#009fff","panelTitle.activeForeground":"#fbfbfb","panelTitle.inactiveForeground":"#84848A","statusBar.background":"#141415","statusBar.foreground":"#adadb1","statusBar.border":"#070707","statusBar.noFolderBackground":"#141415","statusBar.debuggingBackground":"#ffca00","statusBar.debuggingForeground":"#070707","statusBarItem.remoteBackground":"#141415","statusBarItem.remoteForeground":"#adadb1","input.background":"#1F1F21","input.border":"#424245","input.foreground":"#fbfbfb","input.placeholderForeground":"#79797F","dropdown.background":"#1F1F21","dropdown.border":"#424245","dropdown.foreground":"#fbfbfb","button.background":"#009fff","button.foreground":"#070707","button.hoverBackground":"#0190e6","textLink.foreground":"#009fff","textLink.activeForeground":"#009fff","gitDecoration.addedResourceForeground":"#00cab1","gitDecoration.conflictingResourceForeground":"#ffca00","gitDecoration.modifiedResourceForeground":"#009fff","gitDecoration.deletedResourceForeground":"#ff2e3f","gitDecoration.untrackedResourceForeground":"#00cab1","gitDecoration.ignoredResourceForeground":"#84848A","terminal.titleForeground":"#adadb1","terminal.titleInactiveForeground":"#84848A","terminal.background":"#141415","terminal.foreground":"#adadb1","terminal.ansiBlack":"#141415","terminal.ansiRed":"#ff2e3f","terminal.ansiGreen":"#0dbe4e","terminal.ansiYellow":"#ffca00","terminal.ansiBlue":"#009fff","terminal.ansiMagenta":"#c635e4","terminal.ansiCyan":"#08c0ef","terminal.ansiWhite":"#c6c6c8","terminal.ansiBrightBlack":"#141415","terminal.ansiBrightRed":"#ff2e3f","terminal.ansiBrightGreen":"#0dbe4e","terminal.ansiBrightYellow":"#ffca00","terminal.ansiBrightBlue":"#009fff","terminal.ansiBrightMagenta":"#c635e4","terminal.ansiBrightCyan":"#08c0ef","terminal.ansiBrightWhite":"#c6c6c8"},v=[{scope:["comment","punctuation.definition.comment"],settings:{foreground:"#84848A"}},{scope:"comment markup.link",settings:{foreground:"#84848A"}},{scope:["string","constant.other.symbol"],settings:{foreground:"#5ecc71"}},{scope:["punctuation.definition.string.begin","punctuation.definition.string.end"],settings:{foreground:"#5ecc71"}},{scope:["constant.numeric","constant.language.boolean"],settings:{foreground:"#68cdf2"}},{scope:"constant",settings:{foreground:"#ffd452"}},{scope:"punctuation.definition.constant",settings:{foreground:"#ffd452"}},{scope:"constant.language",settings:{foreground:"#68cdf2"}},{scope:"variable.other.constant",settings:{foreground:"#ffca00"}},{scope:"keyword",settings:{foreground:"#ff678d"}},{scope:"keyword.control",settings:{foreground:"#ff678d"}},{scope:["storage","storage.type","storage.modifier"],settings:{foreground:"#ff678d"}},{scope:"token.storage",settings:{foreground:"#ff678d"}},{scope:["keyword.operator.new","keyword.operator.expression.instanceof","keyword.operator.expression.typeof","keyword.operator.expression.void","keyword.operator.expression.delete","keyword.operator.expression.in","keyword.operator.expression.of","keyword.operator.expression.keyof"],settings:{foreground:"#ff678d"}},{scope:"keyword.operator.delete",settings:{foreground:"#ff678d"}},{scope:["variable","identifier","meta.definition.variable"],settings:{foreground:"#ffa359"}},{scope:["variable.other.readwrite","meta.object-literal.key","support.variable.property","support.variable.object.process","support.variable.object.node"],settings:{foreground:"#ffa359"}},{scope:"variable.language",settings:{foreground:"#ffca00"}},{scope:"variable.parameter.function",settings:{foreground:"#adadb1"}},{scope:"function.parameter",settings:{foreground:"#adadb1"}},{scope:"variable.parameter",settings:{foreground:"#adadb1"}},{scope:"variable.parameter.function.language.python",settings:{foreground:"#ffd452"}},{scope:"variable.parameter.function.python",settings:{foreground:"#ffd452"}},{scope:["support.function","entity.name.function","meta.function-call","meta.require","support.function.any-method","variable.function"],settings:{foreground:"#9d6afb"}},{scope:"keyword.other.special-method",settings:{foreground:"#9d6afb"}},{scope:"entity.name.function",settings:{foreground:"#9d6afb"}},{scope:"support.function.console",settings:{foreground:"#9d6afb"}},{scope:["support.type","entity.name.type","entity.name.class","storage.type"],settings:{foreground:"#d568ea"}},{scope:["support.class","entity.name.type.class"],settings:{foreground:"#d568ea"}},{scope:["entity.name.class","variable.other.class.js","variable.other.class.ts"],settings:{foreground:"#d568ea"}},{scope:"entity.name.class.identifier.namespace.type",settings:{foreground:"#d568ea"}},{scope:"entity.name.type.namespace",settings:{foreground:"#ffca00"}},{scope:"entity.other.inherited-class",settings:{foreground:"#d568ea"}},{scope:"entity.name.namespace",settings:{foreground:"#ffca00"}},{scope:"keyword.operator",settings:{foreground:"#79797F"}},{scope:["keyword.operator.logical","keyword.operator.bitwise","keyword.operator.channel"],settings:{foreground:"#08c0ef"}},{scope:["keyword.operator.arithmetic","keyword.operator.comparison","keyword.operator.relational","keyword.operator.increment","keyword.operator.decrement"],settings:{foreground:"#08c0ef"}},{scope:"keyword.operator.assignment",settings:{foreground:"#08c0ef"}},{scope:"keyword.operator.assignment.compound",settings:{foreground:"#ff678d"}},{scope:["keyword.operator.assignment.compound.js","keyword.operator.assignment.compound.ts"],settings:{foreground:"#08c0ef"}},{scope:"keyword.operator.ternary",settings:{foreground:"#ff678d"}},{scope:"keyword.operator.optional",settings:{foreground:"#ff678d"}},{scope:"punctuation",settings:{foreground:"#79797F"}},{scope:"punctuation.separator.delimiter",settings:{foreground:"#79797F"}},{scope:"punctuation.separator.key-value",settings:{foreground:"#79797F"}},{scope:"punctuation.terminator",settings:{foreground:"#79797F"}},{scope:"meta.brace",settings:{foreground:"#79797F"}},{scope:"meta.brace.square",settings:{foreground:"#79797F"}},{scope:"meta.brace.round",settings:{foreground:"#79797F"}},{scope:"function.brace",settings:{foreground:"#79797F"}},{scope:["punctuation.definition.parameters","punctuation.definition.typeparameters"],settings:{foreground:"#79797F"}},{scope:["punctuation.definition.block","punctuation.definition.tag"],settings:{foreground:"#79797F"}},{scope:["meta.tag.tsx","meta.tag.jsx","meta.tag.js","meta.tag.ts"],settings:{foreground:"#79797F"}},{scope:"keyword.operator.expression.import",settings:{foreground:"#9d6afb"}},{scope:"keyword.operator.module",settings:{foreground:"#ff678d"}},{scope:"support.type.object.console",settings:{foreground:"#ffa359"}},{scope:["support.module.node","support.type.object.module","entity.name.type.module"],settings:{foreground:"#ffca00"}},{scope:"support.constant.math",settings:{foreground:"#ffca00"}},{scope:"support.constant.property.math",settings:{foreground:"#ffd452"}},{scope:"support.constant.json",settings:{foreground:"#ffd452"}},{scope:"support.type.object.dom",settings:{foreground:"#08c0ef"}},{scope:["support.variable.dom","support.variable.property.dom"],settings:{foreground:"#ffa359"}},{scope:"support.variable.property.process",settings:{foreground:"#ffd452"}},{scope:"meta.property.object",settings:{foreground:"#ffa359"}},{scope:"variable.parameter.function.js",settings:{foreground:"#ffa359"}},{scope:["keyword.other.template.begin","keyword.other.template.end"],settings:{foreground:"#5ecc71"}},{scope:["keyword.other.substitution.begin","keyword.other.substitution.end"],settings:{foreground:"#5ecc71"}},{scope:["punctuation.definition.template-expression.begin","punctuation.definition.template-expression.end"],settings:{foreground:"#ff678d"}},{scope:"meta.template.expression",settings:{foreground:"#79797F"}},{scope:"punctuation.section.embedded",settings:{foreground:"#ffa359"}},{scope:"variable.interpolation",settings:{foreground:"#ffa359"}},{scope:["punctuation.section.embedded.begin","punctuation.section.embedded.end"],settings:{foreground:"#ff678d"}},{scope:"punctuation.quasi.element",settings:{foreground:"#ff678d"}},{scope:["support.type.primitive.ts","support.type.builtin.ts","support.type.primitive.tsx","support.type.builtin.tsx"],settings:{foreground:"#d568ea"}},{scope:"support.type.type.flowtype",settings:{foreground:"#9d6afb"}},{scope:"support.type.primitive",settings:{foreground:"#d568ea"}},{scope:"support.variable.magic.python",settings:{foreground:"#ff6762"}},{scope:"variable.parameter.function.language.special.self.python",settings:{foreground:"#ffca00"}},{scope:["punctuation.separator.period.python","punctuation.separator.element.python","punctuation.parenthesis.begin.python","punctuation.parenthesis.end.python"],settings:{foreground:"#79797F"}},{scope:["punctuation.definition.arguments.begin.python","punctuation.definition.arguments.end.python","punctuation.separator.arguments.python","punctuation.definition.list.begin.python","punctuation.definition.list.end.python"],settings:{foreground:"#79797F"}},{scope:"support.type.python",settings:{foreground:"#08c0ef"}},{scope:"keyword.operator.logical.python",settings:{foreground:"#ff678d"}},{scope:"meta.function-call.generic.python",settings:{foreground:"#9d6afb"}},{scope:"constant.character.format.placeholder.other.python",settings:{foreground:"#ffd452"}},{scope:"meta.function.decorator.python",settings:{foreground:"#9d6afb"}},{scope:["support.token.decorator.python","meta.function.decorator.identifier.python"],settings:{foreground:"#08c0ef"}},{scope:"storage.modifier.lifetime.rust",settings:{foreground:"#79797F"}},{scope:"support.function.std.rust",settings:{foreground:"#9d6afb"}},{scope:"entity.name.lifetime.rust",settings:{foreground:"#ffca00"}},{scope:"variable.language.rust",settings:{foreground:"#ff6762"}},{scope:"keyword.operator.misc.rust",settings:{foreground:"#79797F"}},{scope:"keyword.operator.sigil.rust",settings:{foreground:"#ff678d"}},{scope:"support.constant.core.rust",settings:{foreground:"#ffd452"}},{scope:["meta.function.c","meta.function.cpp"],settings:{foreground:"#ff6762"}},{scope:["punctuation.section.block.begin.bracket.curly.cpp","punctuation.section.block.end.bracket.curly.cpp","punctuation.terminator.statement.c","punctuation.section.block.begin.bracket.curly.c","punctuation.section.block.end.bracket.curly.c","punctuation.section.parens.begin.bracket.round.c","punctuation.section.parens.end.bracket.round.c","punctuation.section.parameters.begin.bracket.round.c","punctuation.section.parameters.end.bracket.round.c"],settings:{foreground:"#79797F"}},{scope:["keyword.operator.assignment.c","keyword.operator.comparison.c","keyword.operator.c","keyword.operator.increment.c","keyword.operator.decrement.c","keyword.operator.bitwise.shift.c"],settings:{foreground:"#ff678d"}},{scope:["keyword.operator.assignment.cpp","keyword.operator.comparison.cpp","keyword.operator.cpp","keyword.operator.increment.cpp","keyword.operator.decrement.cpp","keyword.operator.bitwise.shift.cpp"],settings:{foreground:"#ff678d"}},{scope:["punctuation.separator.c","punctuation.separator.cpp"],settings:{foreground:"#ff678d"}},{scope:["support.type.posix-reserved.c","support.type.posix-reserved.cpp"],settings:{foreground:"#08c0ef"}},{scope:["keyword.operator.sizeof.c","keyword.operator.sizeof.cpp"],settings:{foreground:"#ff678d"}},{scope:"variable.c",settings:{foreground:"#79797F"}},{scope:["storage.type.annotation.java","storage.type.object.array.java"],settings:{foreground:"#ffca00"}},{scope:"source.java",settings:{foreground:"#ff6762"}},{scope:["punctuation.section.block.begin.java","punctuation.section.block.end.java","punctuation.definition.method-parameters.begin.java","punctuation.definition.method-parameters.end.java","meta.method.identifier.java","punctuation.section.method.begin.java","punctuation.section.method.end.java","punctuation.terminator.java","punctuation.section.class.begin.java","punctuation.section.class.end.java","punctuation.section.inner-class.begin.java","punctuation.section.inner-class.end.java","meta.method-call.java","punctuation.section.class.begin.bracket.curly.java","punctuation.section.class.end.bracket.curly.java","punctuation.section.method.begin.bracket.curly.java","punctuation.section.method.end.bracket.curly.java","punctuation.separator.period.java","punctuation.bracket.angle.java","punctuation.definition.annotation.java","meta.method.body.java"],settings:{foreground:"#79797F"}},{scope:"meta.method.java",settings:{foreground:"#9d6afb"}},{scope:["storage.modifier.import.java","storage.type.java","storage.type.generic.java"],settings:{foreground:"#ffca00"}},{scope:"keyword.operator.instanceof.java",settings:{foreground:"#ff678d"}},{scope:"meta.definition.variable.name.java",settings:{foreground:"#ff6762"}},{scope:"token.variable.parameter.java",settings:{foreground:"#79797F"}},{scope:"import.storage.java",settings:{foreground:"#ffca00"}},{scope:"token.package.keyword",settings:{foreground:"#ff678d"}},{scope:"token.package",settings:{foreground:"#79797F"}},{scope:"token.storage.type.java",settings:{foreground:"#ffca00"}},{scope:"keyword.operator.assignment.go",settings:{foreground:"#ffca00"}},{scope:["keyword.operator.arithmetic.go","keyword.operator.address.go"],settings:{foreground:"#ff678d"}},{scope:"entity.name.package.go",settings:{foreground:"#ffca00"}},{scope:["support.other.namespace.use.php","support.other.namespace.use-as.php","support.other.namespace.php","entity.other.alias.php","meta.interface.php"],settings:{foreground:"#ffca00"}},{scope:"keyword.operator.error-control.php",settings:{foreground:"#ff678d"}},{scope:"keyword.operator.type.php",settings:{foreground:"#ff678d"}},{scope:["punctuation.section.array.begin.php","punctuation.section.array.end.php"],settings:{foreground:"#79797F"}},{scope:["storage.type.php","meta.other.type.phpdoc.php","keyword.other.type.php","keyword.other.array.phpdoc.php"],settings:{foreground:"#ffca00"}},{scope:["meta.function-call.php","meta.function-call.object.php","meta.function-call.static.php"],settings:{foreground:"#9d6afb"}},{scope:["punctuation.definition.parameters.begin.bracket.round.php","punctuation.definition.parameters.end.bracket.round.php","punctuation.separator.delimiter.php","punctuation.section.scope.begin.php","punctuation.section.scope.end.php","punctuation.terminator.expression.php","punctuation.definition.arguments.begin.bracket.round.php","punctuation.definition.arguments.end.bracket.round.php","punctuation.definition.storage-type.begin.bracket.round.php","punctuation.definition.storage-type.end.bracket.round.php","punctuation.definition.array.begin.bracket.round.php","punctuation.definition.array.end.bracket.round.php","punctuation.definition.begin.bracket.round.php","punctuation.definition.end.bracket.round.php","punctuation.definition.begin.bracket.curly.php","punctuation.definition.end.bracket.curly.php","punctuation.definition.section.switch-block.end.bracket.curly.php","punctuation.definition.section.switch-block.start.bracket.curly.php","punctuation.definition.section.switch-block.begin.bracket.curly.php","punctuation.definition.section.switch-block.end.bracket.curly.php"],settings:{foreground:"#79797F"}},{scope:["support.constant.ext.php","support.constant.std.php","support.constant.core.php","support.constant.parser-token.php"],settings:{foreground:"#ffd452"}},{scope:["entity.name.goto-label.php","support.other.php"],settings:{foreground:"#9d6afb"}},{scope:["keyword.operator.logical.php","keyword.operator.bitwise.php","keyword.operator.arithmetic.php"],settings:{foreground:"#08c0ef"}},{scope:"keyword.operator.regexp.php",settings:{foreground:"#ff678d"}},{scope:"keyword.operator.comparison.php",settings:{foreground:"#08c0ef"}},{scope:["keyword.operator.heredoc.php","keyword.operator.nowdoc.php"],settings:{foreground:"#ff678d"}},{scope:"variable.other.class.php",settings:{foreground:"#ff6762"}},{scope:"invalid.illegal.non-null-typehinted.php",settings:{foreground:"#f44747"}},{scope:"variable.other.generic-type.haskell",settings:{foreground:"#ff678d"}},{scope:"storage.type.haskell",settings:{foreground:"#ffd452"}},{scope:"storage.type.cs",settings:{foreground:"#ffca00"}},{scope:"entity.name.variable.local.cs",settings:{foreground:"#ff6762"}},{scope:"entity.name.label.cs",settings:{foreground:"#ffca00"}},{scope:["entity.name.scope-resolution.function.call","entity.name.scope-resolution.function.definition"],settings:{foreground:"#ffca00"}},{scope:["punctuation.definition.delayed.unison","punctuation.definition.list.begin.unison","punctuation.definition.list.end.unison","punctuation.definition.ability.begin.unison","punctuation.definition.ability.end.unison","punctuation.operator.assignment.as.unison","punctuation.separator.pipe.unison","punctuation.separator.delimiter.unison","punctuation.definition.hash.unison"],settings:{foreground:"#ff6762"}},{scope:"support.constant.edge",settings:{foreground:"#ff678d"}},{scope:"support.type.prelude.elm",settings:{foreground:"#08c0ef"}},{scope:"support.constant.elm",settings:{foreground:"#ffd452"}},{scope:"entity.global.clojure",settings:{foreground:"#ffca00"}},{scope:"meta.symbol.clojure",settings:{foreground:"#ff6762"}},{scope:"constant.keyword.clojure",settings:{foreground:"#08c0ef"}},{scope:["meta.arguments.coffee","variable.parameter.function.coffee"],settings:{foreground:"#ff6762"}},{scope:"storage.modifier.import.groovy",settings:{foreground:"#ffca00"}},{scope:"meta.method.groovy",settings:{foreground:"#9d6afb"}},{scope:"meta.definition.variable.name.groovy",settings:{foreground:"#ff6762"}},{scope:"meta.definition.class.inherited.classes.groovy",settings:{foreground:"#5ecc71"}},{scope:"support.variable.semantic.hlsl",settings:{foreground:"#ffca00"}},{scope:["support.type.texture.hlsl","support.type.sampler.hlsl","support.type.object.hlsl","support.type.object.rw.hlsl","support.type.fx.hlsl","support.type.object.hlsl"],settings:{foreground:"#ff678d"}},{scope:["text.variable","text.bracketed"],settings:{foreground:"#ff6762"}},{scope:["support.type.swift","support.type.vb.asp"],settings:{foreground:"#ffca00"}},{scope:"meta.scope.prerequisites.makefile",settings:{foreground:"#ff6762"}},{scope:"source.makefile",settings:{foreground:"#ffca00"}},{scope:"source.ini",settings:{foreground:"#5ecc71"}},{scope:"constant.language.symbol.ruby",settings:{foreground:"#08c0ef"}},{scope:["function.parameter.ruby","function.parameter.cs"],settings:{foreground:"#79797F"}},{scope:"constant.language.symbol.elixir",settings:{foreground:"#08c0ef"}},{scope:"text.html.laravel-blade source.php.embedded.line.html entity.name.tag.laravel-blade",settings:{foreground:"#ff678d"}},{scope:"text.html.laravel-blade source.php.embedded.line.html support.constant.laravel-blade",settings:{foreground:"#ff678d"}},{scope:"entity.name.function.xi",settings:{foreground:"#ffca00"}},{scope:"entity.name.class.xi",settings:{foreground:"#08c0ef"}},{scope:"constant.character.character-class.regexp.xi",settings:{foreground:"#ff6762"}},{scope:"constant.regexp.xi",settings:{foreground:"#ff678d"}},{scope:"keyword.control.xi",settings:{foreground:"#08c0ef"}},{scope:"invalid.xi",settings:{foreground:"#79797F"}},{scope:"beginning.punctuation.definition.quote.markdown.xi",settings:{foreground:"#5ecc71"}},{scope:"beginning.punctuation.definition.list.markdown.xi",settings:{foreground:"#84848A"}},{scope:"constant.character.xi",settings:{foreground:"#9d6afb"}},{scope:"accent.xi",settings:{foreground:"#9d6afb"}},{scope:"wikiword.xi",settings:{foreground:"#ffd452"}},{scope:"constant.other.color.rgb-value.xi",settings:{foreground:"#ffffff"}},{scope:"punctuation.definition.tag.xi",settings:{foreground:"#84848A"}},{scope:["support.constant.property-value.scss","support.constant.property-value.css"],settings:{foreground:"#ffd452"}},{scope:["keyword.operator.css","keyword.operator.scss","keyword.operator.less"],settings:{foreground:"#08c0ef"}},{scope:["support.constant.color.w3c-standard-color-name.css","support.constant.color.w3c-standard-color-name.scss"],settings:{foreground:"#ffd452"}},{scope:"punctuation.separator.list.comma.css",settings:{foreground:"#79797F"}},{scope:"support.type.vendored.property-name.css",settings:{foreground:"#08c0ef"}},{scope:"support.type.property-name.css",settings:{foreground:"#08c0ef"}},{scope:"support.type.property-name",settings:{foreground:"#79797F"}},{scope:"support.constant.property-value",settings:{foreground:"#79797F"}},{scope:"support.constant.font-name",settings:{foreground:"#ffd452"}},{scope:"entity.other.attribute-name.class.css",settings:{foreground:"#61d5c0",fontStyle:"normal"}},{scope:"entity.other.attribute-name.id",settings:{foreground:"#9d6afb",fontStyle:"normal"}},{scope:["entity.other.attribute-name.pseudo-element","entity.other.attribute-name.pseudo-class"],settings:{foreground:"#08c0ef"}},{scope:"meta.selector",settings:{foreground:"#ff678d"}},{scope:"selector.sass",settings:{foreground:"#ff6762"}},{scope:"rgb-value",settings:{foreground:"#08c0ef"}},{scope:"inline-color-decoration rgb-value",settings:{foreground:"#ffd452"}},{scope:"less rgb-value",settings:{foreground:"#ffd452"}},{scope:"control.elements",settings:{foreground:"#ffd452"}},{scope:"keyword.operator.less",settings:{foreground:"#ffd452"}},{scope:"entity.name.tag",settings:{foreground:"#ff6762"}},{scope:"entity.other.attribute-name",settings:{foreground:"#61d5c0",fontStyle:"normal"}},{scope:"constant.character.entity",settings:{foreground:"#ff6762"}},{scope:"meta.tag",settings:{foreground:"#79797F"}},{scope:"invalid.illegal.bad-ampersand.html",settings:{foreground:"#79797F"}},{scope:"markup.heading",settings:{foreground:"#ff6762"}},{scope:["markup.heading punctuation.definition.heading","entity.name.section"],settings:{foreground:"#9d6afb"}},{scope:"entity.name.section.markdown",settings:{foreground:"#ff6762"}},{scope:"punctuation.definition.heading.markdown",settings:{foreground:"#ff6762"}},{scope:"markup.heading.setext",settings:{foreground:"#79797F"}},{scope:["markup.heading.setext.1.markdown","markup.heading.setext.2.markdown"],settings:{foreground:"#ff6762"}},{scope:["markup.bold","todo.bold"],settings:{foreground:"#ffd452"}},{scope:"punctuation.definition.bold",settings:{foreground:"#ffca00"}},{scope:"punctuation.definition.bold.markdown",settings:{foreground:"#ffd452"}},{scope:["markup.italic","punctuation.definition.italic","todo.emphasis"],settings:{foreground:"#ff678d",fontStyle:"italic"}},{scope:"emphasis md",settings:{foreground:"#ff678d"}},{scope:"markup.italic.markdown",settings:{fontStyle:"italic"}},{scope:["markup.underline.link.markdown","markup.underline.link.image.markdown"],settings:{foreground:"#ff678d"}},{scope:["string.other.link.title.markdown","string.other.link.description.markdown"],settings:{foreground:"#9d6afb"}},{scope:"punctuation.definition.metadata.markdown",settings:{foreground:"#ff6762"}},{scope:["markup.inline.raw.markdown","markup.inline.raw.string.markdown"],settings:{foreground:"#5ecc71"}},{scope:"punctuation.definition.list.begin.markdown",settings:{foreground:"#ff6762"}},{scope:"punctuation.definition.list.markdown",settings:{foreground:"#ff6762"}},{scope:"beginning.punctuation.definition.list.markdown",settings:{foreground:"#ff6762"}},{scope:["punctuation.definition.string.begin.markdown","punctuation.definition.string.end.markdown"],settings:{foreground:"#ff6762"}},{scope:"markup.quote.markdown",settings:{foreground:"#84848A"}},{scope:"keyword.other.unit",settings:{foreground:"#ff6762"}},{scope:"markup.changed.diff",settings:{foreground:"#ffca00"}},{scope:["meta.diff.header.from-file","meta.diff.header.to-file","punctuation.definition.from-file.diff","punctuation.definition.to-file.diff"],settings:{foreground:"#9d6afb"}},{scope:"markup.inserted.diff",settings:{foreground:"#5ecc71"}},{scope:"markup.deleted.diff",settings:{foreground:"#ff6762"}},{scope:"string.regexp",settings:{foreground:"#64d1db"}},{scope:"constant.other.character-class.regexp",settings:{foreground:"#ff6762"}},{scope:"keyword.operator.quantifier.regexp",settings:{foreground:"#ffd452"}},{scope:"constant.character.escape",settings:{foreground:"#68cdf2"}},{scope:"source.json meta.structure.dictionary.json > string.quoted.json",settings:{foreground:"#ff6762"}},{scope:"source.json meta.structure.dictionary.json > string.quoted.json > punctuation.string",settings:{foreground:"#ff6762"}},{scope:["source.json meta.structure.dictionary.json > value.json > string.quoted.json","source.json meta.structure.array.json > value.json > string.quoted.json","source.json meta.structure.dictionary.json > value.json > string.quoted.json > punctuation","source.json meta.structure.array.json > value.json > string.quoted.json > punctuation"],settings:{foreground:"#5ecc71"}},{scope:["source.json meta.structure.dictionary.json > constant.language.json","source.json meta.structure.array.json > constant.language.json"],settings:{foreground:"#08c0ef"}},{scope:"support.type.property-name.json",settings:{foreground:"#ff6762"}},{scope:"support.type.property-name.json punctuation",settings:{foreground:"#ff6762"}},{scope:"punctuation.definition.block.sequence.item.yaml",settings:{foreground:"#79797F"}},{scope:"block.scope.end",settings:{foreground:"#79797F"}},{scope:"block.scope.begin",settings:{foreground:"#79797F"}},{scope:"token.info-token",settings:{foreground:"#9d6afb"}},{scope:"token.warn-token",settings:{foreground:"#ffd452"}},{scope:"token.error-token",settings:{foreground:"#f44747"}},{scope:"token.debug-token",settings:{foreground:"#ff678d"}},{scope:"invalid.illegal",settings:{foreground:"#ffffff"}},{scope:"invalid.broken",settings:{foreground:"#ffffff"}},{scope:"invalid.deprecated",settings:{foreground:"#ffffff"}},{scope:"invalid.unimplemented",settings:{foreground:"#ffffff"}}],w={comment:"#84848A",string:"#5ecc71",number:"#68cdf2",regexp:"#64d1db",keyword:"#ff678d",variable:"#ffa359",parameter:"#adadb1",property:"#ffa359",function:"#9d6afb",method:"#9d6afb",type:"#d568ea",class:"#d568ea",namespace:"#ffca00",enumMember:"#08c0ef","variable.constant":"#ffd452","variable.defaultLibrary":"#ffca00"},x={name:b,type:h,colors:q,tokenColors:v,semanticTokenColors:w};export{h as type,v as tokenColors,w as semanticTokenColors,b as name,x as default,q as colors}; diff --git a/lib/crates/fabro-spa/assets/assets/chunk-tqzz87j8.js b/lib/crates/fabro-spa/assets/assets/chunk-tqzz87j8.js new file mode 100644 index 000000000..b56d27055 --- /dev/null +++ b/lib/crates/fabro-spa/assets/assets/chunk-tqzz87j8.js @@ -0,0 +1 @@ +import"./chunk-q07bg6gn.js";var b="pierre-light",q="light",v={"editor.background":"#ffffff","editor.foreground":"#070707",foreground:"#070707",focusBorder:"#009fff","selection.background":"#dfebff","editor.selectionBackground":"#009fff2e","editor.lineHighlightBackground":"#dfebff8c","editorCursor.foreground":"#009fff","editorLineNumber.foreground":"#84848A","editorLineNumber.activeForeground":"#6C6C71","editorIndentGuide.background":"#eeeeef","editorIndentGuide.activeBackground":"#dbdbdd","diffEditor.insertedTextBackground":"#00cab133","diffEditor.deletedTextBackground":"#ff2e3f33","sideBar.background":"#f8f8f8","sideBar.foreground":"#6C6C71","sideBar.border":"#eeeeef","sideBarTitle.foreground":"#070707","sideBarSectionHeader.background":"#f8f8f8","sideBarSectionHeader.foreground":"#6C6C71","sideBarSectionHeader.border":"#eeeeef","activityBar.background":"#f8f8f8","activityBar.foreground":"#070707","activityBar.border":"#eeeeef","activityBar.activeBorder":"#009fff","activityBarBadge.background":"#009fff","activityBarBadge.foreground":"#ffffff","titleBar.activeBackground":"#f8f8f8","titleBar.activeForeground":"#070707","titleBar.inactiveBackground":"#f8f8f8","titleBar.inactiveForeground":"#84848A","titleBar.border":"#eeeeef","list.activeSelectionBackground":"#dfebffcc","list.activeSelectionForeground":"#070707","list.inactiveSelectionBackground":"#dfebff73","list.hoverBackground":"#dfebff59","list.focusOutline":"#009fff","tab.activeBackground":"#ffffff","tab.activeForeground":"#070707","tab.activeBorderTop":"#009fff","tab.inactiveBackground":"#f8f8f8","tab.inactiveForeground":"#84848A","tab.border":"#eeeeef","editorGroupHeader.tabsBackground":"#f8f8f8","editorGroupHeader.tabsBorder":"#eeeeef","panel.background":"#f8f8f8","panel.border":"#eeeeef","panelTitle.activeBorder":"#009fff","panelTitle.activeForeground":"#070707","panelTitle.inactiveForeground":"#84848A","statusBar.background":"#f8f8f8","statusBar.foreground":"#6C6C71","statusBar.border":"#eeeeef","statusBar.noFolderBackground":"#f8f8f8","statusBar.debuggingBackground":"#ffca00","statusBar.debuggingForeground":"#ffffff","statusBarItem.remoteBackground":"#f8f8f8","statusBarItem.remoteForeground":"#6C6C71","input.background":"#f2f2f3","input.border":"#dbdbdd","input.foreground":"#070707","input.placeholderForeground":"#8E8E95","dropdown.background":"#f2f2f3","dropdown.border":"#dbdbdd","dropdown.foreground":"#070707","button.background":"#009fff","button.foreground":"#ffffff","button.hoverBackground":"#1aa9ff","textLink.foreground":"#009fff","textLink.activeForeground":"#009fff","gitDecoration.addedResourceForeground":"#00cab1","gitDecoration.conflictingResourceForeground":"#ffca00","gitDecoration.modifiedResourceForeground":"#009fff","gitDecoration.deletedResourceForeground":"#ff2e3f","gitDecoration.untrackedResourceForeground":"#00cab1","gitDecoration.ignoredResourceForeground":"#84848A","terminal.titleForeground":"#6C6C71","terminal.titleInactiveForeground":"#84848A","terminal.background":"#f8f8f8","terminal.foreground":"#6C6C71","terminal.ansiBlack":"#1F1F21","terminal.ansiRed":"#ff2e3f","terminal.ansiGreen":"#0dbe4e","terminal.ansiYellow":"#ffca00","terminal.ansiBlue":"#009fff","terminal.ansiMagenta":"#c635e4","terminal.ansiCyan":"#08c0ef","terminal.ansiWhite":"#c6c6c8","terminal.ansiBrightBlack":"#1F1F21","terminal.ansiBrightRed":"#ff2e3f","terminal.ansiBrightGreen":"#0dbe4e","terminal.ansiBrightYellow":"#ffca00","terminal.ansiBrightBlue":"#009fff","terminal.ansiBrightMagenta":"#c635e4","terminal.ansiBrightCyan":"#08c0ef","terminal.ansiBrightWhite":"#c6c6c8"},w=[{scope:["comment","punctuation.definition.comment"],settings:{foreground:"#84848A"}},{scope:"comment markup.link",settings:{foreground:"#84848A"}},{scope:["string","constant.other.symbol"],settings:{foreground:"#199f43"}},{scope:["punctuation.definition.string.begin","punctuation.definition.string.end"],settings:{foreground:"#199f43"}},{scope:["constant.numeric","constant.language.boolean"],settings:{foreground:"#1ca1c7"}},{scope:"constant",settings:{foreground:"#d5a910"}},{scope:"punctuation.definition.constant",settings:{foreground:"#d5a910"}},{scope:"constant.language",settings:{foreground:"#1ca1c7"}},{scope:"variable.other.constant",settings:{foreground:"#d5a910"}},{scope:"keyword",settings:{foreground:"#fc2b73"}},{scope:"keyword.control",settings:{foreground:"#fc2b73"}},{scope:["storage","storage.type","storage.modifier"],settings:{foreground:"#fc2b73"}},{scope:"token.storage",settings:{foreground:"#fc2b73"}},{scope:["keyword.operator.new","keyword.operator.expression.instanceof","keyword.operator.expression.typeof","keyword.operator.expression.void","keyword.operator.expression.delete","keyword.operator.expression.in","keyword.operator.expression.of","keyword.operator.expression.keyof"],settings:{foreground:"#fc2b73"}},{scope:"keyword.operator.delete",settings:{foreground:"#fc2b73"}},{scope:["variable","identifier","meta.definition.variable"],settings:{foreground:"#d47628"}},{scope:["variable.other.readwrite","meta.object-literal.key","support.variable.property","support.variable.object.process","support.variable.object.node"],settings:{foreground:"#d47628"}},{scope:"variable.language",settings:{foreground:"#d5a910"}},{scope:"variable.parameter.function",settings:{foreground:"#79797F"}},{scope:"function.parameter",settings:{foreground:"#79797F"}},{scope:"variable.parameter",settings:{foreground:"#79797F"}},{scope:"variable.parameter.function.language.python",settings:{foreground:"#d5a910"}},{scope:"variable.parameter.function.python",settings:{foreground:"#d5a910"}},{scope:["support.function","entity.name.function","meta.function-call","meta.require","support.function.any-method","variable.function"],settings:{foreground:"#7b43f8"}},{scope:"keyword.other.special-method",settings:{foreground:"#7b43f8"}},{scope:"entity.name.function",settings:{foreground:"#7b43f8"}},{scope:"support.function.console",settings:{foreground:"#7b43f8"}},{scope:["support.type","entity.name.type","entity.name.class","storage.type"],settings:{foreground:"#c635e4"}},{scope:["support.class","entity.name.type.class"],settings:{foreground:"#c635e4"}},{scope:["entity.name.class","variable.other.class.js","variable.other.class.ts"],settings:{foreground:"#c635e4"}},{scope:"entity.name.class.identifier.namespace.type",settings:{foreground:"#c635e4"}},{scope:"entity.name.type.namespace",settings:{foreground:"#d5a910"}},{scope:"entity.other.inherited-class",settings:{foreground:"#c635e4"}},{scope:"entity.name.namespace",settings:{foreground:"#d5a910"}},{scope:"keyword.operator",settings:{foreground:"#79797F"}},{scope:["keyword.operator.logical","keyword.operator.bitwise","keyword.operator.channel"],settings:{foreground:"#08c0ef"}},{scope:["keyword.operator.arithmetic","keyword.operator.comparison","keyword.operator.relational","keyword.operator.increment","keyword.operator.decrement"],settings:{foreground:"#08c0ef"}},{scope:"keyword.operator.assignment",settings:{foreground:"#08c0ef"}},{scope:"keyword.operator.assignment.compound",settings:{foreground:"#fc2b73"}},{scope:["keyword.operator.assignment.compound.js","keyword.operator.assignment.compound.ts"],settings:{foreground:"#08c0ef"}},{scope:"keyword.operator.ternary",settings:{foreground:"#fc2b73"}},{scope:"keyword.operator.optional",settings:{foreground:"#fc2b73"}},{scope:"punctuation",settings:{foreground:"#79797F"}},{scope:"punctuation.separator.delimiter",settings:{foreground:"#79797F"}},{scope:"punctuation.separator.key-value",settings:{foreground:"#79797F"}},{scope:"punctuation.terminator",settings:{foreground:"#79797F"}},{scope:"meta.brace",settings:{foreground:"#79797F"}},{scope:"meta.brace.square",settings:{foreground:"#79797F"}},{scope:"meta.brace.round",settings:{foreground:"#79797F"}},{scope:"function.brace",settings:{foreground:"#79797F"}},{scope:["punctuation.definition.parameters","punctuation.definition.typeparameters"],settings:{foreground:"#79797F"}},{scope:["punctuation.definition.block","punctuation.definition.tag"],settings:{foreground:"#79797F"}},{scope:["meta.tag.tsx","meta.tag.jsx","meta.tag.js","meta.tag.ts"],settings:{foreground:"#79797F"}},{scope:"keyword.operator.expression.import",settings:{foreground:"#7b43f8"}},{scope:"keyword.operator.module",settings:{foreground:"#fc2b73"}},{scope:"support.type.object.console",settings:{foreground:"#d47628"}},{scope:["support.module.node","support.type.object.module","entity.name.type.module"],settings:{foreground:"#d5a910"}},{scope:"support.constant.math",settings:{foreground:"#d5a910"}},{scope:"support.constant.property.math",settings:{foreground:"#d5a910"}},{scope:"support.constant.json",settings:{foreground:"#d5a910"}},{scope:"support.type.object.dom",settings:{foreground:"#08c0ef"}},{scope:["support.variable.dom","support.variable.property.dom"],settings:{foreground:"#d47628"}},{scope:"support.variable.property.process",settings:{foreground:"#d5a910"}},{scope:"meta.property.object",settings:{foreground:"#d47628"}},{scope:"variable.parameter.function.js",settings:{foreground:"#d47628"}},{scope:["keyword.other.template.begin","keyword.other.template.end"],settings:{foreground:"#199f43"}},{scope:["keyword.other.substitution.begin","keyword.other.substitution.end"],settings:{foreground:"#199f43"}},{scope:["punctuation.definition.template-expression.begin","punctuation.definition.template-expression.end"],settings:{foreground:"#fc2b73"}},{scope:"meta.template.expression",settings:{foreground:"#79797F"}},{scope:"punctuation.section.embedded",settings:{foreground:"#d47628"}},{scope:"variable.interpolation",settings:{foreground:"#d47628"}},{scope:["punctuation.section.embedded.begin","punctuation.section.embedded.end"],settings:{foreground:"#fc2b73"}},{scope:"punctuation.quasi.element",settings:{foreground:"#fc2b73"}},{scope:["support.type.primitive.ts","support.type.builtin.ts","support.type.primitive.tsx","support.type.builtin.tsx"],settings:{foreground:"#c635e4"}},{scope:"support.type.type.flowtype",settings:{foreground:"#7b43f8"}},{scope:"support.type.primitive",settings:{foreground:"#c635e4"}},{scope:"support.variable.magic.python",settings:{foreground:"#d52c36"}},{scope:"variable.parameter.function.language.special.self.python",settings:{foreground:"#d5a910"}},{scope:["punctuation.separator.period.python","punctuation.separator.element.python","punctuation.parenthesis.begin.python","punctuation.parenthesis.end.python"],settings:{foreground:"#79797F"}},{scope:["punctuation.definition.arguments.begin.python","punctuation.definition.arguments.end.python","punctuation.separator.arguments.python","punctuation.definition.list.begin.python","punctuation.definition.list.end.python"],settings:{foreground:"#79797F"}},{scope:"support.type.python",settings:{foreground:"#08c0ef"}},{scope:"keyword.operator.logical.python",settings:{foreground:"#fc2b73"}},{scope:"meta.function-call.generic.python",settings:{foreground:"#7b43f8"}},{scope:"constant.character.format.placeholder.other.python",settings:{foreground:"#d5a910"}},{scope:"meta.function.decorator.python",settings:{foreground:"#7b43f8"}},{scope:["support.token.decorator.python","meta.function.decorator.identifier.python"],settings:{foreground:"#08c0ef"}},{scope:"storage.modifier.lifetime.rust",settings:{foreground:"#79797F"}},{scope:"support.function.std.rust",settings:{foreground:"#7b43f8"}},{scope:"entity.name.lifetime.rust",settings:{foreground:"#d5a910"}},{scope:"variable.language.rust",settings:{foreground:"#d52c36"}},{scope:"keyword.operator.misc.rust",settings:{foreground:"#79797F"}},{scope:"keyword.operator.sigil.rust",settings:{foreground:"#fc2b73"}},{scope:"support.constant.core.rust",settings:{foreground:"#d5a910"}},{scope:["meta.function.c","meta.function.cpp"],settings:{foreground:"#d52c36"}},{scope:["punctuation.section.block.begin.bracket.curly.cpp","punctuation.section.block.end.bracket.curly.cpp","punctuation.terminator.statement.c","punctuation.section.block.begin.bracket.curly.c","punctuation.section.block.end.bracket.curly.c","punctuation.section.parens.begin.bracket.round.c","punctuation.section.parens.end.bracket.round.c","punctuation.section.parameters.begin.bracket.round.c","punctuation.section.parameters.end.bracket.round.c"],settings:{foreground:"#79797F"}},{scope:["keyword.operator.assignment.c","keyword.operator.comparison.c","keyword.operator.c","keyword.operator.increment.c","keyword.operator.decrement.c","keyword.operator.bitwise.shift.c"],settings:{foreground:"#fc2b73"}},{scope:["keyword.operator.assignment.cpp","keyword.operator.comparison.cpp","keyword.operator.cpp","keyword.operator.increment.cpp","keyword.operator.decrement.cpp","keyword.operator.bitwise.shift.cpp"],settings:{foreground:"#fc2b73"}},{scope:["punctuation.separator.c","punctuation.separator.cpp"],settings:{foreground:"#fc2b73"}},{scope:["support.type.posix-reserved.c","support.type.posix-reserved.cpp"],settings:{foreground:"#08c0ef"}},{scope:["keyword.operator.sizeof.c","keyword.operator.sizeof.cpp"],settings:{foreground:"#fc2b73"}},{scope:"variable.c",settings:{foreground:"#79797F"}},{scope:["storage.type.annotation.java","storage.type.object.array.java"],settings:{foreground:"#d5a910"}},{scope:"source.java",settings:{foreground:"#d52c36"}},{scope:["punctuation.section.block.begin.java","punctuation.section.block.end.java","punctuation.definition.method-parameters.begin.java","punctuation.definition.method-parameters.end.java","meta.method.identifier.java","punctuation.section.method.begin.java","punctuation.section.method.end.java","punctuation.terminator.java","punctuation.section.class.begin.java","punctuation.section.class.end.java","punctuation.section.inner-class.begin.java","punctuation.section.inner-class.end.java","meta.method-call.java","punctuation.section.class.begin.bracket.curly.java","punctuation.section.class.end.bracket.curly.java","punctuation.section.method.begin.bracket.curly.java","punctuation.section.method.end.bracket.curly.java","punctuation.separator.period.java","punctuation.bracket.angle.java","punctuation.definition.annotation.java","meta.method.body.java"],settings:{foreground:"#79797F"}},{scope:"meta.method.java",settings:{foreground:"#7b43f8"}},{scope:["storage.modifier.import.java","storage.type.java","storage.type.generic.java"],settings:{foreground:"#d5a910"}},{scope:"keyword.operator.instanceof.java",settings:{foreground:"#fc2b73"}},{scope:"meta.definition.variable.name.java",settings:{foreground:"#d52c36"}},{scope:"token.variable.parameter.java",settings:{foreground:"#79797F"}},{scope:"import.storage.java",settings:{foreground:"#d5a910"}},{scope:"token.package.keyword",settings:{foreground:"#fc2b73"}},{scope:"token.package",settings:{foreground:"#79797F"}},{scope:"token.storage.type.java",settings:{foreground:"#d5a910"}},{scope:"keyword.operator.assignment.go",settings:{foreground:"#d5a910"}},{scope:["keyword.operator.arithmetic.go","keyword.operator.address.go"],settings:{foreground:"#fc2b73"}},{scope:"entity.name.package.go",settings:{foreground:"#d5a910"}},{scope:["support.other.namespace.use.php","support.other.namespace.use-as.php","support.other.namespace.php","entity.other.alias.php","meta.interface.php"],settings:{foreground:"#d5a910"}},{scope:"keyword.operator.error-control.php",settings:{foreground:"#fc2b73"}},{scope:"keyword.operator.type.php",settings:{foreground:"#fc2b73"}},{scope:["punctuation.section.array.begin.php","punctuation.section.array.end.php"],settings:{foreground:"#79797F"}},{scope:["storage.type.php","meta.other.type.phpdoc.php","keyword.other.type.php","keyword.other.array.phpdoc.php"],settings:{foreground:"#d5a910"}},{scope:["meta.function-call.php","meta.function-call.object.php","meta.function-call.static.php"],settings:{foreground:"#7b43f8"}},{scope:["punctuation.definition.parameters.begin.bracket.round.php","punctuation.definition.parameters.end.bracket.round.php","punctuation.separator.delimiter.php","punctuation.section.scope.begin.php","punctuation.section.scope.end.php","punctuation.terminator.expression.php","punctuation.definition.arguments.begin.bracket.round.php","punctuation.definition.arguments.end.bracket.round.php","punctuation.definition.storage-type.begin.bracket.round.php","punctuation.definition.storage-type.end.bracket.round.php","punctuation.definition.array.begin.bracket.round.php","punctuation.definition.array.end.bracket.round.php","punctuation.definition.begin.bracket.round.php","punctuation.definition.end.bracket.round.php","punctuation.definition.begin.bracket.curly.php","punctuation.definition.end.bracket.curly.php","punctuation.definition.section.switch-block.end.bracket.curly.php","punctuation.definition.section.switch-block.start.bracket.curly.php","punctuation.definition.section.switch-block.begin.bracket.curly.php","punctuation.definition.section.switch-block.end.bracket.curly.php"],settings:{foreground:"#79797F"}},{scope:["support.constant.ext.php","support.constant.std.php","support.constant.core.php","support.constant.parser-token.php"],settings:{foreground:"#d5a910"}},{scope:["entity.name.goto-label.php","support.other.php"],settings:{foreground:"#7b43f8"}},{scope:["keyword.operator.logical.php","keyword.operator.bitwise.php","keyword.operator.arithmetic.php"],settings:{foreground:"#08c0ef"}},{scope:"keyword.operator.regexp.php",settings:{foreground:"#fc2b73"}},{scope:"keyword.operator.comparison.php",settings:{foreground:"#08c0ef"}},{scope:["keyword.operator.heredoc.php","keyword.operator.nowdoc.php"],settings:{foreground:"#fc2b73"}},{scope:"variable.other.class.php",settings:{foreground:"#d52c36"}},{scope:"invalid.illegal.non-null-typehinted.php",settings:{foreground:"#f44747"}},{scope:"variable.other.generic-type.haskell",settings:{foreground:"#fc2b73"}},{scope:"storage.type.haskell",settings:{foreground:"#d5a910"}},{scope:"storage.type.cs",settings:{foreground:"#d5a910"}},{scope:"entity.name.variable.local.cs",settings:{foreground:"#d52c36"}},{scope:"entity.name.label.cs",settings:{foreground:"#d5a910"}},{scope:["entity.name.scope-resolution.function.call","entity.name.scope-resolution.function.definition"],settings:{foreground:"#d5a910"}},{scope:["punctuation.definition.delayed.unison","punctuation.definition.list.begin.unison","punctuation.definition.list.end.unison","punctuation.definition.ability.begin.unison","punctuation.definition.ability.end.unison","punctuation.operator.assignment.as.unison","punctuation.separator.pipe.unison","punctuation.separator.delimiter.unison","punctuation.definition.hash.unison"],settings:{foreground:"#d52c36"}},{scope:"support.constant.edge",settings:{foreground:"#fc2b73"}},{scope:"support.type.prelude.elm",settings:{foreground:"#08c0ef"}},{scope:"support.constant.elm",settings:{foreground:"#d5a910"}},{scope:"entity.global.clojure",settings:{foreground:"#d5a910"}},{scope:"meta.symbol.clojure",settings:{foreground:"#d52c36"}},{scope:"constant.keyword.clojure",settings:{foreground:"#08c0ef"}},{scope:["meta.arguments.coffee","variable.parameter.function.coffee"],settings:{foreground:"#d52c36"}},{scope:"storage.modifier.import.groovy",settings:{foreground:"#d5a910"}},{scope:"meta.method.groovy",settings:{foreground:"#7b43f8"}},{scope:"meta.definition.variable.name.groovy",settings:{foreground:"#d52c36"}},{scope:"meta.definition.class.inherited.classes.groovy",settings:{foreground:"#199f43"}},{scope:"support.variable.semantic.hlsl",settings:{foreground:"#d5a910"}},{scope:["support.type.texture.hlsl","support.type.sampler.hlsl","support.type.object.hlsl","support.type.object.rw.hlsl","support.type.fx.hlsl","support.type.object.hlsl"],settings:{foreground:"#fc2b73"}},{scope:["text.variable","text.bracketed"],settings:{foreground:"#d52c36"}},{scope:["support.type.swift","support.type.vb.asp"],settings:{foreground:"#d5a910"}},{scope:"meta.scope.prerequisites.makefile",settings:{foreground:"#d52c36"}},{scope:"source.makefile",settings:{foreground:"#d5a910"}},{scope:"source.ini",settings:{foreground:"#199f43"}},{scope:"constant.language.symbol.ruby",settings:{foreground:"#08c0ef"}},{scope:["function.parameter.ruby","function.parameter.cs"],settings:{foreground:"#79797F"}},{scope:"constant.language.symbol.elixir",settings:{foreground:"#08c0ef"}},{scope:"text.html.laravel-blade source.php.embedded.line.html entity.name.tag.laravel-blade",settings:{foreground:"#fc2b73"}},{scope:"text.html.laravel-blade source.php.embedded.line.html support.constant.laravel-blade",settings:{foreground:"#fc2b73"}},{scope:"entity.name.function.xi",settings:{foreground:"#d5a910"}},{scope:"entity.name.class.xi",settings:{foreground:"#08c0ef"}},{scope:"constant.character.character-class.regexp.xi",settings:{foreground:"#d52c36"}},{scope:"constant.regexp.xi",settings:{foreground:"#fc2b73"}},{scope:"keyword.control.xi",settings:{foreground:"#08c0ef"}},{scope:"invalid.xi",settings:{foreground:"#79797F"}},{scope:"beginning.punctuation.definition.quote.markdown.xi",settings:{foreground:"#199f43"}},{scope:"beginning.punctuation.definition.list.markdown.xi",settings:{foreground:"#84848A"}},{scope:"constant.character.xi",settings:{foreground:"#7b43f8"}},{scope:"accent.xi",settings:{foreground:"#7b43f8"}},{scope:"wikiword.xi",settings:{foreground:"#d5a910"}},{scope:"constant.other.color.rgb-value.xi",settings:{foreground:"#ffffff"}},{scope:"punctuation.definition.tag.xi",settings:{foreground:"#84848A"}},{scope:["support.constant.property-value.scss","support.constant.property-value.css"],settings:{foreground:"#d5a910"}},{scope:["keyword.operator.css","keyword.operator.scss","keyword.operator.less"],settings:{foreground:"#08c0ef"}},{scope:["support.constant.color.w3c-standard-color-name.css","support.constant.color.w3c-standard-color-name.scss"],settings:{foreground:"#d5a910"}},{scope:"punctuation.separator.list.comma.css",settings:{foreground:"#79797F"}},{scope:"support.type.vendored.property-name.css",settings:{foreground:"#08c0ef"}},{scope:"support.type.property-name.css",settings:{foreground:"#08c0ef"}},{scope:"support.type.property-name",settings:{foreground:"#79797F"}},{scope:"support.constant.property-value",settings:{foreground:"#79797F"}},{scope:"support.constant.font-name",settings:{foreground:"#d5a910"}},{scope:"entity.other.attribute-name.class.css",settings:{foreground:"#16a994",fontStyle:"normal"}},{scope:"entity.other.attribute-name.id",settings:{foreground:"#7b43f8",fontStyle:"normal"}},{scope:["entity.other.attribute-name.pseudo-element","entity.other.attribute-name.pseudo-class"],settings:{foreground:"#08c0ef"}},{scope:"meta.selector",settings:{foreground:"#fc2b73"}},{scope:"selector.sass",settings:{foreground:"#d52c36"}},{scope:"rgb-value",settings:{foreground:"#08c0ef"}},{scope:"inline-color-decoration rgb-value",settings:{foreground:"#d5a910"}},{scope:"less rgb-value",settings:{foreground:"#d5a910"}},{scope:"control.elements",settings:{foreground:"#d5a910"}},{scope:"keyword.operator.less",settings:{foreground:"#d5a910"}},{scope:"entity.name.tag",settings:{foreground:"#d52c36"}},{scope:"entity.other.attribute-name",settings:{foreground:"#16a994",fontStyle:"normal"}},{scope:"constant.character.entity",settings:{foreground:"#d52c36"}},{scope:"meta.tag",settings:{foreground:"#79797F"}},{scope:"invalid.illegal.bad-ampersand.html",settings:{foreground:"#79797F"}},{scope:"markup.heading",settings:{foreground:"#d52c36"}},{scope:["markup.heading punctuation.definition.heading","entity.name.section"],settings:{foreground:"#7b43f8"}},{scope:"entity.name.section.markdown",settings:{foreground:"#d52c36"}},{scope:"punctuation.definition.heading.markdown",settings:{foreground:"#d52c36"}},{scope:"markup.heading.setext",settings:{foreground:"#79797F"}},{scope:["markup.heading.setext.1.markdown","markup.heading.setext.2.markdown"],settings:{foreground:"#d52c36"}},{scope:["markup.bold","todo.bold"],settings:{foreground:"#d5a910"}},{scope:"punctuation.definition.bold",settings:{foreground:"#d5a910"}},{scope:"punctuation.definition.bold.markdown",settings:{foreground:"#d5a910"}},{scope:["markup.italic","punctuation.definition.italic","todo.emphasis"],settings:{foreground:"#fc2b73",fontStyle:"italic"}},{scope:"emphasis md",settings:{foreground:"#fc2b73"}},{scope:"markup.italic.markdown",settings:{fontStyle:"italic"}},{scope:["markup.underline.link.markdown","markup.underline.link.image.markdown"],settings:{foreground:"#fc2b73"}},{scope:["string.other.link.title.markdown","string.other.link.description.markdown"],settings:{foreground:"#7b43f8"}},{scope:"punctuation.definition.metadata.markdown",settings:{foreground:"#d52c36"}},{scope:["markup.inline.raw.markdown","markup.inline.raw.string.markdown"],settings:{foreground:"#199f43"}},{scope:"punctuation.definition.list.begin.markdown",settings:{foreground:"#d52c36"}},{scope:"punctuation.definition.list.markdown",settings:{foreground:"#d52c36"}},{scope:"beginning.punctuation.definition.list.markdown",settings:{foreground:"#d52c36"}},{scope:["punctuation.definition.string.begin.markdown","punctuation.definition.string.end.markdown"],settings:{foreground:"#d52c36"}},{scope:"markup.quote.markdown",settings:{foreground:"#84848A"}},{scope:"keyword.other.unit",settings:{foreground:"#d52c36"}},{scope:"markup.changed.diff",settings:{foreground:"#d5a910"}},{scope:["meta.diff.header.from-file","meta.diff.header.to-file","punctuation.definition.from-file.diff","punctuation.definition.to-file.diff"],settings:{foreground:"#7b43f8"}},{scope:"markup.inserted.diff",settings:{foreground:"#199f43"}},{scope:"markup.deleted.diff",settings:{foreground:"#d52c36"}},{scope:"string.regexp",settings:{foreground:"#17a5af"}},{scope:"constant.other.character-class.regexp",settings:{foreground:"#d52c36"}},{scope:"keyword.operator.quantifier.regexp",settings:{foreground:"#d5a910"}},{scope:"constant.character.escape",settings:{foreground:"#1ca1c7"}},{scope:"source.json meta.structure.dictionary.json > string.quoted.json",settings:{foreground:"#d52c36"}},{scope:"source.json meta.structure.dictionary.json > string.quoted.json > punctuation.string",settings:{foreground:"#d52c36"}},{scope:["source.json meta.structure.dictionary.json > value.json > string.quoted.json","source.json meta.structure.array.json > value.json > string.quoted.json","source.json meta.structure.dictionary.json > value.json > string.quoted.json > punctuation","source.json meta.structure.array.json > value.json > string.quoted.json > punctuation"],settings:{foreground:"#199f43"}},{scope:["source.json meta.structure.dictionary.json > constant.language.json","source.json meta.structure.array.json > constant.language.json"],settings:{foreground:"#08c0ef"}},{scope:"support.type.property-name.json",settings:{foreground:"#d52c36"}},{scope:"support.type.property-name.json punctuation",settings:{foreground:"#d52c36"}},{scope:"punctuation.definition.block.sequence.item.yaml",settings:{foreground:"#79797F"}},{scope:"block.scope.end",settings:{foreground:"#79797F"}},{scope:"block.scope.begin",settings:{foreground:"#79797F"}},{scope:"token.info-token",settings:{foreground:"#7b43f8"}},{scope:"token.warn-token",settings:{foreground:"#d5a910"}},{scope:"token.error-token",settings:{foreground:"#f44747"}},{scope:"token.debug-token",settings:{foreground:"#fc2b73"}},{scope:"invalid.illegal",settings:{foreground:"#ffffff"}},{scope:"invalid.broken",settings:{foreground:"#ffffff"}},{scope:"invalid.deprecated",settings:{foreground:"#ffffff"}},{scope:"invalid.unimplemented",settings:{foreground:"#ffffff"}}],x={comment:"#84848A",string:"#199f43",number:"#1ca1c7",regexp:"#17a5af",keyword:"#fc2b73",variable:"#d47628",parameter:"#79797F",property:"#d47628",function:"#7b43f8",method:"#7b43f8",type:"#c635e4",class:"#c635e4",namespace:"#d5a910",enumMember:"#08c0ef","variable.constant":"#d5a910","variable.defaultLibrary":"#d5a910"},z={name:b,type:q,colors:v,tokenColors:w,semanticTokenColors:x};export{q as type,w as tokenColors,x as semanticTokenColors,b as name,z as default,v as colors}; diff --git a/lib/crates/fabro-spa/assets/assets/entry-pmqz8d3n.js b/lib/crates/fabro-spa/assets/assets/entry-pmqz8d3n.js deleted file mode 100644 index eabbad3af..000000000 --- a/lib/crates/fabro-spa/assets/assets/entry-pmqz8d3n.js +++ /dev/null @@ -1,2399 +0,0 @@ -import{X as g,Y as R8,Z as a5,_ as f}from"./chunk-q07bg6gn.js";var Y0=R8((el,SU)=>{(function(){function Z(I,Z0){Object.defineProperty(q.prototype,I,{get:function(){console.warn("%s(...) is deprecated in plain JavaScript React classes. %s",Z0[0],Z0[1])}})}function Y(I){if(I===null||typeof I!=="object")return null;return I=K1&&I[K1]||I["@@iterator"],typeof I==="function"?I:null}function J(I,Z0){I=(I=I.constructor)&&(I.displayName||I.name)||"ReactClass";var _0=I+"."+Z0;L0[_0]||(console.error("Can't call %s on a component that is not yet mounted. This is a no-op, but it might indicate a bug in your application. Instead, assign to `this.state` directly or define a `state = {};` class property with the desired state in the %s component.",Z0,I),L0[_0]=!0)}function q(I,Z0,_0){this.props=I,this.context=Z0,this.refs=t5,this.updater=_0||T1}function z(){}function B(I,Z0,_0){this.props=I,this.context=Z0,this.refs=t5,this.updater=_0||T1}function W(){}function U(I){return""+I}function G(I){try{U(I);var Z0=!1}catch(x0){Z0=!0}if(Z0){Z0=console;var _0=Z0.error,v0=typeof Symbol==="function"&&Symbol.toStringTag&&I[Symbol.toStringTag]||I.constructor.name||"Object";return _0.call(Z0,"The provided key is an unsupported type %s. This value must be coerced to a string before using it here.",v0),U(I)}}function w(I){if(I==null)return null;if(typeof I==="function")return I.$$typeof===c6?null:I.displayName||I.name||null;if(typeof I==="string")return I;switch(I){case W0:return"Fragment";case U0:return"Profiler";case x:return"StrictMode";case k0:return"Suspense";case M0:return"SuspenseList";case O1:return"Activity"}if(typeof I==="object")switch(typeof I.tag==="number"&&console.error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),I.$$typeof){case t:return"Portal";case w0:return I.displayName||"Context";case H0:return(I._context.displayName||"Context")+".Consumer";case R0:var Z0=I.render;return I=I.displayName,I||(I=Z0.displayName||Z0.name||"",I=I!==""?"ForwardRef("+I+")":"ForwardRef"),I;case p0:return Z0=I.displayName||null,Z0!==null?Z0:w(I.type)||"Memo";case r0:Z0=I._payload,I=I._init;try{return w(I(Z0))}catch(_0){}}return null}function N(I){if(I===W0)return"<>";if(typeof I==="object"&&I!==null&&I.$$typeof===r0)return"<...>";try{var Z0=w(I);return Z0?"<"+Z0+">":"<...>"}catch(_0){return"<...>"}}function O(){var I=B1.A;return I===null?null:I.getOwner()}function A(){return Error("react-stack-top-frame")}function _(I){if(U4.call(I,"key")){var Z0=Object.getOwnPropertyDescriptor(I,"key").get;if(Z0&&Z0.isReactWarning)return!1}return I.key!==void 0}function P(I,Z0){function _0(){H6||(H6=!0,console.error("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://react.dev/link/special-props)",Z0))}_0.isReactWarning=!0,Object.defineProperty(I,"key",{get:_0,configurable:!0})}function L(){var I=w(this.type);return f4[I]||(f4[I]=!0,console.error("Accessing element.ref was removed in React 19. ref is now a regular prop. It will be removed from the JSX Element type in a future release.")),I=this.props.ref,I!==void 0?I:null}function R(I,Z0,_0,v0,x0,Y1){var f0=_0.ref;return I={$$typeof:Q0,type:I,key:Z0,props:_0,_owner:v0},(f0!==void 0?f0:null)!==null?Object.defineProperty(I,"ref",{enumerable:!1,get:L}):Object.defineProperty(I,"ref",{enumerable:!1,value:null}),I._store={},Object.defineProperty(I._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:0}),Object.defineProperty(I,"_debugInfo",{configurable:!1,enumerable:!1,writable:!0,value:null}),Object.defineProperty(I,"_debugStack",{configurable:!1,enumerable:!1,writable:!0,value:x0}),Object.defineProperty(I,"_debugTask",{configurable:!1,enumerable:!1,writable:!0,value:Y1}),Object.freeze&&(Object.freeze(I.props),Object.freeze(I)),I}function C(I,Z0){return Z0=R(I.type,Z0,I.props,I._owner,I._debugStack,I._debugTask),I._store&&(Z0._store.validated=I._store.validated),Z0}function T(I){v(I)?I._store&&(I._store.validated=1):typeof I==="object"&&I!==null&&I.$$typeof===r0&&(I._payload.status==="fulfilled"?v(I._payload.value)&&I._payload.value._store&&(I._payload.value._store.validated=1):I._store&&(I._store.validated=1))}function v(I){return typeof I==="object"&&I!==null&&I.$$typeof===Q0}function D(I){var Z0={"=":"=0",":":"=2"};return"$"+I.replace(/[=:]/g,function(_0){return Z0[_0]})}function S(I,Z0){return typeof I==="object"&&I!==null&&I.key!=null?(G(I.key),D(""+I.key)):Z0.toString(36)}function j(I){switch(I.status){case"fulfilled":return I.value;case"rejected":throw I.reason;default:switch(typeof I.status==="string"?I.then(W,W):(I.status="pending",I.then(function(Z0){I.status==="pending"&&(I.status="fulfilled",I.value=Z0)},function(Z0){I.status==="pending"&&(I.status="rejected",I.reason=Z0)})),I.status){case"fulfilled":return I.value;case"rejected":throw I.reason}}throw I}function E(I,Z0,_0,v0,x0){var Y1=typeof I;if(Y1==="undefined"||Y1==="boolean")I=null;var f0=!1;if(I===null)f0=!0;else switch(Y1){case"bigint":case"string":case"number":f0=!0;break;case"object":switch(I.$$typeof){case Q0:case t:f0=!0;break;case r0:return f0=I._init,E(f0(I._payload),Z0,_0,v0,x0)}}if(f0){f0=I,x0=x0(f0);var J1=v0===""?"."+S(f0,0):v0;return w1(x0)?(_0="",J1!=null&&(_0=J1.replace(n2,"$&/")+"/"),E(x0,Z0,_0,"",function(H5){return H5})):x0!=null&&(v(x0)&&(x0.key!=null&&(f0&&f0.key===x0.key||G(x0.key)),_0=C(x0,_0+(x0.key==null||f0&&f0.key===x0.key?"":(""+x0.key).replace(n2,"$&/")+"/")+J1),v0!==""&&f0!=null&&v(f0)&&f0.key==null&&f0._store&&!f0._store.validated&&(_0._store.validated=2),x0=_0),Z0.push(x0)),1}if(f0=0,J1=v0===""?".":v0+":",w1(I))for(var y0=0;y0 import('./MyComponent')) - -Did you accidentally put curly braces around the import?`,Z0),"default"in Z0||console.error(`lazy: Expected the result of a dynamic import() call. Instead received: %s - -Your code should look like: - const MyComponent = lazy(() => import('./MyComponent'))`,Z0),Z0.default;throw I._result}function k(){var I=B1.H;return I===null&&console.error(`Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: -1. You might have mismatching versions of React and the renderer (such as React DOM) -2. You might be breaking the Rules of Hooks -3. You might have more than one copy of React in the same app -See https://react.dev/link/invalid-hook-call for tips about how to debug and fix this problem.`),I}function r(){B1.asyncTransitions--}function X0(I){if(G4===null)try{var Z0=("require"+Math.random()).slice(0,7);G4=(SU&&SU[Z0]).call(SU,"timers").setImmediate}catch(_0){G4=function(v0){B2===!1&&(B2=!0,typeof MessageChannel>"u"&&console.error("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."));var x0=new MessageChannel;x0.port1.onmessage=v0,x0.port2.postMessage(void 0)}}return G4(I)}function B0(I){return 1 ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"))}),{then:function(y0,H5){x0=!0,f0.then(function(q5){if(e(Z0,_0),_0===0){try{q0(v0),X0(function(){return m(q5,y0,H5)})}catch(n5){B1.thrownErrors.push(n5)}if(0 ...)"))}),B1.actQueue=null),0B1.recentlyCreatedOwnerStacks++;return R(I,x0,v0,O(),y0?Error("react-stack-top-frame"):S2,y0?j1(N(I)):N6)},el.createRef=function(){var I={current:null};return Object.seal(I),I},el.forwardRef=function(I){I!=null&&I.$$typeof===p0?console.error("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof I!=="function"?console.error("forwardRef requires a render function but was given %s.",I===null?"null":typeof I):I.length!==0&&I.length!==2&&console.error("forwardRef render functions accept exactly two parameters: props and ref. %s",I.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),I!=null&&I.defaultProps!=null&&console.error("forwardRef render functions do not support defaultProps. Did you accidentally pass a React component?");var Z0={$$typeof:R0,render:I},_0;return Object.defineProperty(Z0,"displayName",{enumerable:!1,configurable:!0,get:function(){return _0},set:function(v0){_0=v0,I.name||I.displayName||(Object.defineProperty(I,"name",{value:v0}),I.displayName=v0)}}),Z0},el.isValidElement=v,el.lazy=function(I){I={_status:-1,_result:I};var Z0={$$typeof:r0,_payload:I,_init:i},_0={name:"lazy",start:-1,end:-1,value:null,owner:null,debugStack:Error("react-stack-top-frame"),debugTask:console.createTask?console.createTask("lazy()"):null};return I._ioInfo=_0,Z0._debugInfo=[{awaited:_0}],Z0},el.memo=function(I,Z0){I==null&&console.error("memo: The first argument must be a component. Instead received: %s",I===null?"null":typeof I),Z0={$$typeof:p0,type:I,compare:Z0===void 0?null:Z0};var _0;return Object.defineProperty(Z0,"displayName",{enumerable:!1,configurable:!0,get:function(){return _0},set:function(v0){_0=v0,I.name||I.displayName||(Object.defineProperty(I,"name",{value:v0}),I.displayName=v0)}}),Z0},el.startTransition=function(I){var Z0=B1.T,_0={};_0._updatedFibers=new Set,B1.T=_0;try{var v0=I(),x0=B1.S;x0!==null&&x0(_0,v0),typeof v0==="object"&&v0!==null&&typeof v0.then==="function"&&(B1.asyncTransitions++,v0.then(r,r),v0.then(W,R5))}catch(Y1){R5(Y1)}finally{Z0===null&&_0._updatedFibers&&(I=_0._updatedFibers.size,_0._updatedFibers.clear(),10{(function(){function Z(){if(D=!1,h){var m=Zr.unstable_now();r=m;var q0=!0;try{Z:{T=!1,v&&(v=!1,j(i),i=-1),C=!0;var Q0=R;try{Y:{B(m);for(L=J(A);L!==null&&!(L.expirationTime>m&&U());){var t=L.callback;if(typeof t==="function"){L.callback=null,R=L.priorityLevel;var W0=t(L.expirationTime<=m);if(m=Zr.unstable_now(),typeof W0==="function"){L.callback=W0,B(m),q0=!0;break Y}L===J(A)&&q(A),B(m)}else q(A);L=J(A)}if(L!==null)q0=!0;else{var x=J(_);x!==null&&G(W,x.startTime-m),q0=!1}}break Z}finally{L=null,R=Q0,C=!1}q0=void 0}}finally{q0?X0():h=!1}}}function Y(m,q0){var Q0=m.length;m.push(q0);Z:for(;0>>1,W0=m[t];if(0>>1;tz(H0,Q0))w0z(R0,H0)?(m[t]=R0,m[w0]=Q0,t=w0):(m[t]=H0,m[U0]=Q0,t=U0);else if(w0z(R0,Q0))m[t]=R0,m[w0]=Q0,t=w0;else break Z}}return q0}function z(m,q0){var Q0=m.sortIndex-q0.sortIndex;return Q0!==0?Q0:m.id-q0.id}function B(m){for(var q0=J(_);q0!==null;){if(q0.callback===null)q(_);else if(q0.startTime<=m)q(_),q0.sortIndex=q0.expirationTime,Y(A,q0);else break;q0=J(_)}}function W(m){if(v=!1,B(m),!T)if(J(A)!==null)T=!0,h||(h=!0,X0());else{var q0=J(_);q0!==null&&G(W,q0.startTime-m)}}function U(){return D?!0:Zr.unstable_now()-rm||125t?(m.sortIndex=Q0,Y(_,m),J(A)===null&&m===J(_)&&(v?(j(i),i=-1):v=!0,G(W,Q0-t))):(m.sortIndex=W0,Y(A,m),T||C||(T=!0,h||(h=!0,X0()))),m},Zr.unstable_shouldYield=U,Zr.unstable_wrapCallback=function(m){var q0=R;return function(){var Q0=R;R=q0;try{return m.apply(this,arguments)}finally{R=Q0}}},typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var Yy=R8((Yr)=>{var W_=g(Y0());(function(){function Z(){}function Y(N){return""+N}function J(N,O,A){var _=3` tag.%s',A),typeof N==="string"&&typeof O==="object"&&O!==null&&typeof O.as==="string"){A=O.as;var _=q(A,O.crossOrigin);U.d.L(N,A,{crossOrigin:_,integrity:typeof O.integrity==="string"?O.integrity:void 0,nonce:typeof O.nonce==="string"?O.nonce:void 0,type:typeof O.type==="string"?O.type:void 0,fetchPriority:typeof O.fetchPriority==="string"?O.fetchPriority:void 0,referrerPolicy:typeof O.referrerPolicy==="string"?O.referrerPolicy:void 0,imageSrcSet:typeof O.imageSrcSet==="string"?O.imageSrcSet:void 0,imageSizes:typeof O.imageSizes==="string"?O.imageSizes:void 0,media:typeof O.media==="string"?O.media:void 0})}},Yr.preloadModule=function(N,O){var A="";typeof N==="string"&&N||(A+=" The `href` argument encountered was "+z(N)+"."),O!==void 0&&typeof O!=="object"?A+=" The `options` argument encountered was "+z(O)+".":O&&("as"in O)&&typeof O.as!=="string"&&(A+=" The `as` option encountered was "+z(O.as)+"."),A&&console.error('ReactDOM.preloadModule(): Expected two arguments, a non-empty `href` string and, optionally, an `options` object with an `as` property valid for a `` tag.%s',A),typeof N==="string"&&(O?(A=q(O.as,O.crossOrigin),U.d.m(N,{as:typeof O.as==="string"&&O.as!=="script"?O.as:void 0,crossOrigin:A,integrity:typeof O.integrity==="string"?O.integrity:void 0})):U.d.m(N))},Yr.requestFormReset=function(N){U.d.r(N)},Yr.unstable_batchedUpdates=function(N,O){return N(O)},Yr.useFormState=function(N,O,A){return W().useFormState(N,O,A)},Yr.useFormStatus=function(){return W().useHostTransitionStatus()},Yr.version="19.2.4",typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop==="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(Error())})()});var F3=R8((FY0,Qy)=>{var Qr=g(Yy());Qy.exports=Qr});var Jy=R8((Jr)=>{var a1=g(Zy()),bJ=g(Y0()),$_=g(F3());(function(){function Z(Q,X){for(Q=Q.memoizedState;Q!==null&&0=X.length)return $;var M=X[K],H=L2(Q)?Q.slice():D1({},Q);return H[M]=Y(Q[M],X,K+1,$),H}function J(Q,X,K){if(X.length!==K.length)console.warn("copyWithRename() expects paths of the same length");else{for(var $=0;$K8?console.error("Unexpected pop."):(X!==mO[K8]&&console.error("Unexpected Fiber popped."),Q.current=uO[K8],uO[K8]=null,mO[K8]=null,K8--)}function B0(Q,X,K){K8++,uO[K8]=Q.current,mO[K8]=K,Q.current=X}function e(Q){return Q===null&&console.error("Expected host context to exist. This error is likely caused by a bug in React. Please file an issue."),Q}function m(Q,X){B0(A9,X,Q),B0(vz,Q,Q),B0(O9,null,Q);var K=X.nodeType;switch(K){case 9:case 11:K=K===9?"#document":"#fragment",X=(X=X.documentElement)?(X=X.namespaceURI)?fC(X):P8:P8;break;default:if(K=X.tagName,X=X.namespaceURI)X=fC(X),X=gC(X,K);else switch(K){case"svg":X=TJ;break;case"math":X=CU;break;default:X=P8}}K=K.toLowerCase(),K=yR(null,K),K={context:X,ancestorInfo:K},X0(O9,Q),B0(O9,K,Q)}function q0(Q){X0(O9,Q),X0(vz,Q),X0(A9,Q)}function Q0(){return e(O9.current)}function t(Q){Q.memoizedState!==null&&B0(E$,Q,Q);var X=e(O9.current),K=Q.type,$=gC(X.context,K);K=yR(X.ancestorInfo,K),$={context:$,ancestorInfo:K},X!==$&&(B0(vz,Q,Q),B0(O9,$,Q))}function W0(Q){vz.current===Q&&(X0(O9,Q),X0(vz,Q)),E$.current===Q&&(X0(E$,Q),NK._currentValue=NY)}function x(){}function U0(){if(Tz===0){wb=console.log,Hb=console.info,Nb=console.warn,Ob=console.error,Ab=console.group,_b=console.groupCollapsed,Fb=console.groupEnd;var Q={configurable:!0,enumerable:!0,value:x,writable:!0};Object.defineProperties(console,{info:Q,log:Q,warn:Q,error:Q,group:Q,groupCollapsed:Q,groupEnd:Q})}Tz++}function H0(){if(Tz--,Tz===0){var Q={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:D1({},Q,{value:wb}),info:D1({},Q,{value:Hb}),warn:D1({},Q,{value:Nb}),error:D1({},Q,{value:Ob}),group:D1({},Q,{value:Ab}),groupCollapsed:D1({},Q,{value:_b}),groupEnd:D1({},Q,{value:Fb})})}0>Tz&&console.error("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}function w0(Q){var X=Error.prepareStackTrace;if(Error.prepareStackTrace=void 0,Q=Q.stack,Error.prepareStackTrace=X,Q.startsWith(`Error: react-stack-top-frame -`)&&(Q=Q.slice(29)),X=Q.indexOf(` -`),X!==-1&&(Q=Q.slice(X+1)),X=Q.indexOf("react_stack_bottom_frame"),X!==-1&&(X=Q.lastIndexOf(` -`,X)),X!==-1)Q=Q.slice(0,X);else return"";return Q}function R0(Q){if(pO===void 0)try{throw Error()}catch(K){var X=K.stack.trim().match(/\n( *(at )?)/);pO=X&&X[1]||"",Pb=-1)":-1F||y[H]!==s[F]){var o=` -`+y[H].replace(" at new "," at ");return Q.displayName&&o.includes("")&&(o=o.replace("",Q.displayName)),typeof Q==="function"&&dO.set(Q,o),o}while(1<=H&&0<=F);break}}}finally{cO=!1,G0.H=$,H0(),Error.prepareStackTrace=K}return y=(y=Q?Q.displayName||Q.name:"")?R0(y):"",typeof Q==="function"&&dO.set(Q,y),y}function M0(Q,X){switch(Q.tag){case 26:case 27:case 5:return R0(Q.type);case 16:return R0("Lazy");case 13:return Q.child!==X&&X!==null?R0("Suspense Fallback"):R0("Suspense");case 19:return R0("SuspenseList");case 0:case 15:return k0(Q.type,!1);case 11:return k0(Q.type.render,!1);case 1:return k0(Q.type,!0);case 31:return R0("Activity");default:return""}}function p0(Q){try{var X="",K=null;do{X+=M0(Q,K);var $=Q._debugInfo;if($)for(var M=$.length-1;0<=M;M--){var H=$[M];if(typeof H.name==="string"){var F=X;Z:{var{name:V,env:b,debugLocation:y}=H;if(y!=null){var s=w0(y),o=s.lastIndexOf(` -`),c=o===-1?s:s.slice(o+1);if(c.indexOf(V)!==-1){var z0=` -`+c;break Z}}z0=R0(V+(b?" ["+b+"]":""))}X=F+z0}}K=Q,Q=Q.return}while(Q);return X}catch(T0){return` -Error generating stack: `+T0.message+` -`+T0.stack}}function r0(Q){return(Q=Q?Q.displayName||Q.name:"")?R0(Q):""}function O1(){if(P4===null)return null;var Q=P4._debugOwner;return Q!=null?i(Q):null}function K1(){if(P4===null)return"";var Q=P4;try{var X="";switch(Q.tag===6&&(Q=Q.return),Q.tag){case 26:case 27:case 5:X+=R0(Q.type);break;case 13:X+=R0("Suspense");break;case 19:X+=R0("SuspenseList");break;case 31:X+=R0("Activity");break;case 30:case 0:case 15:case 1:Q._debugOwner||X!==""||(X+=r0(Q.type));break;case 11:Q._debugOwner||X!==""||(X+=r0(Q.type.render))}for(;Q;)if(typeof Q.tag==="number"){var K=Q;Q=K._debugOwner;var $=K._debugStack;if(Q&&$){var M=w0($);M!==""&&(X+=` -`+M)}}else if(Q.debugStack!=null){var H=Q.debugStack;(Q=Q.owner)&&H&&(X+=` -`+w0(H))}else break;var F=X}catch(V){F=` -Error generating stack: `+V.message+` -`+V.stack}return F}function L0(Q,X,K,$,M,H,F){var V=P4;T1(Q);try{return Q!==null&&Q._debugTask?Q._debugTask.run(X.bind(null,K,$,M,H,F)):X(K,$,M,H,F)}finally{T1(V)}throw Error("runWithFiberInDEV should never be called in production. This is a bug in React.")}function T1(Q){G0.getCurrentStack=Q===null?null:K1,$3=!1,P4=Q}function Y5(Q){return typeof Symbol==="function"&&Symbol.toStringTag&&Q[Symbol.toStringTag]||Q.constructor.name||"Object"}function t5(Q){try{return w5(Q),!1}catch(X){return!0}}function w5(Q){return""+Q}function w1(Q,X){if(t5(Q))return console.error("The provided `%s` attribute is an unsupported type %s. This value must be coerced to a string before using it here.",X,Y5(Q)),w5(Q)}function c6(Q,X){if(t5(Q))return console.error("The provided `%s` CSS property is an unsupported type %s. This value must be coerced to a string before using it here.",X,Y5(Q)),w5(Q)}function B1(Q){if(t5(Q))return console.error("Form field values (value, checked, defaultValue, or defaultChecked props) must be strings, not %s. This value must be coerced to a string before using it here.",Y5(Q)),w5(Q)}function U4(Q){if(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u")return!1;var X=__REACT_DEVTOOLS_GLOBAL_HOOK__;if(X.isDisabled)return!0;if(!X.supportsFiber)return console.error("The installed version of React DevTools is too old and will not work with the current version of React. Please update React DevTools. https://react.dev/link/react-devtools"),!0;try{sQ=X.inject(Q),N7=X}catch(K){console.error("React instrumentation encountered an error: %o.",K)}return X.checkDCE?!0:!1}function j1(Q){if(typeof Nd==="function"&&Od(Q),N7&&typeof N7.setStrictMode==="function")try{N7.setStrictMode(sQ,Q)}catch(X){U3||(U3=!0,console.error("React instrumentation encountered an error: %o",X))}}function H6(Q){return Q>>>=0,Q===0?32:31-(Ad(Q)/_d|0)|0}function t2(Q){var X=Q&42;if(X!==0)return X;switch(Q&-Q){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return Q&261888;case 262144:case 524288:case 1048576:case 2097152:return Q&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return Q&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return console.error("Should have found matching lanes. This is a bug in React."),Q}}function f4(Q,X,K){var $=Q.pendingLanes;if($===0)return 0;var M=0,H=Q.suspendedLanes,F=Q.pingedLanes;Q=Q.warmLanes;var V=$&134217727;return V!==0?($=V&~H,$!==0?M=t2($):(F&=V,F!==0?M=t2(F):K||(K=V&~Q,K!==0&&(M=t2(K))))):(V=$&~H,V!==0?M=t2(V):F!==0?M=t2(F):K||(K=$&~Q,K!==0&&(M=t2(K)))),M===0?0:X!==0&&X!==M&&(X&H)===0&&(H=M&-M,K=X&-X,H>=K||H===32&&(K&4194048)!==0)?X:M}function S2(Q,X){return(Q.pendingLanes&~(Q.suspendedLanes&~Q.pingedLanes)&X)===0}function N6(Q,X){switch(Q){case 1:case 2:case 4:case 8:case 64:return X+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return X+5000;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return console.error("Should have found matching lanes. This is a bug in React."),-1}}function O6(){var Q=j$;return j$<<=1,(j$&62914560)===0&&(j$=4194304),Q}function n2(Q){for(var X=[],K=0;31>K;K++)X.push(Q);return X}function R5(Q,X){Q.pendingLanes|=X,X!==268435456&&(Q.suspendedLanes=0,Q.pingedLanes=0,Q.warmLanes=0)}function B2(Q,X,K,$,M,H){var F=Q.pendingLanes;Q.pendingLanes=K,Q.suspendedLanes=0,Q.pingedLanes=0,Q.warmLanes=0,Q.expiredLanes&=K,Q.entangledLanes&=K,Q.errorRecoveryDisabledLanes&=K,Q.shellSuspendCounter=0;var{entanglements:V,expirationTimes:b,hiddenUpdates:y}=Q;for(K=F&~K;0"u")return null;try{return Q.activeElement||Q.body}catch(X){return Q.body}}function S0(Q){return Q.replace(Rd,function(X){return"\\"+X.charCodeAt(0).toString(16)+" "})}function d0(Q,X){X.checked===void 0||X.defaultChecked===void 0||Cb||(console.error("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",O1()||"A component",X.type),Cb=!0),X.value===void 0||X.defaultValue===void 0||Tb||(console.error("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://react.dev/link/controlled-components",O1()||"A component",X.type),Tb=!0)}function a0(Q,X,K,$,M,H,F,V){if(Q.name="",F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"?(w1(F,"type"),Q.type=F):Q.removeAttribute("type"),X!=null)if(F==="number"){if(X===0&&Q.value===""||Q.value!=X)Q.value=""+n(X)}else Q.value!==""+n(X)&&(Q.value=""+n(X));else F!=="submit"&&F!=="reset"||Q.removeAttribute("value");X!=null?l0(Q,F,n(X)):K!=null?l0(Q,F,n(K)):$!=null&&Q.removeAttribute("value"),M==null&&H!=null&&(Q.defaultChecked=!!H),M!=null&&(Q.checked=M&&typeof M!=="function"&&typeof M!=="symbol"),V!=null&&typeof V!=="function"&&typeof V!=="symbol"&&typeof V!=="boolean"?(w1(V,"name"),Q.name=""+n(V)):Q.removeAttribute("name")}function Q1(Q,X,K,$,M,H,F,V){if(H!=null&&typeof H!=="function"&&typeof H!=="symbol"&&typeof H!=="boolean"&&(w1(H,"type"),Q.type=H),X!=null||K!=null){if(!(H!=="submit"&&H!=="reset"||X!==void 0&&X!==null)){P0(Q);return}K=K!=null?""+n(K):"",X=X!=null?""+n(X):K,V||X===Q.value||(Q.value=X),Q.defaultValue=X}$=$!=null?$:M,$=typeof $!=="function"&&typeof $!=="symbol"&&!!$,Q.checked=V?Q.checked:!!$,Q.defaultChecked=!!$,F!=null&&typeof F!=="function"&&typeof F!=="symbol"&&typeof F!=="boolean"&&(w1(F,"name"),Q.name=F),P0(Q)}function l0(Q,X,K){X==="number"&&I0(Q.ownerDocument)===Q||Q.defaultValue===""+K||(Q.defaultValue=""+K)}function m1(Q,X){X.value==null&&(typeof X.children==="object"&&X.children!==null?bJ.Children.forEach(X.children,function(K){K==null||typeof K==="string"||typeof K==="number"||typeof K==="bigint"||Db||(Db=!0,console.error("Cannot infer the option value of complex children. Pass a `value` prop or use a plain string as children to