mishig HF Staff commited on
Commit
a23efda
·
verified ·
1 Parent(s): 3f42a31

Sync from huggingface/lerobot-dataset-visualizer via hub-sync

Browse files
src/components/action-insights-panel.tsx CHANGED
@@ -20,6 +20,7 @@ import type {
20
  JerkyEpisode,
21
  AggAlignment,
22
  } from "@/app/[org]/[dataset]/[episode]/fetch-data";
 
23
 
24
  const FullscreenCtx = React.createContext(false);
25
  const useIsFullscreen = () => React.useContext(FullscreenCtx);
@@ -860,24 +861,7 @@ function VarianceHeatmap({
860
  <h3 className="text-sm font-semibold text-slate-200 mb-2">
861
  Cross-Episode Action Variance
862
  </h3>
863
- <div className="flex items-center gap-2 text-slate-400 text-sm py-8 justify-center">
864
- <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
865
- <circle
866
- className="opacity-25"
867
- cx="12"
868
- cy="12"
869
- r="10"
870
- stroke="currentColor"
871
- strokeWidth="4"
872
- />
873
- <path
874
- className="opacity-75"
875
- fill="currentColor"
876
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
877
- />
878
- </svg>
879
- Loading cross-episode data (sampled up to 500 episodes)…
880
- </div>
881
  </div>
882
  );
883
  }
@@ -1600,40 +1584,19 @@ function ActionInsightsPanel({
1600
  const showAgg = mode === "dataset" && !!crossEpisodeData;
1601
 
1602
  return (
1603
- <div className="w-full max-w-5xl mx-auto py-6 space-y-8">
1604
- <div className="flex items-center justify-between flex-wrap gap-4">
1605
- <div>
1606
- <h2 className="text-xl font-bold text-slate-100">Action Insights</h2>
1607
- <p className="text-sm text-slate-400 mt-1">
1608
- Data-driven analysis to guide action chunking, data quality
1609
- assessment, and training configuration.
1610
- </p>
1611
- </div>
1612
- <div className="flex items-center gap-3 shrink-0">
1613
- <span
1614
- className={`text-sm ${mode === "episode" ? "text-slate-100 font-medium" : "text-slate-500"}`}
1615
- >
1616
- Current Episode
1617
- </span>
1618
- <button
1619
- onClick={() =>
1620
- setMode((m) => (m === "episode" ? "dataset" : "episode"))
1621
- }
1622
- className={`relative inline-flex items-center w-9 h-5 rounded-full transition-colors shrink-0 ${mode === "dataset" ? "bg-cyan-500" : "bg-white/10"}`}
1623
- aria-label="Toggle episode/dataset scope"
1624
- >
1625
- <span
1626
- className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${mode === "dataset" ? "translate-x-[18px]" : "translate-x-[3px]"}`}
1627
- />
1628
- </button>
1629
- <span
1630
- className={`text-sm ${mode === "dataset" ? "text-slate-100 font-medium" : "text-slate-500"}`}
1631
- >
1632
- All Episodes
1633
- {crossEpisodeData ? ` (${crossEpisodeData.numEpisodes})` : ""}
1634
- </span>
1635
- </div>
1636
- </div>
1637
 
1638
  <FullscreenWrapper>
1639
  <AutocorrelationSection
 
20
  JerkyEpisode,
21
  AggAlignment,
22
  } from "@/app/[org]/[dataset]/[episode]/fetch-data";
23
+ import { InlineLoading, PageHeader, Switch } from "@/components/ui";
24
 
25
  const FullscreenCtx = React.createContext(false);
26
  const useIsFullscreen = () => React.useContext(FullscreenCtx);
 
861
  <h3 className="text-sm font-semibold text-slate-200 mb-2">
862
  Cross-Episode Action Variance
863
  </h3>
864
+ <InlineLoading label="Loading cross-episode data…" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
865
  </div>
866
  );
867
  }
 
1584
  const showAgg = mode === "dataset" && !!crossEpisodeData;
1585
 
1586
  return (
1587
+ <div className="w-full max-w-5xl mx-auto py-6 space-y-6">
1588
+ <PageHeader
1589
+ title="Action Insights"
1590
+ description="Data-driven analysis to guide action chunking, data quality assessment, and training configuration."
1591
+ >
1592
+ <Switch
1593
+ checked={mode === "dataset"}
1594
+ onChange={(all) => setMode(all ? "dataset" : "episode")}
1595
+ offLabel="Current Episode"
1596
+ onLabel={`All Episodes${crossEpisodeData ? ` (${crossEpisodeData.numEpisodes})` : ""}`}
1597
+ ariaLabel="Toggle episode/dataset scope"
1598
+ />
1599
+ </PageHeader>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1600
 
1601
  <FullscreenWrapper>
1602
  <AutocorrelationSection
src/components/filtering-panel.tsx CHANGED
@@ -12,6 +12,7 @@ import {
12
  ActionVelocitySection,
13
  FullscreenWrapper,
14
  } from "@/components/action-insights-panel";
 
15
 
16
  // ─── Shared small components ─────────────────────────────────────
17
 
@@ -359,14 +360,11 @@ function FilteringPanel({
359
  onViewFlaggedEpisodes,
360
  }: FilteringPanelProps) {
361
  return (
362
- <div className="w-full max-w-5xl mx-auto py-6 space-y-8">
363
- <div>
364
- <h2 className="text-xl font-bold text-slate-100">Filtering</h2>
365
- <p className="text-sm text-slate-400 mt-1">
366
- Identify and flag problematic episodes for removal. Flagged episodes
367
- appear in the sidebar and can be exported as a CLI command.
368
- </p>
369
- </div>
370
 
371
  <FlaggedIdsCopyBar
372
  repoId={repoId}
@@ -379,28 +377,7 @@ function FilteringPanel({
379
 
380
  {crossEpisodeLoading && (
381
  <div className="bg-[var(--surface-1)]/60 rounded-lg p-5 border border-white/10">
382
- <div className="flex items-center gap-2 text-slate-400 text-sm py-4 justify-center">
383
- <svg
384
- className="animate-spin h-4 w-4"
385
- viewBox="0 0 24 24"
386
- fill="none"
387
- >
388
- <circle
389
- className="opacity-25"
390
- cx="12"
391
- cy="12"
392
- r="10"
393
- stroke="currentColor"
394
- strokeWidth="4"
395
- />
396
- <path
397
- className="opacity-75"
398
- fill="currentColor"
399
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
400
- />
401
- </svg>
402
- Loading cross-episode data…
403
- </div>
404
  </div>
405
  )}
406
 
 
12
  ActionVelocitySection,
13
  FullscreenWrapper,
14
  } from "@/components/action-insights-panel";
15
+ import { InlineLoading, PageHeader } from "@/components/ui";
16
 
17
  // ─── Shared small components ─────────────────────────────────────
18
 
 
360
  onViewFlaggedEpisodes,
361
  }: FilteringPanelProps) {
362
  return (
363
+ <div className="w-full max-w-5xl mx-auto py-6 space-y-6">
364
+ <PageHeader
365
+ title="Filtering"
366
+ description="Identify and flag problematic episodes for removal. Flagged episodes appear in the sidebar and can be exported as a CLI command."
367
+ />
 
 
 
368
 
369
  <FlaggedIdsCopyBar
370
  repoId={repoId}
 
377
 
378
  {crossEpisodeLoading && (
379
  <div className="bg-[var(--surface-1)]/60 rounded-lg p-5 border border-white/10">
380
+ <InlineLoading label="Loading cross-episode data…" />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
381
  </div>
382
  )}
383
 
src/components/overview-panel.tsx CHANGED
@@ -8,6 +8,7 @@ import type {
8
  } from "@/app/[org]/[dataset]/[episode]/fetch-data";
9
  import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
10
  import { proxyHfUrl } from "@/utils/auth";
 
11
 
12
  const PAGE_SIZE = 48;
13
 
@@ -152,26 +153,7 @@ export default function OverviewPanel({
152
  );
153
 
154
  if (loading || !data) {
155
- return (
156
- <div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
157
- <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
158
- <circle
159
- className="opacity-25"
160
- cx="12"
161
- cy="12"
162
- r="10"
163
- stroke="currentColor"
164
- strokeWidth="4"
165
- />
166
- <path
167
- className="opacity-75"
168
- fill="currentColor"
169
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
170
- />
171
- </svg>
172
- Loading episode frames…
173
- </div>
174
- );
175
  }
176
 
177
  const allFrames = data.framesByCamera[selectedCamera] ?? [];
@@ -207,15 +189,11 @@ export default function OverviewPanel({
207
  const pageFrames = frames.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
208
 
209
  return (
210
- <div className="w-full max-w-7xl mx-auto py-6 space-y-5">
211
- <div>
212
- <h2 className="text-xl font-bold text-slate-100">Frames</h2>
213
- <p className="text-sm text-slate-400 mt-1">
214
- Use first/last frame views to spot episodes with bad end states or
215
- other anomalies. Hover over a thumbnail and click the flag icon to
216
- mark episodes with wrong outcomes for review.
217
- </p>
218
- </div>
219
 
220
  {/* Controls row */}
221
  <div className="flex items-center justify-between flex-wrap gap-4">
@@ -267,27 +245,13 @@ export default function OverviewPanel({
267
  )}
268
 
269
  {/* First / Last toggle */}
270
- <div className="flex items-center gap-3">
271
- <span
272
- className={`text-sm ${!showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}
273
- >
274
- First Frame
275
- </span>
276
- <button
277
- onClick={() => setShowLast((v) => !v)}
278
- className={`relative inline-flex items-center w-9 h-5 rounded-full transition-colors shrink-0 ${showLast ? "bg-cyan-500" : "bg-white/10"}`}
279
- aria-label="Toggle first/last frame"
280
- >
281
- <span
282
- className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${showLast ? "translate-x-[18px]" : "translate-x-[3px]"}`}
283
- />
284
- </button>
285
- <span
286
- className={`text-sm ${showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}
287
- >
288
- Last Frame
289
- </span>
290
- </div>
291
  </div>
292
 
293
  {/* Pagination */}
 
8
  } from "@/app/[org]/[dataset]/[episode]/fetch-data";
9
  import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
10
  import { proxyHfUrl } from "@/utils/auth";
11
+ import { InlineLoading, PageHeader, Switch } from "@/components/ui";
12
 
13
  const PAGE_SIZE = 48;
14
 
 
153
  );
154
 
155
  if (loading || !data) {
156
+ return <InlineLoading label="Loading episode frames…" />;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  }
158
 
159
  const allFrames = data.framesByCamera[selectedCamera] ?? [];
 
189
  const pageFrames = frames.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
190
 
191
  return (
192
+ <div className="w-full max-w-5xl mx-auto py-6 space-y-6">
193
+ <PageHeader
194
+ title="Frames"
195
+ description="Use first/last frame views to spot episodes with bad end states or other anomalies. Hover over a thumbnail and click the flag icon to mark episodes with wrong outcomes for review."
196
+ />
 
 
 
 
197
 
198
  {/* Controls row */}
199
  <div className="flex items-center justify-between flex-wrap gap-4">
 
245
  )}
246
 
247
  {/* First / Last toggle */}
248
+ <Switch
249
+ checked={showLast}
250
+ onChange={setShowLast}
251
+ offLabel="First Frame"
252
+ onLabel="Last Frame"
253
+ ariaLabel="Toggle first/last frame"
254
+ />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
255
  </div>
256
 
257
  {/* Pagination */}
src/components/stats-panel.tsx CHANGED
@@ -5,6 +5,7 @@ import type {
5
  EpisodeLengthStats,
6
  CameraInfo,
7
  } from "@/app/[org]/[dataset]/[episode]/fetch-data";
 
8
 
9
  interface StatsPanelProps {
10
  datasetInfo: DatasetDisplayInfo;
@@ -108,15 +109,6 @@ function EpisodeLengthHistogram({
108
  );
109
  }
110
 
111
- function Card({ label, value }: { label: string; value: string | number }) {
112
- return (
113
- <div className="bg-[var(--surface-1)]/60 rounded-lg p-4 border border-white/10">
114
- <p className="text-xs text-slate-400 uppercase tracking-wide">{label}</p>
115
- <p className="text-xl font-bold tabular-nums mt-1">{value}</p>
116
- </div>
117
- );
118
- }
119
-
120
  function StatsPanel({
121
  datasetInfo,
122
  episodeLengthStats,
@@ -125,34 +117,36 @@ function StatsPanel({
125
  const els = episodeLengthStats;
126
 
127
  return (
128
- <div className="w-full max-w-4xl mx-auto py-6 space-y-8">
129
- <div>
130
- <h2 className="text-xl text-slate-100">
131
- <span className="font-bold">Dataset Statistics:</span>{" "}
132
- <span className="font-normal text-slate-400">
133
- {datasetInfo.repoId}
134
- </span>
135
- </h2>
136
- </div>
137
 
138
  {/* Overview cards */}
139
  <div className="grid grid-cols-3 gap-4">
140
- <Card label="Robot Type" value={datasetInfo.robot_type ?? "unknown"} />
141
- <Card label="Dataset Version" value={datasetInfo.codebase_version} />
142
- <Card label="Tasks" value={datasetInfo.total_tasks} />
 
 
 
 
 
 
143
  </div>
144
 
145
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
146
- <Card
147
  label="Total Frames"
148
  value={datasetInfo.total_frames.toLocaleString()}
149
  />
150
- <Card
151
  label="Total Episodes"
152
  value={datasetInfo.total_episodes.toLocaleString()}
153
  />
154
- <Card label="FPS" value={datasetInfo.fps} />
155
- <Card
156
  label="Total Recording Time"
157
  value={formatTotalTime(datasetInfo.total_frames, datasetInfo.fps)}
158
  />
@@ -166,46 +160,17 @@ function StatsPanel({
166
  </h3>
167
  <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
168
  {datasetInfo.cameras.map((cam: CameraInfo) => (
169
- <div
170
  key={cam.name}
171
- className="bg-[var(--surface-0)]/50 rounded-md p-3"
172
- >
173
- <p
174
- className="text-xs text-slate-400 mb-1 truncate"
175
- title={cam.name}
176
- >
177
- {cam.name}
178
- </p>
179
- <p className="text-base font-bold tabular-nums">
180
- {cam.width}×{cam.height}
181
- </p>
182
- </div>
183
  ))}
184
  </div>
185
  </div>
186
  )}
187
 
188
- {/* Loading spinner for async stats */}
189
- {loading && (
190
- <div className="flex items-center gap-2 text-slate-400 text-sm py-4">
191
- <svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
192
- <circle
193
- className="opacity-25"
194
- cx="12"
195
- cy="12"
196
- r="10"
197
- stroke="currentColor"
198
- strokeWidth="4"
199
- />
200
- <path
201
- className="opacity-75"
202
- fill="currentColor"
203
- d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
204
- />
205
- </svg>
206
- Computing episode statistics…
207
- </div>
208
- )}
209
 
210
  {/* Episode length section */}
211
  {els && (
@@ -215,21 +180,24 @@ function StatsPanel({
215
  Episode Lengths
216
  </h3>
217
  <div className="grid grid-cols-3 md:grid-cols-5 gap-4 mb-4">
218
- <Card
219
  label="Shortest"
220
  value={formatSeconds(els.shortestEpisodes[0]?.lengthSeconds)}
221
  />
222
  {/* longestEpisodes is sorted longest first. */}
223
- <Card
224
  label="Longest"
225
  value={formatSeconds(els.longestEpisodes[0]?.lengthSeconds)}
226
  />
227
- <Card label="Mean" value={formatSeconds(els.meanEpisodeLength)} />
228
- <Card
 
 
 
229
  label="Median"
230
  value={formatSeconds(els.medianEpisodeLength)}
231
  />
232
- <Card
233
  label="Std Dev"
234
  value={formatSeconds(els.stdEpisodeLength)}
235
  />
 
5
  EpisodeLengthStats,
6
  CameraInfo,
7
  } from "@/app/[org]/[dataset]/[episode]/fetch-data";
8
+ import { InlineLoading, PageHeader, StatCard } from "@/components/ui";
9
 
10
  interface StatsPanelProps {
11
  datasetInfo: DatasetDisplayInfo;
 
109
  );
110
  }
111
 
 
 
 
 
 
 
 
 
 
112
  function StatsPanel({
113
  datasetInfo,
114
  episodeLengthStats,
 
117
  const els = episodeLengthStats;
118
 
119
  return (
120
+ <div className="w-full max-w-5xl mx-auto py-6 space-y-6">
121
+ <PageHeader
122
+ title="Statistics"
123
+ description={`Size, cameras and episode lengths of ${datasetInfo.repoId}.`}
124
+ />
 
 
 
 
125
 
126
  {/* Overview cards */}
127
  <div className="grid grid-cols-3 gap-4">
128
+ <StatCard
129
+ label="Robot Type"
130
+ value={datasetInfo.robot_type ?? "unknown"}
131
+ />
132
+ <StatCard
133
+ label="Dataset Version"
134
+ value={datasetInfo.codebase_version}
135
+ />
136
+ <StatCard label="Tasks" value={datasetInfo.total_tasks} />
137
  </div>
138
 
139
  <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
140
+ <StatCard
141
  label="Total Frames"
142
  value={datasetInfo.total_frames.toLocaleString()}
143
  />
144
+ <StatCard
145
  label="Total Episodes"
146
  value={datasetInfo.total_episodes.toLocaleString()}
147
  />
148
+ <StatCard label="FPS" value={datasetInfo.fps} />
149
+ <StatCard
150
  label="Total Recording Time"
151
  value={formatTotalTime(datasetInfo.total_frames, datasetInfo.fps)}
152
  />
 
160
  </h3>
161
  <div className="grid grid-cols-2 md:grid-cols-3 gap-3">
162
  {datasetInfo.cameras.map((cam: CameraInfo) => (
163
+ <StatCard
164
  key={cam.name}
165
+ label={cam.name}
166
+ value={`${cam.width}×${cam.height}`}
167
+ />
 
 
 
 
 
 
 
 
 
168
  ))}
169
  </div>
170
  </div>
171
  )}
172
 
173
+ {loading && <InlineLoading label="Computing episode statistics…" />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
  {/* Episode length section */}
176
  {els && (
 
180
  Episode Lengths
181
  </h3>
182
  <div className="grid grid-cols-3 md:grid-cols-5 gap-4 mb-4">
183
+ <StatCard
184
  label="Shortest"
185
  value={formatSeconds(els.shortestEpisodes[0]?.lengthSeconds)}
186
  />
187
  {/* longestEpisodes is sorted longest first. */}
188
+ <StatCard
189
  label="Longest"
190
  value={formatSeconds(els.longestEpisodes[0]?.lengthSeconds)}
191
  />
192
+ <StatCard
193
+ label="Mean"
194
+ value={formatSeconds(els.meanEpisodeLength)}
195
+ />
196
+ <StatCard
197
  label="Median"
198
  value={formatSeconds(els.medianEpisodeLength)}
199
  />
200
+ <StatCard
201
  label="Std Dev"
202
  value={formatSeconds(els.stdEpisodeLength)}
203
  />
src/components/ui.tsx ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ /**
6
+ * Shared building blocks for the analysis tabs (Statistics, Filtering, Frames, Action Insights), so
7
+ * their headings, cards, loading states and toggles look the same everywhere.
8
+ */
9
+
10
+ /** Tab title and one-line description, with an optional control on the right. */
11
+ export function PageHeader({
12
+ title,
13
+ description,
14
+ children,
15
+ }: {
16
+ title: string;
17
+ description?: React.ReactNode;
18
+ children?: React.ReactNode;
19
+ }) {
20
+ return (
21
+ <div className="flex items-start justify-between flex-wrap gap-4">
22
+ <div className="min-w-0">
23
+ <h2 className="text-xl font-semibold text-slate-100">{title}</h2>
24
+ {description && (
25
+ <p className="text-sm text-slate-400 mt-1">{description}</p>
26
+ )}
27
+ </div>
28
+ {children && <div className="shrink-0">{children}</div>}
29
+ </div>
30
+ );
31
+ }
32
+
33
+ /** A labelled number: the label styled like the sidebar's, the value large and tabular. */
34
+ export function StatCard({
35
+ label,
36
+ value,
37
+ }: {
38
+ label: string;
39
+ value: React.ReactNode;
40
+ }) {
41
+ return (
42
+ <div className="rounded-lg border border-white/10 bg-[var(--surface-1)]/60 px-4 py-3 min-w-0">
43
+ <p
44
+ className="text-[10px] uppercase tracking-wide text-slate-500 truncate"
45
+ title={label}
46
+ >
47
+ {label}
48
+ </p>
49
+ <p className="text-lg font-semibold text-slate-100 tabular mt-1 truncate">
50
+ {value}
51
+ </p>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ export function Spinner({ className = "h-4 w-4" }: { className?: string }) {
57
+ return (
58
+ <svg
59
+ className={`animate-spin ${className}`}
60
+ viewBox="0 0 24 24"
61
+ fill="none"
62
+ aria-hidden="true"
63
+ >
64
+ <circle
65
+ className="opacity-25"
66
+ cx="12"
67
+ cy="12"
68
+ r="10"
69
+ stroke="currentColor"
70
+ strokeWidth="4"
71
+ />
72
+ <path
73
+ className="opacity-75"
74
+ fill="currentColor"
75
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
76
+ />
77
+ </svg>
78
+ );
79
+ }
80
+
81
+ /** A spinner with a label, for a section that is still loading. */
82
+ export function InlineLoading({ label }: { label: string }) {
83
+ return (
84
+ <div
85
+ className="flex items-center justify-center gap-2 py-8 text-sm text-slate-400"
86
+ role="status"
87
+ >
88
+ <Spinner />
89
+ {label}
90
+ </div>
91
+ );
92
+ }
93
+
94
+ /** Two-state switch with a label on each side; the active side is highlighted. */
95
+ export function Switch({
96
+ checked,
97
+ onChange,
98
+ offLabel,
99
+ onLabel,
100
+ ariaLabel,
101
+ }: {
102
+ checked: boolean;
103
+ onChange: (checked: boolean) => void;
104
+ offLabel: React.ReactNode;
105
+ onLabel: React.ReactNode;
106
+ ariaLabel: string;
107
+ }) {
108
+ const labelClass = (active: boolean) =>
109
+ `text-sm ${active ? "text-slate-100 font-medium" : "text-slate-500"}`;
110
+ return (
111
+ <div className="flex items-center gap-3">
112
+ <span className={labelClass(!checked)}>{offLabel}</span>
113
+ <button
114
+ type="button"
115
+ role="switch"
116
+ aria-checked={checked}
117
+ aria-label={ariaLabel}
118
+ onClick={() => onChange(!checked)}
119
+ className={`relative inline-flex items-center w-9 h-5 rounded-full transition-colors shrink-0 ${checked ? "bg-cyan-500" : "bg-white/10"}`}
120
+ >
121
+ <span
122
+ className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${checked ? "translate-x-[18px]" : "translate-x-[3px]"}`}
123
+ />
124
+ </button>
125
+ <span className={labelClass(checked)}>{onLabel}</span>
126
+ </div>
127
+ );
128
+ }