@rozenite/controls-plugin 1.13.0 → 2.0.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.
@@ -22,8 +22,8 @@
22
22
  <script>
23
23
  var __ROZENITE_PANEL__ = true;
24
24
  </script>
25
- <script type="module" crossorigin src="../devtools/assets/panel-BoQwJa2M.js"></script>
26
- <link rel="stylesheet" crossorigin href="../devtools/assets/panel-B5zz0XK3.css">
25
+ <script type="module" crossorigin src="../devtools/assets/panel-bqV0H3UX.js"></script>
26
+ <link rel="stylesheet" crossorigin href="../devtools/assets/panel-DOPs3JU-.css">
27
27
  </head>
28
28
  <body>
29
29
  <div id="root"></div>
@@ -1,4 +1,4 @@
1
- import { RozeniteDevToolsClient } from '@rozenite/plugin-bridge';
1
+ import { RozeniteDevToolsRequestClient } from '@rozenite/plugin-bridge';
2
2
 
3
3
  export declare type ControlsButtonItem = {
4
4
  id: string;
@@ -133,6 +133,6 @@ export declare type RozeniteControlsPluginOptionsUpdater = (previousOptions: Roz
133
133
 
134
134
  export declare let useRozeniteControlsPlugin: typeof useRozeniteControlsPlugin_2;
135
135
 
136
- declare const useRozeniteControlsPlugin_2: (optionsInput: RozeniteControlsPluginOptionsInput) => RozeniteDevToolsClient<ControlsEventMap> | null;
136
+ declare const useRozeniteControlsPlugin_2: (optionsInput: RozeniteControlsPluginOptionsInput) => RozeniteDevToolsRequestClient<ControlsEventMap> | null;
137
137
 
138
138
  export { }
@@ -1 +1 @@
1
- {"name":"@rozenite/controls-plugin","version":"1.13.0","description":"A Rozenite plugin for exposing app-defined controls directly in React Native DevTools.","panels":[{"name":"Controls","source":"/devtools/panel.html"}]}
1
+ {"name":"@rozenite/controls-plugin","version":"2.0.0","description":"A Rozenite plugin for exposing app-defined controls directly in React Native DevTools.","panels":[{"name":"Controls","source":"/devtools/panel.html"}]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rozenite/controls-plugin",
3
- "version": "1.13.0",
3
+ "version": "2.0.0",
4
4
  "description": "A Rozenite plugin for exposing app-defined controls directly in React Native DevTools.",
5
5
  "type": "module",
6
6
  "main": "./dist/react-native/index.cjs",
@@ -15,25 +15,22 @@
15
15
  "url": "https://github.com/callstackincubator/rozenite.git"
16
16
  },
17
17
  "dependencies": {
18
- "@rozenite/agent-shared": "1.13.0",
19
- "@rozenite/agent-bridge": "1.13.0",
20
- "@rozenite/plugin-bridge": "1.13.0"
18
+ "@rozenite/agent-shared": "2.0.0",
19
+ "@rozenite/agent-bridge": "2.0.0",
20
+ "@rozenite/plugin-bridge": "2.0.0",
21
+ "@rozenite/ui": "2.0.0"
21
22
  },
22
23
  "devDependencies": {
23
- "@types/react": "~19.2.2",
24
- "@types/react-dom": "~19.1.7",
25
- "autoprefixer": "^10.4.21",
26
- "postcss": "^8.5.6",
27
- "react": "19.2.0",
28
- "react-dom": "19.2.0",
29
- "react-native": "0.83.1",
24
+ "@types/react": "~19.2.3",
25
+ "@types/react-dom": "~19.2.3",
26
+ "react": "19.2.3",
27
+ "react-dom": "19.2.3",
28
+ "react-native": "0.86.0",
30
29
  "react-native-web": "^0.21.2",
31
- "tailwindcss": "^3.4.17",
32
- "tailwindcss-animate": "^1.0.7",
33
30
  "typescript": "~5.9.3",
34
31
  "vite": "^7.3.1",
35
- "@rozenite/vite-plugin": "1.13.0",
36
- "rozenite": "1.13.0"
32
+ "@rozenite/vite-plugin": "2.0.0",
33
+ "rozenite": "2.0.0"
37
34
  },
38
35
  "peerDependencies": {
39
36
  "react": "*",
package/sdk.ts CHANGED
@@ -4,10 +4,7 @@ import {
4
4
  controlsToolDefinitions,
5
5
  } from './src/shared/agent-tools.js';
6
6
 
7
- export {
8
- CONTROLS_AGENT_PLUGIN_ID,
9
- controlsToolDefinitions,
10
- };
7
+ export { CONTROLS_AGENT_PLUGIN_ID, controlsToolDefinitions };
11
8
 
12
9
  export const controlsTools = defineAgentToolDescriptors(
13
10
  CONTROLS_AGENT_PLUGIN_ID,
@@ -1,5 +1,9 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
- import { buildActionRegistry, getActionRegistryKey, serializeSections } from '../shared/serialization';
2
+ import {
3
+ buildActionRegistry,
4
+ getActionRegistryKey,
5
+ serializeSections,
6
+ } from '../shared/serialization';
3
7
  import { createSection } from '../shared/types';
4
8
 
5
9
  describe('controls serialization', () => {
@@ -167,12 +171,14 @@ describe('controls serialization', () => {
167
171
  const registry = buildActionRegistry(sections);
168
172
 
169
173
  const toggleEntry = registry.get(getActionRegistryKey('controls', 'flag'));
170
- const buttonEntry = registry.get(getActionRegistryKey('controls', 'refresh'));
174
+ const buttonEntry = registry.get(
175
+ getActionRegistryKey('controls', 'refresh'),
176
+ );
171
177
  const selectEntry = registry.get(
172
- getActionRegistryKey('controls', 'environment')
178
+ getActionRegistryKey('controls', 'environment'),
173
179
  );
174
180
  const inputEntry = registry.get(
175
- getActionRegistryKey('controls', 'release-label')
181
+ getActionRegistryKey('controls', 'release-label'),
176
182
  );
177
183
 
178
184
  expect(toggleEntry?.type).toBe('toggle');
@@ -2,10 +2,7 @@ import {
2
2
  defineAgentToolContract,
3
3
  type AgentToolContract,
4
4
  } from '@rozenite/agent-shared';
5
- import type {
6
- ControlsItem,
7
- ControlsItemSnapshot,
8
- } from './types';
5
+ import type { ControlsItem, ControlsItemSnapshot } from './types';
9
6
 
10
7
  export const CONTROLS_AGENT_PLUGIN_ID = '@rozenite/controls-plugin';
11
8
 
@@ -34,7 +34,7 @@ export type ActionRegistryEntry =
34
34
 
35
35
  const validateValue = <TValue>(
36
36
  validate: ControlsMutableItemBase<TValue>['validate'],
37
- value: TValue
37
+ value: TValue,
38
38
  ): ControlsValidationResult => {
39
39
  if (!validate) {
40
40
  return { valid: true };
@@ -95,7 +95,7 @@ const toSnapshotItem = (item: ControlsItem): ControlsItemSnapshot => {
95
95
  };
96
96
 
97
97
  export const serializeSections = (
98
- sections: ControlsSection[]
98
+ sections: ControlsSection[],
99
99
  ): ControlsSectionSnapshot[] =>
100
100
  sections.map((section) => ({
101
101
  id: section.id,
package/src/ui/panel.tsx CHANGED
@@ -1,4 +1,14 @@
1
1
  import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
2
+ import {
3
+ Button,
4
+ EmptyState,
5
+ Input,
6
+ PluginShell,
7
+ Select,
8
+ Sidebar,
9
+ Split,
10
+ Switch,
11
+ } from '@rozenite/ui';
2
12
  import type { ReactNode } from 'react';
3
13
  import { useEffect, useRef, useState } from 'react';
4
14
  import type {
@@ -6,15 +16,19 @@ import type {
6
16
  ControlsSnapshotEvent,
7
17
  ControlsUpdateResultEvent,
8
18
  } from '../shared/messaging';
9
- import type { ControlsItemSnapshot, ControlsSectionSnapshot } from '../shared/types';
10
- import './globals.css';
19
+ import type {
20
+ ControlsItemSnapshot,
21
+ ControlsSectionSnapshot,
22
+ } from '../shared/types';
23
+ import '@rozenite/ui/styles.css';
11
24
 
12
25
  type ItemUiState = {
13
26
  pending: boolean;
14
27
  message?: string;
15
28
  };
16
29
 
17
- const getItemKey = (sectionId: string, itemId: string) => `${sectionId}:${itemId}`;
30
+ const getItemKey = (sectionId: string, itemId: string) =>
31
+ `${sectionId}:${itemId}`;
18
32
 
19
33
  const createRequestId = () =>
20
34
  `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
@@ -32,12 +46,12 @@ const RowShell = ({
32
46
  }) => (
33
47
  <div className="flex items-start justify-between gap-4 py-3">
34
48
  <div className="min-w-0">
35
- <div className="text-sm font-medium text-gray-100">{title}</div>
49
+ <div className="text-sm font-medium text-foreground">{title}</div>
36
50
  {description ? (
37
- <div className="mt-1 text-xs text-gray-400">{description}</div>
51
+ <div className="mt-1 text-xs text-muted-foreground">{description}</div>
38
52
  ) : null}
39
53
  {errorMessage ? (
40
- <div className="mt-1 text-xs text-red-400">{errorMessage}</div>
54
+ <div className="mt-1 text-xs text-destructive">{errorMessage}</div>
41
55
  ) : null}
42
56
  </div>
43
57
  {children}
@@ -61,16 +75,12 @@ const ToggleRow = ({
61
75
  description={item.description}
62
76
  errorMessage={uiState?.message}
63
77
  >
64
- <label className="relative inline-flex cursor-pointer items-center">
65
- <input
66
- type="checkbox"
67
- className="peer sr-only"
68
- checked={item.value}
69
- disabled={item.disabled || uiState?.pending}
70
- onChange={(event) => onToggle(sectionId, item.id, event.target.checked)}
71
- />
72
- <div className="h-6 w-11 rounded-full bg-gray-700 transition peer-checked:bg-violet-500 peer-disabled:cursor-not-allowed peer-disabled:opacity-50 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:after:translate-x-5" />
73
- </label>
78
+ <Switch
79
+ aria-label={item.title}
80
+ checked={item.value}
81
+ disabled={item.disabled || uiState?.pending}
82
+ onCheckedChange={(checked) => onToggle(sectionId, item.id, checked)}
83
+ />
74
84
  </RowShell>
75
85
  );
76
86
  };
@@ -92,13 +102,14 @@ const ButtonRow = ({
92
102
  description={item.description}
93
103
  errorMessage={uiState?.message}
94
104
  >
95
- <button
96
- className="rounded-md border border-violet-500/60 bg-violet-500/10 px-3 py-1.5 text-xs font-semibold text-violet-100 transition hover:bg-violet-500/20 disabled:cursor-not-allowed disabled:border-gray-700 disabled:bg-gray-800 disabled:text-gray-500"
105
+ <Button
106
+ size="compact"
107
+ variant="outline"
97
108
  disabled={item.disabled || uiState?.pending}
98
109
  onClick={() => onPress(sectionId, item.id)}
99
110
  >
100
- {uiState?.pending ? 'Running...' : item.actionLabel ?? 'Run'}
101
- </button>
111
+ {uiState?.pending ? 'Running...' : (item.actionLabel ?? 'Run')}
112
+ </Button>
102
113
  </RowShell>
103
114
  );
104
115
  };
@@ -120,18 +131,26 @@ const SelectRow = ({
120
131
  description={item.description}
121
132
  errorMessage={uiState?.message}
122
133
  >
123
- <select
124
- className="min-w-[160px] rounded-md border border-gray-700 bg-gray-950 px-3 py-2 text-xs text-gray-200 outline-none transition focus:border-violet-500 disabled:cursor-not-allowed disabled:opacity-50"
134
+ <Select
125
135
  value={item.value}
126
136
  disabled={item.disabled || uiState?.pending}
127
- onChange={(event) => onSelect(sectionId, item.id, event.target.value)}
137
+ onValueChange={(value) => {
138
+ if (value !== null) {
139
+ onSelect(sectionId, item.id, value);
140
+ }
141
+ }}
128
142
  >
129
- {item.options.map((option) => (
130
- <option key={option.value} value={option.value}>
131
- {option.label}
132
- </option>
133
- ))}
134
- </select>
143
+ <Select.Trigger className="w-40">
144
+ <Select.Value />
145
+ </Select.Trigger>
146
+ <Select.Content>
147
+ {item.options.map((option) => (
148
+ <Select.Item key={option.value} value={option.value}>
149
+ {option.label}
150
+ </Select.Item>
151
+ ))}
152
+ </Select.Content>
153
+ </Select>
135
154
  </RowShell>
136
155
  );
137
156
  };
@@ -143,7 +162,7 @@ const TextRow = ({
143
162
  }) => {
144
163
  return (
145
164
  <RowShell title={item.title} description={item.description}>
146
- <div className="max-w-[50%] rounded-md bg-gray-950/80 px-3 py-1.5 text-right text-xs text-gray-300">
165
+ <div className="max-w-[50%] rounded-md bg-muted px-3 py-1.5 text-right text-xs text-muted-foreground">
147
166
  {item.value}
148
167
  </div>
149
168
  </RowShell>
@@ -174,8 +193,8 @@ const InputRow = ({
174
193
  errorMessage={uiState?.message}
175
194
  >
176
195
  <div className="flex min-w-[240px] items-center gap-2">
177
- <input
178
- className="flex-1 rounded-md border border-gray-700 bg-gray-950 px-3 py-2 text-xs text-gray-200 outline-none transition focus:border-violet-500 disabled:cursor-not-allowed disabled:opacity-50"
196
+ <Input
197
+ className="flex-1"
179
198
  type="text"
180
199
  value={draftValue}
181
200
  placeholder={item.placeholder}
@@ -184,13 +203,14 @@ const InputRow = ({
184
203
  onDraftChange(sectionId, item.id, event.target.value)
185
204
  }
186
205
  />
187
- <button
188
- className="rounded-md border border-violet-500/60 bg-violet-500/10 px-3 py-2 text-xs font-semibold text-violet-100 transition hover:bg-violet-500/20 disabled:cursor-not-allowed disabled:border-gray-700 disabled:bg-gray-800 disabled:text-gray-500"
206
+ <Button
207
+ size="compact"
208
+ variant="outline"
189
209
  disabled={!isChanged || item.disabled || uiState?.pending}
190
210
  onClick={() => onApply(sectionId, item.id)}
191
211
  >
192
- {uiState?.pending ? 'Applying...' : item.applyLabel ?? 'Apply'}
193
- </button>
212
+ {uiState?.pending ? 'Applying...' : (item.applyLabel ?? 'Apply')}
213
+ </Button>
194
214
  </div>
195
215
  </RowShell>
196
216
  );
@@ -214,7 +234,11 @@ const renderItem = ({
214
234
  onToggle: (sectionId: string, itemId: string, value: boolean) => void;
215
235
  onPress: (sectionId: string, itemId: string) => void;
216
236
  onSelect: (sectionId: string, itemId: string, value: string) => void;
217
- onInputDraftChange: (sectionId: string, itemId: string, value: string) => void;
237
+ onInputDraftChange: (
238
+ sectionId: string,
239
+ itemId: string,
240
+ value: string,
241
+ ) => void;
218
242
  onInputApply: (sectionId: string, itemId: string) => void;
219
243
  }) => {
220
244
  if (item.type === 'text') {
@@ -268,9 +292,16 @@ const renderItem = ({
268
292
 
269
293
  export default function ControlsPanel() {
270
294
  const [sections, setSections] = useState<ControlsSectionSnapshot[]>([]);
295
+ const [selectedSectionId, setSelectedSectionId] = useState<string | null>(
296
+ null,
297
+ );
271
298
  const [loading, setLoading] = useState(true);
272
- const [itemUiState, setItemUiState] = useState<Map<string, ItemUiState>>(new Map());
273
- const [inputDrafts, setInputDrafts] = useState<Map<string, string>>(new Map());
299
+ const [itemUiState, setItemUiState] = useState<Map<string, ItemUiState>>(
300
+ new Map(),
301
+ );
302
+ const [inputDrafts, setInputDrafts] = useState<Map<string, string>>(
303
+ new Map(),
304
+ );
274
305
  const committedInputValuesRef = useRef<Map<string, string>>(new Map());
275
306
 
276
307
  const client = useRozeniteDevToolsClient<ControlsEventMap>({
@@ -286,13 +317,21 @@ export default function ControlsPanel() {
286
317
  'snapshot',
287
318
  (event: ControlsSnapshotEvent) => {
288
319
  setSections(event.sections);
320
+ setSelectedSectionId((previous) =>
321
+ event.sections.some((section) => section.id === previous)
322
+ ? previous
323
+ : (event.sections[0]?.id ?? null),
324
+ );
289
325
  setLoading(false);
290
326
 
291
327
  const nextCommittedValues = new Map<string, string>();
292
328
  event.sections.forEach((section) => {
293
329
  section.items.forEach((item) => {
294
330
  if (item.type === 'input') {
295
- nextCommittedValues.set(getItemKey(section.id, item.id), item.value);
331
+ nextCommittedValues.set(
332
+ getItemKey(section.id, item.id),
333
+ item.value,
334
+ );
296
335
  }
297
336
  });
298
337
  });
@@ -310,7 +349,8 @@ export default function ControlsPanel() {
310
349
  const previousCommitted = committedInputValuesRef.current.get(key);
311
350
  const previousDraft = previous.get(key);
312
351
  const isDirty =
313
- previousDraft !== undefined && previousDraft !== previousCommitted;
352
+ previousDraft !== undefined &&
353
+ previousDraft !== previousCommitted;
314
354
 
315
355
  if (!isDirty || previousDraft === committedValue) {
316
356
  next.set(key, committedValue);
@@ -321,7 +361,7 @@ export default function ControlsPanel() {
321
361
  });
322
362
 
323
363
  committedInputValuesRef.current = nextCommittedValues;
324
- }
364
+ },
325
365
  );
326
366
  const updateResultSubscription = client.onMessage(
327
367
  'update-result',
@@ -336,7 +376,7 @@ export default function ControlsPanel() {
336
376
  });
337
377
  return next;
338
378
  });
339
- }
379
+ },
340
380
  );
341
381
 
342
382
  client.send('get-snapshot', {
@@ -352,7 +392,7 @@ export default function ControlsPanel() {
352
392
  const sendUpdateRequest = (
353
393
  sectionId: string,
354
394
  itemId: string,
355
- value: boolean | string
395
+ value: boolean | string,
356
396
  ) => {
357
397
  if (!client) {
358
398
  return;
@@ -403,7 +443,7 @@ export default function ControlsPanel() {
403
443
  const handleInputDraftChange = (
404
444
  sectionId: string,
405
445
  itemId: string,
406
- value: string
446
+ value: string,
407
447
  ) => {
408
448
  const key = getItemKey(sectionId, itemId);
409
449
 
@@ -437,65 +477,76 @@ export default function ControlsPanel() {
437
477
  sendUpdateRequest(sectionId, itemId, draftValue);
438
478
  };
439
479
 
440
- return (
441
- <div className="h-screen bg-gray-900 text-gray-100 flex flex-col">
442
- <div className="flex items-center gap-2 border-b border-gray-700 bg-gray-800 p-2">
443
- <span className="text-sm font-medium text-gray-200">Controls</span>
444
- <div className="flex-1" />
445
- <span className="text-xs text-gray-400">{sections.length} sections</span>
446
- </div>
480
+ const selectedSection = sections.find(
481
+ (section) => section.id === selectedSectionId,
482
+ );
447
483
 
448
- <div className="flex-1 overflow-auto p-4">
449
- {loading ? (
450
- <div className="rounded-lg border border-gray-800 bg-gray-800 p-6 text-sm text-gray-400">
451
- Loading controls snapshot...
452
- </div>
453
- ) : null}
484
+ return (
485
+ <PluginShell className="dark">
486
+ <PluginShell.Body>
487
+ {loading ? <EmptyState title="Loading controls…" /> : null}
454
488
 
455
489
  {!loading && sections.length === 0 ? (
456
- <div className="rounded-lg border border-dashed border-gray-700 bg-gray-800/80 p-6 text-sm text-gray-400">
457
- No controls registered on the device.
458
- </div>
490
+ <EmptyState
491
+ title="No controls registered"
492
+ description="Register controls in the app to manage them here."
493
+ />
459
494
  ) : null}
460
495
 
461
- <div className="space-y-4">
462
- {sections.map((section) => (
463
- <section
464
- key={section.id}
465
- className="rounded-xl border border-gray-800 bg-gray-800/90 shadow-lg shadow-black/10"
466
- >
467
- <div className="border-b border-gray-800 px-4 py-3">
468
- <div className="text-sm font-semibold text-gray-100">
469
- {section.title}
470
- </div>
471
- {section.description ? (
472
- <div className="mt-1 text-xs text-gray-400">
473
- {section.description}
474
- </div>
496
+ {!loading && selectedSection ? (
497
+ <Split direction="horizontal" autoSaveId="controls-sections">
498
+ <Split.Pane defaultSize={22} minSize={15} maxSize={35}>
499
+ <Sidebar className="w-full border-r-0">
500
+ <Sidebar.Group>
501
+ {sections.map((section) => (
502
+ <Sidebar.Item
503
+ key={section.id}
504
+ selected={section.id === selectedSection.id}
505
+ onClick={() => setSelectedSectionId(section.id)}
506
+ >
507
+ {section.title}
508
+ </Sidebar.Item>
509
+ ))}
510
+ </Sidebar.Group>
511
+ </Sidebar>
512
+ </Split.Pane>
513
+ <Split.Handle />
514
+ <Split.Pane>
515
+ <section className="h-full overflow-auto p-6">
516
+ <h1 className="text-base font-semibold text-foreground">
517
+ {selectedSection.title}
518
+ </h1>
519
+ {selectedSection.description ? (
520
+ <p className="mt-1 text-sm text-muted-foreground">
521
+ {selectedSection.description}
522
+ </p>
475
523
  ) : null}
476
- </div>
477
-
478
- <div className="divide-y divide-gray-800 px-4">
479
- {section.items.map((item) => (
480
- <div key={item.id}>
481
- {renderItem({
482
- sectionId: section.id,
483
- item,
484
- uiState: itemUiState.get(getItemKey(section.id, item.id)),
485
- inputDraft: inputDrafts.get(getItemKey(section.id, item.id)),
486
- onToggle: handleToggle,
487
- onPress: handlePress,
488
- onSelect: handleSelect,
489
- onInputDraftChange: handleInputDraftChange,
490
- onInputApply: handleInputApply,
491
- })}
492
- </div>
493
- ))}
494
- </div>
495
- </section>
496
- ))}
497
- </div>
498
- </div>
499
- </div>
524
+ <div className="mt-5 divide-y divide-border">
525
+ {selectedSection.items.map((item) => (
526
+ <div key={item.id}>
527
+ {renderItem({
528
+ sectionId: selectedSection.id,
529
+ item,
530
+ uiState: itemUiState.get(
531
+ getItemKey(selectedSection.id, item.id),
532
+ ),
533
+ inputDraft: inputDrafts.get(
534
+ getItemKey(selectedSection.id, item.id),
535
+ ),
536
+ onToggle: handleToggle,
537
+ onPress: handlePress,
538
+ onSelect: handleSelect,
539
+ onInputDraftChange: handleInputDraftChange,
540
+ onInputApply: handleInputApply,
541
+ })}
542
+ </div>
543
+ ))}
544
+ </div>
545
+ </section>
546
+ </Split.Pane>
547
+ </Split>
548
+ ) : null}
549
+ </PluginShell.Body>
550
+ </PluginShell>
500
551
  );
501
552
  }
@@ -1 +0,0 @@
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,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";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,Monaco,Consolas,Liberation Mono,Courier New,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: 0 0% 3.9%;--card: 0 0% 100%;--card-foreground: 0 0% 3.9%;--popover: 0 0% 100%;--popover-foreground: 0 0% 3.9%;--primary: 0 0% 9%;--primary-foreground: 0 0% 98%;--secondary: 0 0% 96.1%;--secondary-foreground: 0 0% 9%;--muted: 0 0% 96.1%;--muted-foreground: 0 0% 45.1%;--accent: 0 0% 96.1%;--accent-foreground: 0 0% 9%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 0 0% 98%;--border: 0 0% 14.9%;--input: 0 0% 14.9%;--ring: 0 0% 83.1%;--radius: .5rem}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground))}.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}.relative{position:relative}.mt-1{margin-top:.25rem}.flex{display:flex}.inline-flex{display:inline-flex}.h-6{height:1.5rem}.h-screen{height:100vh}.w-11{width:2.75rem}.min-w-0{min-width:0px}.min-w-\[160px\]{min-width:160px}.min-w-\[240px\]{min-width:240px}.max-w-\[50\%\]{max-width:50%}.flex-1{flex:1 1 0%}.cursor-pointer{cursor:pointer}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.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))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-gray-800>:not([hidden])~:not([hidden]){--tw-divide-opacity: 1;border-color:rgb(31 41 55 / var(--tw-divide-opacity, 1))}.overflow-auto{overflow:auto}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-dashed{border-style:dashed}.border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.border-gray-800{--tw-border-opacity: 1;border-color:rgb(31 41 55 / var(--tw-border-opacity, 1))}.border-violet-500\/60{border-color:#8b5cf699}.bg-gray-700{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.bg-gray-800{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.bg-gray-800\/80{background-color:#1f2937cc}.bg-gray-800\/90{background-color:#1f2937e6}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-gray-950{--tw-bg-opacity: 1;background-color:rgb(3 7 18 / var(--tw-bg-opacity, 1))}.bg-gray-950\/80{background-color:#030712cc}.bg-violet-500\/10{background-color:#8b5cf61a}.p-2{padding:.5rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.text-right{text-align:right}.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}.text-gray-100{--tw-text-opacity: 1;color:rgb(243 244 246 / var(--tw-text-opacity, 1))}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-violet-100{--tw-text-opacity: 1;color:rgb(237 233 254 / var(--tw-text-opacity, 1))}.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-black\/10{--tw-shadow-color: rgb(0 0 0 / .1);--tw-shadow: var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.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}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:#1f2937;border-radius:4px}::-webkit-scrollbar-thumb{background:#374151;border-radius:4px;border:1px solid #1f2937}::-webkit-scrollbar-thumb:hover{background:#4b5563}::-webkit-scrollbar-thumb:active{background:#6b7280}::-webkit-scrollbar-corner{background:#1f2937}.after\:absolute:after{content:var(--tw-content);position:absolute}.after\:left-\[2px\]:after{content:var(--tw-content);left:2px}.after\:top-\[2px\]:after{content:var(--tw-content);top:2px}.after\:h-5:after{content:var(--tw-content);height:1.25rem}.after\:w-5:after{content:var(--tw-content);width:1.25rem}.after\:rounded-full:after{content:var(--tw-content);border-radius:9999px}.after\:bg-white:after{content:var(--tw-content);--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.after\:transition-all:after{content:var(--tw-content);transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.after\:content-\[\'\'\]:after{--tw-content: "";content:var(--tw-content)}.hover\:bg-violet-500\/20:hover{background-color:#8b5cf633}.focus\:border-violet-500:focus{--tw-border-opacity: 1;border-color:rgb(139 92 246 / var(--tw-border-opacity, 1))}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:border-gray-700:disabled{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.disabled\:bg-gray-800:disabled{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.disabled\:text-gray-500:disabled{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.disabled\:opacity-50:disabled{opacity:.5}.peer:checked~.peer-checked\:bg-violet-500{--tw-bg-opacity: 1;background-color:rgb(139 92 246 / var(--tw-bg-opacity, 1))}.peer:checked~.peer-checked\:after\:translate-x-5:after{content:var(--tw-content);--tw-translate-x: 1.25rem;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))}.peer:disabled~.peer-disabled\:cursor-not-allowed{cursor:not-allowed}.peer:disabled~.peer-disabled\:opacity-50{opacity:.5}