@rstest/browser 0.7.9 → 0.8.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.
@@ -1,8 +1,11 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import fs from 'node:fs/promises';
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http';
4
+ import type { AddressInfo } from 'node:net';
4
5
  import { fileURLToPath } from 'node:url';
5
6
  import {
7
+ type BrowserTestRunOptions,
8
+ type BrowserTestRunResult,
6
9
  color,
7
10
  type FormattedError,
8
11
  getSetupFiles,
@@ -136,11 +139,9 @@ class ContainerRpcManager {
136
139
 
137
140
  private setupConnectionHandler(): void {
138
141
  this.wss.on('connection', (ws: WebSocket) => {
139
- logger.log(color.gray('[Browser UI] Container WebSocket connected'));
140
- logger.log(
141
- color.gray(
142
- `[Browser UI] Current ws: ${this.ws ? 'exists' : 'null'}, new ws: ${ws ? 'exists' : 'null'}`,
143
- ),
142
+ logger.debug('[Browser UI] Container WebSocket connected');
143
+ logger.debug(
144
+ `[Browser UI] Current ws: ${this.ws ? 'exists' : 'null'}, new ws: ${ws ? 'exists' : 'null'}`,
144
145
  );
145
146
  this.attachWebSocket(ws);
146
147
  });
@@ -203,18 +204,14 @@ class ContainerRpcManager {
203
204
  testFile: string,
204
205
  testNamePattern?: string,
205
206
  ): Promise<void> {
206
- logger.log(
207
- color.gray(
208
- `[Browser UI] reloadTestFile called, rpc: ${this.rpc ? 'exists' : 'null'}, ws: ${this.ws ? 'exists' : 'null'}`,
209
- ),
207
+ logger.debug(
208
+ `[Browser UI] reloadTestFile called, rpc: ${this.rpc ? 'exists' : 'null'}, ws: ${this.ws ? 'exists' : 'null'}`,
210
209
  );
211
210
  if (!this.rpc) {
212
- logger.log(
213
- color.yellow('[Browser UI] RPC not available, skipping reloadTestFile'),
214
- );
211
+ logger.debug('[Browser UI] RPC not available, skipping reloadTestFile');
215
212
  return;
216
213
  }
217
- logger.log(color.gray(`[Browser UI] Calling reloadTestFile: ${testFile}`));
214
+ logger.debug(`[Browser UI] Calling reloadTestFile: ${testFile}`);
218
215
  await this.rpc.reloadTestFile(testFile, testNamePattern);
219
216
  }
220
217
  }
@@ -248,6 +245,8 @@ type WatchContext = {
248
245
  lastTestFiles: TestFileInfo[];
249
246
  hooksEnabled: boolean;
250
247
  cleanupRegistered: boolean;
248
+ chunkHashes: Map<string, string>;
249
+ affectedTestFiles: string[];
251
250
  };
252
251
 
253
252
  const watchContext: WatchContext = {
@@ -255,6 +254,8 @@ const watchContext: WatchContext = {
255
254
  lastTestFiles: [],
256
255
  hooksEnabled: false,
257
256
  cleanupRegistered: false,
257
+ chunkHashes: new Map(),
258
+ affectedTestFiles: [],
258
259
  };
259
260
 
260
261
  // ============================================================================
@@ -363,6 +364,99 @@ const excludePatternsToRegExp = (patterns: string[]): RegExp | null => {
363
364
  return new RegExp(`[\\\\/](${keywords.join('|')})[\\\\/]`);
364
365
  };
365
366
 
367
+ type StatsModule = {
368
+ nameForCondition?: string;
369
+ children?: StatsModule[];
370
+ };
371
+
372
+ type StatsChunk = {
373
+ id?: string | number;
374
+ names?: string[];
375
+ hash?: string;
376
+ files?: string[];
377
+ modules?: StatsModule[];
378
+ };
379
+
380
+ /**
381
+ * Find test file path from chunk modules by matching against known entry files.
382
+ */
383
+ const findTestFileInModules = (
384
+ modules: StatsModule[] | undefined,
385
+ entryTestFiles: Set<string>,
386
+ ): string | null => {
387
+ if (!modules) return null;
388
+
389
+ for (const m of modules) {
390
+ if (m.nameForCondition) {
391
+ const normalizedPath = normalize(m.nameForCondition);
392
+ if (entryTestFiles.has(normalizedPath)) {
393
+ return normalizedPath;
394
+ }
395
+ }
396
+ if (m.children) {
397
+ const found = findTestFileInModules(m.children, entryTestFiles);
398
+ if (found) return found;
399
+ }
400
+ }
401
+ return null;
402
+ };
403
+
404
+ /**
405
+ * Get a stable identifier for a chunk.
406
+ * Prefers chunk.id or chunk.names[0] over file paths for stability.
407
+ */
408
+ const getChunkKey = (chunk: StatsChunk): string | null => {
409
+ if (chunk.id != null) {
410
+ return String(chunk.id);
411
+ }
412
+ if (chunk.names && chunk.names.length > 0) {
413
+ return chunk.names[0]!;
414
+ }
415
+ if (chunk.files && chunk.files.length > 0) {
416
+ return chunk.files[0]!;
417
+ }
418
+ return null;
419
+ };
420
+
421
+ /**
422
+ * Compare chunk hashes and find affected test files for watch mode re-runs.
423
+ * Uses chunk.id/names as stable keys instead of relying on file path patterns.
424
+ */
425
+ const getAffectedTestFiles = (
426
+ chunks: StatsChunk[] | undefined,
427
+ entryTestFiles: Set<string>,
428
+ ): string[] => {
429
+ if (!chunks) return [];
430
+
431
+ const affectedFiles = new Set<string>();
432
+ const currentHashes = new Map<string, string>();
433
+
434
+ for (const chunk of chunks) {
435
+ if (!chunk.hash) continue;
436
+
437
+ // First check if this chunk contains a test entry file
438
+ const testFile = findTestFileInModules(chunk.modules, entryTestFiles);
439
+ if (!testFile) continue;
440
+
441
+ // Get a stable key for this chunk
442
+ const chunkKey = getChunkKey(chunk);
443
+ if (!chunkKey) continue;
444
+
445
+ const prevHash = watchContext.chunkHashes.get(chunkKey);
446
+ currentHashes.set(chunkKey, chunk.hash);
447
+
448
+ if (prevHash !== undefined && prevHash !== chunk.hash) {
449
+ affectedFiles.add(testFile);
450
+ logger.debug(
451
+ `[Watch] Chunk hash changed for ${chunkKey}: ${prevHash} -> ${chunk.hash} (test: ${testFile})`,
452
+ );
453
+ }
454
+ }
455
+
456
+ watchContext.chunkHashes = currentHashes;
457
+ return Array.from(affectedFiles);
458
+ };
459
+
366
460
  const getRuntimeConfigFromProject = (
367
461
  project: ProjectContext,
368
462
  ): RuntimeConfig => {
@@ -747,8 +841,8 @@ const createBrowserRuntime = async ({
747
841
  plugins: userPlugins,
748
842
  server: {
749
843
  printUrls: false,
750
- port: context.normalizedConfig.browser.port,
751
- strictPort: context.normalizedConfig.browser.port !== undefined,
844
+ port: context.normalizedConfig.browser.port ?? 4000,
845
+ strictPort: context.normalizedConfig.browser.strictPort,
752
846
  },
753
847
  dev: {
754
848
  client: {
@@ -766,56 +860,63 @@ const createBrowserRuntime = async ({
766
860
  {
767
861
  name: 'rstest:browser-user-config',
768
862
  setup(api) {
769
- api.modifyEnvironmentConfig((config, { mergeEnvironmentConfig }) => {
770
- // Merge order: current config -> userConfig -> rstest required config (highest priority)
771
- const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
772
- source: {
773
- entry: {
774
- runner: resolveBrowserFile('client/entry.ts'),
863
+ api.modifyEnvironmentConfig({
864
+ handler: (config, { mergeEnvironmentConfig }) => {
865
+ // Merge order: current config -> userConfig -> rstest required config (highest priority)
866
+ const merged = mergeEnvironmentConfig(config, userRsbuildConfig, {
867
+ resolve: {
868
+ alias: rstestInternalAliases,
775
869
  },
776
- },
777
- resolve: {
778
- alias: rstestInternalAliases,
779
- },
780
- output: {
781
- target: 'web',
782
- // Enable source map for inline snapshot support
783
- sourceMap: {
784
- js: 'source-map',
870
+ output: {
871
+ target: 'web',
872
+ // Enable source map for inline snapshot support
873
+ sourceMap: {
874
+ js: 'source-map',
875
+ },
785
876
  },
786
- },
787
- tools: {
788
- rspack: (rspackConfig) => {
789
- rspackConfig.mode = 'development';
790
- rspackConfig.lazyCompilation = {
791
- imports: true,
792
- entries: false,
793
- };
794
- rspackConfig.plugins = rspackConfig.plugins || [];
795
- rspackConfig.plugins.push(virtualManifestPlugin);
796
-
797
- // Extract and merge sourcemaps from pre-built @rstest/core files
798
- // This preserves the sourcemap chain for inline snapshot support
799
- // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
800
- const browserRuntimeDir = dirname(browserRuntimePath);
801
- rspackConfig.module = rspackConfig.module || {};
802
- rspackConfig.module.rules = rspackConfig.module.rules || [];
803
- rspackConfig.module.rules.unshift({
804
- test: /\.js$/,
805
- include: browserRuntimeDir,
806
- extractSourceMap: true,
807
- });
808
-
809
- if (isDebug()) {
810
- logger.log(
811
- `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
812
- );
813
- }
877
+ tools: {
878
+ rspack: (rspackConfig) => {
879
+ rspackConfig.mode = 'development';
880
+ rspackConfig.lazyCompilation = {
881
+ imports: true,
882
+ entries: false,
883
+ };
884
+ rspackConfig.plugins = rspackConfig.plugins || [];
885
+ rspackConfig.plugins.push(virtualManifestPlugin);
886
+
887
+ // Extract and merge sourcemaps from pre-built @rstest/core files
888
+ // This preserves the sourcemap chain for inline snapshot support
889
+ // See: https://rspack.dev/config/module-rules#rulesextractsourcemap
890
+ const browserRuntimeDir = dirname(browserRuntimePath);
891
+ rspackConfig.module = rspackConfig.module || {};
892
+ rspackConfig.module.rules = rspackConfig.module.rules || [];
893
+ rspackConfig.module.rules.unshift({
894
+ test: /\.js$/,
895
+ include: browserRuntimeDir,
896
+ extractSourceMap: true,
897
+ });
898
+
899
+ if (isDebug()) {
900
+ logger.log(
901
+ `[rstest:browser] extractSourceMap rule added for: ${browserRuntimeDir}`,
902
+ );
903
+ }
904
+ },
814
905
  },
815
- },
816
- });
817
-
818
- return merged;
906
+ });
907
+
908
+ // Completely overwrite entry to prevent Rsbuild default entry detection from taking effect.
909
+ // In browser mode, entry is fully controlled by rstest (not user's src/index.ts).
910
+ // This must be done after mergeEnvironmentConfig to ensure highest priority.
911
+ merged.source = merged.source || {};
912
+ merged.source.entry = {
913
+ runner: resolveBrowserFile('client/entry.ts'),
914
+ };
915
+
916
+ return merged;
917
+ },
918
+ // Execute after all other plugins to ensure rstest's entry config has the highest priority
919
+ order: 'post',
819
920
  });
820
921
  },
821
922
  },
@@ -834,10 +935,34 @@ const createBrowserRuntime = async ({
834
935
  logger.log(color.cyan('\nFile changed, re-running tests...\n'));
835
936
  });
836
937
 
837
- api.onAfterDevCompile(async () => {
938
+ api.onAfterDevCompile(async ({ stats }) => {
939
+ // Collect hashes even during initial build to establish baseline
940
+ if (stats) {
941
+ const projectEntries = await collectProjectEntries(context);
942
+ const entryTestFiles = new Set<string>(
943
+ projectEntries.flatMap((entry) =>
944
+ entry.testFiles.map((f) => normalize(f)),
945
+ ),
946
+ );
947
+
948
+ const statsJson = stats.toJson({ all: true });
949
+ const affected = getAffectedTestFiles(
950
+ statsJson.chunks,
951
+ entryTestFiles,
952
+ );
953
+ watchContext.affectedTestFiles = affected;
954
+
955
+ if (affected.length > 0) {
956
+ logger.debug(
957
+ `[Watch] Affected test files: ${affected.join(', ')}`,
958
+ );
959
+ }
960
+ }
961
+
838
962
  if (!watchContext.hooksEnabled) {
839
963
  return;
840
964
  }
965
+
841
966
  await onTriggerRerun();
842
967
  });
843
968
  },
@@ -890,10 +1015,8 @@ const createBrowserRuntime = async ({
890
1015
  res.end(html);
891
1016
  return true;
892
1017
  } catch (error) {
893
- logger.log(
894
- color.yellow(
895
- `[Browser UI] Failed to fetch container HTML from dev server: ${String(error)}`,
896
- ),
1018
+ logger.debug(
1019
+ `[Browser UI] Failed to fetch container HTML from dev server: ${String(error)}`,
897
1020
  );
898
1021
  return false;
899
1022
  }
@@ -925,10 +1048,8 @@ const createBrowserRuntime = async ({
925
1048
  res.end(buffer);
926
1049
  return true;
927
1050
  } catch (error) {
928
- logger.log(
929
- color.yellow(
930
- `[Browser UI] Failed to proxy asset from dev server: ${String(error)}`,
931
- ),
1051
+ logger.debug(
1052
+ `[Browser UI] Failed to proxy asset from dev server: ${String(error)}`,
932
1053
  );
933
1054
  return false;
934
1055
  }
@@ -952,15 +1073,13 @@ const createBrowserRuntime = async ({
952
1073
  res.statusCode = 204;
953
1074
  res.end();
954
1075
  } catch (error) {
955
- logger.log(
956
- color.yellow(`[Browser UI] Failed to open editor: ${String(error)}`),
957
- );
1076
+ logger.debug(`[Browser UI] Failed to open editor: ${String(error)}`);
958
1077
  res.statusCode = 500;
959
1078
  res.end('Failed to open editor');
960
1079
  }
961
1080
  return;
962
1081
  }
963
- if (url.pathname === '/' || url.pathname === '/container.html') {
1082
+ if (url.pathname === '/') {
964
1083
  if (await respondWithDevServerHtml(url, res)) {
965
1084
  return;
966
1085
  }
@@ -1003,12 +1122,16 @@ const createBrowserRuntime = async ({
1003
1122
 
1004
1123
  const { port } = await devServer.listen();
1005
1124
 
1006
- // Create WebSocket server on a different port
1007
- const wsPort = port + 1;
1008
- const wss = new WebSocketServer({ port: wsPort });
1009
- logger.log(
1010
- color.gray(`[Browser UI] WebSocket server started on port ${wsPort}`),
1011
- );
1125
+ // Create WebSocket server on an available port
1126
+ // Using port: 0 lets the OS assign an available port, avoiding conflicts
1127
+ // when the fixed port (e.g., container port + 1) is already in use
1128
+ const wss = new WebSocketServer({ port: 0 });
1129
+ await new Promise<void>((resolve, reject) => {
1130
+ wss.once('listening', resolve);
1131
+ wss.once('error', reject);
1132
+ });
1133
+ const wsPort = (wss.address() as AddressInfo).port;
1134
+ logger.debug(`[Browser UI] WebSocket server started on port ${wsPort}`);
1012
1135
 
1013
1136
  let browserLauncher: BrowserType;
1014
1137
  const browserName = context.normalizedConfig.browser.browser;
@@ -1059,7 +1182,11 @@ const createBrowserRuntime = async ({
1059
1182
  // Main Entry Point
1060
1183
  // ============================================================================
1061
1184
 
1062
- export const runBrowserController = async (context: Rstest): Promise<void> => {
1185
+ export const runBrowserController = async (
1186
+ context: Rstest,
1187
+ options?: BrowserTestRunOptions,
1188
+ ): Promise<BrowserTestRunResult | void> => {
1189
+ const { skipOnTestRunEnd = false } = options ?? {};
1063
1190
  const buildStart = Date.now();
1064
1191
  const containerDevServerEnv = process.env.RSTEST_CONTAINER_DEV_SERVER;
1065
1192
  let containerDevServer: string | undefined;
@@ -1068,10 +1195,8 @@ export const runBrowserController = async (context: Rstest): Promise<void> => {
1068
1195
  if (containerDevServerEnv) {
1069
1196
  try {
1070
1197
  containerDevServer = new URL(containerDevServerEnv).toString();
1071
- logger.log(
1072
- color.gray(
1073
- `[Browser UI] Using dev server for container: ${containerDevServer}`,
1074
- ),
1198
+ logger.debug(
1199
+ `[Browser UI] Using dev server for container: ${containerDevServer}`,
1075
1200
  );
1076
1201
  } catch (error) {
1077
1202
  logger.error(
@@ -1276,9 +1401,12 @@ export const runBrowserController = async (context: Rstest): Promise<void> => {
1276
1401
  // Create RPC methods that can access test state variables
1277
1402
  const createRpcMethods = (): HostRpcMethods => ({
1278
1403
  async rerunTest(testFile: string, testNamePattern?: string) {
1404
+ const projectName = context.normalizedConfig.name || 'project';
1405
+ const relativePath = relative(context.rootPath, testFile);
1406
+ const displayPath = `<${projectName}>/${relativePath}`;
1279
1407
  logger.log(
1280
1408
  color.cyan(
1281
- `\nRe-running test: ${testFile}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
1409
+ `\nRe-running test: ${displayPath}${testNamePattern ? ` (pattern: ${testNamePattern})` : ''}\n`,
1282
1410
  ),
1283
1411
  );
1284
1412
  await rpcManager.reloadTestFile(testFile, testNamePattern);
@@ -1405,14 +1533,12 @@ export const runBrowserController = async (context: Rstest): Promise<void> => {
1405
1533
 
1406
1534
  // Only navigate on first creation
1407
1535
  if (isNewPage) {
1408
- await containerPage.goto(`http://localhost:${port}/container.html`, {
1536
+ await containerPage.goto(`http://localhost:${port}/`, {
1409
1537
  waitUntil: 'load',
1410
1538
  });
1411
1539
 
1412
1540
  logger.log(
1413
- color.cyan(
1414
- `\nContainer page opened at http://localhost:${port}/container.html\n`,
1415
- ),
1541
+ color.cyan(`\nBrowser mode opened at http://localhost:${port}/\n`),
1416
1542
  );
1417
1543
  }
1418
1544
 
@@ -1474,7 +1600,21 @@ export const runBrowserController = async (context: Rstest): Promise<void> => {
1474
1600
  await rpcManager.notifyTestFileUpdate(currentTestFiles);
1475
1601
  }
1476
1602
 
1477
- logger.log(color.cyan('Tests will be re-executed automatically\n'));
1603
+ const affectedFiles = watchContext.affectedTestFiles;
1604
+ watchContext.affectedTestFiles = [];
1605
+
1606
+ if (affectedFiles.length > 0) {
1607
+ logger.log(
1608
+ color.cyan(
1609
+ `Re-running ${affectedFiles.length} affected test file(s)...\n`,
1610
+ ),
1611
+ );
1612
+ for (const testFile of affectedFiles) {
1613
+ await rpcManager.reloadTestFile(testFile);
1614
+ }
1615
+ } else if (!filesChanged) {
1616
+ logger.log(color.cyan('Tests will be re-executed automatically\n'));
1617
+ }
1478
1618
  };
1479
1619
  }
1480
1620
 
@@ -1505,14 +1645,24 @@ export const runBrowserController = async (context: Rstest): Promise<void> => {
1505
1645
  ensureProcessExitCode(1);
1506
1646
  }
1507
1647
 
1508
- for (const reporter of context.reporters) {
1509
- await reporter.onTestRunEnd?.({
1510
- results: context.reporterResults.results,
1511
- testResults: context.reporterResults.testResults,
1512
- duration,
1513
- snapshotSummary: context.snapshotManager.summary,
1514
- getSourcemap: async () => null,
1515
- });
1648
+ const result: BrowserTestRunResult = {
1649
+ results: reporterResults,
1650
+ testResults: caseResults,
1651
+ duration,
1652
+ hasFailure: isFailure,
1653
+ };
1654
+
1655
+ // Only call onTestRunEnd if not skipped (for unified reporter output)
1656
+ if (!skipOnTestRunEnd) {
1657
+ for (const reporter of context.reporters) {
1658
+ await reporter.onTestRunEnd?.({
1659
+ results: context.reporterResults.results,
1660
+ testResults: context.reporterResults.testResults,
1661
+ duration,
1662
+ snapshotSummary: context.snapshotManager.summary,
1663
+ getSourcemap: async () => null,
1664
+ });
1665
+ }
1516
1666
  }
1517
1667
 
1518
1668
  // Enable watch hooks AFTER initial test run to avoid duplicate runs
@@ -1522,6 +1672,8 @@ export const runBrowserController = async (context: Rstest): Promise<void> => {
1522
1672
  color.cyan('\nWatch mode enabled - will re-run tests on file changes\n'),
1523
1673
  );
1524
1674
  }
1675
+
1676
+ return result;
1525
1677
  };
1526
1678
 
1527
1679
  // ============================================================================
package/src/index.ts CHANGED
@@ -1,12 +1,19 @@
1
- import type { Rstest } from '@rstest/core/browser';
1
+ import type {
2
+ BrowserTestRunOptions,
3
+ BrowserTestRunResult,
4
+ Rstest,
5
+ } from '@rstest/core/browser';
2
6
  import {
3
7
  type ListBrowserTestsResult,
4
8
  listBrowserTests as listBrowserTestsImpl,
5
9
  runBrowserController,
6
10
  } from './hostController';
7
11
 
8
- export async function runBrowserTests(context: Rstest): Promise<void> {
9
- await runBrowserController(context);
12
+ export async function runBrowserTests(
13
+ context: Rstest,
14
+ options?: BrowserTestRunOptions,
15
+ ): Promise<BrowserTestRunResult | void> {
16
+ return runBrowserController(context, options);
10
17
  }
11
18
 
12
19
  export async function listBrowserTests(
@@ -15,4 +22,8 @@ export async function listBrowserTests(
15
22
  return listBrowserTestsImpl(context);
16
23
  }
17
24
 
18
- export type { ListBrowserTestsResult };
25
+ export type {
26
+ BrowserTestRunOptions,
27
+ BrowserTestRunResult,
28
+ ListBrowserTestsResult,
29
+ };
@@ -1 +0,0 @@
1
- @import "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap";@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{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-duration:initial}::backdrop{--tw-translate-x:0;--tw-translate-y:0;--tw-translate-z:0;--tw-space-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-duration:initial}}}@layer theme{:root,:host{--font-mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--color-gray-400:#99a1af;--color-zinc-950:#09090b;--color-black:#000;--color-white:#fff;--spacing:.25rem;--text-xs:.75rem;--text-xs--line-height:calc(1/.75);--text-sm:.875rem;--text-sm--line-height:calc(1.25/.875);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--tracking-wide:.025em;--animate-spin:spin 1s linear infinite;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4,0,.2,1)}@supports (color:color(display-p3 0 0 0)){:root,:host{--color-gray-400:color(display-p3 .605734 .630385 .680158);--color-zinc-950:color(display-p3 .0353716 .0353595 .0435539)}}@supports (color:lab(0% 0 0)){:root,:host{--color-gray-400:lab(65.9269% -.832707 -8.17473);--color-zinc-950:lab(2.51107% .242703 -.886115)}}}@layer base,components;@layer utilities{.absolute{position:absolute}.absolute\!{position:absolute!important}.relative{position:relative}.inset-0{inset:calc(var(--spacing)*0)}.inset-x-0{inset-inline:calc(var(--spacing)*0)}.bottom-0{bottom:calc(var(--spacing)*0)}.z-10{z-index:10}.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}}.m-0{margin:calc(var(--spacing)*0)}.m-0\!{margin:calc(var(--spacing)*0)!important}.m-1\!{margin:calc(var(--spacing)*1)!important}.mr-0\!{margin-right:calc(var(--spacing)*0)!important}.block{display:block}.flex{display:flex}.grid{display:grid}.inline{display:inline}.inline-flex{display:inline-flex}.inline-flex\!{display:inline-flex!important}.h-1\.5{height:calc(var(--spacing)*1.5)}.h-5{height:calc(var(--spacing)*5)}.h-5\!{height:calc(var(--spacing)*5)!important}.h-7{height:calc(var(--spacing)*7)}.h-\[52px\]{height:52px}.h-full{height:100%}.h-screen{height:100vh}.min-h-0{min-height:calc(var(--spacing)*0)}.w-1\.5{width:calc(var(--spacing)*1.5)}.w-5\!{width:calc(var(--spacing)*5)!important}.w-7{width:calc(var(--spacing)*7)}.w-\[18px\]{width:18px}.w-full{width:100%}.flex-1{flex:1}.shrink-0{flex-shrink:0}.translate-y-\[calc\(50\%-2px\)\]{--tw-translate-y:calc(50% - 2px);translate:var(--tw-translate-x)var(--tw-translate-y)}.animate-spin{animation:var(--animate-spin)}.cursor-default{cursor:default}.grid-cols-\[auto_minmax\(0\,1fr\)_auto\]{grid-template-columns:auto minmax(0,1fr) auto}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.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-\[2px\]{gap:2px}: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)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded-full{border-radius:3.40282e38px}.border{border-style:var(--tw-border-style);border-width:1px}.border-0{border-style:var(--tw-border-style);border-width:0}.bg-black\/\[0\.02\]{background-color:#00000005}@supports (color:color-mix(in lab, red, red)){.bg-black\/\[0\.02\]{background-color:color-mix(in oklab,var(--color-black)2%,transparent)}}.bg-transparent{background-color:#0000}.bg-zinc-950{background-color:var(--color-zinc-950)}.p-0{padding:calc(var(--spacing)*0)}.p-0\!{padding:calc(var(--spacing)*0)!important}.p-3{padding:calc(var(--spacing)*3)}.px-3{padding-inline:calc(var(--spacing)*3)}.px-4{padding-inline:calc(var(--spacing)*4)}.py-2{padding-block:calc(var(--spacing)*2)}.font-mono{font-family:var(--font-mono)}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]\!{font-size:10px!important}.text-\[13px\]{font-size:13px}.text-\[13px\]\!{font-size:13px!important}.leading-none{--tw-leading:1;line-height:1}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.font-semibold\!{--tw-font-weight:var(--font-weight-semibold)!important;font-weight:var(--font-weight-semibold)!important}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.text-black\!{color:var(--color-black)!important}.text-gray-400{color:var(--color-gray-400)}.text-white{color:var(--color-white)}.opacity-0{opacity:0}.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-opacity{transition-property:opacity;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}.select-none{-webkit-user-select:none;user-select:none}@media (hover:hover){.group-hover\:opacity-100:is(:where(.group):hover *){opacity:1}}}:root{--lightningcss-light: ;--lightningcss-dark:initial;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}body{color:#eef1f6;background:#06070d;min-height:100vh;margin:0;font-family:Space Grotesk,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Noto Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;overflow:hidden}.animate-spin{animation-duration:.6s}@keyframes status-flash{0%{filter:brightness()}5%{filter:brightness(2.5)}15%{filter:brightness(1.8)}40%{filter:brightness(1.3)}to{filter:brightness()}}.status-icon-flash{animation:1.5s ease-out status-flash}@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-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@keyframes spin{to{transform:rotate(360deg)}}