ldrouter 1.10.2 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.11.0] - 2026-09-01
8
+
9
+ ### Added
10
+
11
+ - **Admin-site IP access control** (Settings → Access Control tab): Allow/Block IP lists (CIDR, one per line) now restrict access to the entire admin website — login, setup, and static UI included. Non-matching IPs get a plain 403 "Không có quyền truy cập". Model traffic (`/v1/*`) and `/health` are never affected. Lockout guard: saving a non-empty allow list auto-adds your current IP so you can't lock yourself out.
12
+ - **Live route lighting on /statistics**: while a request is being served — from the first token (TTFT) until completion — the Request → Gateway → Provider line lights up in the brand primary color with a soft pulsing glow; the completion pulse dot still flashes when the request finishes. Powered by a new live-only `request_started` SSE event.
13
+ - **API key editing**: the API keys page now has an Edit (✏️) button per key — name, expiry, RPM/TPM/concurrency limits, and model scope are editable via the existing dialog; the key secret itself is never changed on edit.
14
+
15
+ ### Fixed
16
+
17
+ - **Ghost notifications**: reconnects (after the 5-minute stream cycle or a network drop) replayed recent requests over SSE, which re-triggered the notification card + sound even though no new request existed. The notification hook now dedupes by request ID across reconnects.
18
+ - **Model Test no longer returns an opaque 500 "Gateway error"** when the provider credential can't be decrypted (master-key mismatch, e.g. after restoring a backup from another instance): it now returns a readable `authentication_error` telling you to re-save the provider API key; the non-streaming test route also wraps unexpected runner errors instead of leaking them.
19
+
7
20
  ## [1.10.2] - 2026-09-01
8
21
 
9
22
  ### Fixed
@@ -19,6 +19,7 @@ import { isMasterKeyConfigured } from './auth/crypto.js';
19
19
  import { registerAdminRoutes } from './routes/admin.js';
20
20
  import { registerGatewayRoutes } from './routes/gateway.js';
21
21
  import { registerHealthRoutes } from './routes/health.js';
22
+ import { registerAdminIpGate } from './security/admin-ip-gate.js';
22
23
  import { metricsRegistry } from './metrics/registry.js';
23
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
24
25
  export async function buildApp(opts = {}) {
@@ -76,6 +77,9 @@ export async function buildApp(opts = {}) {
76
77
  const e = new GatewayError('gateway_error', 'Internal gateway error', { safe: false, cause: err });
77
78
  reply.code(500).send(isAnthropic ? toAnthropicError(e, requestId) : toOpenAIError(e, requestId));
78
79
  });
80
+ // Admin-site IP access control (Settings → Access Control). Root scope so it
81
+ // covers the static UI, login/setup, and all admin APIs; /health stays open.
82
+ registerAdminIpGate(app);
79
83
  // Static admin UI (if built). The not-found handler is registered once:
80
84
  // with a built UI it serves the SPA index.html for non-API paths; without it,
81
85
  // every miss returns a JSON 404 in the requesting protocol's shape.
@@ -89,6 +89,8 @@ function buildInitialSchemaSql() {
89
89
  master_key_configured INTEGER NOT NULL DEFAULT 0,
90
90
  notifications_enabled INTEGER NOT NULL DEFAULT 1,
91
91
  notification_sound_enabled INTEGER NOT NULL DEFAULT 1,
92
+ admin_ip_allow TEXT,
93
+ admin_ip_block TEXT,
92
94
  updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
93
95
  );
94
96
 
@@ -19,6 +19,8 @@ function rowToSettings(row) {
19
19
  masterKeyVersion: row.masterKeyVersion,
20
20
  notificationsEnabled: row.notificationsEnabled,
21
21
  notificationSoundEnabled: row.notificationSoundEnabled,
22
+ adminIpAllow: row.adminIpAllow,
23
+ adminIpBlock: row.adminIpBlock,
22
24
  };
23
25
  }
24
26
  export function getSettings() {
@@ -61,6 +63,10 @@ export function updateSettings(patch) {
61
63
  update.notificationsEnabled = patch.notificationsEnabled;
62
64
  if (patch.notificationSoundEnabled !== undefined)
63
65
  update.notificationSoundEnabled = patch.notificationSoundEnabled;
66
+ if (patch.adminIpAllow !== undefined)
67
+ update.adminIpAllow = patch.adminIpAllow;
68
+ if (patch.adminIpBlock !== undefined)
69
+ update.adminIpBlock = patch.adminIpBlock;
64
70
  db.update(schema.appSettings).set(update).where(eq(schema.appSettings.id, 1)).run();
65
71
  return getSettings();
66
72
  }
@@ -22,6 +22,10 @@ export const appSettings = sqliteTable('app_settings', {
22
22
  // Admin UI notification preferences (v1.8.0). Default true: notifications on, sound on.
23
23
  notificationsEnabled: integer('notifications_enabled', { mode: 'boolean' }).notNull().default(true),
24
24
  notificationSoundEnabled: integer('notification_sound_enabled', { mode: 'boolean' }).notNull().default(true),
25
+ // Admin site IP access control (v1.11.0): newline-delimited CIDR lists.
26
+ // null = feature disabled.
27
+ adminIpAllow: text('admin_ip_allow'),
28
+ adminIpBlock: text('admin_ip_block'),
25
29
  updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
26
30
  });
27
31
  // ============================================================================
@@ -4,6 +4,7 @@ import { EventEmitter } from 'node:events';
4
4
  const bus = new EventEmitter();
5
5
  bus.setMaxListeners(50);
6
6
  const REQUEST_LOGGED = 'request_logged';
7
+ const REQUEST_STARTED = 'request_started';
7
8
  export function emitRequestLogged(requestId) {
8
9
  try {
9
10
  bus.emit(REQUEST_LOGGED, requestId);
@@ -12,9 +13,23 @@ export function emitRequestLogged(requestId) {
12
13
  // Never let listener failures affect the request path.
13
14
  }
14
15
  }
16
+ export function emitRequestStarted(requestId, providerId, modelId, requestedModel, ttftMs) {
17
+ try {
18
+ bus.emit(REQUEST_STARTED, { requestId, providerId, modelId, requestedModel, ttftMs });
19
+ }
20
+ catch {
21
+ // Never let listener failures affect the request path.
22
+ }
23
+ }
15
24
  export function onRequestLogged(cb) {
16
25
  bus.on(REQUEST_LOGGED, cb);
17
26
  }
27
+ export function onRequestStarted(cb) {
28
+ bus.on(REQUEST_STARTED, cb);
29
+ }
18
30
  export function offRequestLogged(cb) {
19
31
  bus.off(REQUEST_LOGGED, cb);
20
32
  }
33
+ export function offRequestStarted(cb) {
34
+ bus.off(REQUEST_STARTED, cb);
35
+ }
@@ -17,7 +17,7 @@ import { redactString, redactValue } from '../security/redact.js';
17
17
  import { getSettings } from '../db/repositories/settings.js';
18
18
  import { buildCacheKey, lookupCache, storeCache, cacheAllowed } from '../caching/store.js';
19
19
  import { metrics } from '../metrics/registry.js';
20
- import { emitRequestLogged } from './events.js';
20
+ import { emitRequestLogged, emitRequestStarted } from './events.js';
21
21
  export class GatewayRunner {
22
22
  async execute(req, ctx) {
23
23
  const start = Date.now();
@@ -175,6 +175,11 @@ export class GatewayRunner {
175
175
  const out = await this.runOneAttempt(req, ctx, candidate, provider.name, cfg, required, (isStreamStarted) => {
176
176
  attempt.streamStarted = isStreamStarted;
177
177
  attempt.ttftMs = Date.now() - attemptStart;
178
+ }, (ttftMs) => {
179
+ // Live signal for the monitoring dashboard: the request is now
180
+ // being served by this provider (lit up from TTFT until the
181
+ // completion event fires on persist).
182
+ emitRequestStarted(ctx.requestId, provider.id, candidate.modelId, ctx.requestedModel, ttftMs);
178
183
  });
179
184
  attempt.statusCode = out.statusCode ?? null;
180
185
  attempt.success = true;
@@ -324,9 +329,9 @@ export class GatewayRunner {
324
329
  capabilities: safeJson(m.capabilitiesJson),
325
330
  })).filter((m) => m.enabled && m.upstreamAvailable && providerEnabled.get(m.providerId));
326
331
  }
327
- async runOneAttempt(req, ctx, candidate, providerName, cfg, required, onStreamStart) {
332
+ async runOneAttempt(req, ctx, candidate, providerName, cfg, required, onStreamStart, onFirstToken) {
328
333
  if (req.canonical.stream) {
329
- return this.runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart);
334
+ return this.runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart, onFirstToken);
330
335
  }
331
336
  return this.runNonStreamingAttempt(req, candidate, cfg);
332
337
  }
@@ -371,7 +376,7 @@ export class GatewayRunner {
371
376
  }
372
377
  return { statusCode: call.status, ttftMs: call.ttftMs, upstreamRequestId: call.upstreamRequestId, usage, result };
373
378
  }
374
- async runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart) {
379
+ async runStreamingAttempt(req, ctx, candidate, cfg, onStreamStart, onFirstToken) {
375
380
  const upstreamModel = candidate.publicModelId.split('/').slice(1).join('/');
376
381
  const encoder = req.protocol === 'openai' ? openaiStreamEncoder : anthropicStreamEncoder;
377
382
  // Hard streaming invariant: the client SSE head is NOT written until the
@@ -392,6 +397,7 @@ export class GatewayRunner {
392
397
  });
393
398
  };
394
399
  let streamStarted = false;
400
+ const streamStartTs = Date.now();
395
401
  let textBuf = '';
396
402
  const toolBuf = [];
397
403
  let finishReason = null;
@@ -445,6 +451,7 @@ export class GatewayRunner {
445
451
  if (isFirst) {
446
452
  streamStarted = true;
447
453
  onStreamStart(true);
454
+ onFirstToken(Date.now() - streamStartTs);
448
455
  }
449
456
  const encoded = encoder(chunk.data, chunk.event);
450
457
  if (!headWritten)
@@ -294,7 +294,17 @@ export async function registerModelRoutes(app) {
294
294
  protocol: 'openai',
295
295
  endpoint: 'chat/completions',
296
296
  };
297
- const outcome = await runner.execute(gatewayReq, ctx);
297
+ let outcome;
298
+ try {
299
+ outcome = await runner.execute(gatewayReq, ctx);
300
+ }
301
+ catch (e) {
302
+ // Never let a raw non-GatewayError (e.g. MasterKeyError from credential
303
+ // decryption) escape into the global handler as an opaque "Gateway error".
304
+ if (e instanceof GatewayError)
305
+ throw e;
306
+ throw new GatewayError('gateway_error', e.message, { cause: e });
307
+ }
298
308
  recordAudit({ action: 'model.test', success: outcome.success, targetType: 'model', targetId: id, targetName: m.publicModelId, ip: req.ip });
299
309
  return {
300
310
  success: outcome.success,
@@ -3,7 +3,7 @@ import { and, desc, eq, gte, like, lte, sql } from 'drizzle-orm';
3
3
  import { getDb, schema } from '../../db/index.js';
4
4
  import { requireAdminAuth } from '../../auth/middleware.js';
5
5
  import { redactJsonString } from '../../security/redact.js';
6
- import { onRequestLogged, offRequestLogged } from '../../gateway/events.js';
6
+ import { onRequestLogged, offRequestLogged, onRequestStarted, offRequestStarted } from '../../gateway/events.js';
7
7
  export function loadSummaryMaps() {
8
8
  const db = getDb();
9
9
  const keys = db.select().from(schema.apiKeys).all();
@@ -144,6 +144,16 @@ export async function registerRequestRoutes(app) {
144
144
  send(`event: request\ndata: ${JSON.stringify(toSummary(row, maps))}\n\n`);
145
145
  };
146
146
  onRequestLogged(handleRequestLogged);
147
+ // A request is being served (first token reached): live-only — not part of
148
+ // history replay. The monitoring dashboard uses this to light the route
149
+ // from TTFT until the completion (`request`) event arrives.
150
+ const handleRequestStarted = (data) => {
151
+ if (closed || !data.providerId)
152
+ return;
153
+ const provider = maps.providerMap.get(data.providerId);
154
+ send(`event: request_started\ndata: ${JSON.stringify({ requestId: data.requestId, providerId: data.providerId, providerName: provider?.name ?? null, modelId: data.modelId, requestedModel: data.requestedModel, ttftMs: data.ttftMs, createdAt: new Date().toISOString() })}\n\n`);
155
+ };
156
+ onRequestStarted(handleRequestStarted);
147
157
  // Keepalive ping every 25s.
148
158
  const keepAlive = setInterval(() => send(': ping\n\n'), 25_000);
149
159
  // Auto-close after 5 min; the client reconnects with its last seen timestamp.
@@ -156,6 +166,7 @@ export async function registerRequestRoutes(app) {
156
166
  clearInterval(keepAlive);
157
167
  clearTimeout(autoClose);
158
168
  offRequestLogged(handleRequestLogged);
169
+ offRequestStarted(handleRequestStarted);
159
170
  };
160
171
  // Listen on the *response*: req.raw (IncomingMessage) emits 'close' as soon as the
161
172
  // request message is consumed (immediately for a GET), which would tear down the
@@ -10,6 +10,8 @@ import { uuid } from '../../auth/ids.js';
10
10
  import { isMasterKeyConfigured, encryptSecret, decryptSecret } from '../../auth/crypto.js';
11
11
  import { GatewayError } from '../../errors.js';
12
12
  import { runRetentionCleanup } from '../../maintenance/retention.js';
13
+ import { parseCidr, ipMatchesAny } from '../../util/cidr.js';
14
+ import { resolveClientIp } from '../../util/client-ip.js';
13
15
  const UpdateBody = z.object({
14
16
  retentionDays: z.number().int().min(1).max(3650).optional(),
15
17
  contentLogMode: z.enum(['off', 'metadata', 'prompt', 'prompt_and_response']).optional(),
@@ -20,6 +22,8 @@ const UpdateBody = z.object({
20
22
  gatewayCacheMaxSizeMb: z.number().int().min(1).max(10240).optional(),
21
23
  notificationsEnabled: z.boolean().optional(),
22
24
  notificationSoundEnabled: z.boolean().optional(),
25
+ adminIpAllow: z.string().max(4096).optional().nullable(),
26
+ adminIpBlock: z.string().max(4096).optional().nullable(),
23
27
  });
24
28
  const PasswordChange = z.object({
25
29
  currentPassword: z.string().min(1),
@@ -46,9 +50,43 @@ export async function registerSettingsRoutes(app) {
46
50
  });
47
51
  app.patch('/api/admin/settings', async (req) => {
48
52
  const body = UpdateBody.parse(req.body);
53
+ let addedIp = null;
54
+ // Validate + normalize the admin IP access lists.
55
+ if (body.adminIpAllow !== undefined) {
56
+ const lines = (body.adminIpAllow ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 100);
57
+ for (const l of lines) {
58
+ try {
59
+ parseCidr(l);
60
+ }
61
+ catch {
62
+ throw new GatewayError('invalid_request_error', `Invalid entry in IP allow list: "${l}"`, { status: 400 });
63
+ }
64
+ }
65
+ // Lockout guard: when a non-empty allow list is configured, the caller's
66
+ // own IP must be covered — otherwise saving would lock the admin out of
67
+ // their own site (the gate is enforced on the next request).
68
+ const callerIp = resolveClientIp(req);
69
+ if (lines.length > 0 && !ipMatchesAny(callerIp, lines)) {
70
+ lines.push(callerIp);
71
+ addedIp = callerIp;
72
+ }
73
+ body.adminIpAllow = lines.length ? lines.join('\n') : null;
74
+ }
75
+ if (body.adminIpBlock !== undefined) {
76
+ const lines = (body.adminIpBlock ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean).slice(0, 100);
77
+ for (const l of lines) {
78
+ try {
79
+ parseCidr(l);
80
+ }
81
+ catch {
82
+ throw new GatewayError('invalid_request_error', `Invalid entry in IP block list: "${l}"`, { status: 400 });
83
+ }
84
+ }
85
+ body.adminIpBlock = lines.length ? lines.join('\n') : null;
86
+ }
49
87
  updateSettings(body);
50
- recordAudit({ action: 'settings.update', success: true, ip: req.ip, metadata: body });
51
- return { ok: true };
88
+ recordAudit({ action: 'settings.update', success: true, ip: req.ip, metadata: { fields: Object.keys(body), addedIp } });
89
+ return { ok: true, addedIp };
52
90
  });
53
91
  app.post('/api/admin/settings/cleanup', async (req) => {
54
92
  const result = runRetentionCleanup();
@@ -0,0 +1,42 @@
1
+ // Root-scope admin-site IP access control (Settings → Access Control).
2
+ //
3
+ // Gates the ENTIRE admin website (login, setup, static UI, all /api/admin
4
+ // endpoints): an IP in the block list, or — when an allow list is configured —
5
+ // any IP not covered by it, is rejected with 403 before any route runs.
6
+ //
7
+ // NOT gated (by design): model-traffic endpoints /v1/* (LLM API requests are
8
+ // governed by API keys, not this site access list) and operational endpoints
9
+ // (/health, /ready, /metrics) so Docker health checks keep working.
10
+ import { getSettings } from '../db/repositories/settings.js';
11
+ import { ipMatchesAny } from '../util/cidr.js';
12
+ import { resolveClientIp } from '../util/client-ip.js';
13
+ function parseList(raw) {
14
+ if (!raw)
15
+ return [];
16
+ return raw.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
17
+ }
18
+ export function registerAdminIpGate(app) {
19
+ app.addHook('onRequest', async (req, reply) => {
20
+ const url = req.url.split('?')[0] ?? req.url;
21
+ // Operational + model-traffic endpoints are exempt.
22
+ if (url === '/health' || url.startsWith('/health/') ||
23
+ url === '/ready' || url.startsWith('/ready/') ||
24
+ url === '/metrics' || url.startsWith('/metrics/') ||
25
+ url.startsWith('/v1/') || url === '/v1')
26
+ return;
27
+ const s = getSettings();
28
+ const allow = parseList(s.adminIpAllow);
29
+ const block = parseList(s.adminIpBlock);
30
+ if (allow.length === 0 && block.length === 0)
31
+ return; // feature disabled
32
+ const ip = resolveClientIp(req);
33
+ if (block.length > 0 && ipMatchesAny(ip, block)) {
34
+ reply.code(403).type('text/plain').send('Không có quyền truy cập');
35
+ return;
36
+ }
37
+ if (allow.length > 0 && !ipMatchesAny(ip, allow)) {
38
+ reply.code(403).type('text/plain').send('Không có quyền truy cập');
39
+ return;
40
+ }
41
+ });
42
+ }
@@ -3,13 +3,31 @@ import { decryptSecret, decryptCustomHeaders } from '../auth/crypto.js';
3
3
  import { GatewayError } from '../errors.js';
4
4
  import { buildHeaders, stripSlash } from '../providers/index.js';
5
5
  export function providerToUpstreamConfig(p) {
6
+ let apiKey;
7
+ let customHeaders;
8
+ try {
9
+ apiKey = decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion });
10
+ }
11
+ catch {
12
+ // The stored credential cannot be decrypted with the current master key
13
+ // (e.g. LATEDEV_MASTER_KEY changed, or the DB was restored from another
14
+ // instance). This must surface as a readable error — not an uncaught
15
+ // MasterKeyError that Fastify wraps into an opaque 500 "Gateway error".
16
+ throw new GatewayError('authentication_error', 'Provider credentials cannot be decrypted (master key mismatch). Re-save the provider API key.', { status: 500 });
17
+ }
18
+ try {
19
+ customHeaders = decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce
20
+ ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 }
21
+ : null);
22
+ }
23
+ catch {
24
+ throw new GatewayError('authentication_error', 'Provider custom headers cannot be decrypted (master key mismatch). Re-save the provider.', { status: 500 });
25
+ }
6
26
  return {
7
27
  type: p.type,
8
28
  baseUrl: p.baseUrl,
9
- apiKey: decryptSecret({ ciphertext: p.encryptedApiKey, nonce: p.apiKeyNonce, version: p.apiKeyVersion }),
10
- customHeaders: decryptCustomHeaders(p.customHeadersEncrypted && p.customHeadersNonce
11
- ? { ciphertext: p.customHeadersEncrypted, nonce: p.customHeadersNonce, version: 1 }
12
- : null),
29
+ apiKey,
30
+ customHeaders,
13
31
  connectTimeoutMs: p.connectTimeoutMs,
14
32
  firstTokenTimeoutMs: p.firstTokenTimeoutMs,
15
33
  streamIdleTimeoutMs: p.streamIdleTimeoutMs,
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}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;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 0 0% 100%;--foreground: 240 10% 3.9%;--card: 0 0% 100%;--card-foreground: 240 10% 3.9%;--popover: 0 0% 100%;--popover-foreground: 240 10% 3.9%;--primary: 341 86% 41%;--primary-foreground: 0 0% 100%;--secondary: 240 4.8% 95.9%;--secondary-foreground: 240 5.9% 10%;--muted: 240 4.8% 95.9%;--muted-foreground: 240 3.8% 46.1%;--accent: 240 4.8% 95.9%;--accent-foreground: 240 5.9% 10%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 0 0% 98%;--border: 240 5.9% 90%;--input: 240 5.9% 90%;--ring: 341 86% 41%;--sidebar: 240 5.9% 98%;--sidebar-foreground: 240 10% 3.9%;--radius: .5rem}.dark{--background: 240 10% 3.9%;--foreground: 0 0% 98%;--card: 240 10% 6%;--card-foreground: 0 0% 98%;--popover: 240 10% 5%;--popover-foreground: 0 0% 98%;--primary: 341 86% 55%;--primary-foreground: 0 0% 100%;--secondary: 240 3.7% 15.9%;--secondary-foreground: 0 0% 98%;--muted: 240 3.7% 15.9%;--muted-foreground: 240 5% 64.9%;--accent: 240 3.7% 15.9%;--accent-foreground: 0 0% 98%;--destructive: 0 72% 51%;--destructive-foreground: 0 0% 98%;--border: 240 3.7% 15.9%;--input: 240 3.7% 15.9%;--ring: 341 86% 55%;--sidebar: 240 10% 5%;--sidebar-foreground: 0 0% 98%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));font-feature-settings:"rlig" 1,"calt" 1}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border-width:0}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.left-1\/2{left:50%}.left-2{left:.5rem}.left-2\.5{left:.625rem}.left-\[50\%\]{left:50%}.right-2{right:.5rem}.right-4{right:1rem}.top-1\/2{top:50%}.top-16{top:4rem}.top-2\.5{top:.625rem}.top-4{top:1rem}.top-\[50\%\]{top:50%}.z-50{z-index:50}.-mx-1{margin-left:-.25rem;margin-right:-.25rem}.my-1{margin-top:.25rem;margin-bottom:.25rem}.mb-1{margin-bottom:.25rem}.mb-3{margin-bottom:.75rem}.mb-6{margin-bottom:1.5rem}.ml-0\.5{margin-left:.125rem}.ml-1{margin-left:.25rem}.ml-1\.5{margin-left:.375rem}.ml-2{margin-left:.5rem}.mr-0\.5{margin-right:.125rem}.mr-1{margin-right:.25rem}.mr-2{margin-right:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-10{height:2.5rem}.h-14{height:3.5rem}.h-2{height:.5rem}.h-20{height:5rem}.h-3{height:.75rem}.h-3\.5{height:.875rem}.h-4{height:1rem}.h-40{height:10rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-7{height:1.75rem}.h-8{height:2rem}.h-9{height:2.25rem}.h-auto{height:auto}.h-px{height:1px}.h-screen{height:100vh}.max-h-40{max-height:10rem}.max-h-48{max-height:12rem}.max-h-60{max-height:15rem}.max-h-80{max-height:20rem}.max-h-96{max-height:24rem}.max-h-\[calc\(100vh-8rem\)\]{max-height:calc(100vh - 8rem)}.min-h-\[60px\]{min-height:60px}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-2{width:.5rem}.w-20{width:5rem}.w-28{width:7rem}.w-3{width:.75rem}.w-3\.5{width:.875rem}.w-32{width:8rem}.w-36{width:9rem}.w-4{width:1rem}.w-40{width:10rem}.w-56{width:14rem}.w-60{width:15rem}.w-7{width:1.75rem}.w-8{width:2rem}.w-80{width:20rem}.w-9{width:2.25rem}.w-full{width:100%}.min-w-0{min-width:0px}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.max-w-2xl{max-width:42rem}.max-w-3xl{max-width:48rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xs{max-width:20rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.translate-x-\[-50\%\]{--tw-translate-x: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-y-\[-50\%\]{--tw-translate-y: -50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.-rotate-90{--tw-rotate: -90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-end{justify-content:flex-end}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.space-x-1>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.25rem * var(--tw-space-x-reverse));margin-left:calc(.25rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-amber-200{--tw-border-opacity: 1;border-color:rgb(253 230 138 / var(--tw-border-opacity, 1))}.border-amber-500\/40{border-color:#f59e0b66}.border-amber-600\/50{border-color:#d9770680}.border-border{border-color:hsl(var(--border))}.border-destructive{border-color:hsl(var(--destructive))}.border-destructive\/40{border-color:hsl(var(--destructive) / .4)}.border-input{border-color:hsl(var(--input))}.border-primary{border-color:hsl(var(--primary))}.border-transparent{border-color:transparent}.bg-amber-400{--tw-bg-opacity: 1;background-color:rgb(251 191 36 / var(--tw-bg-opacity, 1))}.bg-amber-50{--tw-bg-opacity: 1;background-color:rgb(255 251 235 / var(--tw-bg-opacity, 1))}.bg-amber-500{--tw-bg-opacity: 1;background-color:rgb(245 158 11 / var(--tw-bg-opacity, 1))}.bg-amber-500\/90{background-color:#f59e0be6}.bg-background{background-color:hsl(var(--background))}.bg-black\/15{background-color:#00000026}.bg-black\/80{background-color:#000c}.bg-card{background-color:hsl(var(--card))}.bg-destructive{background-color:hsl(var(--destructive))}.bg-destructive\/10{background-color:hsl(var(--destructive) / .1)}.bg-emerald-100{--tw-bg-opacity: 1;background-color:rgb(209 250 229 / var(--tw-bg-opacity, 1))}.bg-emerald-400{--tw-bg-opacity: 1;background-color:rgb(52 211 153 / var(--tw-bg-opacity, 1))}.bg-emerald-600{--tw-bg-opacity: 1;background-color:rgb(5 150 105 / var(--tw-bg-opacity, 1))}.bg-green-100{--tw-bg-opacity: 1;background-color:rgb(220 252 231 / var(--tw-bg-opacity, 1))}.bg-muted{background-color:hsl(var(--muted))}.bg-popover{background-color:hsl(var(--popover))}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/70{background-color:hsl(var(--primary) / .7)}.bg-red-100{--tw-bg-opacity: 1;background-color:rgb(254 226 226 / var(--tw-bg-opacity, 1))}.bg-red-400{--tw-bg-opacity: 1;background-color:rgb(248 113 113 / var(--tw-bg-opacity, 1))}.bg-secondary{background-color:hsl(var(--secondary))}.bg-sidebar{background-color:hsl(var(--sidebar))}.bg-transparent{background-color:transparent}.fill-amber-400{fill:#fbbf24}.fill-amber-500\/10{fill:#f59e0b1a}.fill-emerald-400{fill:#34d399}.fill-emerald-500{fill:#10b981}.fill-foreground{fill:hsl(var(--foreground))}.fill-muted-foreground{fill:hsl(var(--muted-foreground))}.fill-primary\/10{fill:hsl(var(--primary) / .1)}.fill-red-400{fill:#f87171}.fill-red-500{fill:#ef4444}.stroke-amber-500{stroke:#f59e0b}.stroke-emerald-500{stroke:#10b981}.stroke-primary{stroke:hsl(var(--primary))}.object-contain{-o-object-fit:contain;object-fit:contain}.p-1{padding:.25rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.pb-1{padding-bottom:.25rem}.pb-2{padding-bottom:.5rem}.pb-6{padding-bottom:1.5rem}.pl-8{padding-left:2rem}.pr-2{padding-right:.5rem}.pt-0{padding-top:0}.pt-2{padding-top:.5rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-middle{vertical-align:middle}.align-text-bottom{vertical-align:text-bottom}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.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)}.leading-none{line-height:1}.tracking-tight{letter-spacing:-.025em}.text-amber-500{--tw-text-opacity: 1;color:rgb(245 158 11 / var(--tw-text-opacity, 1))}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-amber-800{--tw-text-opacity: 1;color:rgb(146 64 14 / var(--tw-text-opacity, 1))}.text-amber-900{--tw-text-opacity: 1;color:rgb(120 53 15 / var(--tw-text-opacity, 1))}.text-amber-950{--tw-text-opacity: 1;color:rgb(69 26 3 / var(--tw-text-opacity, 1))}.text-blue-500{--tw-text-opacity: 1;color:rgb(59 130 246 / var(--tw-text-opacity, 1))}.text-card-foreground{color:hsl(var(--card-foreground))}.text-current{color:currentColor}.text-destructive{color:hsl(var(--destructive))}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-500{--tw-text-opacity: 1;color:rgb(16 185 129 / var(--tw-text-opacity, 1))}.text-emerald-700{--tw-text-opacity: 1;color:rgb(4 120 87 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-green-800{--tw-text-opacity: 1;color:rgb(22 101 52 / var(--tw-text-opacity, 1))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-muted-foreground\/60{color:hsl(var(--muted-foreground) / .6)}.text-popover-foreground{color:hsl(var(--popover-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-red-500{--tw-text-opacity: 1;color:rgb(239 68 68 / var(--tw-text-opacity, 1))}.text-red-700{--tw-text-opacity: 1;color:rgb(185 28 28 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.text-sidebar-foreground{color:hsl(var(--sidebar-foreground))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-60{opacity:.6}.opacity-70{opacity:.7}.opacity-90{opacity:.9}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.ring-offset-background{--tw-ring-offset-color: hsl(var(--background))}.blur{--tw-blur: blur(8px);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)}.filter{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)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}@keyframes route-active-pulse{0%{opacity:.35}50%{opacity:1}to{opacity:.35}}.route-active{animation:route-active-pulse 1.4s ease-in-out infinite}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-thumb{background:hsl(var(--border));border-radius:4px}::-webkit-scrollbar-track{background:transparent}.file\:border-0::file-selector-button{border-width:0px}.file\:bg-transparent::file-selector-button{background-color:transparent}.file\:text-sm::file-selector-button{font-size:.875rem;line-height:1.25rem}.file\:font-medium::file-selector-button{font-weight:500}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.last\:border-0:last-child{border-width:0px}.hover\:bg-accent:hover{background-color:hsl(var(--accent))}.hover\:bg-destructive\/90:hover{background-color:hsl(var(--destructive) / .9)}.hover\:bg-muted\/50:hover{background-color:hsl(var(--muted) / .5)}.hover\:bg-primary\/90:hover{background-color:hsl(var(--primary) / .9)}.hover\:bg-secondary\/80:hover{background-color:hsl(var(--secondary) / .8)}.hover\:text-accent-foreground:hover{color:hsl(var(--accent-foreground))}.hover\:text-foreground:hover{color:hsl(var(--foreground))}.hover\:underline:hover{text-decoration-line:underline}.hover\:opacity-100:hover{opacity:1}.hover\:opacity-95:hover{opacity:.95}.focus\:bg-accent:focus{background-color:hsl(var(--accent))}.focus\:text-accent-foreground:focus{color:hsl(var(--accent-foreground))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:ring-1:focus{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus\:ring-ring:focus{--tw-ring-color: hsl(var(--ring))}.focus-visible\:outline-none:focus-visible{outline:2px solid transparent;outline-offset:2px}.focus-visible\:ring-1:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-2:focus-visible{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.focus-visible\:ring-ring:focus-visible{--tw-ring-color: hsl(var(--ring))}.focus-visible\:ring-offset-2:focus-visible{--tw-ring-offset-width: 2px}.focus-visible\:ring-offset-background:focus-visible{--tw-ring-offset-color: hsl(var(--background))}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.peer:disabled~.peer-disabled\:cursor-not-allowed{cursor:not-allowed}.peer:disabled~.peer-disabled\:opacity-70{opacity:.7}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[state\=checked\]\:translate-x-4[data-state=checked]{--tw-translate-x: 1rem;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.data-\[state\=active\]\:bg-background[data-state=active]{background-color:hsl(var(--background))}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:hsl(var(--primary))}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:hsl(var(--muted))}.data-\[state\=unchecked\]\:bg-input[data-state=unchecked]{background-color:hsl(var(--input))}.data-\[state\=active\]\:text-foreground[data-state=active]{color:hsl(var(--foreground))}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:hsl(var(--primary-foreground))}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[state\=active\]\:shadow[data-state=active]{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.dark\:border-amber-800:is(.dark *){--tw-border-opacity: 1;border-color:rgb(146 64 14 / var(--tw-border-opacity, 1))}.dark\:bg-amber-950\/20:is(.dark *){background-color:#451a0333}.dark\:bg-emerald-900\/40:is(.dark *){background-color:#064e3b66}.dark\:bg-green-900:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.dark\:bg-red-900\/40:is(.dark *){background-color:#7f1d1d66}.dark\:text-amber-200:is(.dark *){--tw-text-opacity: 1;color:rgb(253 230 138 / var(--tw-text-opacity, 1))}.dark\:text-amber-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.dark\:text-emerald-300:is(.dark *){--tw-text-opacity: 1;color:rgb(110 231 183 / var(--tw-text-opacity, 1))}.dark\:text-green-200:is(.dark *){--tw-text-opacity: 1;color:rgb(187 247 208 / var(--tw-text-opacity, 1))}.dark\:text-red-300:is(.dark *){--tw-text-opacity: 1;color:rgb(252 165 165 / var(--tw-text-opacity, 1))}@media(min-width:640px){.sm\:max-w-\[425px\]{max-width:425px}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:justify-end{justify-content:flex-end}.sm\:space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.sm\:rounded-lg{border-radius:var(--radius)}.sm\:text-left{text-align:left}}@media(min-width:768px){.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-col{flex-direction:column}}@media(min-width:1024px){.lg\:col-span-2{grid-column:span 2 / span 2}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-width:0px}.\[\&_tr\]\:border-b tr{border-bottom-width:1px}