Theseus: one language chip, auto-translate, picker owns up to what works

Two chips carried the same word in two shapes — a globe (Accept-Language)
and a translate chip (chip lights when page lang differs) — both labelled
"RU" at the same time for a Russian user. The chip for translation is
gone. The globe menu now covers both: a "Translate this page from X to Y"
item appears at the top when the loaded page is in another supported
language, flipping to "Show original" while a translation is on screen.
The chip's own code still shows the user's language (EN, RU, …); its
tooltip switches to "Translated to <X>. Menu: Show original." when a
translation is up, so the one chip reads the whole state.

With "Translate automatically" on, Theseus translates in place on
did-finish-load the first time it sees a supported source + target
mismatch for the active tab — no chip-click needed. A `_tr.autoTried`
latch keeps it to one attempt per document (a failing backend doesn't
retry on every reflow), and the latch resets on did-start-navigation so
the next page gets a fresh shot. The setting copy in Settings › Language
now says "Translate automatically" instead of "Offer to translate", so
the switch's label matches the behaviour.

The picker (both in Settings and in the globe menu) still lists every
language in WEBSITE_LANGUAGE_QUICK, but entries whose base code isn't
on the translator backend (en, es, fr, de, el, ru today) are shown
greyed out with "— translator coming later", and "Other… (Accept-Language
only, no translation)" is explicit about what free-form tags buy you.
The menu is a roadmap, not a lie: a user picking one of the greyed
entries sets Accept-Language and nothing else surprises them.
This commit is contained in:
Silent Mode 2026-10-03 19:37:04 +02:00
parent 8186407b61
commit 3536cd89bd
3 changed files with 111 additions and 72 deletions

View file

@ -506,21 +506,19 @@
makes it installable (Chrome's omnibox icon). Filled once the makes it installable (Chrome's omnibox icon). Filled once the
app is installed; click then opens it or removes it. --> app is installed; click then opens it or removes it. -->
<button class="appchip" id="appchip" title="Install app" hidden><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="12" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 5 V9.5 M6 7.8 L8 9.8 L10 7.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 14 H10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button> <button class="appchip" id="appchip" title="Install app" hidden><svg viewBox="0 0 16 16" aria-hidden="true"><rect x="2" y="2.5" width="12" height="9" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 5 V9.5 M6 7.8 L8 9.8 L10 7.8" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M5.5 14 H10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg></button>
<!-- Website-language chip: click to change the language sites serve <!-- Language chip: one control for both the Accept-Language that
you in. Icon = globe with meridian + equator (universal "language" sites see AND the translator. Icon = globe with meridian +
glyph); label = 2-letter code, or "AUTO" when we follow the OS. --> equator (universal "language" glyph); label = 2-letter code of
the user's language, or "AUTO" when we follow the OS. Click
opens a menu that offers "Translate this page" (when the
loaded page is in another supported language), the language
picker (unsupported languages are present but greyed), and a
jump to the full language list in Settings. The "on" state
means the page is currently showing a translation. -->
<button class="langchip" id="langBtn" title="Language websites serve you in"> <button class="langchip" id="langBtn" title="Language websites serve you in">
<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M2 8 H14"/><path d="M8 2 C5 5 5 11 8 14 C11 11 11 5 8 2"/></svg> <svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6"/><path d="M2 8 H14"/><path d="M8 2 C5 5 5 11 8 14 C11 11 11 5 8 2"/></svg>
<span class="lcode" id="langCode">AUTO</span> <span class="lcode" id="langCode">AUTO</span>
</button> </button>
<!-- Translate chip: lights up when the page's declared language
differs from the user's preferred one. Click translates in
place; click again reverts. Right-click opens the chip menu
(change target, translator settings). -->
<button class="langchip" id="trBtn" title="Translate this page" hidden>
<svg viewBox="0 0 16 16" aria-hidden="true"><path d="M2 4 H8 M5 2 V4 M3 4 C3 7 5 10 8 10 M8 10 C5 10 3 11 2 13 M3 10 C6 10 8 7 8 4"/><path d="M8.5 14 L11 7 L13.5 14 M9.3 12 H12.7"/></svg>
<span class="lcode" id="trCode">EN</span>
</button>
<button class="star" id="star" title="Save this page">☆</button> <button class="star" id="star" title="Save this page">☆</button>
</div> </div>
<div class="bardrag" id="bardrag" title="Drag to shift the address / search bar ratio"></div> <div class="bardrag" id="bardrag" title="Drag to shift the address / search bar ratio"></div>
@ -768,57 +766,30 @@
.then(renderLang).catch(() => {}); .then(renderLang).catch(() => {});
T.onSettingsUpdate && T.onSettingsUpdate(renderLang); T.onSettingsUpdate && T.onSettingsUpdate(renderLang);
// ---- page-translate chip ---- // The language chip's tooltip also carries the translation state: when
// Shows when settings.translateAutoOffer is on and the loaded page's // main broadcasts `translated: true` the tooltip switches to "Translated
// declared language (<html lang>) differs from the user's target. Click // to <X> — menu offers Show original". The actual translate / revert /
// toggles between translated and original. Right-click (or long-press) // change-language actions live in the menu that opens on click (the
// opens the chip menu. // chip has one surface, not two).
const trBtn = $("trBtn"), trCode = $("trCode"); let trStateCached = null;
let trSettingsCached = null, trStateCached = null; function trCurrentTarget() {
function trTargetBase() { const el = $("langCode"); const code = el ? el.textContent : "AUTO";
const s = trSettingsCached || {}; if (code === "AUTO") return String(systemLocaleCached || "en").split("-")[0].toLowerCase();
const tag = s.languageMode === "manual" && s.languageValue ? s.languageValue : systemLocaleCached; return code.toLowerCase();
return String(tag || "en").split("-")[0].toLowerCase();
} }
function renderTr() { function applyTranslateHint() {
if (!trBtn) return;
const s = trSettingsCached || {};
const st = trStateCached || {}; const st = trStateCached || {};
const target = trTargetBase(); const target = trCurrentTarget();
const page = String(st.pageLang || "").toLowerCase(); if (st.translated) {
const translated = !!st.translated; langBtn.title = `Translated to ${langName(target)}. Menu: Show original.`;
// Hide when auto-offer is off AND we aren't already translated, OR when } else if (st.pageLang && st.pageLang !== target) {
// the page is already in the user's target language. langBtn.title = `Page is in ${langName(st.pageLang)}. Menu: translate to ${langName(target)}.`;
const shouldShow = translated || (s.translateAutoOffer !== false && page && page !== target);
trBtn.hidden = !shouldShow;
if (!shouldShow) return;
trCode.textContent = (target || "??").toUpperCase().slice(0, 2);
if (translated) {
trBtn.classList.add("on");
trBtn.title = `Translated to ${langName(target)}. Click to show the original.`;
} else if (st.error) {
trBtn.classList.remove("on");
trBtn.title = `Translation failed: ${st.error}. Right-click for settings.`;
} else {
trBtn.classList.remove("on");
trBtn.title = `Translate this page from ${langName(page) || page} to ${langName(target)}`;
} }
// When page lang matches the user's target, the base renderLang tooltip
// already covers it — we leave it alone.
} }
if (trBtn) { T.onPageTranslateState && T.onPageTranslateState((d) => { trStateCached = d; applyTranslateHint(); });
trBtn.onclick = () => { if (T.pageTranslateState) T.pageTranslateState().then((d) => { trStateCached = d; applyTranslateHint(); }).catch(() => {});
if (trStateCached?.translated) T.pageTranslateRevert && T.pageTranslateRevert();
else T.pageTranslate && T.pageTranslate();
};
trBtn.addEventListener("contextmenu", (e) => {
e.preventDefault();
const r = trBtn.getBoundingClientRect();
T.pageTranslateMenu && T.pageTranslateMenu({ x: Math.round(r.left), y: Math.round(r.bottom + 4) });
});
}
T.onPageTranslateState && T.onPageTranslateState((d) => { trStateCached = d; renderTr(); });
T.onSettingsUpdate && T.onSettingsUpdate((s) => { trSettingsCached = s; renderTr(); });
T.getSettings().then((s) => { trSettingsCached = s; renderTr(); }).catch(() => {});
if (T.pageTranslateState) T.pageTranslateState().then((d) => { trStateCached = d; renderTr(); }).catch(() => {});
// ---- extension dock ---- // ---- extension dock ----
// One toolbar button per registered addon sidebar-panel, plus a static // One toolbar button per registered addon sidebar-panel, plus a static

72
main.js
View file

@ -3734,8 +3734,9 @@ function createTab(initial, opts = {}) {
wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } }); wc.on("did-navigate", () => { if (tab.id === activeId) { notifyTabChange(); emitPwAvailability(); } });
wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); }); wc.on("did-navigate-in-page", () => { if (tab.id === activeId) notifyTabChange(); });
// Translator state is per-document: a new navigation drops any "translated" // Translator state is per-document: a new navigation drops any "translated"
// flag and clears the cached page language. The chip then re-decides on // flag, the autoTried latch, and the cached page language. The chip then
// the next pageLang read whether to light up for this new page. // re-decides on the next pageLang read whether to light up — and auto-
// translate gets one more shot on the new page.
wc.on("did-start-navigation", (_e, _url, _ihr, isMainFrame) => { wc.on("did-start-navigation", (_e, _url, _ihr, isMainFrame) => {
if (!isMainFrame) return; if (!isMainFrame) return;
tab._tr = null; tab.pageLang = ""; tab._tr = null; tab.pageLang = "";
@ -3743,13 +3744,30 @@ function createTab(initial, opts = {}) {
}); });
// After the page has committed, read <html lang> once so the chip knows // After the page has committed, read <html lang> once so the chip knows
// what language the server actually served (which may not match whatever // what language the server actually served (which may not match whatever
// we asked for via Accept-Language). // we asked for via Accept-Language). If auto-translate is on and the page
// is in a language different from the user's, both of them supported, fire
// the translation now so the user reads the page in their own language
// without clicking anything.
wc.on("did-finish-load", async () => { wc.on("did-finish-load", async () => {
try { try {
const lang = await wc.executeJavaScript(`document.documentElement.lang || ""`); const lang = await wc.executeJavaScript(`document.documentElement.lang || ""`);
tab.pageLang = String(lang || "").toLowerCase().split("-")[0]; tab.pageLang = String(lang || "").toLowerCase().split("-")[0];
} catch { tab.pageLang = ""; } } catch { tab.pageLang = ""; }
if (tab.id === activeId) emitTranslateState(tab); if (tab.id === activeId) emitTranslateState(tab);
try {
const target = translationTargetBase();
if (settings.translateAutoOffer &&
tab.pageLang && tab.pageLang !== target &&
isTranslatorSupported(tab.pageLang) && isTranslatorSupported(target) &&
!tabTranslateState(tab).translated &&
!tabTranslateState(tab).autoTried) {
tabTranslateState(tab).autoTried = true;
// Only auto-translate when this is the active tab, to spare a cold
// backend the N parallel requests of a many-tab restore; a background
// tab gets translated when the user focuses it (same hook, next load).
if (tab.id === activeId) await translateActiveTab();
}
} catch (e) { console.warn("[translate] auto failed:", e?.message); }
}); });
// Ctrl+wheel / pinch: Chromium only reports the intent on Windows and // Ctrl+wheel / pinch: Chromium only reports the intent on Windows and
// Linux, the zoom itself is up to us. // Linux, the zoom itself is up to us.
@ -4558,6 +4576,16 @@ const WEBSITE_LANGUAGE_QUICK = [
{ tag: "he-IL", label: "עברית" }, { tag: "he-IL", label: "עברית" },
{ tag: "zh-TW", label: "中文(繁體)" }, { tag: "zh-TW", label: "中文(繁體)" },
]; ];
// Base BCP-47 codes the translator backend (silentmode.st/libre) currently has
// models for. The picker shows every entry in WEBSITE_LANGUAGE_QUICK but
// greys out the ones not on this list — a user who picks an unsupported one
// still gets its Accept-Language sent, but the translator stays dark on
// pages served in another language because there's no model path from the
// server's language to theirs. Grow this when the backend --load-only grows.
const TRANSLATOR_SUPPORTED = new Set(["en", "es", "fr", "de", "el", "ru"]);
function isTranslatorSupported(tag) {
return TRANSLATOR_SUPPORTED.has(String(tag || "").split("-")[0].toLowerCase());
}
function setWebsiteLanguage(mode, value) { function setWebsiteLanguage(mode, value) {
const before = { mode: settings.languageMode, value: settings.languageValue }; const before = { mode: settings.languageMode, value: settings.languageValue };
if (mode === "show") { if (mode === "show") {
@ -4817,16 +4845,46 @@ ipcMain.handle("website-language-menu-popup", (e, rect) => {
const osLoc = app.getLocale() || "en-US"; const osLoc = app.getLocale() || "en-US";
const isAuto = settings.languageMode === "show"; const isAuto = settings.languageMode === "show";
const current = isAuto ? null : (settings.languageValue || "").toLowerCase(); const current = isAuto ? null : (settings.languageValue || "").toLowerCase();
// Translate action goes at the top when the active page can actually be
// translated: a web tab, with the page's declared language different from
// the user's target, both languages on the translator backend. The two
// chips (globe + translate) merged into one — the globe menu covers both.
const t = activeTab();
const canTranslate = t && !t.settings && !t.addonId && !t.pending && t.pageLang &&
t.pageLang !== translationTargetBase() &&
isTranslatorSupported(t.pageLang) && isTranslatorSupported(translationTargetBase());
const st = t ? tabTranslateState(t) : null;
const translateItems = [];
if (st?.translated) {
translateItems.push(
{ label: `Show original (${languageNameFor(st.source || t.pageLang || "auto")})`, click: () => revertActiveTab() },
{ type: "separator" },
);
} else if (canTranslate) {
translateItems.push(
{ label: `Translate this page from ${languageNameFor(t.pageLang)} to ${languageNameFor(translationTargetBase())}`, click: () => translateActiveTab() },
{ type: "separator" },
);
}
// Languages the translator can actually handle appear enabled; the rest
// show but are greyed out with a "coming later" hint so the user knows the
// picker will grow, and that picking one today only sets Accept-Language.
const template = [ const template = [
...translateItems,
{ label: `Automatic (${languageNameFor(osLoc)})${isAuto ? " ✓" : ""}`, { label: `Automatic (${languageNameFor(osLoc)})${isAuto ? " ✓" : ""}`,
click: () => setWebsiteLanguage("show") }, click: () => setWebsiteLanguage("show") },
{ type: "separator" }, { type: "separator" },
...WEBSITE_LANGUAGE_QUICK.map((L) => ({ ...WEBSITE_LANGUAGE_QUICK.map((L) => {
label: `${L.label}${current === L.tag.toLowerCase() ? " ✓" : ""}`, const supported = isTranslatorSupported(L.tag);
const check = current === L.tag.toLowerCase() ? " ✓" : "";
return {
label: supported ? `${L.label}${check}` : `${L.label} — translator coming later${check}`,
enabled: supported,
click: () => setWebsiteLanguage("manual", L.tag), click: () => setWebsiteLanguage("manual", L.tag),
})), };
}),
{ type: "separator" }, { type: "separator" },
{ label: "More languages…", click: () => { try { openSettingsTab("general"); } catch {} } }, { label: "More languages…", click: () => { try { openSettingsTab("language"); } catch {} } },
]; ];
const popup = Menu.buildFromTemplate(template); const popup = Menu.buildFromTemplate(template);
const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 }; const chromeBounds = chrome ? chrome.getBounds() : { x: 0, y: 0 };

View file

@ -373,7 +373,7 @@
<h2 class="sub">Page translation</h2> <h2 class="sub">Page translation</h2>
<p class="subd">When the page's declared language is different from yours, Theseus can translate its visible text in place. The source stays untouched — click the chip again to revert.</p> <p class="subd">When the page's declared language is different from yours, Theseus can translate its visible text in place. The source stays untouched — click the chip again to revert.</p>
<div class="row"> <div class="row">
<div class="txt"><div class="t">Offer to translate</div><div class="d">Light up a translate chip in the address bar when the page's language differs from yours.</div></div> <div class="txt"><div class="t">Translate automatically</div><div class="d">When the page's declared language is different from yours (and both are supported by the translator), Theseus translates it in place as soon as it loads. Turn this off to leave pages in their original language — the globe chip's menu still offers a one-click translation.</div></div>
<label class="sw"><input type="checkbox" id="translateAutoOffer"><span class="track"><span class="knob"></span></span></label> <label class="sw"><input type="checkbox" id="translateAutoOffer"><span class="track"><span class="knob"></span></span></label>
</div> </div>
<div class="row" style="flex-direction:column;align-items:stretch;gap:8px"> <div class="row" style="flex-direction:column;align-items:stretch;gap:8px">
@ -1174,12 +1174,22 @@
if (!webLangPick) return; if (!webLangPick) return;
let osLoc = "en-US"; let osLoc = "en-US";
try { osLoc = (C.systemLocale && await C.systemLocale()) || "en-US"; } catch {} try { osLoc = (C.systemLocale && await C.systemLocale()) || "en-US"; } catch {}
// Build options: "Automatic" first, then the quick list, then "Other…". // Supported-by-the-translator languages come enabled; the rest are
// No BCP-47 tag in visible labels — the tag surfaces only as the 2-letter // shown but disabled with a "— coming later" suffix, so the picker is
// chip in the URL bar. // a roadmap, not a lie — the user sees which languages the chip will
// actually translate pages into today. "Other…" is still here for a
// user who needs to pin an unlisted BCP-47 tag for Accept-Language
// alone, but it's marked so they know translation won't follow.
const SUPPORTED = new Set(["en", "es", "fr", "de", "el", "ru"]);
const baseOf = (t) => String(t || "").split("-")[0].toLowerCase();
const opts = [`<option value="__auto__">Automatic (${langNameFor(osLoc)})</option>`, const opts = [`<option value="__auto__">Automatic (${langNameFor(osLoc)})</option>`,
...WEB_LANG_QUICK.map((L) => `<option value="${L.tag}">${L.label}</option>`), ...WEB_LANG_QUICK.map((L) => {
`<option value="__other__">Other…</option>`]; const supported = SUPPORTED.has(baseOf(L.tag));
return supported
? `<option value="${L.tag}">${L.label}</option>`
: `<option value="${L.tag}" disabled>${L.label} — translator coming later</option>`;
}),
`<option value="__other__">Other… (Accept-Language only, no translation)</option>`];
webLangPick.innerHTML = opts.join(""); webLangPick.innerHTML = opts.join("");
const paint = (cur) => { const paint = (cur) => {
const mode = cur.languageMode || "show"; const mode = cur.languageMode || "show";