@agent-native/toolkit 0.13.3 → 0.13.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.
- package/dist/composer/useRealtimeVoiceMode.d.ts +3 -0
- package/dist/composer/useRealtimeVoiceMode.d.ts.map +1 -1
- package/dist/composer/useRealtimeVoiceMode.js +2 -0
- package/dist/composer/useRealtimeVoiceMode.js.map +1 -1
- package/dist/composer/useVoiceDictation.browser.spec.d.ts +2 -0
- package/dist/composer/useVoiceDictation.browser.spec.d.ts.map +1 -0
- package/dist/composer/useVoiceDictation.browser.spec.js +220 -0
- package/dist/composer/useVoiceDictation.browser.spec.js.map +1 -0
- package/dist/composer/useVoiceDictation.d.ts +1 -0
- package/dist/composer/useVoiceDictation.d.ts.map +1 -1
- package/dist/composer/useVoiceDictation.js +76 -22
- package/dist/composer/useVoiceDictation.js.map +1 -1
- package/dist/design-tweaks/visual-style-controls.d.ts.map +1 -1
- package/dist/design-tweaks/visual-style-controls.js +1 -1
- package/dist/design-tweaks/visual-style-controls.js.map +1 -1
- package/package.json +1 -1
- package/src/composer/useRealtimeVoiceMode.tsx +4 -0
- package/src/composer/useVoiceDictation.browser.spec.tsx +265 -0
- package/src/composer/useVoiceDictation.ts +85 -20
- package/src/design-tweaks/visual-style-controls.tsx +7 -2
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// @vitest-environment happy-dom
|
|
2
|
+
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
useVoiceDictation,
|
|
9
|
+
type VoiceDictationApi,
|
|
10
|
+
} from "./useVoiceDictation.js";
|
|
11
|
+
|
|
12
|
+
class FakeSpeechRecognition {
|
|
13
|
+
static last: FakeSpeechRecognition | undefined;
|
|
14
|
+
continuous = false;
|
|
15
|
+
interimResults = false;
|
|
16
|
+
lang = "";
|
|
17
|
+
onaudiostart: (() => void) | undefined;
|
|
18
|
+
onresult: ((event: unknown) => void) | undefined;
|
|
19
|
+
onerror: ((event: { error?: string }) => void) | undefined;
|
|
20
|
+
onend: (() => void) | undefined;
|
|
21
|
+
started = false;
|
|
22
|
+
|
|
23
|
+
constructor() {
|
|
24
|
+
FakeSpeechRecognition.last = this;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
start() {
|
|
28
|
+
this.started = true;
|
|
29
|
+
}
|
|
30
|
+
stop() {}
|
|
31
|
+
abort() {}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class FakeMediaRecorder {
|
|
35
|
+
static last: FakeMediaRecorder | undefined;
|
|
36
|
+
static isTypeSupported = () => true;
|
|
37
|
+
ondataavailable: ((event: unknown) => void) | undefined;
|
|
38
|
+
onstop: (() => void) | undefined;
|
|
39
|
+
mimeType = "audio/webm";
|
|
40
|
+
started = false;
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
FakeMediaRecorder.last = this;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
start() {
|
|
47
|
+
this.started = true;
|
|
48
|
+
}
|
|
49
|
+
stop() {}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function stubSpeechEnvironment(getUserMedia = vi.fn()) {
|
|
53
|
+
FakeSpeechRecognition.last = undefined;
|
|
54
|
+
FakeMediaRecorder.last = undefined;
|
|
55
|
+
vi.stubGlobal("SpeechRecognition", FakeSpeechRecognition);
|
|
56
|
+
vi.stubGlobal("webkitSpeechRecognition", FakeSpeechRecognition);
|
|
57
|
+
vi.stubGlobal("MediaRecorder", undefined);
|
|
58
|
+
Object.defineProperty(navigator, "mediaDevices", {
|
|
59
|
+
configurable: true,
|
|
60
|
+
value: { getUserMedia },
|
|
61
|
+
});
|
|
62
|
+
vi.stubGlobal(
|
|
63
|
+
"fetch",
|
|
64
|
+
vi.fn().mockResolvedValue(new Response("{}", { status: 404 })),
|
|
65
|
+
);
|
|
66
|
+
return getUserMedia;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function renderVoiceDictation() {
|
|
70
|
+
const seen: VoiceDictationApi[] = [];
|
|
71
|
+
function Probe() {
|
|
72
|
+
seen.push(useVoiceDictation({ onTranscript: vi.fn() }));
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const container = document.createElement("div");
|
|
76
|
+
document.body.append(container);
|
|
77
|
+
const root = createRoot(container);
|
|
78
|
+
await act(async () => {
|
|
79
|
+
root.render(<Probe />);
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
latest: () => seen[seen.length - 1]!,
|
|
83
|
+
async cleanup() {
|
|
84
|
+
await act(async () => {
|
|
85
|
+
root.unmount();
|
|
86
|
+
});
|
|
87
|
+
container.remove();
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
beforeEach(() => {
|
|
93
|
+
(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
vi.unstubAllGlobals();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("useVoiceDictation — browser speech path", () => {
|
|
101
|
+
it("reports a session that ends before the microphone ever opened", async () => {
|
|
102
|
+
stubSpeechEnvironment();
|
|
103
|
+
const probe = await renderVoiceDictation();
|
|
104
|
+
await act(async () => {
|
|
105
|
+
await probe.latest().start();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const recognition = FakeSpeechRecognition.last!;
|
|
109
|
+
expect(recognition.started).toBe(true);
|
|
110
|
+
await act(async () => {
|
|
111
|
+
recognition.onerror?.({ error: "aborted" });
|
|
112
|
+
recognition.onend?.();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(probe.latest().state).toBe("error");
|
|
116
|
+
expect(probe.latest().errorMessage).toContain(
|
|
117
|
+
"stopped before it captured any audio",
|
|
118
|
+
);
|
|
119
|
+
await probe.cleanup();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("treats a silent but live session as an ordinary empty result", async () => {
|
|
123
|
+
stubSpeechEnvironment(vi.fn().mockRejectedValue(new Error("no device")));
|
|
124
|
+
const probe = await renderVoiceDictation();
|
|
125
|
+
await act(async () => {
|
|
126
|
+
await probe.latest().start();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const recognition = FakeSpeechRecognition.last!;
|
|
130
|
+
await act(async () => {
|
|
131
|
+
recognition.onaudiostart?.();
|
|
132
|
+
recognition.onerror?.({ error: "no-speech" });
|
|
133
|
+
recognition.onend?.();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(probe.latest().state).toBe("idle");
|
|
137
|
+
expect(probe.latest().errorMessage).toBeNull();
|
|
138
|
+
await probe.cleanup();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("keeps a reported speech error visible when `end` follows `error`", async () => {
|
|
142
|
+
stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] }));
|
|
143
|
+
const probe = await renderVoiceDictation();
|
|
144
|
+
await act(async () => {
|
|
145
|
+
await probe.latest().start();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
const recognition = FakeSpeechRecognition.last!;
|
|
149
|
+
await act(async () => {
|
|
150
|
+
recognition.onaudiostart?.();
|
|
151
|
+
recognition.onerror?.({ error: "network" });
|
|
152
|
+
recognition.onend?.();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(probe.latest().state).toBe("error");
|
|
156
|
+
expect(probe.latest().errorMessage).toContain("couldn't reach its service");
|
|
157
|
+
await probe.cleanup();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("falls back to the upload path when the speech service is unreachable", async () => {
|
|
161
|
+
stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] }));
|
|
162
|
+
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
|
163
|
+
const probe = await renderVoiceDictation();
|
|
164
|
+
await act(async () => {
|
|
165
|
+
await probe.latest().start();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await act(async () => {
|
|
169
|
+
FakeSpeechRecognition.last!.onaudiostart?.();
|
|
170
|
+
FakeSpeechRecognition.last!.onerror?.({ error: "network" });
|
|
171
|
+
FakeSpeechRecognition.last!.onend?.();
|
|
172
|
+
});
|
|
173
|
+
await act(async () => {});
|
|
174
|
+
|
|
175
|
+
expect(FakeMediaRecorder.last?.started).toBe(true);
|
|
176
|
+
expect(probe.latest().state).toBe("recording");
|
|
177
|
+
expect(probe.latest().errorMessage).toBeNull();
|
|
178
|
+
await probe.cleanup();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("delivers partial speech instead of failing over after a mid-session drop", async () => {
|
|
182
|
+
stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] }));
|
|
183
|
+
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
|
184
|
+
const probe = await renderVoiceDictation();
|
|
185
|
+
await act(async () => {
|
|
186
|
+
await probe.latest().start();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
await act(async () => {
|
|
190
|
+
const recognition = FakeSpeechRecognition.last!;
|
|
191
|
+
recognition.onaudiostart?.();
|
|
192
|
+
recognition.onresult?.({
|
|
193
|
+
resultIndex: 0,
|
|
194
|
+
results: [{ 0: { transcript: "make it blue" }, isFinal: true }],
|
|
195
|
+
});
|
|
196
|
+
recognition.onerror?.({ error: "network" });
|
|
197
|
+
recognition.onend?.();
|
|
198
|
+
});
|
|
199
|
+
await act(async () => {});
|
|
200
|
+
|
|
201
|
+
expect(FakeMediaRecorder.last).toBeUndefined();
|
|
202
|
+
expect(probe.latest().state).toBe("idle");
|
|
203
|
+
await probe.cleanup();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("falls back to the upload path when the recognizer has no speech backend", async () => {
|
|
207
|
+
const getUserMedia = stubSpeechEnvironment(
|
|
208
|
+
vi.fn().mockResolvedValue({ getTracks: () => [] }),
|
|
209
|
+
);
|
|
210
|
+
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
|
211
|
+
const probe = await renderVoiceDictation();
|
|
212
|
+
await act(async () => {
|
|
213
|
+
await probe.latest().start();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await act(async () => {
|
|
217
|
+
FakeSpeechRecognition.last!.onerror?.({ error: "aborted" });
|
|
218
|
+
FakeSpeechRecognition.last!.onend?.();
|
|
219
|
+
});
|
|
220
|
+
await act(async () => {});
|
|
221
|
+
|
|
222
|
+
expect(getUserMedia).toHaveBeenCalled();
|
|
223
|
+
expect(FakeMediaRecorder.last?.started).toBe(true);
|
|
224
|
+
expect(probe.latest().state).toBe("recording");
|
|
225
|
+
expect(probe.latest().errorMessage).toBeNull();
|
|
226
|
+
await probe.cleanup();
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("does not retry through the upload path after a denied microphone", async () => {
|
|
230
|
+
stubSpeechEnvironment(vi.fn().mockResolvedValue({ getTracks: () => [] }));
|
|
231
|
+
vi.stubGlobal("MediaRecorder", FakeMediaRecorder);
|
|
232
|
+
const probe = await renderVoiceDictation();
|
|
233
|
+
await act(async () => {
|
|
234
|
+
await probe.latest().start();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
await act(async () => {
|
|
238
|
+
FakeSpeechRecognition.last!.onerror?.({ error: "not-allowed" });
|
|
239
|
+
FakeSpeechRecognition.last!.onend?.();
|
|
240
|
+
});
|
|
241
|
+
await act(async () => {});
|
|
242
|
+
|
|
243
|
+
expect(FakeMediaRecorder.last).toBeUndefined();
|
|
244
|
+
expect(probe.latest().state).toBe("error");
|
|
245
|
+
expect(probe.latest().errorMessage).toContain("site controls icon");
|
|
246
|
+
await probe.cleanup();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("opens the meter capture only after recognition owns the microphone", async () => {
|
|
250
|
+
const getUserMedia = stubSpeechEnvironment(
|
|
251
|
+
vi.fn().mockResolvedValue({ getTracks: () => [] }),
|
|
252
|
+
);
|
|
253
|
+
const probe = await renderVoiceDictation();
|
|
254
|
+
await act(async () => {
|
|
255
|
+
await probe.latest().start();
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
expect(getUserMedia).not.toHaveBeenCalled();
|
|
259
|
+
await act(async () => {
|
|
260
|
+
FakeSpeechRecognition.last!.onaudiostart?.();
|
|
261
|
+
});
|
|
262
|
+
expect(getUserMedia).toHaveBeenCalledTimes(1);
|
|
263
|
+
await probe.cleanup();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
@@ -348,7 +348,18 @@ export function voiceDictationStartErrorMessage(error: unknown): string {
|
|
|
348
348
|
return message || "Could not start recording";
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
-
|
|
351
|
+
/** Retrying these through another provider re-prompts and fails the same way. */
|
|
352
|
+
function isMicPermissionError(error: string | undefined): boolean {
|
|
353
|
+
return (
|
|
354
|
+
error === "not-allowed" ||
|
|
355
|
+
error === "service-not-allowed" ||
|
|
356
|
+
error === "audio-capture"
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function voiceDictationSpeechErrorMessage(
|
|
361
|
+
error: string | undefined,
|
|
362
|
+
): string {
|
|
352
363
|
if (error === "not-allowed" || error === "service-not-allowed") {
|
|
353
364
|
return voiceDictationStartErrorMessage({
|
|
354
365
|
name: "NotAllowedError",
|
|
@@ -358,7 +369,13 @@ function voiceDictationSpeechErrorMessage(error: string | undefined): string {
|
|
|
358
369
|
if (error === "audio-capture") {
|
|
359
370
|
return "No microphone was found. Plug one in or choose a different input, then try again.";
|
|
360
371
|
}
|
|
361
|
-
|
|
372
|
+
if (error === "network") {
|
|
373
|
+
return "Speech recognition couldn't reach its service. Check your connection, or pick a different source in Settings → Voice Transcription.";
|
|
374
|
+
}
|
|
375
|
+
if (error === "aborted" || error === undefined) {
|
|
376
|
+
return "Dictation stopped before it captured any audio. Another app or tab may be holding the microphone — close it, or pick a different source in Settings → Voice Transcription.";
|
|
377
|
+
}
|
|
378
|
+
return `Speech recognition error: ${error}`;
|
|
362
379
|
}
|
|
363
380
|
|
|
364
381
|
export function useVoiceDictation(
|
|
@@ -688,28 +705,20 @@ export function useVoiceDictation(
|
|
|
688
705
|
);
|
|
689
706
|
|
|
690
707
|
const startBrowser = useCallback(
|
|
691
|
-
async (
|
|
708
|
+
async (
|
|
709
|
+
prefs: VoicePrefs,
|
|
710
|
+
/** Return true to take over when the recognizer never opened the mic.
|
|
711
|
+
* Brave ships `webkitSpeechRecognition` with no speech backend, so
|
|
712
|
+
* feature detection alone cannot tell dictation will work. */
|
|
713
|
+
onUnavailable?: (error: string | undefined) => boolean,
|
|
714
|
+
) => {
|
|
692
715
|
const Ctor = getSpeechRecognitionCtor();
|
|
693
716
|
if (!Ctor) {
|
|
694
717
|
throw new Error(
|
|
695
718
|
"Your browser doesn't support speech recognition. Add an OpenAI API key in settings for Whisper transcription.",
|
|
696
719
|
);
|
|
697
720
|
}
|
|
698
|
-
// Still request mic to drive the amplitude meter, so the UI doesn't look
|
|
699
|
-
// dead while the user talks. SpeechRecognition manages its own capture
|
|
700
|
-
// under the hood in most browsers.
|
|
701
|
-
let stream: MediaStream | null = null;
|
|
702
|
-
try {
|
|
703
|
-
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
704
|
-
mediaStreamRef.current = stream;
|
|
705
|
-
startMeter(stream);
|
|
706
|
-
} catch {
|
|
707
|
-
/* non-fatal — recognition can still work without our analyser */
|
|
708
|
-
}
|
|
709
|
-
|
|
710
721
|
if (cancelledRef.current) {
|
|
711
|
-
if (stream) for (const track of stream.getTracks()) track.stop();
|
|
712
|
-
mediaStreamRef.current = null;
|
|
713
722
|
cancelledRef.current = false;
|
|
714
723
|
setState("idle");
|
|
715
724
|
return;
|
|
@@ -723,7 +732,34 @@ export function useVoiceDictation(
|
|
|
723
732
|
speechRef.current = recognition;
|
|
724
733
|
speechTranscriptRef.current = "";
|
|
725
734
|
|
|
735
|
+
let capturing = false;
|
|
736
|
+
let fatal = false;
|
|
737
|
+
let lastError: string | undefined;
|
|
738
|
+
|
|
739
|
+
// Opening our own capture before the speech service has claimed the
|
|
740
|
+
// device makes Chrome abort the session outright. Attach the meter only
|
|
741
|
+
// once recognition is actually listening.
|
|
742
|
+
recognition.onaudiostart = () => {
|
|
743
|
+
capturing = true;
|
|
744
|
+
if (cancelledRef.current) return;
|
|
745
|
+
void Promise.resolve()
|
|
746
|
+
.then(() => navigator.mediaDevices?.getUserMedia({ audio: true }))
|
|
747
|
+
.then((stream) => {
|
|
748
|
+
if (!stream) return;
|
|
749
|
+
if (cancelledRef.current || speechRef.current !== recognition) {
|
|
750
|
+
for (const track of stream.getTracks()) track.stop();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
mediaStreamRef.current = stream;
|
|
754
|
+
startMeter(stream);
|
|
755
|
+
})
|
|
756
|
+
.catch(() => {
|
|
757
|
+
/* the meter is decoration; recognition owns the real capture */
|
|
758
|
+
});
|
|
759
|
+
};
|
|
760
|
+
|
|
726
761
|
recognition.onresult = (event: any) => {
|
|
762
|
+
capturing = true;
|
|
727
763
|
let interim = "";
|
|
728
764
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
729
765
|
const result = event.results[i];
|
|
@@ -736,16 +772,30 @@ export function useVoiceDictation(
|
|
|
736
772
|
}
|
|
737
773
|
onLiveUpdateRef.current?.(speechTranscriptRef.current, interim);
|
|
738
774
|
};
|
|
775
|
+
// `end` always follows `error`, so every outcome is decided there. Acting
|
|
776
|
+
// here too would either pre-empt the fallback or be overwritten by it.
|
|
739
777
|
recognition.onerror = (event: any) => {
|
|
778
|
+
lastError = event?.error;
|
|
740
779
|
if (event?.error === "no-speech" || event?.error === "aborted") return;
|
|
741
|
-
|
|
780
|
+
fatal = true;
|
|
742
781
|
};
|
|
743
782
|
recognition.onend = () => {
|
|
744
783
|
const text = speechTranscriptRef.current.trim();
|
|
745
784
|
const wasCancelled = cancelledRef.current;
|
|
746
785
|
cancelledRef.current = false;
|
|
747
786
|
teardown();
|
|
748
|
-
if (wasCancelled
|
|
787
|
+
if (wasCancelled) {
|
|
788
|
+
setState("idle");
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
if (!text) {
|
|
792
|
+
// A recognizer that failed, or that ended before the mic ever opened,
|
|
793
|
+
// produced nothing usable — that is not the same as hearing silence.
|
|
794
|
+
if (fatal || !capturing) {
|
|
795
|
+
if (onUnavailable?.(lastError)) return;
|
|
796
|
+
failWith(voiceDictationSpeechErrorMessage(lastError));
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
749
799
|
setState("idle");
|
|
750
800
|
return;
|
|
751
801
|
}
|
|
@@ -1082,7 +1132,22 @@ export function useVoiceDictation(
|
|
|
1082
1132
|
}
|
|
1083
1133
|
await startGoogleRealtime(prefs);
|
|
1084
1134
|
} else {
|
|
1085
|
-
|
|
1135
|
+
// Only "auto" promised a working recognizer of any kind; an explicit
|
|
1136
|
+
// browser preference must surface its own failure instead.
|
|
1137
|
+
await startBrowser(
|
|
1138
|
+
prefs,
|
|
1139
|
+
pref === "auto" && mediaRecorderSupported
|
|
1140
|
+
? (error) => {
|
|
1141
|
+
if (isMicPermissionError(error)) return false;
|
|
1142
|
+
activeProviderRef.current = "openai";
|
|
1143
|
+
setState("starting");
|
|
1144
|
+
void startOpenAi("auto", prefs.instructions).catch((err) =>
|
|
1145
|
+
failWith(voiceDictationStartErrorMessage(err)),
|
|
1146
|
+
);
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
: undefined,
|
|
1150
|
+
);
|
|
1086
1151
|
}
|
|
1087
1152
|
} catch (err) {
|
|
1088
1153
|
if (cancelledRef.current) {
|
|
@@ -500,10 +500,15 @@ export function VisualColorPicker({
|
|
|
500
500
|
{glyph && variant === "swatch" ? (
|
|
501
501
|
/* A real underline rather than a stacked bar: the browser places
|
|
502
502
|
it against the glyph's baseline, so the pair cannot drift out of
|
|
503
|
-
alignment the way hand-positioned boxes do.
|
|
503
|
+
alignment the way hand-positioned boxes do. The current color
|
|
504
|
+
can be anything (including near-white), so a plain colored
|
|
505
|
+
underline can vanish against this toolbar's own light
|
|
506
|
+
background — the four cardinal drop-shadows fake a thin outline
|
|
507
|
+
around the underline itself (a box-shadow/border can't reach a
|
|
508
|
+
text-decoration) that stays visible regardless of the color. */
|
|
504
509
|
<span
|
|
505
510
|
aria-hidden="true"
|
|
506
|
-
className="text-[13px] font-semibold leading-none text-foreground underline decoration-[3px] underline-offset-[3px]"
|
|
511
|
+
className="text-[13px] font-semibold leading-none text-foreground underline decoration-[3px] underline-offset-[3px] [filter:drop-shadow(0.5px_0_0_rgba(0,0,0,0.25))_drop-shadow(-0.5px_0_0_rgba(0,0,0,0.25))_drop-shadow(0_0.5px_0_rgba(0,0,0,0.25))_drop-shadow(0_-0.5px_0_rgba(0,0,0,0.25))]"
|
|
507
512
|
style={mixed ? undefined : { textDecorationColor: value }}
|
|
508
513
|
>
|
|
509
514
|
{glyph}
|