dsh-coding-subscription-oauth 0.5.3 → 0.5.5

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/INSTALL.md +4 -4
  3. package/README.de.md +23 -6
  4. package/README.es.md +24 -6
  5. package/README.fr.md +24 -6
  6. package/README.ja.md +24 -6
  7. package/README.ko.md +24 -6
  8. package/README.md +23 -6
  9. package/README.pt-BR.md +24 -6
  10. package/README.ru.md +24 -6
  11. package/README.zh-CN.md +23 -6
  12. package/docs/02-architecture.md +1 -1
  13. package/docs/02-architecture.zh-CN.md +1 -1
  14. package/lib/client.js +34 -1
  15. package/lib/client.js.map +4 -4
  16. package/media/settings_accounts.png +0 -0
  17. package/media/settings_capabilities.png +0 -0
  18. package/media/settings_gateway.png +0 -0
  19. package/media/settings_overview.png +0 -0
  20. package/package.json +2 -1
  21. package/src/client/GrokBuildSettings.tsx +270 -1773
  22. package/src/client/api.ts +88 -0
  23. package/src/client/components/AboutTab.tsx +20 -0
  24. package/src/client/components/AccountsTab.tsx +224 -0
  25. package/src/client/components/Badge.tsx +33 -0
  26. package/src/client/components/CapabilitiesTab.tsx +227 -0
  27. package/src/client/components/CliPullPreview.tsx +107 -0
  28. package/src/client/components/CopyButton.tsx +57 -0
  29. package/src/client/components/GatewayTab.tsx +364 -0
  30. package/src/client/components/NoticeBanner.tsx +46 -0
  31. package/src/client/components/ProgressBar.tsx +52 -0
  32. package/src/client/components/ProviderCard.tsx +502 -0
  33. package/src/client/components/SettingsTabs.tsx +75 -0
  34. package/src/client/components/ToggleSwitch.tsx +60 -0
  35. package/src/client/constants.ts +206 -0
  36. package/src/client/display.ts +61 -0
  37. package/src/client/gatewaySnippets.ts +36 -0
  38. package/src/client/locales.ts +81 -15
  39. package/src/client/microStyles.ts +33 -0
  40. package/src/client/parsers.ts +386 -0
  41. package/src/client/styles.ts +306 -0
  42. package/src/client/types.ts +185 -0
@@ -0,0 +1,502 @@
1
+ /** Single provider account card for the Accounts tab. */
2
+
3
+ import { useMemo, useState } from "react";
4
+ import { SOURCE_REASON_KEY } from "../constants.ts";
5
+ import { methodLabel, orderedLoginMethods, shouldShowPerCardSourceReason } from "../display.ts";
6
+ import { formatEpoch, modelFields, usageHasVisibleFields } from "../parsers.ts";
7
+ import {
8
+ bodyStyle,
9
+ buttonStyle,
10
+ cardStyle,
11
+ checkRowStyle,
12
+ codeStyle,
13
+ compactButtonStyle,
14
+ hintStyle,
15
+ inputStyle,
16
+ listStyle,
17
+ monoStyle,
18
+ nestedStyle,
19
+ primaryButtonStyle,
20
+ rowStyle,
21
+ stepActiveStyle,
22
+ stepNumberActiveStyle,
23
+ stepNumberStyle,
24
+ stepRowStyle,
25
+ titleStyle,
26
+ } from "../styles.ts";
27
+ import type {
28
+ GrokBuildSettingsInjected,
29
+ GrokStatus,
30
+ LoginMethod,
31
+ ProviderCardDefinition,
32
+ ProviderStatus,
33
+ SourceStatus,
34
+ UsageView,
35
+ } from "../types.ts";
36
+ import { Badge } from "./Badge.tsx";
37
+ import { CopyButton } from "./CopyButton.tsx";
38
+ import { ProgressBar } from "./ProgressBar.tsx";
39
+
40
+ export interface ProviderCardProps {
41
+ t: GrokBuildSettingsInjected["t"];
42
+ definition: ProviderCardDefinition;
43
+ providerStatus: ProviderStatus;
44
+ busy: boolean;
45
+ sourcesBusy: boolean;
46
+ remote: boolean;
47
+ codeInput: string;
48
+ popupBlocked: boolean;
49
+ expanded: boolean;
50
+ source: SourceStatus | undefined;
51
+ showUsage: boolean;
52
+ usage: UsageView | undefined;
53
+ usageError: string | undefined;
54
+ usageLoading: boolean;
55
+ onSignIn: (method: LoginMethod) => void;
56
+ onSignOut: () => void;
57
+ onCancelLogin: () => void;
58
+ onSubmitCode: () => void;
59
+ onCodeChange: (value: string) => void;
60
+ onToggleExpanded: () => void;
61
+ onPreviewSource: () => void;
62
+ onSaveModels: (selected: string[]) => void;
63
+ }
64
+
65
+ function SignInSteps({
66
+ t,
67
+ activeMethod,
68
+ userCode,
69
+ url,
70
+ popupBlocked,
71
+ }: {
72
+ t: GrokBuildSettingsInjected["t"];
73
+ activeMethod: LoginMethod;
74
+ userCode: string | undefined;
75
+ url: string | undefined;
76
+ popupBlocked: boolean;
77
+ }) {
78
+ const hasCode = userCode !== undefined && userCode.length > 0;
79
+ const hasUrl = url !== undefined && url.length > 0;
80
+ const needsPaste = activeMethod !== "device";
81
+
82
+ return (
83
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
84
+ <div style={hasUrl ? stepActiveStyle : stepRowStyle}>
85
+ <span style={hasUrl ? stepNumberActiveStyle : stepNumberStyle} aria-hidden="true">
86
+ 1
87
+ </span>
88
+ <span>{t("signInStepOpen")}</span>
89
+ </div>
90
+ {hasUrl ? (
91
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8, paddingLeft: 32 }}>
92
+ <a href={url} target="_blank" rel="noreferrer" style={primaryButtonStyle}>
93
+ {t("openAuthUrl")}
94
+ </a>
95
+ <CopyButton text={url} idleLabel={t("copy")} copiedLabel={t("copied")} failedLabel={t("copyFailed")} />
96
+ </div>
97
+ ) : null}
98
+ {popupBlocked && hasUrl ? <p style={hintStyle}>{t("popupBlocked")}</p> : null}
99
+ {hasCode ? (
100
+ <>
101
+ <div style={stepActiveStyle}>
102
+ <span style={stepNumberActiveStyle} aria-hidden="true">
103
+ 2
104
+ </span>
105
+ <span>{t("signInStepCode")}</span>
106
+ </div>
107
+ <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 8, paddingLeft: 32 }}>
108
+ <span style={codeStyle}>{userCode}</span>
109
+ <CopyButton
110
+ text={userCode}
111
+ idleLabel={t("copyUserCode")}
112
+ copiedLabel={t("copied")}
113
+ failedLabel={t("copyFailed")}
114
+ primary
115
+ />
116
+ </div>
117
+ </>
118
+ ) : null}
119
+ <div style={stepActiveStyle}>
120
+ <span style={stepNumberActiveStyle} aria-hidden="true">
121
+ {hasCode ? 3 : 2}
122
+ </span>
123
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
124
+ <span
125
+ aria-hidden="true"
126
+ style={{
127
+ width: 14,
128
+ height: 14,
129
+ border: "2px solid var(--dsw-alias-brand-primary, #1677ff)",
130
+ borderTopColor: "transparent",
131
+ borderRadius: "50%",
132
+ animation: "dsh-coding-oauth-spin 0.8s linear infinite",
133
+ }}
134
+ />
135
+ {t("signInStepWait")}
136
+ </span>
137
+ </div>
138
+ {needsPaste ? (
139
+ <div style={{ display: "flex", flexDirection: "column", gap: 8, paddingLeft: 32 }}>
140
+ <p style={bodyStyle}>{t(activeMethod === "browser" ? "pasteBrowserCodeHint" : "pasteCodeHint")}</p>
141
+ </div>
142
+ ) : null}
143
+ </div>
144
+ );
145
+ }
146
+
147
+ export function ProviderCard({
148
+ t,
149
+ definition,
150
+ providerStatus,
151
+ busy,
152
+ sourcesBusy,
153
+ remote,
154
+ codeInput,
155
+ popupBlocked,
156
+ expanded,
157
+ source,
158
+ showUsage,
159
+ usage,
160
+ usageError,
161
+ usageLoading,
162
+ onSignIn,
163
+ onSignOut,
164
+ onCancelLogin,
165
+ onSubmitCode,
166
+ onCodeChange,
167
+ onToggleExpanded,
168
+ onPreviewSource,
169
+ onSaveModels,
170
+ }: ProviderCardProps) {
171
+ const [showAltMethods, setShowAltMethods] = useState(false);
172
+ const [modelFilter, setModelFilter] = useState("");
173
+
174
+ const ordered = orderedLoginMethods(definition, remote);
175
+ const primaryMethod: LoginMethod = ordered[0] ?? definition.recommended;
176
+ const altMethods = ordered.filter((method) => method !== primaryMethod);
177
+
178
+ const statusLabel =
179
+ providerStatus.status === "signed-in"
180
+ ? t("signedIn")
181
+ : providerStatus.status === "signing-in"
182
+ ? t("signingIn")
183
+ : providerStatus.status === "error"
184
+ ? t("requestFailed")
185
+ : t("signedOut");
186
+ const activeMethod = providerStatus.status === "signing-in" ? providerStatus.method : primaryMethod;
187
+ const { available, selected } = modelFields(providerStatus);
188
+ const grokProviderStatus = definition.slug === "grok" ? (providerStatus as GrokStatus) : undefined;
189
+ const showSourceReason = shouldShowPerCardSourceReason(source);
190
+
191
+ const filteredModels = useMemo(() => {
192
+ const query = modelFilter.trim().toLowerCase();
193
+ if (query.length === 0) return available;
194
+ return available.filter((id) => id.toLowerCase().includes(query));
195
+ }, [available, modelFilter]);
196
+
197
+ const usagePercent =
198
+ definition.slug === "codex" && showUsage
199
+ ? usage?.individualRemainingPercent === undefined
200
+ ? usage?.rateLimits[0]?.windows[0]?.usedPercent
201
+ : 100 - usage.individualRemainingPercent
202
+ : undefined;
203
+ const fetchedAt = formatEpoch(usage?.fetchedAt);
204
+
205
+ return (
206
+ <div style={cardStyle}>
207
+ <div style={rowStyle}>
208
+ <div>
209
+ <h3 style={{ ...titleStyle, fontSize: 16 }}>{t(definition.titleKey)}</h3>
210
+ {providerStatus.status === "signed-in" && !expanded ? (
211
+ <p style={{ ...hintStyle, marginTop: 4 }}>
212
+ {t("modelsSummary", { selected: selected.length, total: available.length })}
213
+ {usagePercent === undefined ? "" : ` · ${t("usageUsedShort", { value: `${String(usagePercent)}%` })}`}
214
+ </p>
215
+ ) : (
216
+ <>
217
+ <p style={{ ...bodyStyle, marginTop: 4 }}>{t(definition.descriptionKey)}</p>
218
+ <p style={{ ...bodyStyle, marginTop: 4 }}>
219
+ <span style={monoStyle}>{definition.route}</span>
220
+ </p>
221
+ </>
222
+ )}
223
+ </div>
224
+ <Badge label={statusLabel} providerStatus={providerStatus.status} />
225
+ </div>
226
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
227
+ {providerStatus.status === "signed-in" ? (
228
+ <>
229
+ <button type="button" style={buttonStyle} disabled={busy} onClick={onSignOut}>
230
+ {busy ? t("working") : t("logout")}
231
+ </button>
232
+ <button type="button" style={buttonStyle} onClick={onToggleExpanded}>
233
+ {expanded ? t("collapseModels") : t("expandModels")}
234
+ </button>
235
+ {source?.available === true ? (
236
+ <button type="button" style={buttonStyle} disabled={sourcesBusy} onClick={onPreviewSource}>
237
+ {t("sourcesPullCopy")}
238
+ </button>
239
+ ) : null}
240
+ </>
241
+ ) : providerStatus.status === "signing-in" ? (
242
+ <>
243
+ <button type="button" style={buttonStyle} disabled={busy} onClick={onCancelLogin}>
244
+ {t("cancelLogin")}
245
+ </button>
246
+ {altMethods.length > 0 ? (
247
+ <button
248
+ type="button"
249
+ style={compactButtonStyle}
250
+ disabled={busy}
251
+ onClick={() => {
252
+ setShowAltMethods((current) => !current);
253
+ }}
254
+ >
255
+ {showAltMethods ? t("hideOtherLoginMethods") : t("otherLoginMethods")}
256
+ </button>
257
+ ) : null}
258
+ {showAltMethods
259
+ ? altMethods.map((method) => (
260
+ <button
261
+ key={method}
262
+ type="button"
263
+ style={compactButtonStyle}
264
+ disabled={busy}
265
+ onClick={() => {
266
+ onSignIn(method);
267
+ }}
268
+ >
269
+ {methodLabel(method, t, { remote, primary: false })}
270
+ </button>
271
+ ))
272
+ : null}
273
+ </>
274
+ ) : (
275
+ <>
276
+ <button
277
+ type="button"
278
+ style={primaryButtonStyle}
279
+ disabled={busy}
280
+ onClick={() => {
281
+ onSignIn(primaryMethod);
282
+ }}
283
+ >
284
+ {busy ? t("working") : methodLabel(primaryMethod, t, { remote, primary: true })}
285
+ </button>
286
+ {altMethods.length > 0 ? (
287
+ <button
288
+ type="button"
289
+ style={compactButtonStyle}
290
+ disabled={busy}
291
+ onClick={() => {
292
+ setShowAltMethods((current) => !current);
293
+ }}
294
+ >
295
+ {showAltMethods ? t("hideOtherLoginMethods") : t("otherLoginMethods")}
296
+ </button>
297
+ ) : null}
298
+ {showAltMethods
299
+ ? altMethods.map((method) => (
300
+ <button
301
+ key={method}
302
+ type="button"
303
+ style={compactButtonStyle}
304
+ disabled={busy}
305
+ onClick={() => {
306
+ onSignIn(method);
307
+ }}
308
+ >
309
+ {methodLabel(method, t, { remote, primary: false })}
310
+ </button>
311
+ ))
312
+ : null}
313
+ {source?.available === true ? (
314
+ <button type="button" style={buttonStyle} disabled={sourcesBusy} onClick={onPreviewSource}>
315
+ {t("sourcesPullCopy")}
316
+ </button>
317
+ ) : showSourceReason && source?.reason !== undefined ? (
318
+ <span style={hintStyle}>{t(SOURCE_REASON_KEY[source.reason])}</span>
319
+ ) : null}
320
+ </>
321
+ )}
322
+ </div>
323
+ {providerStatus.status === "error" ? (
324
+ <p style={{ ...bodyStyle, color: "var(--dsw-alias-state-error-primary)" }}>{providerStatus.message}</p>
325
+ ) : null}
326
+ {providerStatus.status === "signing-in" ? (
327
+ <SignInSteps
328
+ t={t}
329
+ activeMethod={activeMethod}
330
+ userCode={providerStatus.userCode}
331
+ url={providerStatus.url}
332
+ popupBlocked={popupBlocked}
333
+ />
334
+ ) : null}
335
+ {providerStatus.status === "signing-in" && activeMethod !== "device" ? (
336
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
337
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
338
+ <input
339
+ style={{ ...inputStyle, flex: "1 1 360px" }}
340
+ value={codeInput}
341
+ placeholder={t("pasteCodePlaceholder")}
342
+ disabled={busy}
343
+ onChange={(event) => {
344
+ onCodeChange(event.target.value);
345
+ }}
346
+ onKeyDown={(event) => {
347
+ if (event.key === "Enter") {
348
+ event.preventDefault();
349
+ onSubmitCode();
350
+ }
351
+ }}
352
+ />
353
+ <button
354
+ type="button"
355
+ style={primaryButtonStyle}
356
+ disabled={busy || codeInput.trim().length === 0}
357
+ onClick={onSubmitCode}
358
+ >
359
+ {t("submitCode")}
360
+ </button>
361
+ </div>
362
+ </div>
363
+ ) : null}
364
+ {providerStatus.status === "signed-in" && expanded ? (
365
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
366
+ <div style={rowStyle}>
367
+ <h4 style={{ ...titleStyle, fontSize: 14 }}>{t("models")}</h4>
368
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
369
+ <button
370
+ type="button"
371
+ style={compactButtonStyle}
372
+ disabled={busy}
373
+ onClick={() => {
374
+ onSaveModels([]);
375
+ }}
376
+ >
377
+ {t("deselectAll")}
378
+ </button>
379
+ <button
380
+ type="button"
381
+ style={compactButtonStyle}
382
+ disabled={busy}
383
+ onClick={() => {
384
+ onSaveModels([...available]);
385
+ }}
386
+ >
387
+ {t("selectAll")}
388
+ </button>
389
+ <button
390
+ type="button"
391
+ style={compactButtonStyle}
392
+ disabled={busy}
393
+ onClick={() => {
394
+ onSaveModels(available.length > 0 ? [available[0]!] : []);
395
+ }}
396
+ >
397
+ {t("resetModelsDefault")}
398
+ </button>
399
+ </div>
400
+ </div>
401
+ <input
402
+ type="search"
403
+ style={inputStyle}
404
+ value={modelFilter}
405
+ placeholder={t("modelFilterPlaceholder")}
406
+ disabled={busy}
407
+ onChange={(event) => {
408
+ setModelFilter(event.target.value);
409
+ }}
410
+ />
411
+ {grokProviderStatus?.status === "signed-in" ? (
412
+ <p style={bodyStyle}>
413
+ {grokProviderStatus.catalogSource === "live"
414
+ ? t("catalogLive")
415
+ : grokProviderStatus.catalogSource === "cache"
416
+ ? t("catalogCache")
417
+ : t("catalogFallback")}
418
+ </p>
419
+ ) : null}
420
+ <p style={bodyStyle}>
421
+ {t("modelHint")} <span style={monoStyle}>{definition.route}/&lt;id&gt;</span>
422
+ </p>
423
+ <ul style={listStyle}>
424
+ {filteredModels.map((id) => {
425
+ const checked = selected.includes(id);
426
+ return (
427
+ <li key={id}>
428
+ <label style={checkRowStyle}>
429
+ <input
430
+ type="checkbox"
431
+ checked={checked}
432
+ disabled={busy}
433
+ onChange={() => {
434
+ const current = new Set(selected);
435
+ if (checked) current.delete(id);
436
+ else current.add(id);
437
+ onSaveModels([...current]);
438
+ }}
439
+ />
440
+ <span style={monoStyle}>{id}</span>
441
+ </label>
442
+ </li>
443
+ );
444
+ })}
445
+ </ul>
446
+ {filteredModels.length === 0 ? <p style={hintStyle}>{t("modelFilterPlaceholder")}</p> : null}
447
+ {grokProviderStatus?.status === "signed-in" && grokProviderStatus.catalogError !== undefined ? (
448
+ <p style={{ ...bodyStyle, color: "var(--dsw-alias-state-error-primary)" }}>{t("catalogError")}</p>
449
+ ) : null}
450
+ {definition.slug === "codex" && showUsage ? (
451
+ <div style={nestedStyle}>
452
+ <p style={{ ...bodyStyle, color: "var(--dsw-alias-label-primary)" }}>{t("usageTitle")}</p>
453
+ {usageError === undefined ? null : (
454
+ <p style={{ ...bodyStyle, color: "var(--dsw-alias-state-error-primary)" }} role="alert">
455
+ {usageError}
456
+ </p>
457
+ )}
458
+ {usageLoading && usage === undefined ? (
459
+ <p style={hintStyle}>{t("usageLoading")}</p>
460
+ ) : usage === undefined || !usageHasVisibleFields(usage) ? (
461
+ <p style={hintStyle}>{t("usageEmpty")}</p>
462
+ ) : (
463
+ <>
464
+ {fetchedAt === undefined ? null : <p style={hintStyle}>{t("usageFetchedAt", { time: fetchedAt })}</p>}
465
+ {usagePercent !== undefined ? (
466
+ <ProgressBar
467
+ value={usagePercent}
468
+ label={t("usageRateLimit")}
469
+ meta={t("usageUsed", { value: `${String(usagePercent)}%` })}
470
+ />
471
+ ) : null}
472
+ {usage.rateLimits.map((limit) => {
473
+ const window = limit.windows[0];
474
+ const used = window?.usedPercent;
475
+ const resetsAt = formatEpoch(window?.resetsAt);
476
+ return (
477
+ <div key={limit.id} style={{ display: "flex", flexDirection: "column", gap: 4 }}>
478
+ {used === undefined ? (
479
+ <p style={hintStyle}>{limit.name ?? t("usageRateLimit")}</p>
480
+ ) : (
481
+ <ProgressBar
482
+ value={used}
483
+ label={limit.name ?? t("usageRateLimit")}
484
+ meta={
485
+ resetsAt === undefined
486
+ ? t("usageUsed", { value: `${String(used)}%` })
487
+ : `${t("usageUsed", { value: `${String(used)}%` })} · ${t("usageResets", { time: resetsAt })}`
488
+ }
489
+ />
490
+ )}
491
+ </div>
492
+ );
493
+ })}
494
+ </>
495
+ )}
496
+ </div>
497
+ ) : null}
498
+ </div>
499
+ ) : null}
500
+ </div>
501
+ );
502
+ }
@@ -0,0 +1,75 @@
1
+ /** Accessible segmented tablist for Coding OAuth settings. */
2
+
3
+ import type { KeyboardEvent } from "react";
4
+ import { SETTINGS_TABS } from "../constants.ts";
5
+ import { segmentedNavStyle, segmentedTabActiveStyle, segmentedTabStyle } from "../styles.ts";
6
+ import type { GrokBuildSettingsInjected, SettingsTabId } from "../types.ts";
7
+
8
+ export interface SettingsTabHint {
9
+ id: SettingsTabId;
10
+ suffix?: string;
11
+ }
12
+
13
+ export interface SettingsTabsProps {
14
+ t: GrokBuildSettingsInjected["t"];
15
+ activeTab: SettingsTabId;
16
+ onChange: (tab: SettingsTabId) => void;
17
+ hints?: readonly SettingsTabHint[];
18
+ }
19
+
20
+ export function SettingsTabs({ t, activeTab, onChange, hints }: SettingsTabsProps) {
21
+ const hintFor = (id: SettingsTabId): string | undefined => hints?.find((entry) => entry.id === id)?.suffix;
22
+
23
+ const focusTab = (index: number): void => {
24
+ const tab = SETTINGS_TABS[index];
25
+ if (tab === undefined) return;
26
+ onChange(tab.id);
27
+ const button = document.getElementById(`coding-oauth-tab-${tab.id}`);
28
+ button?.focus();
29
+ };
30
+
31
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
32
+ const current = SETTINGS_TABS.findIndex((tab) => tab.id === activeTab);
33
+ if (current < 0) return;
34
+ if (event.key === "ArrowRight" || event.key === "ArrowDown") {
35
+ event.preventDefault();
36
+ focusTab((current + 1) % SETTINGS_TABS.length);
37
+ } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
38
+ event.preventDefault();
39
+ focusTab((current - 1 + SETTINGS_TABS.length) % SETTINGS_TABS.length);
40
+ } else if (event.key === "Home") {
41
+ event.preventDefault();
42
+ focusTab(0);
43
+ } else if (event.key === "End") {
44
+ event.preventDefault();
45
+ focusTab(SETTINGS_TABS.length - 1);
46
+ }
47
+ };
48
+
49
+ return (
50
+ <div role="tablist" aria-label={t("title")} style={segmentedNavStyle} onKeyDown={onKeyDown}>
51
+ {SETTINGS_TABS.map((tab) => {
52
+ const selected = activeTab === tab.id;
53
+ const suffix = hintFor(tab.id);
54
+ const label = suffix === undefined ? t(tab.label) : `${t(tab.label)} (${suffix})`;
55
+ return (
56
+ <button
57
+ key={tab.id}
58
+ id={`coding-oauth-tab-${tab.id}`}
59
+ type="button"
60
+ role="tab"
61
+ aria-selected={selected}
62
+ aria-controls={`coding-oauth-panel-${tab.id}`}
63
+ tabIndex={selected ? 0 : -1}
64
+ style={selected ? segmentedTabActiveStyle : segmentedTabStyle}
65
+ onClick={() => {
66
+ onChange(tab.id);
67
+ }}
68
+ >
69
+ {label}
70
+ </button>
71
+ );
72
+ })}
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,60 @@
1
+ /** Accessible toggle switch replacing native checkboxes for capability settings. */
2
+
3
+ import type { CSSProperties } from "react";
4
+ import { TRANSITION } from "../styles.ts";
5
+
6
+ export interface ToggleSwitchProps {
7
+ checked: boolean;
8
+ disabled?: boolean;
9
+ onChange: (checked: boolean) => void;
10
+ /** Accessible label (visually hidden when label prop is rendered externally). */
11
+ ariaLabel?: string;
12
+ id?: string;
13
+ }
14
+
15
+ const trackStyle = (checked: boolean, disabled: boolean): CSSProperties => ({
16
+ position: "relative",
17
+ width: 40,
18
+ height: 22,
19
+ borderRadius: 11,
20
+ flex: "0 0 auto",
21
+ background: checked
22
+ ? "var(--dsw-alias-brand-primary, #315fc7)"
23
+ : "var(--dsw-alias-border-l4, rgba(127, 127, 127, 0.45))",
24
+ opacity: disabled ? 0.5 : 1,
25
+ cursor: disabled ? "not-allowed" : "pointer",
26
+ transition: TRANSITION,
27
+ border: "none",
28
+ padding: 0,
29
+ });
30
+
31
+ const thumbStyle = (checked: boolean): CSSProperties => ({
32
+ position: "absolute",
33
+ top: 2,
34
+ left: checked ? 20 : 2,
35
+ width: 18,
36
+ height: 18,
37
+ borderRadius: "50%",
38
+ background: "#ffffff",
39
+ boxShadow: "0 1px 3px rgba(0, 0, 0, 0.25)",
40
+ transition: TRANSITION,
41
+ });
42
+
43
+ export function ToggleSwitch({ checked, disabled = false, onChange, ariaLabel, id }: ToggleSwitchProps) {
44
+ return (
45
+ <button
46
+ id={id}
47
+ type="button"
48
+ role="switch"
49
+ aria-checked={checked}
50
+ aria-label={ariaLabel}
51
+ disabled={disabled}
52
+ style={trackStyle(checked, disabled)}
53
+ onClick={() => {
54
+ if (!disabled) onChange(!checked);
55
+ }}
56
+ >
57
+ <span aria-hidden="true" style={thumbStyle(checked)} />
58
+ </button>
59
+ );
60
+ }