@walkinissue/angy 0.2.17

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.
@@ -0,0 +1,787 @@
1
+ <!-- @wc-ignore-file -->
2
+ <script lang="ts">
3
+ import { browser, dev } from "$app/environment";
4
+ import { page } from "$app/state";
5
+ import { loadLocale } from "wuchale/load-utils";
6
+ import { onMount } from "svelte";
7
+ import { draggable } from "./dragItem";
8
+ import PendingChangesDialog from "./PendingChangesDialog.svelte";
9
+ import TranslationHelperForm from "./TranslationHelperForm.svelte";
10
+ import {
11
+ readSuggestionCache,
12
+ requestTranslationSuggestions
13
+ } from "./translationSuggestions";
14
+ import {
15
+ getCandidateList,
16
+ getEffectiveResolvedKey,
17
+ getEntryOrigin,
18
+ getEntryOriginalValue,
19
+ sameResolvedKey,
20
+ translationKey,
21
+ type DraftTranslation,
22
+ type ResolvedKey,
23
+ type TranslationContextResult
24
+ } from "./toggleQA.shared";
25
+
26
+ const runtimeConfig = globalThis as typeof globalThis & {
27
+ __ANGY_ROUTE_PATH__?: string;
28
+ __ANGY_LOCALES__?: string[];
29
+ };
30
+
31
+ const defaultEndpoint =
32
+ typeof runtimeConfig.__ANGY_ROUTE_PATH__ !== "undefined"
33
+ ? runtimeConfig.__ANGY_ROUTE_PATH__
34
+ : "/api/translations";
35
+ const defaultLocales =
36
+ Array.isArray(runtimeConfig.__ANGY_LOCALES__) && runtimeConfig.__ANGY_LOCALES__.length
37
+ ? [...runtimeConfig.__ANGY_LOCALES__]
38
+ : ["en", "sv"];
39
+
40
+ let { endpoint = defaultEndpoint } = $props<{
41
+ endpoint?: string;
42
+ }>();
43
+
44
+ let dragQua = $state(true);
45
+ let selectedResolvedKey = $state<ResolvedKey | null>(null);
46
+ let focusedAltKey = $state<string | null>(null);
47
+
48
+ let contextPending = $state(false);
49
+ let contextError = $state<string | null>(null);
50
+ let contextResult = $state<TranslationContextResult | null>(null);
51
+
52
+ let stagedTranslations = $state<Record<string, DraftTranslation>>({});
53
+
54
+ let currentLocale = $state(defaultLocales);
55
+ let locale = 0;
56
+
57
+ let activeSwitchLocale = $state(true);
58
+ let selectionStarted = false;
59
+ let capturedSelection: string | undefined = $state(undefined);
60
+ let spawnTranslation = $state(false);
61
+ let translatedValue = $state("");
62
+
63
+ let pending = $state(false);
64
+ let rotatePending = $state(false);
65
+ let error = $state<string | null>(null);
66
+ let success = $state<string | null>(null);
67
+ let renderedLocale = $state<string>(currentLocale[locale] ?? "en");
68
+ let showPendingChangesDialog = $state(false);
69
+ let suggestionLookup = $state<Record<string, string>>(readSuggestionCache());
70
+ let suggestionPending = $state(false);
71
+
72
+ let panelEl: HTMLDivElement | null = null;
73
+ let translationInputEl: HTMLTextAreaElement | null = null;
74
+
75
+ function setTranslationInputEl(element: HTMLTextAreaElement | null) {
76
+ translationInputEl = element;
77
+ }
78
+
79
+ function getLocale(increase = true) {
80
+ if (increase) {
81
+ locale = locale >= currentLocale.length - 1 ? 0 : locale + 1;
82
+ }
83
+
84
+ renderedLocale = currentLocale[locale];
85
+ return currentLocale[locale];
86
+ }
87
+
88
+ function focusTranslationInput() {
89
+ queueMicrotask(() => {
90
+ translationInputEl?.focus();
91
+ translationInputEl?.select();
92
+ });
93
+ }
94
+
95
+ function handleLocaleToggle(event: MouseEvent) {
96
+ if (activeSwitchLocale) return;
97
+ event.preventDefault();
98
+ loadLocale(getLocale());
99
+ }
100
+
101
+ function getStaged(msgid: string, msgctxt: string | null) {
102
+ return stagedTranslations[translationKey(msgid, msgctxt)] ?? null;
103
+ }
104
+
105
+ function getSuggestion(msgid: string, msgctxt: string | null) {
106
+ return suggestionLookup[translationKey(msgid, msgctxt)] ?? "";
107
+ }
108
+
109
+ function isStaged(msgid: string, msgctxt: string | null) {
110
+ return Boolean(getStaged(msgid, msgctxt));
111
+ }
112
+
113
+ function isSelectedAlt(msgid: string, msgctxt: string | null) {
114
+ return sameResolvedKey(selectedResolvedKey, { msgid, msgctxt });
115
+ }
116
+
117
+ function setFocusedAltKey(key: string | null) {
118
+ focusedAltKey = key;
119
+ }
120
+
121
+ function resetFeedback() {
122
+ error = null;
123
+ success = null;
124
+ }
125
+
126
+ async function rotateCatalogs() {
127
+ if (rotatePending || pending) return;
128
+ if (!confirm("Rotate catalogs? This will back up both catalogs and promote the working catalog into the base catalog.")) {
129
+ return;
130
+ }
131
+
132
+ rotatePending = true;
133
+ resetFeedback();
134
+
135
+ try {
136
+ const response = await fetch(`${endpoint}?intent=rotate-catalogs`, {
137
+ method: "POST"
138
+ });
139
+ const payload = await response.json().catch(() => null);
140
+
141
+ if (!response.ok) {
142
+ error = payload?.error ?? "Catalog rotation failed";
143
+ return;
144
+ }
145
+
146
+ success = payload?.message ?? "Catalogs rotated";
147
+ } catch {
148
+ error = "Catalog rotation failed";
149
+ } finally {
150
+ rotatePending = false;
151
+ }
152
+ }
153
+
154
+ function resetContextState() {
155
+ contextError = null;
156
+ contextResult = null;
157
+ }
158
+
159
+ function hasDirtyStagedTranslations() {
160
+ return Object.values(stagedTranslations).some((item) => item.isDirty);
161
+ }
162
+
163
+ function applyCommittedTranslations(
164
+ currentContext: TranslationContextResult | null,
165
+ items: DraftTranslation[]
166
+ ): TranslationContextResult | null {
167
+ if (!currentContext || !items.length) return currentContext;
168
+
169
+ const updates = new Map(
170
+ items.map((item) => [translationKey(item.msgid, item.msgctxt), item.value])
171
+ );
172
+
173
+ const updateEntry = <T extends { msgid: string; msgctxt: string | null; msgstr: string[]; hasTranslation?: boolean; isCommittedToWorking?: boolean; isFuzzy?: boolean | null; translationOrigin?: "base" | "working" | null }>(
174
+ entry: T
175
+ ): T => {
176
+ const nextValue = updates.get(translationKey(entry.msgid, entry.msgctxt));
177
+ if (!nextValue) return entry;
178
+
179
+ return {
180
+ ...entry,
181
+ msgstr: [nextValue],
182
+ hasTranslation: true,
183
+ isCommittedToWorking: true,
184
+ matchesTargetTranslation: false,
185
+ isFuzzy: false,
186
+ translationOrigin: "working"
187
+ };
188
+ };
189
+
190
+ return {
191
+ ...currentContext,
192
+ entry: updateEntry(currentContext.entry),
193
+ alternatives: currentContext.alternatives.map((alternative) => updateEntry(alternative))
194
+ };
195
+ }
196
+
197
+ function selectResolvedKey(msgid: string, msgctxt: string | null, focusInput = false) {
198
+ selectedResolvedKey = { msgid, msgctxt };
199
+ translatedValue =
200
+ getStaged(msgid, msgctxt)?.value ||
201
+ getSuggestion(msgid, msgctxt) ||
202
+ getEntryOriginalValue(contextResult, msgid, msgctxt);
203
+
204
+ resetFeedback();
205
+
206
+ if (focusInput) {
207
+ focusTranslationInput();
208
+ }
209
+ }
210
+
211
+ function hasActiveSuggestion() {
212
+ const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
213
+ if (!resolved) return false;
214
+
215
+ if (getStaged(resolved.msgid, resolved.msgctxt)) {
216
+ return false;
217
+ }
218
+
219
+ const suggestion = getSuggestion(resolved.msgid, resolved.msgctxt);
220
+ if (!suggestion) return false;
221
+
222
+ const originalValue = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
223
+ return translatedValue === suggestion && suggestion !== originalValue;
224
+ }
225
+
226
+ function handleSelectAlt(event: Event, msgid: string, msgctxt: string | null) {
227
+ event.preventDefault();
228
+ event.stopPropagation();
229
+ selectResolvedKey(msgid, msgctxt, true);
230
+ }
231
+
232
+ function moveSelection(direction: 1 | -1) {
233
+ const items = getCandidateList(contextResult);
234
+ if (!items.length) return false;
235
+
236
+ const current = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
237
+ const currentIndex = current
238
+ ? items.findIndex(
239
+ (item) =>
240
+ item.msgid === current.msgid &&
241
+ (item.msgctxt ?? null) === (current.msgctxt ?? null)
242
+ )
243
+ : -1;
244
+
245
+ for (let step = 1; step <= items.length; step++) {
246
+ const nextIndex =
247
+ ((Math.max(currentIndex, 0) + direction * step) % items.length + items.length) %
248
+ items.length;
249
+ const next = items[nextIndex];
250
+ const skipForKeyboard = next.hasTranslation && !next.isFuzzy;
251
+
252
+ if (skipForKeyboard) continue;
253
+
254
+ selectResolvedKey(next.msgid, next.msgctxt, true);
255
+ return true;
256
+ }
257
+
258
+ const nextIndex =
259
+ ((Math.max(currentIndex, 0) + direction) % items.length + items.length) % items.length;
260
+ const next = items[nextIndex];
261
+ selectResolvedKey(next.msgid, next.msgctxt, true);
262
+ return true;
263
+ }
264
+
265
+ function onAltKeydown(event: KeyboardEvent, msgid: string, msgctxt: string | null) {
266
+ if (event.key === "Enter" || event.key === " ") {
267
+ event.preventDefault();
268
+ selectResolvedKey(msgid, msgctxt, true);
269
+ return;
270
+ }
271
+
272
+ if (event.key === "Tab") {
273
+ event.preventDefault();
274
+ moveSelection(event.shiftKey ? -1 : 1);
275
+ }
276
+ }
277
+
278
+ function stageCurrentTranslation(event?: Event) {
279
+ event?.preventDefault();
280
+
281
+ const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
282
+ if (!resolved?.msgid) {
283
+ error = "No resolved translation key selected";
284
+ return;
285
+ }
286
+
287
+ const value = translatedValue.trim();
288
+ if (!value) {
289
+ error = "No translation";
290
+ return;
291
+ }
292
+
293
+ const originalValue = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
294
+ stagedTranslations = {
295
+ ...stagedTranslations,
296
+ [translationKey(resolved.msgid, resolved.msgctxt)]: {
297
+ msgid: resolved.msgid,
298
+ msgctxt: resolved.msgctxt,
299
+ value,
300
+ origin: getEntryOrigin(contextResult, resolved.msgid, resolved.msgctxt),
301
+ originalValue,
302
+ isDirty: value !== originalValue
303
+ }
304
+ };
305
+
306
+ error = null;
307
+ success = "Staged";
308
+
309
+ if (!moveSelection(1)) {
310
+ focusTranslationInput();
311
+ }
312
+ }
313
+
314
+ async function commitStagedTranslations() {
315
+ const payload = Object.values(stagedTranslations).filter((item) => item.isDirty);
316
+ if (!payload.length) {
317
+ error = "No changed translations to submit";
318
+ return false;
319
+ }
320
+
321
+ pending = true;
322
+ resetFeedback();
323
+
324
+ const res = await fetch(`${endpoint}?intent=commit-batch`, {
325
+ method: "POST",
326
+ headers: {
327
+ "content-type": "application/json"
328
+ },
329
+ body: JSON.stringify({
330
+ items: payload.map((item) => ({
331
+ resolvedMsgid: item.msgid,
332
+ resolvedMsgctxt: item.msgctxt,
333
+ translationValue: item.value
334
+ }))
335
+ })
336
+ });
337
+
338
+ const json = await res.json();
339
+ pending = false;
340
+
341
+ if (!res.ok) {
342
+ error = json.error ?? "Batch commit failed";
343
+ return false;
344
+ }
345
+
346
+ success = json.message ?? "Translations committed";
347
+
348
+ const next = { ...stagedTranslations };
349
+ for (const item of payload) {
350
+ delete next[translationKey(item.msgid, item.msgctxt)];
351
+ }
352
+ stagedTranslations = next;
353
+ contextResult = applyCommittedTranslations(contextResult, payload);
354
+ return true;
355
+ }
356
+
357
+ async function submitStagedTranslations(event: SubmitEvent) {
358
+ if (!event.defaultPrevented) {
359
+ event.preventDefault();
360
+ }
361
+
362
+ await commitStagedTranslations();
363
+ }
364
+
365
+ function openTranslation(text: string) {
366
+ capturedSelection = text;
367
+ translatedValue = "";
368
+ resetFeedback();
369
+ resetContextState();
370
+ spawnTranslation = true;
371
+ showPendingChangesDialog = false;
372
+ }
373
+
374
+ async function fetchContext() {
375
+ if (!capturedSelection) return;
376
+
377
+ contextPending = true;
378
+ resetContextState();
379
+ resetFeedback();
380
+
381
+ const body = new FormData();
382
+ body.set("translationKey", capturedSelection);
383
+ body.set("currentPath", page.url.pathname);
384
+
385
+ const res = await fetch(`${endpoint}?intent=context`, {
386
+ method: "POST",
387
+ body
388
+ });
389
+
390
+ const json = await res.json();
391
+ contextPending = false;
392
+
393
+ if (!res.ok) {
394
+ contextError = json.error ?? "Context lookup failed";
395
+ return;
396
+ }
397
+
398
+ contextResult = json;
399
+ selectedResolvedKey = {
400
+ msgid: json.entry.msgid,
401
+ msgctxt: json.entry.msgctxt
402
+ };
403
+ translatedValue =
404
+ getStaged(json.entry.msgid, json.entry.msgctxt)?.value ||
405
+ getSuggestion(json.entry.msgid, json.entry.msgctxt) ||
406
+ json.entry.msgstr?.[0] ||
407
+ "";
408
+
409
+ if (!suggestionPending) {
410
+ suggestionPending = true;
411
+ void requestTranslationSuggestions(json, endpoint)
412
+ .then((nextSuggestions) => {
413
+ suggestionLookup = nextSuggestions;
414
+
415
+ const resolved = getEffectiveResolvedKey(selectedResolvedKey, contextResult);
416
+ if (!resolved) return;
417
+
418
+ const staged = getStaged(resolved.msgid, resolved.msgctxt);
419
+ const original = getEntryOriginalValue(contextResult, resolved.msgid, resolved.msgctxt);
420
+ const currentSuggestion = getSuggestion(resolved.msgid, resolved.msgctxt);
421
+
422
+ if (!staged && translatedValue === original && currentSuggestion) {
423
+ translatedValue = currentSuggestion;
424
+ }
425
+ })
426
+ .catch(() => {
427
+ // Suggestion lookup is best-effort in this dev tool.
428
+ })
429
+ .finally(() => {
430
+ suggestionPending = false;
431
+ });
432
+ }
433
+
434
+ focusTranslationInput();
435
+ }
436
+
437
+ function closeTranslation() {
438
+ spawnTranslation = false;
439
+ capturedSelection = undefined;
440
+ selectionStarted = false;
441
+ translatedValue = "";
442
+ selectedResolvedKey = null;
443
+ focusedAltKey = null;
444
+ showPendingChangesDialog = false;
445
+ resetFeedback();
446
+ resetContextState();
447
+ }
448
+
449
+ function requestCloseTranslation() {
450
+ if (pending) return;
451
+
452
+ if (hasDirtyStagedTranslations()) {
453
+ showPendingChangesDialog = true;
454
+ return;
455
+ }
456
+
457
+ closeTranslation();
458
+ }
459
+
460
+ function discardAndCloseTranslation() {
461
+ stagedTranslations = {};
462
+ closeTranslation();
463
+ }
464
+
465
+ async function submitAndCloseTranslation() {
466
+ const committed = await commitStagedTranslations();
467
+ if (committed) {
468
+ closeTranslation();
469
+ }
470
+ }
471
+
472
+ function getElementTranslationCandidate(target: EventTarget | null): string | null {
473
+ const el =
474
+ target instanceof Element
475
+ ? target.closest(
476
+ 'button, a, [role="button"], input[type="button"], input[type="submit"]'
477
+ )
478
+ : null;
479
+
480
+ if (!el) return null;
481
+
482
+ if (el instanceof HTMLInputElement) {
483
+ return (
484
+ el.value?.trim() ||
485
+ el.getAttribute("aria-label")?.trim() ||
486
+ el.title?.trim() ||
487
+ null
488
+ );
489
+ }
490
+
491
+ return (
492
+ el.getAttribute("aria-label")?.trim() ||
493
+ el.textContent?.trim() ||
494
+ el.getAttribute("title")?.trim() ||
495
+ null
496
+ );
497
+ }
498
+
499
+ function translationForm() {
500
+ if (!browser || !dev) return;
501
+
502
+ const onSelectStart = () => {
503
+ selectionStarted = dragQua;
504
+ };
505
+
506
+ function onCaptureInteractivePointerDown(event: PointerEvent) {
507
+ if (!event.altKey) return;
508
+
509
+ const text = getElementTranslationCandidate(event.target);
510
+ if (!text) return;
511
+
512
+ event.preventDefault();
513
+ event.stopPropagation();
514
+ event.stopImmediatePropagation?.();
515
+
516
+ openTranslation(text);
517
+ void fetchContext();
518
+ }
519
+
520
+ const onMouseUp = () => {
521
+ if (!selectionStarted) return;
522
+
523
+ const text = document.getSelection()?.toString().trim();
524
+ if (text) {
525
+ openTranslation(text);
526
+ void fetchContext();
527
+ }
528
+
529
+ selectionStarted = false;
530
+ };
531
+
532
+ const onDocumentMouseDown = (event: MouseEvent) => {
533
+ if (!spawnTranslation) return;
534
+ if (showPendingChangesDialog) return;
535
+
536
+ const target = event.target as Node | null;
537
+ if (panelEl && target && panelEl.contains(target)) return;
538
+
539
+ requestCloseTranslation();
540
+ };
541
+
542
+ function onCtrlDown(event: KeyboardEvent) {
543
+ dragQua = !event.ctrlKey;
544
+ }
545
+
546
+ function onCtrlUp() {
547
+ dragQua = true;
548
+ }
549
+
550
+ document.addEventListener("selectstart", onSelectStart);
551
+ document.addEventListener("mouseup", onMouseUp);
552
+ document.addEventListener("mousedown", onDocumentMouseDown);
553
+ document.addEventListener("keydown", onCtrlDown);
554
+ document.addEventListener("keyup", onCtrlUp);
555
+ document.addEventListener("pointerdown", onCaptureInteractivePointerDown, true);
556
+
557
+ return () => {
558
+ document.removeEventListener("selectstart", onSelectStart);
559
+ document.removeEventListener("mouseup", onMouseUp);
560
+ document.removeEventListener("mousedown", onDocumentMouseDown);
561
+ document.removeEventListener("keydown", onCtrlDown);
562
+ document.removeEventListener("keyup", onCtrlUp);
563
+ document.removeEventListener("pointerdown", onCaptureInteractivePointerDown, true);
564
+ };
565
+ }
566
+
567
+ onMount(translationForm);
568
+ </script>
569
+
570
+ <div class="translator-actions sticky-rotate">
571
+ <button
572
+ type="button"
573
+ class="translator-action-button"
574
+ onclick={rotateCatalogs}
575
+ disabled={rotatePending || pending}
576
+ aria-disabled={rotatePending || pending}
577
+ title="Rotate catalogs"
578
+ >
579
+ {rotatePending ? "Rotating..." : "Rotate"}
580
+ </button>
581
+
582
+ {#if success}
583
+ <div class="translator-feedback success">{success}</div>
584
+ {:else if error}
585
+ <div class="translator-feedback error">{error}</div>
586
+ {/if}
587
+ </div>
588
+
589
+ <div
590
+ class="translator-shell sticky"
591
+ bind:this={panelEl}
592
+ use:draggable={{
593
+ active: (activate: boolean) => {
594
+ activeSwitchLocale = activate;
595
+ },
596
+ drag: () => dragQua
597
+ }}
598
+ >
599
+ <div class="translator-toggle">
600
+ <button
601
+ type="button"
602
+ class="translator-toggle-button"
603
+ onclick={handleLocaleToggle}
604
+ title="qa-button"
605
+ disabled={activeSwitchLocale}
606
+ aria-disabled={activeSwitchLocale}
607
+ >
608
+ [-QA-] {renderedLocale}
609
+ </button>
610
+ </div>
611
+
612
+ {#if spawnTranslation}
613
+ <TranslationHelperForm
614
+ {capturedSelection}
615
+ {contextPending}
616
+ {contextError}
617
+ {contextResult}
618
+ {selectedResolvedKey}
619
+ {focusedAltKey}
620
+ bind:translatedValue
621
+ {stagedTranslations}
622
+ hasActiveSuggestion={hasActiveSuggestion()}
623
+ {pending}
624
+ {error}
625
+ {success}
626
+ onClose={requestCloseTranslation}
627
+ onSubmit={submitStagedTranslations}
628
+ onStage={stageCurrentTranslation}
629
+ onAltFocusChange={setFocusedAltKey}
630
+ {onAltKeydown}
631
+ onSelectAlt={handleSelectAlt}
632
+ {isStaged}
633
+ {isSelectedAlt}
634
+ {setTranslationInputEl}
635
+ {moveSelection}
636
+ />
637
+ {/if}
638
+
639
+ {#if showPendingChangesDialog}
640
+ <PendingChangesDialog
641
+ {pending}
642
+ onCancel={() => (showPendingChangesDialog = false)}
643
+ onDiscard={discardAndCloseTranslation}
644
+ onSubmit={() => void submitAndCloseTranslation()}
645
+ />
646
+ {/if}
647
+ </div>
648
+
649
+ <style>
650
+ .translator-shell {
651
+ display: flex;
652
+ flex-direction: column;
653
+ align-items: flex-start;
654
+ gap: 0.75rem;
655
+ font-family: Inter, ui-sans-serif, system-ui, sans-serif;
656
+ }
657
+
658
+ .translator-actions {
659
+ display: flex;
660
+ flex-direction: column;
661
+ align-items: flex-end;
662
+ gap: 0.4rem;
663
+ }
664
+
665
+ .translator-action-button {
666
+ appearance: none;
667
+ border: 1px solid rgba(255, 213, 128, 0.45);
668
+ background: linear-gradient(180deg, rgba(58, 42, 16, 0.96), rgba(39, 28, 10, 0.96));
669
+ color: #fff2d7;
670
+ border-radius: 999px;
671
+ padding: 0.45rem 0.8rem;
672
+ font: inherit;
673
+ font-size: 0.74rem;
674
+ font-weight: 700;
675
+ letter-spacing: 0.08em;
676
+ text-transform: uppercase;
677
+ cursor: pointer;
678
+ box-shadow: 0 10px 24px rgba(7, 10, 8, 0.22);
679
+ transition:
680
+ transform 0.16s ease,
681
+ box-shadow 0.16s ease,
682
+ opacity 0.16s ease,
683
+ border-color 0.16s ease;
684
+ }
685
+
686
+ .translator-action-button:hover:not(:disabled) {
687
+ transform: translateY(-1px);
688
+ box-shadow: 0 14px 28px rgba(7, 10, 8, 0.28);
689
+ border-color: rgba(255, 213, 128, 0.7);
690
+ }
691
+
692
+ .translator-action-button:disabled {
693
+ opacity: 0.45;
694
+ cursor: default;
695
+ }
696
+
697
+ .translator-feedback {
698
+ max-width: min(20rem, calc(100vw - 2rem));
699
+ padding: 0.45rem 0.7rem;
700
+ border-radius: 0.75rem;
701
+ font-size: 0.74rem;
702
+ line-height: 1.35;
703
+ }
704
+
705
+ .translator-feedback.success {
706
+ background: rgba(70, 255, 170, 0.12);
707
+ border: 1px solid rgba(102, 255, 178, 0.28);
708
+ color: rgba(236, 255, 243, 0.98);
709
+ }
710
+
711
+ .translator-feedback.error {
712
+ background: rgba(255, 110, 110, 0.12);
713
+ border: 1px solid rgba(255, 130, 130, 0.28);
714
+ color: rgba(255, 224, 224, 0.98);
715
+ }
716
+
717
+ .translator-toggle {
718
+ display: inline-flex;
719
+ opacity: 0.72;
720
+ transition: opacity 0.18s ease;
721
+ }
722
+
723
+ .translator-toggle-button {
724
+ appearance: none;
725
+ border: 1px solid rgba(145, 214, 176, 0.4);
726
+ background:
727
+ linear-gradient(180deg, rgba(33, 54, 43, 0.96), rgba(20, 30, 25, 0.96));
728
+ color: #e9fff1;
729
+ border-radius: 999px;
730
+ padding: 0.45rem 0.8rem;
731
+ font: inherit;
732
+ font-size: 0.78rem;
733
+ font-weight: 700;
734
+ letter-spacing: 0.08em;
735
+ text-transform: uppercase;
736
+ cursor: pointer;
737
+ box-shadow: 0 10px 24px rgba(7, 10, 8, 0.22);
738
+ transition:
739
+ transform 0.16s ease,
740
+ box-shadow 0.16s ease,
741
+ opacity 0.16s ease,
742
+ border-color 0.16s ease;
743
+ }
744
+
745
+ .translator-toggle-button:hover:not(:disabled) {
746
+ transform: translateY(-1px);
747
+ box-shadow: 0 14px 28px rgba(7, 10, 8, 0.28);
748
+ border-color: rgba(145, 214, 176, 0.7);
749
+ }
750
+
751
+ .translator-toggle-button:disabled {
752
+ opacity: 0.38;
753
+ cursor: default;
754
+ }
755
+
756
+ .translator-toggle:hover {
757
+ opacity: 1;
758
+ }
759
+
760
+ .sticky {
761
+ position: fixed;
762
+ top: 10%;
763
+ left: 5%;
764
+ z-index: 500000;
765
+ }
766
+
767
+ .sticky-rotate {
768
+ position: fixed;
769
+ right: 1rem;
770
+ bottom: 1rem;
771
+ z-index: 500000;
772
+ }
773
+
774
+ @media (max-width: 640px) {
775
+ .sticky {
776
+ left: 0.75rem;
777
+ right: 0.75rem;
778
+ top: auto;
779
+ bottom: 5rem;
780
+ }
781
+
782
+ .sticky-rotate {
783
+ left: 0.75rem;
784
+ right: 0.75rem;
785
+ }
786
+ }
787
+ </style>