Commit Graph

1102 Commits

Author SHA1 Message Date
github-actions[bot] 3cad09bbf2 chore(release): version packages (#2113) v1.46.5 2026-08-25 00:24:11 -07:00
MengXi 984894c1ad docs(subtitles): point the subtitle CSS editor at its own tutorial (#2126)
The "How to configure?" link in the subtitle custom CSS editor opened the page-translation
stylesheet guide. That page documents `[data-read-frog-custom-translation-style]` and the
translated-content wrapper, neither of which exists inside the subtitles shadow root, so a
reader following the link found selectors that match nothing here.

It now opens `/docs/subtitle-custom-css`, which documents the three class hooks the overlay
actually exposes, the preset templates, and the two properties the style controls write
inline.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 00:22:04 -07:00
ananaBMaster d0fc8e57fa fix: custom css editor page (#2125)
* feat(subtitles): add custom CSS and preset templates to the subtitle style page

The subtitle style page could only set fonts, sizes, weights and colours, so
effects like blurring the translation while training your listening had nowhere
to live. A custom CSS row at the bottom of that page now opens an editor with
the same live preview above it, plus a preset dropdown carrying three ready-made
templates — blur translation, dashed translation, and dim original — that append
into the editor so they can be stacked.

The picked font, size, weight and colour now reach the subtitle lines as inline
CSS variables consumed by an unlayered base rule, rather than as inline styles.
Inline styles outrank every stylesheet rule, so without this a user writing
`.subtitles-main { color: red }` would get nothing and have to discover
`!important` on their own.

The preview renders in a shadow root, the same way the real overlay does. That
is what contains the CSS being previewed: a rewriting pass over the selectors
leaks through at-rules that carry none, cannot stop `@keyframes` names from
hijacking the settings page's own animations, and adds specificity production
never adds. Layout is the one thing a shadow root does not contain, so the
light-DOM wrapper carries the containment for `position: fixed` and runaway
heights.

Config gains `videoSubtitles.style.customCSS`, with the v099 to v100 migration
and fixtures that go with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(translation): stop custom CSS from breaking the settings page it is previewed on

The custom CSS written for translated text was injected into the options page's
own document to render the preview, so it styled the settings UI as well. A rule
as ordinary as `* { display: none }` left the options page blank on every load —
editor and sidebar included — and because the CSS is saved in config it came
back on the next load too, with no way out through the UI.

The preview now renders inside a same-origin `srcdoc` frame. A frame is the only
container that is also a Document, which is what production injects into, so
unlike a shadow root it agrees with a real page on `:root` variables, `body`
selectors, `@font-face` and `@property` — a shadow root would hide the first
four and answer to `:host`, which production never does. A bounded-height frame
is also its own viewport, so `position: fixed` and runaway heights stay inside
it without any containment tricks.

Two supporting changes, both no-ops on a real page: `decorateTranslationNode`
resolves the node's own document rather than the ambient one, and the injector's
Document check uses `nodeType` because an iframe's Document fails `instanceof`
when tested from the parent realm.

The frame follows the extension's theme and reads its colours from the same
`--rf-*` tokens as the page around it, and the sample now inherits an ordinary
page's 16px rather than the settings page's 14px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(translation): apply the removal of custom CSS without needing a reload

Switching translated text back to a preset style, or emptying the custom CSS
box, only changed the marker attribute on the node — the stylesheet itself
stayed adopted until the page was reloaded. Anything the preset does not set
kept the old styling: the `border` preset sets no colour, so a cleared `color`
rule went on applying.

A reload eventually hid this on a real page. The options preview never reloads,
so there it reads as deleting the CSS having done nothing at all.

The withdrawal is guarded, so a root that never had custom CSS does not acquire
an empty stylesheet just for being styled by a preset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 23:12:38 -07:00
taiiiyang fe2957c84c fix(subtitles): initialize YouTube subtitles on /live/ URLs (#2108)
* fix(subtitles): initialize YouTube subtitles on /live/ URLs

The translate button never appeared on youtube.com/live/<id> pages
because initialization only matched youtube.com/watch, embed, and
shorts URLs. Live VOD pages use the standard watch player, so treat
them as watch mode.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: add changeset

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-23 16:10:39 +08:00
MengXi 68ec81c2e3 Merge pull request #2112 from mengxi-ream/claude/migration-provider-description-blank-55b0a7 2026-08-21 21:38:48 -07:00
ananaBMaster a7647583a1 fix(providers): fill in the missing description for providers seeded by a config migration
A provider's `description` is not a stored default. It is resolved from
`options.apiProviders.providers.description.<type>` at the moment the provider
is created and then persisted, which is why fresh installs and providers added
from the options page have one.

A migration cannot do that. It may not import the i18n facade (frozen-snapshot
rule), and `background/index.ts` awaits `ensureInitializedConfig()` — which runs
the migrations — before calling `initI18n()`, so i18next is uninitialized at that
point and `i18n.t` returns the raw key rather than a translation.

So the Jalapeno Cloud provider that v098 seeded for existing users reached them
with an empty description box, while new installs saw its blurb. The same is true
of anything a user added before descriptions were persisted at all.

v099 fills in the English description for every API provider that has none,
hardcoded inline like the rest of the frozen snapshot. Translating it would mean
carrying all nine locales plus a copy of the "auto" locale resolution in the
migration and freezing that copy the day the file is written; the field stays
editable, so a user who wants their own wording has it.

- Treat an empty description as no description — the field is an override, and a
  provider showing nothing is the state this repairs however it got there
- Never overwrite a description that is already there, so re-running is a no-op
- Add a checklist line to the migration-scripts skill: a migration that seeds a
  record must carry every field the app resolves at creation time, because
  nothing backfills it afterwards

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:15:28 -07:00
github-actions[bot] 3f73393070 chore(release): version packages (#2110) v1.46.4 2026-08-20 23:22:04 -07:00
taiiiyang df8f243763 feat(subtitles): use the subtitles-AI icon and add an AI transcription tooltip (#2106)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 23:20:44 -07:00
ananaBMaster 59e155ed97 fix(translate): stop the giant-paragraph split from stranding a container's own text (#2109)
Tall containers are split into their descendant paragraphs so viewport-lazy
translation still applies (#1881). That split silently drops any text the
container holds directly. Harmless on a nested <article> — docs.docker.com's
203k-px <article> owns 0 chars of direct text — but inverted on <br>-delimited
article bodies, where the bare text IS the article and the inline fragments are
the strays:

  strobist.blogspot.com   6,580 chars -> 527 covered (8%)
  paulgraham.com          66,529 chars -> 723 covered (1.1%)

The same defect produced the second symptom: a mid-sentence <i> became its own
observed unit, so its translation was inserted in the middle of the sentence.

The guard rests on a property of the labeling walk: any non-whitespace text
node's immediate parent is itself labeled a paragraph, so a text node inside a
giant can only be stranded when its parent IS the giant. One loop over
childNodes is therefore complete, and structurally cannot see <script> bodies,
hidden subtrees or our own wrappers.

- Refuse the split when the container owns prose and has a block-level child,
  so translateWalkedElement's run path re-segments it instead
- Keep splitting when it owns no prose, when its own text carries no letters
  (separators, dates), when it has no block child to re-segment on (refusing
  there would ship the whole container as one request), when it is <body>, and
  above a unit-count cap that keeps #1881's gating guarantee
- Apply the same guard to the translate side's giant fallback, so both paths
  make one decision

Verified with the built extension in Chrome: strobist 11 -> 41 wrappers with no
mid-sentence insertion, paulgraham 236 wrappers all translated, and
docs.docker.com still gated (80 wrappers before scrolling, 583 after, of 5,376
paragraphs).

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:17:59 -07:00
MengXi b022eff7dd fix(translation): explain stale extension context instead of the raw error (#2107)
Chrome destroys the old extension instance on update, reload or reinstall,
but it does not re-inject content scripts into already-open tabs. Those
scripts keep running against a dead `chrome.runtime`, so the next message to
the background rejects with "Extension context invalidated." — which we
surfaced verbatim in the selection popover and the page-translation error
card.

Nothing in the tab detects this on its own: WXT's `ctx.isInvalid` is a lazy
getter, so `ctx.onInvalidated` only fires when something reads it or when a
newer content script announces itself, and a store update does neither. The
UI stays mounted and clickable, and its retry button can never recover the
channel — only a page reload can.

Detect the dead context and show a localized "the extension was updated,
refresh this page" hint instead. "Receiving end does not exist" is left
alone: a cold background worker is a different, self-healing failure that
does not need a reload.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 13:17:47 -07:00
github-actions[bot] 61b3e76c36 chore(release): version packages (#2105)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v1.46.3
2026-08-18 10:53:25 -07:00
ananaBMaster 34349b892b feat(providers): prioritize GLM 5.2 for Jalapeno Cloud (#2104) 2026-08-18 10:46:57 -07:00
github-actions[bot] 20fed2fba2 chore(release): version packages (#2093) v1.46.2 2026-08-17 22:49:13 -07:00
ananaBMaster fac49a135a feat(account): show the account's plan beside its name (#2102)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 22:48:13 -07:00
ananaBMaster 6043b16e8d chore: update deps (#2098) 2026-08-17 18:20:10 -07:00
ananaBMaster 3c32b98ae6 fix(options): recognize configured LLM providers for language detection (#2097) 2026-08-17 16:35:47 -07:00
ananaBMaster 73ac569add fix(options): show one success notification after exporting config (#2096) 2026-08-17 16:31:45 -07:00
ananaBMaster 208397b797 fix(hosted-ai): expose language detection in Built-in AI feature assignments (#2095) 2026-08-17 14:22:07 -07:00
ananaBMaster aea6f604ed fix(popup): allow Prompt selection with Built-in AI (#2094) 2026-08-17 14:21:57 -07:00
ananaBMaster f4efced764 fix(i18n): follow configured UI language for What's New (#2092) 2026-08-17 11:30:04 -07:00
dependabot[bot] af3d5ae07e chore: bump the ai-sdk group across 1 directory with 24 updates (#2088) 2026-08-16 22:42:55 -07:00
github-actions[bot] 258d869fdb chore(release): version packages (#2082) v1.46.1 2026-08-15 21:12:44 -07:00
ananaBMaster c2b528b176 fix: webpage translation issue 0b9658 (#2087)
* fix(translation): translate plain-text documents rendered as one <pre>

A text/plain URL reaches the page as a single browser-generated <pre>
wrapping the entire file — Chrome also gives it `white-space: pre-wrap`.
Because PRE is in DONT_WALK_AND_TRANSLATE_TAGS, the walk stopped at that
one element and the page labeled zero paragraphs, so nifty.org story
pages (prose served as text/plain) translated as nothing at all.

Exempt PRE from the tag block when `ownerDocument.contentType` is exactly
`text/plain`. An authored <pre> inside an HTML document still holds code,
logs or ASCII art and stays blocked, and JSON/markdown/XML viewers stay
blocked too. `isDontWalkIntoAndDontTranslateAsChildElement` is the single
place the tag set is read — traversal, the mutation walkability cache,
extractTextContent, unwrapDeepestOnlyHTMLChild and the virtual-paragraph
plan's collectRawSource all route through it — so one exemption covers
every consumer.

PRE is also in FORCE_BLOCK_TAGS, so an exempted viewer labels as a block
paragraph, and its preserved white-space feeds the existing bilingual
virtual-paragraph plan: a story now translates one blank-line paragraph
at a time instead of as one giant request.

The exemption un-blocks what the defaults block, so rule authors keep the
last word: `dontWalkTags.add: ["PRE"]` now wins over it (resolve tracks
explicit adds separately from the merged set), and `excludeSelectors`
remains available either way.

Known gap, unchanged here: translationOnly has no virtual-paragraph plan,
so it still sends such a page as one request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(translate): resolve virtual paragraphs onto whole child nodes

Bilingual mode consumes a virtual paragraph plan by inserting a wrapper at
each unit's boundary, so a unit may end anywhere — mid-Text, inside a
nested span. translationOnly swaps text in place instead, which needs the
opposite shape: each unit as its own run of whole child nodes, so the
ordinary per-run pipeline (attribute protection, alignment, in-place swap,
restore records) can handle one paragraph at a time.

resolveVirtualParagraphUnitEdges decides whether a plan has that shape. A
unit qualifies when both content edges, after the existing edge lifting,
land between two children or strictly inside a top-level Text node that a
splitText can cut. An edge stranded inside a nested element does not
qualify: X puts tweet text in inline spans, so a note tweet's blank lines
sit inside a span and cutting the unit out would split that element and
its styling apart. A unit whose node range holds a textless element (a
twemoji img) is refused too, since shipping that markup would make
alignment depend on the provider echoing the tag back.

materializeVirtualParagraphUnitRuns applies it, cutting only top-level Text
nodes and only at unit edges — the blank lines between units keep their own
nodes. Cuts run backwards through the container, and within a unit the end
edge is cut before the start edge, so every offset computed against the
pre-split DOM is still valid when its turn comes. Node references are
captured before the first cut, since a cut shifts later child indices.
Cuts are recorded as TextSplitRecords (the same shape wrapper insertion
produces, rejoined by restoreTextSplit) and marked extension-driven so the
mutation pipeline does not read our own split as a host edit.

Both live in the plan builder's own modules, so the observation gate and
the translate path can consult one decision and cannot drift. No caller
yet: this only adds the primitives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(translate): let a translationOnly anchor own its virtual generation

Preparation for per-paragraph translationOnly: the container's anchor state
gains the two things a wrapper-less virtual generation needs.

`splitRecords` holds the Text cuts that turned blank-line paragraphs into
whole-node runs, so the anchor that owns the swaps also owns the cuts.
Finalizing rejoins them, and only there — restoreTextSplit compares the
rejoined value against the original, so translated text still in place
would make it refuse. Restoring the swaps first and rejoining second is
therefore the only order that works.

`virtualGeneration` keeps the anchor alive mid-generation. A unit that is
a single element unwraps into that element and registers its record there,
so the container's own swap list is legitimately empty between units;
without the pin the first finalize would rejoin the cuts under the feet of
units still awaiting a provider. It also lets a late response tell whether
its generation is still current.

teardownVirtualTranslationOnlyGeneration undoes a whole generation in the
one order that holds: wrappers first (a wrapper between a cut source and
its tail blocks the rejoin, and the fallback strategy parks original nodes
inside one), then descendant anchors, then the container itself. A
page-wide cleanup needs no changes — it already visits anchors by marker,
and materialization claims the marker up front.

ensureTranslationOnlyAnchorState is applyInPlaceTextSwap's own anchor
registration, extracted so the virtual path can claim the anchor when it
cuts, before any request is sent. Behavior for the existing swap path is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(translate): split translationOnly into a dispatcher and a run

translateNodeTranslationOnlyMode did two jobs: decide what one translation
request covers, and carry it out. Per-paragraph translation needs the first
decision to change while the second stays exactly as it is, so separate
them now, while the split is provably behavior-preserving.

The body moves verbatim to a private translateTranslationOnlyRun; the
exported name becomes the dispatcher and currently just forwards. Its two
internal retries call the run directly: they retry a run whose granularity
was already decided, and re-deciding mid-retry is the observation/translate
drift the virtual-paragraph design exists to avoid.

The run also takes an `isCurrent` predicate, defaulting to the current
always-true behavior, and consults it where the existing in-flight guards
live: the spinner's own check and both batched DOM applies. A caller that
owns several runs can then invalidate the ones still awaiting a provider —
a wrapper surviving the round trip is not proof the run should still land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(translate): translate one paragraph at a time in translationOnly

A plain-text page reaches translationOnly mode as one container holding the
whole file, so the whole file went out as a single request — past provider
length limits, with no partial progress and nothing to show until the last
paragraph came back. Bilingual mode has segmented such containers by blank
line since #1881; this brings translationOnly to the same granularity.

The dispatcher plans the container, and when every unit maps onto whole
child nodes it cuts them apart and hands each unit to the ordinary run
path. Everything a run already does then applies per paragraph: its own
request and cache entry, its own small-paragraph and target-language
filtering, its own spinner and error UI, its own restore record. A failing
paragraph now costs one paragraph.

Containers that cannot be segmented keep today's behavior, with one
exception that matters: a giant one is translated per labeled paragraph
instead of as a single run. The observer only hands a giant over whole
because the materializability predicate said its units were separable, so
if the host reshaped the DOM in between, falling back to one run would
ship a 22k-char note tweet in one payload and, on a failed alignment,
displace every framework-owned node inside it. Per-paragraph translation
reproduces what per-paragraph observation would have done.

A live generation is torn down and rebuilt rather than patched, so a plan
is never built over half-restored text — that would read our own
translations as source. Toggling off hands the container back with its
cuts rejoined; a container where nothing landed (every unit filtered, or
every translation equal to its source) is left byte-identical. The
generation ends through the same batch its units apply in, since running
before them would read an empty anchor and undo the generation just as its
translations arrive.

Unit requests keep the existing HTML format rather than switching to the
plain one bilingual's virtual units use: it preserves in-place alignment
for units that contain markup, and for a hard-wrapped story the collapsed
line breaks are the desirable outcome — preserving them would translate a
paragraph line by line, severing sentences at the source's wrap column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(translate): let translationOnly observe segmentable containers whole

The giant-paragraph split guard was bilingual-only, on the grounds that
translationOnly had no virtual-paragraph plan to segment a container with.
It has one now, so the mode condition moves into the guard itself, where
the plan that decides is already built.

The two modes need different things from the same plan. Bilingual can
interleave a wrapper at any boundary, so any container the plan segments
is better observed whole. translationOnly segments by cutting units apart
into whole child nodes, which a plan whose blank lines sit inside an inline
element cannot express — the X note tweet — and observing such a container
whole would cost it the per-span granularity and viewport gating it has
today while gaining nothing. So translationOnly additionally requires the
units to be materializable, decided by the predicate its translate path
uses, keeping observation and translation from drifting apart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changeset): add changeset for plain-text page translation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(translate): keep virtual translationOnly toggles and cuts safe

Two defects found by an adversarial review of the finished diff, both
introduced by the per-paragraph translationOnly work and both reproduced
against base before fixing.

A generation whose units are whole elements never cuts a Text node, and
each unit registers its swap on the element it unwrapped into, so neither
the container's swaps nor its split records recorded that the container
was segmented at all. The generation marker was the only sign left, and it
was being cleared as soon as the units settled. A later toggle therefore
read the container as untouched, fell through to the single-run path, and
translated its own output: "show original" did nothing, the spans stayed
swapped, and a provider request was spent on translated text.

The marker now lives until a full restore ends the generation, and that
release moves into restoreTranslationOnlySwapsForAnchor, where both
deliberate endings already pass — a toggle and a page-wide cleanup. The
paths that reach the anchor incidentally, like a sibling unit dropping its
records on the way to the fallback wrapper, keep finding the generation
live and leave the cuts alone.

The second defect could delete page content. The fallback strategy detaches
a unit's original nodes into its wrapper's registry, and the first unit's
node is the source every later unit was cut from. restoreTextSplit reads a
detached source as a framework rewrite and removes every tail still holding
its post-split value — correct for that case, but here those tails are the
container's remaining paragraphs. Finalize now skips a cut whose source has
left the document: adjacent Text nodes are invisible, deleting the rest of
the page is not.

Also stop re-reading targetNode.parentElement after the restore-first pass,
which throws when that pass just detached the node; the parent captured and
null-checked earlier is what the lookup wants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(translate): pin page-wide cleanup against a live virtual generation

The existing cleanup test cleared the generation marker by hand before
stopping the page, so nothing covered the case a reviewer asked about:
stopping translation (or switching modes) while a unit is still awaiting
its provider. Drop the manual clear so the generation stays live, and add
the harsher variant where no unit ever resolved and the container holds
only its marker and its cuts.

Both fail against the cleanup code as it stood before the generation
release moved into restoreTranslationOnlySwapsForAnchor: the container kept
its marker, its dir/lang and its split text nodes, the next walk skipped
the region, and a hung request meant nothing would ever release it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 19:01:30 -07:00
ananaBMaster eb04c6da21 fix(floating-button): hide the floating button while the page is fullscreen (#2084)
Bilibili and X fullscreen the player element, so the browser never paints our
body-anchored shadow host and the frog disappears for free. YouTube fullscreens
`document.documentElement` instead — the whole document keeps painting, so the
button stayed parked on top of the video.

Track `fullscreenchange` and take the existing early return, which unmounts the
subtree rather than hiding it, so nothing can paint over a fullscreen player on
any site.

Cancel an in-flight drag on the way in: the button's node disappears, so its
pointerup never lands and the body would otherwise keep `cursor: grabbing` and
`user-select: none` after fullscreen exits.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:15:44 -07:00
MengXi 550ea7588b fix(translation): translate pages whose document root opts out with notranslate (#2085)
Telegram Web A ships `<html translate="no" class="notranslate">`; Telegram
Web K ships a bare `<html lang="en">`. Since #1992 moved the page-translation
walk root from <body> to documentElement, that one class aborted the walk on
its very first blocked-element check, so full-page translation labeled zero
paragraphs on web.telegram.org/a/ while /k/ kept working. Measured on a live
Telegram Web A chat: 0 paragraphs and 0 block nodes before, 240 and 1455 with
the class removed.

`isDontWalkIntoButTranslateAsChildElement` means "don't descend, but let the
parent translate this as one inline chunk" — <html> has no parent to fold that
text into, so blocking it drops the whole document instead of merging it.
Exempt the document root from the class rule; nested notranslate elements
(read frog's own injected UI included) still block descent. This mirrors the
existing decision to ignore the `translate="no"` attribute (#459), and it is
not reachable from site rules: the class check is hard-coded, which is why the
telegram rule's `excludeSelectors.remove` of `.notranslate` never helped.

Also extend the telegram site rule for Web A's DOM, which shares the rule with
Web K but is a different codebase (telegram-tt vs tweb):

- include `.message-content-wrapper .Button`, Web A's inline keyboard buttons
  (Web K's `.reply-markup-button-text` matches nothing there)
- exclude `.MessageMeta`, which wraps Web A's message timestamp and view count
  inside `.text-content` (Web K excludes the equivalent via `.time`)

Only stable class names are used: half of Web A's classes are CSS-module
hashes that change every Telegram release.

Co-authored-by: ananaBMaster <68643891+ananaBMaster@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 13:14:40 -07:00
ananaBMaster 77e02edaf3 feat(providers): add a Get API key button for Atlas Cloud and Jalapeno Cloud (#2083)
Providers opt in through a new `apiKeyUrl` on PROVIDER_ITEMS, so only the two
sponsors that have a landing page we can link into get the button. It sits
right beside the API key label via a new `labelAfter` slot — outside the
`<label>` element, since a nested anchor is invalid markup and would swallow
the label's click — leaving Test connection where it was.

Also gives the Jalapeno sponsor CTA a leading verb in every locale.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:22:38 -07:00
ananaBMaster b0b87dc30e feat: jalapeno cloud integration (#2081) 2026-08-14 22:05:01 -07:00
github-actions[bot] 76298f6233 chore(release): version packages (#2058) v1.46.0 2026-08-14 17:24:11 -07:00
MengXi 1590dfd17e feat(subtitles): take AI subtitles out of beta behind a Pro/Ultra minute quota (#2072)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:23:07 -07:00
ananaBMaster e80a0e525c ci: migrate workflows to pnpm setup (#2074) 2026-08-14 16:54:47 -07:00
MengXi fa9201bbf6 fix(translation): translate YouTube watch titles through hover translation (#2071) 2026-08-14 16:54:35 -07:00
ananaBMaster 8c7751c40a Revert "feat(subtitles): point the AI subtitles access gate at the pricing pa…" (#2076)
This reverts commit 5b9a5775df.
2026-08-14 16:20:32 -07:00
taiiiyang 5b9a5775df feat(subtitles): point the AI subtitles access gate at the pricing page (#2061) 2026-08-14 16:17:48 -07:00
ananaBMaster 10a35a7791 feat(note-suggestions): choose which suggested words to save (#2066) 2026-08-12 12:22:23 -07:00
MengXi 800184fec0 feat(hosted-ai): run subtitles, input translation and language detection on Built-in AI (#2065) 2026-08-11 23:17:37 -07:00
MengXi 0e831f8245 feat(hosted-ai): run selection translation and note suggestions on Built-in AI (#2064)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 19:55:37 -07:00
taiiiyang 1034a9a9b1 fix(subtitles): scale prefetch look-ahead by playback rate (#2054)
Subtitle translation and AI segmentation prefetch used fixed media-time
look-ahead windows (30s / 60s) that never read video.playbackRate. At
2x/3x the same media-time buffer drains in half/third the wall-clock
time, so the pipeline cannot keep up and the active cue intermittently
sits in "loading".

Scale both look-ahead windows by the effective playback rate
(clamped to [1, 4]) so the wall-clock prefetch budget is restored at
higher speeds. 1x and slow playback are unchanged. Existing cue dedup
guards keep the wider window from re-translating cues.

Closes #1713

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-10 21:45:38 +08:00
MengXi c7f453588a feat(hosted-ai): add Built-in AI Normal and Ultra tiers with quota UI (#2059)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 22:23:15 -07:00
ananaBMaster 8486f478ee fix(background): scope auth cache invalidation and stop the config-backup wake loop (#2057)
Two independent background-worker issues found while tracing why
/api/identity/get-session accounts for ~75-88% of API request volume.

Auth cookie handling: every auth-cookie change cleared *all* proxy-fetch
cache groups, and the before/after comparison meant to suppress no-op
changes read the cookie store after the change had landed, so it always
compared the new value against itself. A single session refresh therefore
wiped the auth and blog-fetch caches and fired two events (the "overwrite"
removal plus the add), each triggering another full clear. Skip the
overwrite half, track the last-seen value in session storage so an
unchanged token is a no-op, and clear only the auth group.

Config backup alarm: the alarm was re-created unconditionally on every
service-worker start with delayInMinutes 1, so each start rescheduled a
wake one minute out, which restarted the worker and rescheduled again.
Guard on alarms.get like db-cleanup.ts does, and use the backup interval
for the initial delay.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:08:28 -07:00
github-actions[bot] 07cfb3097f chore(release): version packages (#2051)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v1.45.3
2026-08-08 19:45:42 -07:00
ananaBMaster 8fa9f26eb4 fix(translate): stop the no-translation marker from dropping translatable paragraphs (#2053)
Paragraphs went missing from page translations, most visibly with DeepSeek
models. Two code paths turn "the model declined to translate this" into
rendering nothing: isNoTranslationSentinel maps {{NO_TRANSLATION_NEEDED}} to "",
and getDisplayTranslation maps a translation equal to its source to "". The
prompt was over-triggering the first.

The batch format example demonstrated the marker in one of its three output
slots, and models imitated that as a base rate — emitting it for roughly one
paragraph in three regardless of language. The rule also said the marker applies
when a paragraph "is already entirely written in X and needs no translation",
and models read the second conjunct as an independent trigger for anything they
judged untranslatable.

The fix is a net deletion. The page pipeline now uses the same plain
DEFAULT_BATCH_TRANSLATE_PROMPT the subtitles pipeline already used, and the
marker rule is one line stating a pure language test. The assembled batch system
prompt goes from 2106 to 1888 characters — shorter than before the bug existed.

Measured on 120 real paragraphs from react.dev, MDN, Wikipedia, vitejs and
arXiv, hand-labelled for whether a translation is actually owed (identifiers and
code excluded, leaving 107), 4 runs x 8 models: 7.9% of owed paragraphs rendered
nothing, now 4.7%. deepseek-v4-pro goes from 18.0% to 1.6%. The difference is
significant (z = -5.5) while two longer variants that add text back are not
distinguishable from this one, so the shortest wording wins; the doc comment
records why each of them was rejected.

Suppression of paragraphs already in the target language is unchanged at 97-99%,
since the equality check catches them independently.

Also fixes the subtitles guard test, which never passed isBatch and so asserted
against a prompt that had no batch block at all.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 19:43:23 -07:00
ananaBMaster 6ce4f1331e fix: intermittent scroll bug (#2052) 2026-08-07 16:41:11 -07:00
ananaBMaster c9d98221bd feat(translation-hub): open the Translation Hub with a keyboard shortcut (#2050)
The hub is only reachable from the popup icon and the options sidebar, so
people who use it as their free-form translation input have to dig for it
every time.

Adds a configurable hotkey (default Alt+Shift+H) bound in the host content
script alongside the existing translation shortcuts, which asks the
background to open the hub through the same `openPage` route the popup
button and sidebar link already use.

- New `translationHub.shortcut` config key, validated by the shared
  shortcut schema; migration v093 -> v094 backfills it and falls back to
  Alt+Shift+U when the primary key is already bound elsewhere.
- New row on the Shortcuts settings page, registered in the command
  palette's search items and translated into all 9 locales.
- The popup hub button's tooltip now shows the key hint, matching the
  translate button.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:40:53 -07:00
MengXi 19df7c2389 fix(translate): keep bilingual translations beside tall floats instead of stranding them below (#2047)
A block translation renders as `display: inline-block` so its decoration hugs
the text. That makes it an atomic inline: when a float leaves the line too
narrow, the browser drops the whole box below the float instead of wrapping
text beside it. Against a tall float the translation is stranded that far
below its own paragraph, stretching the paragraph into a page-tall blank gap.

#1188 added a `data-read-frog-float-wrap` override for this, but decided it by
scanning the paragraph's DOM siblings for a floated element. A float only has
to share a block formatting context to intrude — it does not have to be near
the paragraph in the tree. On ja.wikipedia the body is
`div.mw-parser-output > section > section > p` while the infobox floats out of
the outer section's sibling, so the sibling scan never saw it and every
paragraph below the lead section was left displaced. Measured on the 毛沢東
article: the 生い立ち translation landed 5643px below its paragraph.

Decide by measuring the rendered result instead. A Range spanning everything
before the wrapper gives the source text's last-line bottom; if the translated
node sits more than `margin-top + line-height` below it, the box was displaced.
That is structure-agnostic, so ancestor and cousin floats are covered too, and
it costs two rect reads instead of a per-sibling subtree scan.

`flowSource` existed only to locate the paragraph for the old scan, so its
plumbing is removed.

Verified on ja.wikipedia 毛沢東 (bilingual, zh): worst gap across all
translated paragraphs 5643px -> 14px, zero stranded. Line boxes confirm the
mechanism — 859px (full column, ignoring the float) -> 515px (shortened beside
the infobox). Instrumented rAF shows all 14 float-wrapped nodes are decided
before the first paint, so no displaced frame is ever shown.

jsdom implements no layout and omits `Range.prototype.getBoundingClientRect`
entirely, so vitest.setup.ts stubs it (guarded — that file also loads for
node-environment tests).

Co-authored-by: ananaBMaster <68643891+ananaBMaster@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 15:40:43 -07:00
github-actions[bot] ce1fe3a642 chore(release): version packages (#2033)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
v1.45.2
2026-08-06 18:09:47 -07:00
ananaBMaster e8858f726b chore: update deps (#2046) 2026-08-06 18:08:11 -07:00
ananaBMaster f4bcbf0806 fix(translate): move Microsoft translation to the unauthenticated edge translatetext endpoint (#2045)
The old token endpoint (edge.microsoft.com/translate/auth) was removed
upstream and now answers 404, which broke Microsoft translation entirely.
The replacement endpoint needs no auth but has no markup-preserving mode,
so translation-only page mode no longer offers Microsoft:

- rewrite the adapter: bare string-array body, plain-text escaping against
  the endpoint's always-on tag aligner, entity decode on output, retry
  metadata, and a hard error on html-format input
- gate the combination in the UI (provider pickers hide Microsoft while
  translation-only is active; mode controls explain why it cannot be
  entered) and migrate existing configs to Google Translate (v093)
- restore the microsoftTranslate free-api tests against the live endpoint
  and drop the dead warmup/batch-translate chain

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 12:10:52 -07:00
ananaBMaster 8a82358c4a fix(translation-hub): swap languages while the source is auto-detected (#2042)
The exchange button carried `disabled={sourceLangCode === "auto"}`, and
`language.sourceCode` ships as `auto` — so unless someone had pinned a source
language by hand, the button was permanently dead. Nothing said so: with `auto`
selected the source combobox reads the *detected* language's name, and the
trigger drops the `auto` badge the list rows carry, so the box looked exactly
like a pinned language sitting next to a button that ignored every click.

Resolve `auto` against the detected language instead of bailing out, so the
swap hands back the pair the two boxes were reading, and drop the `disabled`.
The `eng` fallback for "detected nothing yet" moves out of the component into
`detectedLangCodeAtom`, so the label and the swap read one value.

Also render the `auto` badge on the trigger, the way the popup appends
`(auto)`, so the source box stops impersonating a hand-picked language. The
existing `!autoLabel` guard keeps it out of the settings and TTS comboboxes.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 15:26:57 -07:00
ananaBMaster 86b0031ab3 fix(options): keep the sidebar and search reachable in a narrow window (#2041)
Below 768px the options sidebar becomes an off-canvas sheet that starts
closed, taking the whole nav and the command palette's search box with it.
The only visible toggle is `hidden md:block` and renders inside `<Sidebar>`
— i.e. inside the sheet — so the sidebar was reachable only through the
undiscoverable Cmd/Ctrl+B shortcut.

Add a sticky top bar that shows below `md` with the sidebar trigger and a
search button, and pull the shell (sidebar + `SidebarInset` + top bar) into
an `AppShell` component so `main.tsx` only wires providers. `SidebarInset`
also gives the page its first `<main>` landmark.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 14:55:45 -07:00
ananaBMaster 6470d7ec0c fix(translate): keep X Chat message timestamps out of translations (#2040)
chat.x.com matched no built-in rule: the `twitter` rule's `x.com` pattern
is an exact host, so it never covered the subdomain. The whole page fell
back to the generic walk and every bubble's send time landed inside the
message's translation unit ("... -EB 上午11:08").

X Chat renders the message footer (send time + delivery receipt) twice —
once as an aria-hidden `opacity-0` spacer inside the text flow, once in
the absolutely positioned overlay — and the walker only skips
`display:none` / `visibility:hidden`, so both copies were read. Excluding
the footer root covers every branch that renders it, including the
`contents` wrapper used for long messages.

The footer's `.absolute.bottom-0.inset-e-0` wrapper is deliberately left
walkable: excluding it too hides it from unwrapDeepestOnlyHTMLChild,
which collapses the bubble row into an only-child chain and moves the
translation wrapper's insertion point down into the pre-wrap text block.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 12:44:06 -07:00