langcoach 0.1.0 → 0.3.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.
@@ -0,0 +1,934 @@
1
+ (function() {
2
+ /** At most `max` UTF-16 code units (the core/router limit), never ending on half a surrogate pair. */
3
+ function capUnits(s, max) {
4
+ if (s.length <= max) return s;
5
+ const cut = s.slice(0, max);
6
+ const last = cut.charCodeAt(cut.length - 1);
7
+ return last >= 55296 && last <= 56319 ? cut.slice(0, -1) : cut;
8
+ }
9
+ var STATES = [
10
+ "available",
11
+ "downloadable",
12
+ "downloading",
13
+ "unavailable"
14
+ ];
15
+ [...Object.values({
16
+ english: "en",
17
+ ingilizce: "en",
18
+ "i̇ngilizce": "en",
19
+ inglés: "en",
20
+ ingles: "en",
21
+ anglais: "en",
22
+ inglês: "en",
23
+ turkish: "tr",
24
+ türkçe: "tr",
25
+ turkce: "tr",
26
+ turco: "tr",
27
+ turc: "tr",
28
+ spanish: "es",
29
+ español: "es",
30
+ espanol: "es",
31
+ espagnol: "es",
32
+ espanhol: "es",
33
+ french: "fr",
34
+ français: "fr",
35
+ francais: "fr",
36
+ francés: "fr",
37
+ francês: "fr",
38
+ portuguese: "pt",
39
+ português: "pt",
40
+ portugues: "pt",
41
+ portugués: "pt",
42
+ german: "de",
43
+ deutsch: "de",
44
+ arabic: "ar",
45
+ chinese: "zh",
46
+ japanese: "ja",
47
+ korean: "ko",
48
+ russian: "ru",
49
+ hindi: "hi",
50
+ italian: "it",
51
+ italiano: "it",
52
+ dutch: "nl",
53
+ polish: "pl",
54
+ ukrainian: "uk",
55
+ vietnamese: "vi",
56
+ indonesian: "id",
57
+ greek: "el",
58
+ hebrew: "he",
59
+ persian: "fa",
60
+ urdu: "ur",
61
+ bengali: "bn",
62
+ thai: "th"
63
+ })];
64
+ var globalApi = () => {
65
+ const g = globalThis;
66
+ return "Translator" in globalThis ? g.Translator : void 0;
67
+ };
68
+ function createTranslatorCache(api, getGlobal = globalApi) {
69
+ const getApi = () => api ?? getGlobal();
70
+ /** Pair key → translator being created / created (a failed create is forgotten so it can be retried). */
71
+ const translators = /* @__PURE__ */ new Map();
72
+ const results = /* @__PURE__ */ new Map();
73
+ const key = (src, dst) => `${src}\u0000${dst}`;
74
+ async function available(src, dst) {
75
+ try {
76
+ const a = getApi();
77
+ if (!a || typeof a.availability !== "function") return "unavailable";
78
+ const r = await a.availability({
79
+ sourceLanguage: src,
80
+ targetLanguage: dst
81
+ });
82
+ return STATES.includes(r) ? r : "unavailable";
83
+ } catch {
84
+ return "unavailable";
85
+ }
86
+ }
87
+ /** Starts `create` synchronously (keeps the click's user activation); caches the promise. */
88
+ function create(src, dst, onProgress) {
89
+ const k = key(src, dst);
90
+ const existing = translators.get(k);
91
+ if (existing) return existing;
92
+ let p;
93
+ try {
94
+ const a = getApi();
95
+ if (!a || typeof a.create !== "function") return Promise.resolve(null);
96
+ p = Promise.resolve(a.create({
97
+ sourceLanguage: src,
98
+ targetLanguage: dst,
99
+ monitor(m) {
100
+ try {
101
+ m.addEventListener("downloadprogress", (e) => {
102
+ const loaded = e.loaded;
103
+ if (typeof loaded === "number" && onProgress) onProgress(loaded);
104
+ });
105
+ } catch {}
106
+ }
107
+ })).then((t) => t && typeof t.translate === "function" ? t : null, () => null);
108
+ } catch {
109
+ p = Promise.resolve(null);
110
+ }
111
+ translators.set(k, p);
112
+ p.then((t) => {
113
+ if (!t && translators.get(k) === p) translators.delete(k);
114
+ });
115
+ return p;
116
+ }
117
+ async function ensureReady(src, dst, opts) {
118
+ if (opts.userGesture) return await create(src, dst, opts.onProgress) ? "available" : "unavailable";
119
+ const k = key(src, dst);
120
+ if (translators.has(k)) return await translators.get(k) ? "available" : "unavailable";
121
+ const a = await available(src, dst);
122
+ if (a === "downloadable") return "needs_download";
123
+ if (a !== "available") return a;
124
+ return await create(src, dst) ? "available" : "unavailable";
125
+ }
126
+ async function translateOne(t, text) {
127
+ try {
128
+ const out = await t.translate(text);
129
+ if (typeof out !== "string") return null;
130
+ const trimmed = out.trim();
131
+ return trimmed === "" ? null : capUnits(trimmed, 500);
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+ async function translateMany(texts, src, dst) {
137
+ try {
138
+ const clean = (Array.isArray(texts) ? texts : []).map((s) => typeof s === "string" ? s.trim() : "");
139
+ if (!clean.some((s) => s !== "")) return clean.map(() => null);
140
+ if (await ensureReady(src, dst, { userGesture: false }) !== "available") return clean.map(() => null);
141
+ const t = await create(src, dst);
142
+ if (!t) return clean.map(() => null);
143
+ const unique = [...new Set(clean.filter((s) => s !== ""))];
144
+ for (const s of unique) {
145
+ const rk = `${key(src, dst)}\u0000${s}`;
146
+ if (!results.has(rk)) {
147
+ const p = translateOne(t, s);
148
+ results.set(rk, p);
149
+ p.then((r) => {
150
+ if (r === null) results.delete(rk);
151
+ });
152
+ await p;
153
+ }
154
+ }
155
+ return Promise.all(clean.map((s) => s === "" ? null : results.get(`${key(src, dst)}\u0000${s}`) ?? null));
156
+ } catch {
157
+ return (Array.isArray(texts) ? texts : []).map(() => null);
158
+ }
159
+ }
160
+ return {
161
+ available,
162
+ ensureReady,
163
+ translateMany
164
+ };
165
+ }
166
+ //#endregion
167
+ //#region src/content/native-overlay.ts
168
+ /**
169
+ * Best-effort native-language line under LLL's own meanings ("coffee" → "kahve"). Finds leaf elements
170
+ * whose visible text equals a saved English meaning (or, when no meaning element exists for an entry,
171
+ * the term) and inserts one small marked span right after them. LLL's own nodes are never edited; a
172
+ * debounced MutationObserver re-applies after LLL re-renders. Every failure is swallowed.
173
+ */
174
+ var NATIVE_ATTR = "data-langcoach-native";
175
+ var SKIP = /* @__PURE__ */ new Set([
176
+ "SCRIPT",
177
+ "STYLE",
178
+ "NOSCRIPT",
179
+ "TEMPLATE",
180
+ "TEXTAREA",
181
+ "INPUT",
182
+ "SELECT",
183
+ "OPTION",
184
+ "TITLE",
185
+ "HEAD"
186
+ ]);
187
+ var MAX_ENTRIES = 1e3;
188
+ var MAX_TEXT = 500;
189
+ var norm$1 = (s) => s.replace(/\s+/g, " ").trim();
190
+ var termVariants = (term) => {
191
+ const n = norm$1(term);
192
+ return n.includes(" / ") ? [n, norm$1(n.split(" / ").join(" "))] : [n];
193
+ };
194
+ var isOurs = (el) => el.hasAttribute("data-langcoach-native") || el.closest("[data-langcoach-toast]") !== null;
195
+ function createNativeOverlay(doc, opts) {
196
+ const debounceMs = opts.debounceMs ?? 250;
197
+ /** meaning key → native; term key → native (fallback only). */
198
+ const byMeaning = /* @__PURE__ */ new Map();
199
+ const byTerm = /* @__PURE__ */ new Map();
200
+ let enabled = true;
201
+ let destroyed = false;
202
+ let timer;
203
+ let observer;
204
+ function makeSpan(key, native) {
205
+ const span = doc.createElement("span");
206
+ span.className = "langcoach-native";
207
+ span.setAttribute(NATIVE_ATTR, key);
208
+ span.setAttribute("dir", "auto");
209
+ span.setAttribute("lang", opts.lang);
210
+ span.style.display = "block";
211
+ span.style.fontSize = "0.85em";
212
+ span.style.lineHeight = "1.3";
213
+ span.style.opacity = "0.72";
214
+ span.style.marginTop = "2px";
215
+ span.style.fontStyle = "normal";
216
+ span.textContent = native;
217
+ return span;
218
+ }
219
+ function removeAll() {
220
+ for (const s of [...doc.querySelectorAll(`[${NATIVE_ATTR}]`)]) s.remove();
221
+ }
222
+ /** Puts `native` right after `el` (updating our existing span there); returns true if the DOM changed. */
223
+ function place(el, key, native) {
224
+ const next = el.nextElementSibling;
225
+ if (next && next.hasAttribute("data-langcoach-native")) {
226
+ if (next.getAttribute("data-langcoach-native") !== key) next.setAttribute(NATIVE_ATTR, key);
227
+ if (next.textContent !== native) next.textContent = native;
228
+ return;
229
+ }
230
+ el.after(makeSpan(key, native));
231
+ }
232
+ function apply() {
233
+ try {
234
+ if (destroyed || !doc.body) return;
235
+ if (!enabled || byMeaning.size === 0 && byTerm.size === 0) {
236
+ if (doc.querySelector(`[data-langcoach-native]`)) removeAll();
237
+ return;
238
+ }
239
+ const meaningHits = [];
240
+ const termHits = [];
241
+ for (const el of doc.body.querySelectorAll("*")) {
242
+ if (el.childElementCount !== 0 || SKIP.has(el.tagName) || isOurs(el)) continue;
243
+ const text = el.textContent;
244
+ if (!text || text.length > MAX_TEXT * 2) continue;
245
+ const k = norm$1(text);
246
+ const m = byMeaning.get(k);
247
+ if (m !== void 0) {
248
+ meaningHits.push([
249
+ el,
250
+ k,
251
+ m
252
+ ]);
253
+ continue;
254
+ }
255
+ const t = byTerm.get(k);
256
+ if (t !== void 0) termHits.push([
257
+ el,
258
+ k,
259
+ t.native,
260
+ t.meaning
261
+ ]);
262
+ }
263
+ const keep = /* @__PURE__ */ new Set();
264
+ const shownMeanings = new Set(meaningHits.map(([, k]) => k));
265
+ for (const [el, k, native] of meaningHits) {
266
+ place(el, k, native);
267
+ keep.add(el.nextElementSibling);
268
+ }
269
+ for (const [el, k, native, meaning] of termHits) {
270
+ if (shownMeanings.has(meaning)) continue;
271
+ place(el, k, native);
272
+ keep.add(el.nextElementSibling);
273
+ }
274
+ for (const s of [...doc.querySelectorAll(`[${NATIVE_ATTR}]`)]) if (!keep.has(s)) s.remove();
275
+ } catch {}
276
+ }
277
+ function schedule() {
278
+ if (destroyed) return;
279
+ if (timer !== void 0) clearTimeout(timer);
280
+ timer = setTimeout(() => {
281
+ timer = void 0;
282
+ apply();
283
+ }, debounceMs);
284
+ }
285
+ function observe() {
286
+ if (observer || destroyed) return;
287
+ try {
288
+ const MO = doc.defaultView?.MutationObserver;
289
+ if (!MO || !doc.body) return;
290
+ observer = new MO((records) => {
291
+ if (records.some((r) => {
292
+ const nodes = [...r.addedNodes, ...r.removedNodes];
293
+ if (r.type === "characterData") {
294
+ const p = r.target.parentElement;
295
+ return !(p && p.hasAttribute("data-langcoach-native"));
296
+ }
297
+ if (r.type === "childList" && r.target instanceof Element && r.target.hasAttribute("data-langcoach-native")) return false;
298
+ return nodes.length === 0 || nodes.some((n) => !(n instanceof Element && n.hasAttribute("data-langcoach-native")));
299
+ })) schedule();
300
+ });
301
+ observer.observe(doc.body, {
302
+ childList: true,
303
+ subtree: true,
304
+ characterData: true
305
+ });
306
+ } catch {
307
+ observer = void 0;
308
+ }
309
+ }
310
+ return {
311
+ add(entries) {
312
+ try {
313
+ if (destroyed || !Array.isArray(entries)) return;
314
+ for (const e of entries) {
315
+ if (!e || typeof e.meaning !== "string" || typeof e.native !== "string") continue;
316
+ const native = capUnits(norm$1(e.native), MAX_TEXT);
317
+ const meaning = norm$1(e.meaning);
318
+ if (!native || !meaning || meaning.length > MAX_TEXT) continue;
319
+ if (byMeaning.size >= MAX_ENTRIES && !byMeaning.has(meaning)) continue;
320
+ byMeaning.set(meaning, native);
321
+ if (typeof e.term === "string" && e.term.trim()) {
322
+ for (const v of termVariants(e.term)) if (v !== meaning) byTerm.set(v, {
323
+ native,
324
+ meaning
325
+ });
326
+ }
327
+ }
328
+ apply();
329
+ observe();
330
+ } catch {}
331
+ },
332
+ setEnabled(next) {
333
+ enabled = next;
334
+ apply();
335
+ },
336
+ destroy() {
337
+ destroyed = true;
338
+ if (timer !== void 0) clearTimeout(timer);
339
+ observer?.disconnect();
340
+ observer = void 0;
341
+ try {
342
+ removeAll();
343
+ } catch {}
344
+ }
345
+ };
346
+ }
347
+ var TINY_LESSON_PATH = /^\/lll\/[a-z]{2}(?:[-_][a-z]{2,4})?\/experiments\/tiny-lesson\/?$/i;
348
+ var SITE_HOME_PATH = /^\/lll\/[a-z]{2}(?:[-_][a-z]{2,4})?\/?$/i;
349
+ /** "Generate" in the LLL site languages (en, es, fr, pt). */
350
+ var GENERATE_TEXT = /generat|generar|g[ée]n[ée]rer|gerar/i;
351
+ var isTinyLessonPage = (win) => TINY_LESSON_PATH.test(win.location.pathname);
352
+ /** Rendered (has a layout box) and interactive. */
353
+ function isUsable(el) {
354
+ const h = el;
355
+ if (h.disabled || h.hidden || el.closest("[hidden], [aria-hidden=\"true\"], [inert]")) return false;
356
+ return el.getClientRects().length > 0;
357
+ }
358
+ /**
359
+ * The Tiny Lesson purpose field: exactly one usable `input` whose type is text and that is not the
360
+ * language combobox (live markup: no form, no maxLength, no type attribute, rotating placeholder).
361
+ * Anything else is ambiguous, so null; never "the first input on the page".
362
+ */
363
+ function findPurposeField(win) {
364
+ const candidates = [...win.document.querySelectorAll("input")].filter((el) => el.type === "text" && el.getAttribute("role") !== "combobox" && isUsable(el));
365
+ return candidates.length === 1 ? candidates[0] : null;
366
+ }
367
+ /**
368
+ * Writes `theme` into the Tiny Lesson purpose field so that React registers it: the value goes through the
369
+ * element's own prototype setter (bypassing React's instance-level value tracker), then a bubbling `input`
370
+ * event. Returns false (and changes nothing) outside the Tiny Lesson page or when the field is not found.
371
+ */
372
+ function fillPurposeField(win, theme) {
373
+ if (!isTinyLessonPage(win)) return false;
374
+ const el = findPurposeField(win);
375
+ return el ? writeValue(win, el, theme) : false;
376
+ }
377
+ function writeValue(win, el, theme) {
378
+ const w = win;
379
+ const limit = el.maxLength > 0 ? Math.min(el.maxLength, 50) : 50;
380
+ const value = [...theme].slice(0, limit).join("");
381
+ const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value")?.set;
382
+ if (!setter) return false;
383
+ el.focus();
384
+ setter.call(el, value);
385
+ el.dispatchEvent(new w.Event("input", { bubbles: true }));
386
+ return true;
387
+ }
388
+ var norm = (s) => (s ?? "").replace(/\s+/g, " ").trim().toLowerCase();
389
+ function languageCombobox(win) {
390
+ return [...win.document.querySelectorAll("[role=\"combobox\"]")].find(isUsable) ?? null;
391
+ }
392
+ /** First visit: the intro splash ("Start" → site home) or the "Before you begin" notice (checkbox). */
393
+ function isSplash(win) {
394
+ if (languageCombobox(win)) return false;
395
+ const doc = win.document;
396
+ const toHome = [...doc.querySelectorAll("a[href]")].some((a) => {
397
+ try {
398
+ const u = new URL(a.href, win.location.href);
399
+ return u.origin === win.location.origin && SITE_HOME_PATH.test(u.pathname) && isUsable(a);
400
+ } catch {
401
+ return false;
402
+ }
403
+ });
404
+ const consent = [...doc.querySelectorAll("input[type=\"checkbox\"]")].some(isUsable);
405
+ return toHome || consent;
406
+ }
407
+ function selectControl(combo) {
408
+ return combo.closest("[class*=\"__control\"]") ?? combo.closest("[class*=\"control\"]");
409
+ }
410
+ function selectRoot(combo) {
411
+ return combo.closest("[class*=\"-container\"]") ?? selectControl(combo)?.parentElement ?? null;
412
+ }
413
+ function currentLanguage(combo) {
414
+ return norm(selectControl(combo)?.querySelector("[class*=\"single-value\"]")?.textContent);
415
+ }
416
+ async function waitFor(win, probe, timing) {
417
+ const end = Date.now() + timing.timeoutMs;
418
+ for (;;) {
419
+ const v = probe();
420
+ if (v) return v;
421
+ if (Date.now() >= end) return null;
422
+ await new Promise((r) => win.setTimeout(r, timing.stepMs));
423
+ }
424
+ }
425
+ function mouse(win, el, type) {
426
+ const w = win;
427
+ el.dispatchEvent(new w.MouseEvent(type, {
428
+ bubbles: true,
429
+ cancelable: true,
430
+ button: 0
431
+ }));
432
+ }
433
+ /** Chooses `label` in the react-select picker; true when the control then shows it. */
434
+ async function chooseLanguage(win, combo, label, timing) {
435
+ const want = norm(label);
436
+ if (currentLanguage(combo) === want) return true;
437
+ const control = selectControl(combo);
438
+ if (!control) return false;
439
+ mouse(win, control, "mousedown");
440
+ const options = await waitFor(win, () => {
441
+ const list = [...win.document.querySelectorAll("[role=\"listbox\"] [role=\"option\"]")];
442
+ return list.length > 0 ? list : null;
443
+ }, timing);
444
+ const option = options?.find((o) => norm(o.textContent) === want);
445
+ if (!option) {
446
+ if (options) mouse(win, control, "mousedown");
447
+ return false;
448
+ }
449
+ mouse(win, option, "mousedown");
450
+ mouse(win, option, "mouseup");
451
+ option.click();
452
+ return await waitFor(win, () => currentLanguage(combo) === want, timing) === true;
453
+ }
454
+ /** The Generate button: after the purpose field, outside the picker, labelled Generate (or the only one). */
455
+ function findGenerate(win, purpose, combo) {
456
+ const picker = combo ? selectRoot(combo) : null;
457
+ const after = [...win.document.querySelectorAll("button")].filter((b) => purpose.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING && !(picker && picker.contains(b)) && b.getClientRects().length > 0);
458
+ const named = after.find((b) => GENERATE_TEXT.test(b.textContent ?? "") || GENERATE_TEXT.test(b.getAttribute("aria-label") ?? ""));
459
+ if (named) return named;
460
+ for (let box = purpose.parentElement; box && box !== win.document.body; box = box.parentElement) {
461
+ const inBox = after.filter((b) => box.contains(b));
462
+ if (inBox.length === 1) return inBox[0];
463
+ if (inBox.length > 1) return null;
464
+ }
465
+ return null;
466
+ }
467
+ var enabled = (b) => !b.disabled && b.getAttribute("aria-disabled") !== "true";
468
+ /**
469
+ * Starts a Tiny Lesson for `theme`: (a) splash → reason 'splash'; (b) select the target language when it
470
+ * differs; (c) fill the purpose (≤50 chars, React-safe); (d) wait ≤timeout for Generate, click it.
471
+ */
472
+ async function startScenario(win, req, timingIn = {}) {
473
+ const timing = {
474
+ timeoutMs: timingIn.timeoutMs ?? 3e3,
475
+ stepMs: timingIn.stepMs ?? 50
476
+ };
477
+ try {
478
+ if (!isTinyLessonPage(win)) return {
479
+ ok: false,
480
+ reason: "not_tiny_lesson"
481
+ };
482
+ await waitFor(win, () => isSplash(win) || !!findPurposeField(win), timing);
483
+ if (isSplash(win)) return {
484
+ ok: false,
485
+ reason: "splash"
486
+ };
487
+ if (!findPurposeField(win)) return {
488
+ ok: false,
489
+ reason: "field"
490
+ };
491
+ const combo = languageCombobox(win);
492
+ if (req.targetLabel) {
493
+ if (!combo || !await chooseLanguage(win, combo, req.targetLabel, timing)) return {
494
+ ok: false,
495
+ reason: "language"
496
+ };
497
+ }
498
+ const purpose = findPurposeField(win);
499
+ if (!purpose || !writeValue(win, purpose, req.theme)) return {
500
+ ok: false,
501
+ reason: "field"
502
+ };
503
+ const button = await waitFor(win, () => {
504
+ const b = findGenerate(win, purpose, combo);
505
+ return b && enabled(b) ? b : null;
506
+ }, timing);
507
+ if (!button) return {
508
+ ok: false,
509
+ reason: "generate"
510
+ };
511
+ button.click();
512
+ return { ok: true };
513
+ } catch {
514
+ return {
515
+ ok: false,
516
+ reason: "error"
517
+ };
518
+ }
519
+ }
520
+ //#endregion
521
+ //#region src/content/protocol.ts
522
+ /** Attribute the isolated script sets on <html> to hand the per-page nonce to the MAIN-world script. */
523
+ var NONCE_ATTR = "data-langcoach-nonce";
524
+ //#endregion
525
+ //#region src/content/toast.ts
526
+ var TOAST_MS = 6e3;
527
+ var CSS = `
528
+ :host { all: initial; }
529
+ .box {
530
+ position: fixed;
531
+ left: 16px;
532
+ bottom: 16px;
533
+ z-index: 2147483647;
534
+ max-width: 360px;
535
+ box-sizing: border-box;
536
+ display: flex;
537
+ flex-wrap: wrap;
538
+ align-items: center;
539
+ gap: 8px 12px;
540
+ padding: 10px 14px;
541
+ border-radius: 14px;
542
+ font: 500 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
543
+ color: #1d2433;
544
+ background: #ffffff;
545
+ border: 1px solid #d9dee8;
546
+ box-shadow: 0 6px 24px rgba(16, 24, 40, 0.18);
547
+ }
548
+ .text { flex: 1 1 auto; min-width: 0; overflow-wrap: anywhere; }
549
+ .actions { display: flex; gap: 6px; }
550
+ button {
551
+ font: inherit;
552
+ font-weight: 600;
553
+ cursor: pointer;
554
+ padding: 4px 10px;
555
+ border-radius: 999px;
556
+ border: 1px solid #b9c3d6;
557
+ background: #f2f5fb;
558
+ color: #1a4fd6;
559
+ }
560
+ button:hover { background: #e4ebfa; }
561
+ button:focus-visible { outline: 3px solid #1a4fd6; outline-offset: 2px; }
562
+ @media (prefers-color-scheme: dark) {
563
+ .box { color: #e8ecf4; background: #1f2430; border-color: #3a4256; box-shadow: 0 6px 24px rgba(0, 0, 0, 0.5); }
564
+ button { color: #9ec0ff; background: #2a3142; border-color: #4a5470; }
565
+ button:hover { background: #343c50; }
566
+ button:focus-visible { outline-color: #9ec0ff; }
567
+ }
568
+ @media (prefers-reduced-motion: no-preference) {
569
+ .box { animation: in 160ms ease-out; }
570
+ @keyframes in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
571
+ }
572
+ `;
573
+ /**
574
+ * One toast at a time, bottom-left (clear of the lesson page's main buttons), in a closed shadow
575
+ * root on a host appended to <html>. Auto-hides after 6 s; hovering or focusing it pauses the timer.
576
+ */
577
+ function createToaster(doc = document, duration = TOAST_MS) {
578
+ let host = null;
579
+ let root = null;
580
+ let timer;
581
+ let deadline = 0;
582
+ let remaining = duration;
583
+ let hovered = false;
584
+ let focused = false;
585
+ const clear = () => {
586
+ if (timer !== void 0) clearTimeout(timer);
587
+ timer = void 0;
588
+ };
589
+ const start = (ms) => {
590
+ clear();
591
+ remaining = ms;
592
+ deadline = Date.now() + ms;
593
+ timer = setTimeout(hide, ms);
594
+ };
595
+ const pause = () => {
596
+ if (timer === void 0) return;
597
+ remaining = Math.max(0, deadline - Date.now());
598
+ clear();
599
+ };
600
+ const resume = () => {
601
+ if (!hovered && !focused && host) start(remaining);
602
+ };
603
+ function hide() {
604
+ clear();
605
+ try {
606
+ host?.remove();
607
+ } catch {}
608
+ host = null;
609
+ root = null;
610
+ hovered = focused = false;
611
+ }
612
+ function show(spec) {
613
+ hide();
614
+ const h = doc.createElement("div");
615
+ h.setAttribute("data-langcoach-toast", "");
616
+ const r = h.attachShadow({ mode: "closed" });
617
+ const style = doc.createElement("style");
618
+ style.textContent = CSS;
619
+ const box = doc.createElement("div");
620
+ box.className = "box";
621
+ box.setAttribute("role", "status");
622
+ box.setAttribute("aria-live", "polite");
623
+ const text = doc.createElement("span");
624
+ text.className = "text";
625
+ text.textContent = spec.text;
626
+ box.append(text);
627
+ if (spec.actions?.length) {
628
+ const actions = doc.createElement("span");
629
+ actions.className = "actions";
630
+ for (const a of spec.actions) {
631
+ const b = doc.createElement("button");
632
+ b.type = "button";
633
+ b.textContent = a.label;
634
+ b.addEventListener("click", (e) => {
635
+ e.stopPropagation();
636
+ a.onClick();
637
+ });
638
+ actions.append(b);
639
+ }
640
+ box.append(actions);
641
+ }
642
+ r.append(style, box);
643
+ h.addEventListener("mouseenter", () => {
644
+ hovered = true;
645
+ pause();
646
+ });
647
+ h.addEventListener("mouseleave", () => {
648
+ hovered = false;
649
+ resume();
650
+ });
651
+ h.addEventListener("focusin", () => {
652
+ focused = true;
653
+ pause();
654
+ });
655
+ h.addEventListener("focusout", () => {
656
+ focused = false;
657
+ resume();
658
+ });
659
+ doc.documentElement.append(h);
660
+ host = h;
661
+ root = r;
662
+ start(duration);
663
+ }
664
+ return {
665
+ show,
666
+ hide,
667
+ get root() {
668
+ return root;
669
+ }
670
+ };
671
+ }
672
+ //#endregion
673
+ //#region src/content/isolated.ts
674
+ var KINDS = [
675
+ "terms",
676
+ "grammar",
677
+ "conversation",
678
+ "object-descriptors"
679
+ ];
680
+ var isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
681
+ function randomNonce(win) {
682
+ const b = /* @__PURE__ */ new Uint8Array(16);
683
+ win.crypto.getRandomValues(b);
684
+ return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
685
+ }
686
+ /**
687
+ * Isolated-world entry (document_start). Publishes a per-page nonce for the MAIN-world script, accepts
688
+ * only same-window, same-origin captures carrying that nonce, relays them to the background and shows
689
+ * the result as a toast. A page script that reads the nonce could still forge a capture; the worst it
690
+ * can do is add vocabulary to the learner's own deck.
691
+ */
692
+ function startIsolated(deps) {
693
+ const { win, t } = deps;
694
+ const toaster = deps.toaster ?? createToaster(win.document);
695
+ const nonce = randomNonce(win);
696
+ win.document.documentElement.setAttribute(NONCE_ATTR, nonce);
697
+ /** Resolves to the response data, or undefined on any failure (errors stay in the extension). */
698
+ async function ask(msg) {
699
+ try {
700
+ const res = await deps.sendMessage(msg);
701
+ return res && res.ok ? res.data : void 0;
702
+ } catch {
703
+ return;
704
+ }
705
+ }
706
+ const openAction = () => ({
707
+ label: t("toastOpen"),
708
+ onClick: () => {
709
+ ask({ type: "openPanel" });
710
+ toaster.hide();
711
+ }
712
+ });
713
+ const translator = deps.translator ?? createTranslatorCache();
714
+ let overlay = null;
715
+ let overlayLang = "";
716
+ let showOnPage = true;
717
+ function overlayFor(lang) {
718
+ if (!overlay || overlayLang !== lang) {
719
+ overlay?.destroy();
720
+ overlay = createNativeOverlay(win.document, {
721
+ lang,
722
+ debounceMs: deps.overlayDebounceMs
723
+ });
724
+ overlayLang = lang;
725
+ }
726
+ overlay.setEnabled(showOnPage);
727
+ return overlay;
728
+ }
729
+ try {
730
+ deps.onShowNativeChanged?.((show) => {
731
+ showOnPage = show;
732
+ overlay?.setEnabled(show);
733
+ });
734
+ } catch {}
735
+ const str = (v) => typeof v === "string" && v.trim() !== "";
736
+ /**
737
+ * After the background saved a capture: translate meanings that have no native translation yet (only
738
+ * when the model is already on this device; no download without a click), store them, and show them
739
+ * under LLL's meanings. Grammar examples are only shown, not stored. Any failure leaves English as is.
740
+ */
741
+ async function applyNative(r) {
742
+ try {
743
+ const hint = r.native;
744
+ if (!isObj(hint) || !str(hint.source) || !str(hint.target)) return;
745
+ const { source, target } = hint;
746
+ if (typeof hint.overlay === "boolean") showOnPage = hint.overlay;
747
+ const shown = [];
748
+ if (r.status === "saved") {
749
+ const items = (Array.isArray(hint.items) ? hint.items : []).filter((i) => isObj(i) && str(i.cardId) && str(i.meaning)).map((i) => ({
750
+ cardId: i.cardId,
751
+ term: typeof i.term === "string" ? i.term : "",
752
+ meaning: i.meaning,
753
+ native: str(i.native) ? i.native : void 0
754
+ }));
755
+ if (items.length === 0) return;
756
+ const missing = items.filter((i) => !i.native);
757
+ const out = missing.length ? await translator.translateMany(missing.map((i) => i.meaning), source, target) : [];
758
+ const entries = [];
759
+ missing.forEach((i, k) => {
760
+ const text = out[k];
761
+ if (text) {
762
+ i.native = text;
763
+ entries.push({
764
+ cardId: i.cardId,
765
+ text
766
+ });
767
+ }
768
+ });
769
+ if (entries.length) ask({
770
+ type: "setNativeTranslations",
771
+ entries: entries.slice(0, 500)
772
+ });
773
+ for (const i of items) if (i.native) shown.push({
774
+ term: i.term,
775
+ meaning: i.meaning,
776
+ native: i.native
777
+ });
778
+ } else if (r.status === "grammar") {
779
+ const examples = (Array.isArray(hint.examples) ? hint.examples : []).filter(str).slice(0, 100);
780
+ const out = examples.length ? await translator.translateMany(examples, source, target) : [];
781
+ examples.forEach((ex, k) => {
782
+ const text = out[k];
783
+ if (text) shown.push({
784
+ meaning: ex,
785
+ native: text
786
+ });
787
+ });
788
+ }
789
+ if (shown.length) overlayFor(target).add(shown);
790
+ } catch {}
791
+ }
792
+ function withXp(text, xp) {
793
+ return xp > 0 ? `${text} · ${t("toastXp", String(xp))}` : text;
794
+ }
795
+ function showResult(r, capture) {
796
+ if (!isObj(r)) return;
797
+ if (r.status === "saved" || r.status === "grammar") applyNative(r);
798
+ if (r.status === "pending") {
799
+ toaster.show({
800
+ text: t("extName"),
801
+ actions: [{
802
+ label: t("toastSaveN", String(r.count)),
803
+ onClick: () => {
804
+ toaster.hide();
805
+ ask({
806
+ ...capture,
807
+ force: true
808
+ }).then((res) => showResult(res, capture));
809
+ }
810
+ }]
811
+ });
812
+ return;
813
+ }
814
+ if (r.status !== "saved") return;
815
+ const sent = (text) => r.forwarded === true ? `${text} · ${t("toastForwarded")}` : text;
816
+ if (r.newCards === 0) {
817
+ toaster.show({
818
+ text: withXp(sent(t("toastAlready")), r.xp),
819
+ actions: [openAction()]
820
+ });
821
+ return;
822
+ }
823
+ const lessonIds = r.lessonIds;
824
+ toaster.show({
825
+ text: withXp(sent(t("toastSaved", String(r.newCards))), r.xp),
826
+ actions: [{
827
+ label: t("toastUndo"),
828
+ onClick: () => {
829
+ toaster.hide();
830
+ ask({
831
+ type: "undo",
832
+ lessonIds
833
+ }).then((u) => {
834
+ if (u) toaster.show({ text: t("toastRemoved") });
835
+ });
836
+ }
837
+ }, openAction()]
838
+ });
839
+ }
840
+ win.addEventListener("message", (e) => {
841
+ try {
842
+ if (e.source !== win || e.origin !== win.location.origin) return;
843
+ const d = e.data;
844
+ if (!isObj(d) || d.nonce !== nonce) return;
845
+ if (d.source === "langcoach-ack") {
846
+ win.document.documentElement.removeAttribute(NONCE_ATTR);
847
+ return;
848
+ }
849
+ if (d.source !== "langcoach-capture" || !KINDS.includes(d.kind)) return;
850
+ const capture = {
851
+ type: "capture",
852
+ kind: d.kind,
853
+ request: d.request,
854
+ response: d.response,
855
+ pageUrl: win.location.href
856
+ };
857
+ ask(capture).then((r) => showResult(r, capture));
858
+ } catch {}
859
+ });
860
+ deps.onMessage.addListener((msg, sender, sendResponse) => {
861
+ if (sender?.id !== deps.runtimeId || !isObj(msg)) return;
862
+ if (msg.type === "ping") {
863
+ sendResponse?.({ ready: true });
864
+ return;
865
+ }
866
+ if (msg.type === "startScenario") {
867
+ if (!(typeof msg.theme === "string" && msg.theme.trim() !== "" && (msg.targetLabel === void 0 || typeof msg.targetLabel === "string"))) {
868
+ sendResponse?.({
869
+ ok: false,
870
+ reason: "invalid"
871
+ });
872
+ return;
873
+ }
874
+ startScenario(win, {
875
+ theme: msg.theme,
876
+ ...typeof msg.targetLabel === "string" && msg.targetLabel ? { targetLabel: msg.targetLabel } : {}
877
+ }).catch(() => ({
878
+ ok: false,
879
+ reason: "error"
880
+ })).then((r) => {
881
+ try {
882
+ sendResponse?.(r);
883
+ } catch {}
884
+ });
885
+ return true;
886
+ }
887
+ if (msg.type === "fillTheme" && typeof msg.theme === "string") {
888
+ let filled = false;
889
+ try {
890
+ filled = fillPurposeField(win, msg.theme);
891
+ } catch {}
892
+ sendResponse?.({ filled });
893
+ return;
894
+ }
895
+ if (msg.type !== "toast") return;
896
+ if (msg.variant === "manual") toaster.show({
897
+ text: t("toastSavedManual"),
898
+ actions: [openAction()]
899
+ });
900
+ else if (msg.variant === "already") toaster.show({
901
+ text: t("toastAlready"),
902
+ actions: [openAction()]
903
+ });
904
+ });
905
+ return {
906
+ nonce,
907
+ /** Removes the native-language lines and stops observing the page (tests, extension reload). */
908
+ dispose: () => {
909
+ overlay?.destroy();
910
+ overlay = null;
911
+ }
912
+ };
913
+ }
914
+ //#endregion
915
+ //#region src/content-isolated.ts
916
+ /** Same key as background/router.ts SETTINGS_KEY (not imported: keeps the background out of this bundle). */
917
+ var SETTINGS_KEY = "langcoach:settings";
918
+ startIsolated({
919
+ win: window,
920
+ runtimeId: chrome.runtime.id,
921
+ sendMessage: (msg) => chrome.runtime.sendMessage(msg),
922
+ onMessage: chrome.runtime.onMessage,
923
+ t: (key, subs) => chrome.i18n.getMessage(key, subs),
924
+ onShowNativeChanged: (cb) => {
925
+ try {
926
+ chrome.storage.onChanged.addListener((changes, area) => {
927
+ const next = changes[SETTINGS_KEY]?.newValue;
928
+ if (area === "local" && next && typeof next === "object") cb(next.showNativeOnPage !== false);
929
+ });
930
+ } catch {}
931
+ }
932
+ });
933
+ //#endregion
934
+ })();